mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-09-10 21:26:48 +00:00
Compare commits
83 Commits
4d68ed2a24
...
v2.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| d115fc99b2 | |||
| e96e8684e6 | |||
| 747ce379cc | |||
| ef1c8ff09a | |||
| 0c67f999f6 | |||
| efabbd3b2e | |||
| 2a35b2910a | |||
| a42930a699 | |||
| 03b7cef90e | |||
| 4b29448b33 | |||
| 3f30621630 | |||
| 39a41e561c | |||
| 4424db22cb | |||
| 28a796e24f | |||
| f3c15e3e1c | |||
| 3301f8d95f | |||
| a575fddaa2 | |||
| c9fe7d8583 | |||
| 03f17a6e05 | |||
| 3132aab2a5 | |||
| 3d1db1f8ba | |||
| 58872ced81 | |||
| 13b3af3330 | |||
| eca7084f4e | |||
| 6861b2c0eb | |||
| 66b1f0d029 | |||
| a29a0b1862 | |||
| 252b50d2a7 | |||
| 2c61da9fc3 | |||
| 2d8185b747 | |||
| a47a5babce | |||
| 3f6c90cc3c | |||
| 69c4f15ce7 | |||
| 56c1b0d0cd | |||
| 91e9caea48 | |||
| feb6af28ef | |||
| f8c2b4236b | |||
| dc2626e020 | |||
| 46b0b744ca | |||
| 5f2e7ef696 | |||
| 152a85bfb8 | |||
| fdfe301868 | |||
| cbfe1ed8ae | |||
| 9470162236 | |||
| 6a57fa1e00 | |||
| ab67fc0b29 | |||
| e18566d801 | |||
| 7bc0f32145 | |||
| 6ed3e60dd0 | |||
| ab8ea0dbd6 | |||
| b0446dcd29 | |||
| 55d309b2d7 | |||
| d99a8c8452 | |||
| 3f6a195ecb | |||
| 430ea4a120 | |||
| cc0fc9b77f | |||
| 9ada9acb3a | |||
| 246ef1b059 | |||
| 6572a39d48 | |||
| a80262c0d4 | |||
| 531c2295bd | |||
| 0640ec6439 | |||
| a7eb14046f | |||
| 539580ad09 | |||
| faf5bd1e8c | |||
| 97378422bd | |||
| 2632c21de3 | |||
| 64db9a4e6a | |||
| d0f8d7d172 | |||
| 20b6c731b8 | |||
| 2f63009c31 | |||
| f0d4206731 | |||
| b8aad8b695 | |||
| 697696347f | |||
| d6389157ec | |||
| 25dbc3f331 | |||
| bb8799eb8a | |||
| 86fd72b623 | |||
| 9c24a8658f | |||
| 5fc19f6ccb | |||
| 35bfeeb51e | |||
| dfbc840c69 | |||
| 1bea9703ea |
3
.claude/.gitignore
vendored
Normal file
3
.claude/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
!.settings.json
|
||||||
52
.claude/settings.json
Normal file
52
.claude/settings.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Read",
|
||||||
|
"Edit",
|
||||||
|
"Write",
|
||||||
|
"Bash(*)",
|
||||||
|
"Read(//tmp/**)",
|
||||||
|
"WebSearch",
|
||||||
|
"WebFetch(domain:github.com)",
|
||||||
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
|
"WebFetch(domain:api.github.com)",
|
||||||
|
"WebFetch(domain:docs.docker.com)",
|
||||||
|
"WebFetch(domain:pypi.org)",
|
||||||
|
"WebFetch(domain:docs.cypress.io)",
|
||||||
|
"WebFetch(domain:flask.palletsprojects.com)",
|
||||||
|
"Skill(update-config)",
|
||||||
|
"Skill(update-config:*)"
|
||||||
|
],
|
||||||
|
"deny": [
|
||||||
|
"Bash(git push --force*)",
|
||||||
|
"Bash(git reset --hard*)",
|
||||||
|
"Bash(rm -rf*)",
|
||||||
|
"Bash(sudo*)"
|
||||||
|
],
|
||||||
|
"ask": [
|
||||||
|
"Bash(git push*)",
|
||||||
|
"Bash(docker run*)",
|
||||||
|
"Bash(curl*)"
|
||||||
|
],
|
||||||
|
"additionalDirectories": [
|
||||||
|
"/tmp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sandbox": {
|
||||||
|
"enabled": true,
|
||||||
|
"autoAllowBashIfSandboxed": true,
|
||||||
|
"filesystem": {
|
||||||
|
"allowWrite": [
|
||||||
|
".",
|
||||||
|
"/tmp"
|
||||||
|
],
|
||||||
|
"denyRead": [
|
||||||
|
"~/.ssh",
|
||||||
|
"~/.gnupg",
|
||||||
|
"~/.kube",
|
||||||
|
"~/.aws",
|
||||||
|
"~/.config/gcloud"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
.github/workflows/ci.yml
vendored
Normal file
90
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "**"
|
||||||
|
tags-ignore:
|
||||||
|
- "**"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
security:
|
||||||
|
name: Run security workflow
|
||||||
|
uses: ./.github/workflows/security.yml
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
tests:
|
||||||
|
name: Run test workflow
|
||||||
|
uses: ./.github/workflows/tests.yml
|
||||||
|
|
||||||
|
lint:
|
||||||
|
name: Run lint workflow
|
||||||
|
uses: ./.github/workflows/lint.yml
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- security
|
||||||
|
- tests
|
||||||
|
- lint
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Detect semver tag on current commit
|
||||||
|
id: semver
|
||||||
|
run: |
|
||||||
|
SEMVER_TAG="$(git tag --points-at "$GITHUB_SHA" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || true)"
|
||||||
|
if [ -n "$SEMVER_TAG" ]; then
|
||||||
|
{
|
||||||
|
echo "found=true"
|
||||||
|
echo "raw_tag=$SEMVER_TAG"
|
||||||
|
echo "version=${SEMVER_TAG#v}"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Compute image name
|
||||||
|
if: steps.semver.outputs.found == 'true'
|
||||||
|
id: image
|
||||||
|
run: echo "name=ghcr.io/$(echo "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
if: steps.semver.outputs.found == 'true'
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GHCR
|
||||||
|
if: steps.semver.outputs.found == 'true'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and publish image
|
||||||
|
if: steps.semver.outputs.found == 'true'
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.image.outputs.name }}:${{ steps.semver.outputs.version }}
|
||||||
132
.github/workflows/lint.yml
vendored
Normal file
132
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
name: Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-actions:
|
||||||
|
name: Lint GitHub Actions
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Run actionlint
|
||||||
|
run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest
|
||||||
|
|
||||||
|
lint-yaml:
|
||||||
|
name: Lint YAML
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install lint dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install ".[dev]"
|
||||||
|
|
||||||
|
- name: Run yamllint
|
||||||
|
run: yamllint --strict .
|
||||||
|
|
||||||
|
lint-js:
|
||||||
|
name: Lint JavaScript
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: app/package.json
|
||||||
|
|
||||||
|
- name: Install Node dependencies
|
||||||
|
working-directory: app
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Run eslint
|
||||||
|
working-directory: app
|
||||||
|
run: npx eslint .
|
||||||
|
|
||||||
|
lint-shell:
|
||||||
|
name: Lint shell scripts
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Run shellcheck
|
||||||
|
run: docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable scripts/*.sh
|
||||||
|
|
||||||
|
lint-python:
|
||||||
|
name: Lint Python
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install lint dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install ".[dev]"
|
||||||
|
|
||||||
|
- name: Ruff lint
|
||||||
|
run: ruff check .
|
||||||
|
|
||||||
|
- name: Ruff format check
|
||||||
|
run: ruff format --check .
|
||||||
|
|
||||||
|
lint-docker:
|
||||||
|
name: Lint Dockerfile
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Run hadolint
|
||||||
|
id: hadolint
|
||||||
|
continue-on-error: true
|
||||||
|
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5
|
||||||
|
with:
|
||||||
|
dockerfile: ./Dockerfile
|
||||||
|
format: sarif
|
||||||
|
output-file: hadolint-results.sarif
|
||||||
|
failure-threshold: warning
|
||||||
|
|
||||||
|
- name: Upload hadolint SARIF
|
||||||
|
if: always() && github.event_name == 'push'
|
||||||
|
uses: github/codeql-action/upload-sarif@v4
|
||||||
|
with:
|
||||||
|
sarif_file: hadolint-results.sarif
|
||||||
|
wait-for-processing: true
|
||||||
|
category: hadolint
|
||||||
|
|
||||||
|
- name: Fail on hadolint warnings
|
||||||
|
if: always()
|
||||||
|
run: python3 utils/check_hadolint_sarif.py hadolint-results.sarif
|
||||||
48
.github/workflows/security.yml
vendored
Normal file
48
.github/workflows/security.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
name: Security
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Run security scan
|
||||||
|
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: read
|
||||||
|
security-events: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- language: actions
|
||||||
|
build-mode: none
|
||||||
|
- language: javascript-typescript
|
||||||
|
build-mode: none
|
||||||
|
- language: python
|
||||||
|
build-mode: none
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v4
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
build-mode: ${{ matrix.build-mode }}
|
||||||
|
queries: security-extended,security-and-quality
|
||||||
|
|
||||||
|
- name: Run manual build steps
|
||||||
|
if: matrix.build-mode == 'manual'
|
||||||
|
run: |
|
||||||
|
echo "No manual build is configured for this repository."
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Perform CodeQL analysis
|
||||||
|
uses: github/codeql-action/analyze@v4
|
||||||
|
with:
|
||||||
|
category: /language:${{ matrix.language }}
|
||||||
194
.github/workflows/tests.yml
vendored
Normal file
194
.github/workflows/tests.yml
vendored
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-lint:
|
||||||
|
name: Run lint tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Run lint test suite
|
||||||
|
run: python -m unittest discover -s tests/lint -t .
|
||||||
|
|
||||||
|
test-integration:
|
||||||
|
name: Run integration tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install integration test dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install --ignore-installed .
|
||||||
|
|
||||||
|
- name: Run integration test suite
|
||||||
|
run: python -m unittest discover -s tests/integration -t .
|
||||||
|
|
||||||
|
test-unit:
|
||||||
|
name: Run unit tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install unit test dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install --ignore-installed .
|
||||||
|
|
||||||
|
- name: Run unit test suite
|
||||||
|
run: python -m unittest discover -s tests/unit -t .
|
||||||
|
|
||||||
|
security-python:
|
||||||
|
name: Run Python security checks
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install security dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install --ignore-installed ".[dev]"
|
||||||
|
|
||||||
|
- name: Run Bandit
|
||||||
|
run: python -m bandit -q -ll -ii -r app main.py
|
||||||
|
|
||||||
|
- name: Export runtime requirements
|
||||||
|
run: python utils/export_runtime_requirements.py > runtime-requirements.txt
|
||||||
|
|
||||||
|
- name: Audit Python runtime dependencies
|
||||||
|
run: python -m pip_audit -r runtime-requirements.txt
|
||||||
|
|
||||||
|
test-security:
|
||||||
|
name: Run security guardrail tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install security test dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install --ignore-installed .
|
||||||
|
|
||||||
|
- name: Run security test suite
|
||||||
|
run: python -m unittest discover -s tests/security -t .
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
name: Run end-to-end tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- test-lint
|
||||||
|
- test-unit
|
||||||
|
- test-integration
|
||||||
|
- security-python
|
||||||
|
- test-security
|
||||||
|
env:
|
||||||
|
FLASK_HOST: "127.0.0.1"
|
||||||
|
FLASK_PORT: "5001"
|
||||||
|
PORT: "5001"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install --ignore-installed .
|
||||||
|
|
||||||
|
- name: Prepare app config for CI
|
||||||
|
run: cp app/config.sample.yaml app/config.yaml
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: app/package.json
|
||||||
|
|
||||||
|
- name: Install Node dependencies
|
||||||
|
working-directory: app
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Install Cypress system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libasound2t64 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libatspi2.0-0t64 \
|
||||||
|
libcups2t64 \
|
||||||
|
libdrm2 \
|
||||||
|
libgbm1 \
|
||||||
|
libglib2.0-0t64 \
|
||||||
|
libgtk-3-0t64 \
|
||||||
|
libnotify4 \
|
||||||
|
libnspr4 \
|
||||||
|
libnss3 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libpangocairo-1.0-0 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libxrandr2 \
|
||||||
|
libxss1 \
|
||||||
|
libxtst6 \
|
||||||
|
xauth \
|
||||||
|
xvfb
|
||||||
|
|
||||||
|
- name: Run Cypress tests
|
||||||
|
uses: cypress-io/github-action@v6
|
||||||
|
with:
|
||||||
|
working-directory: app
|
||||||
|
install: false
|
||||||
|
start: python app.py
|
||||||
|
wait-on: http://127.0.0.1:5001
|
||||||
|
wait-on-timeout: 120
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,3 +1,14 @@
|
|||||||
app/config.yaml
|
app/config.yaml
|
||||||
|
app/i18n/content/
|
||||||
*__pycache__*
|
*__pycache__*
|
||||||
app/static/cache/*
|
app/static/cache/*
|
||||||
|
.env
|
||||||
|
app/cypress/screenshots/*
|
||||||
|
.ruff_cache/
|
||||||
|
app/node_modules/
|
||||||
|
app/static/vendor/
|
||||||
|
hadolint-results.sarif
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
app/core.*
|
||||||
|
.mcp.json
|
||||||
|
|||||||
23
.yamllint
Normal file
23
.yamllint
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
# Correctness only. No `extends: default`, so nothing but the rules below runs:
|
||||||
|
# the repository predates this linter and its cosmetic findings (indentation,
|
||||||
|
# line length, trailing spaces) would be noise nobody acts on.
|
||||||
|
#
|
||||||
|
# key-duplicates is the rule that earns its keep: PyYAML keeps the last of two
|
||||||
|
# identical keys without complaining, so a duplicated entry in a translation
|
||||||
|
# catalogue silently drops a translation.
|
||||||
|
ignore: |
|
||||||
|
.git/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
node_modules/
|
||||||
|
app/node_modules/
|
||||||
|
app/static/vendor/
|
||||||
|
build/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
rules:
|
||||||
|
key-duplicates: enable
|
||||||
|
octal-values:
|
||||||
|
forbid-implicit-octal: true
|
||||||
|
forbid-explicit-octal: true
|
||||||
13
AGENTS.md
Normal file
13
AGENTS.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Agent Instructions
|
||||||
|
|
||||||
|
## Pre-Commit Validation
|
||||||
|
|
||||||
|
- You MUST run `make test` before every commit whenever the staged change includes at least one file that is not `.md` or `.rst`, unless explicitly instructed otherwise.
|
||||||
|
- You MUST commit only after all tests pass.
|
||||||
|
- You MUST NOT commit automatically without explicit confirmation from the user.
|
||||||
|
|
||||||
|
## Vendor Assets
|
||||||
|
|
||||||
|
- Browser vendor assets (Bootstrap, Font Awesome, etc.) are managed via npm.
|
||||||
|
- Run `npm install` inside `app/` to populate `app/static/vendor/` before starting the dev server or running e2e tests.
|
||||||
|
- Never commit `app/node_modules/` or `app/static/vendor/` — both are gitignored and generated at build time.
|
||||||
49
CHANGELOG.md
Normal file
49
CHANGELOG.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [2.1.0] - 2026-08-22
|
||||||
|
|
||||||
|
* Multilingual site: every ISO 639-1 language has its own URL, */* follows the visitor's browser language, and a switcher in the navbar lists all 184 in their own script
|
||||||
|
* Right-to-left layouts: Arabic, Hebrew, Persian, Urdu and six more mirror the page automatically
|
||||||
|
* Machine translation: *make i18n* fills your card and menu texts from a LibreTranslate instance, *make i18n-ui* the interface strings; hand-corrected entries are never overwritten
|
||||||
|
* Brand protection: list your product names in *app/i18n/keep.txt* and they stay untranslated in every language
|
||||||
|
* Search engines: each page declares a canonical URL and an *hreflang* alternate per language
|
||||||
|
* Security: configuration and translations are HTML-escaped, script URLs are stripped from links, images and the iframe view — set *TRUSTED_HOSTS* in *.env* to pin your public hostname
|
||||||
|
* Reliability: a slow NASA APOD lookup no longer takes the page down
|
||||||
|
* Developer tooling: *make test-e2e* runs Cypress directly instead of through act, and YAML, JavaScript and shell now have linters in CI
|
||||||
|
|
||||||
|
## [2.0.0] - 2026-05-18
|
||||||
|
|
||||||
|
* * Asset resolution: new probe-first resolver tries a HEAD request and embeds reachable image URLs directly via a new external_url field, falling back to the cache-download path only when the probe fails; broken /static/https://... URLs no longer appear when the source cannot be downloaded
|
||||||
|
* Template integration: an asset_src context processor in app.py picks external_url first and url_for(static, cache) second, so base.html.j2, navigation.html.j2, and card.html.j2 never wrap an absolute URL in the static prefix
|
||||||
|
* Test coverage: 16 new unit tests cover every probe, cache, and fallback branch; a live integration test exercises https://file.infinito.nexus/assets/img/logo.png to prove the probe-first path works end-to-end without writing to the cache directory
|
||||||
|
* Sample configuration: new Infinito.Nexus card in app/config.sample.yaml driven by the canonical file.infinito.nexus asset URL
|
||||||
|
* SPOT for build variables: env.example is the single source of truth for IMAGE_NAME and PORT; the Makefile and docker-compose.yml reference both with no defaults and fail loudly when either variable is missing
|
||||||
|
* Bootstrap targets: make env and make config materialise .env and app/config.yaml from their checked-in templates without overwriting existing files; build, build-no-cache, up, run-dev, run-prod, dev, prod, and browse depend on both so a fresh checkout runs in a single make invocation
|
||||||
|
* Recipe sourcing: Makefile recipes load .env at recipe-execution time via a shared _require_env helper, so a freshly bootstrapped .env is picked up in the same make invocation that created it
|
||||||
|
* README: PortUI screenshot added under the title
|
||||||
|
* Lint: removed an unused sys import in the live integration test
|
||||||
|
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-05-11
|
||||||
|
|
||||||
|
* * Navigation behavior: Top-level dropdowns now open reliably on hover and click via Bootstrap, escape the header and navbar overflow clips, and flip between downward and upward based on whether more space is above or below the toggle
|
||||||
|
* Compose-driven dependencies: docker-compose runs npm install inside the container on every up and persists node_modules plus static/vendor in named volumes, removing the host-side npm-install step from up, dev, prod, and test-e2e
|
||||||
|
* Test coverage: New Cypress specs cover both header and footer dropdown directions, with a Jinja unit test guarding the data-bs-toggle attribute on top-level dropdown toggles
|
||||||
|
* Harness configuration: Enabled the Claude Code sandbox with scoped filesystem rules, consolidated the bash allowlist behind a single wildcard, and gitignored local-only state under .claude
|
||||||
|
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-03-30
|
||||||
|
|
||||||
|
* *CI stabilization and modularization*: Split into reusable workflows (lint, security, tests) with correct permissions for CodeQL and SARIF uploads
|
||||||
|
* *Modern Python packaging*: Migration to pyproject.toml and updated Dockerfile using Python 3.12
|
||||||
|
* *Improved test coverage*: Added unit, integration, lint, security, and E2E tests using act
|
||||||
|
* *Local vendor assets*: Replaced external CDNs with npm-based local asset pipeline
|
||||||
|
* *Enhanced build workflow*: Extended Makefile with targets for test, lint, security, and CI plus vendor build process
|
||||||
|
* *Frontend fix*: Prevented navbar wrapping and improved layout behavior
|
||||||
|
* *Developer guidelines*: Introduced AGENTS.md and CLAUDE.md with enforced pre-commit rules
|
||||||
|
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-02-19
|
||||||
|
|
||||||
|
* Official Release🥳
|
||||||
|
|
||||||
5
CLAUDE.md
Normal file
5
CLAUDE.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
## Startup
|
||||||
|
|
||||||
|
You MUST read `AGENTS.md` and follow all instructions in it at the start of every conversation before doing anything else.
|
||||||
28
Dockerfile
28
Dockerfile
@@ -1,18 +1,20 @@
|
|||||||
# Base image for Python
|
FROM python:3.12-slim
|
||||||
FROM python:slim
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
FLASK_HOST=0.0.0.0
|
||||||
|
|
||||||
|
# hadolint ignore=DL3008
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /tmp/build
|
||||||
|
|
||||||
|
COPY pyproject.toml README.md main.py ./
|
||||||
|
COPY app ./app
|
||||||
|
RUN python -m pip install --no-cache-dir .
|
||||||
|
|
||||||
# Set the working directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy and install dependencies
|
|
||||||
COPY app/requirements.txt requirements.txt
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy application code
|
|
||||||
COPY app/ .
|
COPY app/ .
|
||||||
|
RUN npm install --prefix /app
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 5000
|
|
||||||
|
|
||||||
# Start command
|
|
||||||
CMD ["python", "app.py"]
|
CMD ["python", "app.py"]
|
||||||
|
|||||||
4
MIRRORS
Normal file
4
MIRRORS
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
git@github.com:kevinveenbirkenbach/port-ui.git
|
||||||
|
ssh://git@code.infinito.nexus:2201/kevinveenbirkenbach/port-ui.git
|
||||||
|
ssh://git@git.veen.world:2201/kevinveenbirkenbach/port-ui.git
|
||||||
|
|
||||||
256
Makefile
Normal file
256
Makefile
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
# Load environment variables from .env
|
||||||
|
ifneq (,$(wildcard .env))
|
||||||
|
include .env
|
||||||
|
# Export variables defined in .env
|
||||||
|
export $(shell sed 's/=.*//' .env)
|
||||||
|
endif
|
||||||
|
|
||||||
|
PYTHON ?= python3
|
||||||
|
ACT ?= act
|
||||||
|
|
||||||
|
# Bootstrap the local .env from the checked-in env.example template.
|
||||||
|
# Idempotent: leaves an existing .env untouched.
|
||||||
|
.PHONY: env
|
||||||
|
env:
|
||||||
|
@if [ -f .env ]; then \
|
||||||
|
echo ".env already exists — leaving it alone."; \
|
||||||
|
else \
|
||||||
|
cp env.example .env; \
|
||||||
|
echo "Created .env from env.example — review and adjust."; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bootstrap app/config.yaml from the checked-in app/config.sample.yaml
|
||||||
|
# template. Idempotent: leaves an existing config.yaml untouched. The
|
||||||
|
# Dockerfile COPYs the whole app/ directory at build time, so this file
|
||||||
|
# must exist before `make build` / `make up`.
|
||||||
|
.PHONY: config
|
||||||
|
config:
|
||||||
|
@if [ -f app/config.yaml ]; then \
|
||||||
|
echo "app/config.yaml already exists — leaving it alone."; \
|
||||||
|
else \
|
||||||
|
cp app/config.sample.yaml app/config.yaml; \
|
||||||
|
echo "Created app/config.yaml from app/config.sample.yaml — review and adjust."; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build/run recipes source .env at recipe-execution time (not at make
|
||||||
|
# parse time) so they work in the same invocation that bootstrapped
|
||||||
|
# .env via the `env` prereq. Without this, the inner $$IMAGE_NAME /
|
||||||
|
# $$PORT would be empty on the very first `make build` after a fresh
|
||||||
|
# checkout — the parse-time `include .env` happens before `env` runs.
|
||||||
|
define _require_env
|
||||||
|
if [ ! -f .env ]; then echo "ERROR: .env missing"; exit 1; fi; \
|
||||||
|
. ./.env; \
|
||||||
|
for v in $(1); do \
|
||||||
|
eval "val=\$$$$v"; \
|
||||||
|
[ -n "$$val" ] || { echo "ERROR: $$v is empty in .env (see env.example)"; exit 1; }; \
|
||||||
|
done
|
||||||
|
endef
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build: env config
|
||||||
|
# Build the Docker image.
|
||||||
|
@$(call _require_env,IMAGE_NAME); \
|
||||||
|
docker build -t "$$IMAGE_NAME" .
|
||||||
|
|
||||||
|
.PHONY: build-no-cache
|
||||||
|
build-no-cache: env config
|
||||||
|
# Build the Docker image without cache.
|
||||||
|
@$(call _require_env,IMAGE_NAME); \
|
||||||
|
docker build --no-cache -t "$$IMAGE_NAME" .
|
||||||
|
|
||||||
|
.PHONY: up
|
||||||
|
up: env config
|
||||||
|
# Start the application using docker-compose with build.
|
||||||
|
docker-compose up -d --build --force-recreate
|
||||||
|
|
||||||
|
.PHONY: down
|
||||||
|
down:
|
||||||
|
# Stop and remove the 'portfolio' container, ignore errors, and bring down compose.
|
||||||
|
- docker stop portfolio || true
|
||||||
|
- docker rm portfolio || true
|
||||||
|
- docker-compose down
|
||||||
|
|
||||||
|
.PHONY: run-dev
|
||||||
|
run-dev: env config config
|
||||||
|
# Run the container in development mode (hot-reload).
|
||||||
|
@$(call _require_env,IMAGE_NAME PORT); \
|
||||||
|
docker run -d \
|
||||||
|
-p "$$PORT:$$PORT" \
|
||||||
|
--name portfolio \
|
||||||
|
-v "$(PWD)/app/:/app" \
|
||||||
|
-e PORT="$$PORT" \
|
||||||
|
-e TRUSTED_HOSTS="$$TRUSTED_HOSTS" \
|
||||||
|
-e FLASK_APP=app.py \
|
||||||
|
-e FLASK_ENV=development \
|
||||||
|
"$$IMAGE_NAME"
|
||||||
|
|
||||||
|
.PHONY: run-prod
|
||||||
|
run-prod: env config config
|
||||||
|
# Run the container in production mode.
|
||||||
|
@$(call _require_env,IMAGE_NAME PORT); \
|
||||||
|
docker run -d \
|
||||||
|
-p "$$PORT:$$PORT" \
|
||||||
|
--name portfolio \
|
||||||
|
-e PORT="$$PORT" \
|
||||||
|
-e TRUSTED_HOSTS="$$TRUSTED_HOSTS" \
|
||||||
|
"$$IMAGE_NAME"
|
||||||
|
|
||||||
|
.PHONY: logs
|
||||||
|
logs:
|
||||||
|
# Display the logs of the 'portfolio' container.
|
||||||
|
docker logs -f portfolio
|
||||||
|
|
||||||
|
.PHONY: dev
|
||||||
|
dev: env config
|
||||||
|
# Start the application in development mode using docker-compose.
|
||||||
|
FLASK_ENV=development docker-compose up -d
|
||||||
|
|
||||||
|
.PHONY: prod
|
||||||
|
prod: env config
|
||||||
|
# Start the application in production mode using docker-compose (with build).
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
.PHONY: cleanup
|
||||||
|
cleanup:
|
||||||
|
# Remove all stopped Docker containers to reclaim space.
|
||||||
|
docker container prune -f
|
||||||
|
|
||||||
|
.PHONY: delete
|
||||||
|
delete:
|
||||||
|
# Force remove the 'portfolio' container if it exists.
|
||||||
|
- docker rm -f portfolio
|
||||||
|
|
||||||
|
.PHONY: browse
|
||||||
|
browse: env
|
||||||
|
# Open the application in the browser at http://localhost:$$PORT
|
||||||
|
@$(call _require_env,PORT); \
|
||||||
|
chromium "http://localhost:$$PORT"
|
||||||
|
|
||||||
|
.PHONY: install
|
||||||
|
install:
|
||||||
|
# Install runtime Python dependencies from pyproject.toml.
|
||||||
|
$(PYTHON) -m pip install -e .
|
||||||
|
|
||||||
|
.PHONY: install-dev
|
||||||
|
install-dev:
|
||||||
|
# Install runtime and developer dependencies from pyproject.toml.
|
||||||
|
$(PYTHON) -m pip install -e ".[dev]"
|
||||||
|
|
||||||
|
.PHONY: i18n
|
||||||
|
i18n: env config
|
||||||
|
# Fill missing content translations in app/i18n/content/ via LibreTranslate.
|
||||||
|
@$(call _require_env,LIBRETRANSLATE_URL); \
|
||||||
|
$(PYTHON) utils/i18n_sync.py \
|
||||||
|
--url "$$LIBRETRANSLATE_URL" \
|
||||||
|
--api-key "$$LIBRETRANSLATE_API_KEY"
|
||||||
|
|
||||||
|
.PHONY: i18n-ui
|
||||||
|
i18n-ui: env
|
||||||
|
# Fill missing interface translations in app/i18n/ui/ via LibreTranslate.
|
||||||
|
@$(call _require_env,LIBRETRANSLATE_URL); \
|
||||||
|
$(PYTHON) utils/i18n_sync.py \
|
||||||
|
--catalog ui \
|
||||||
|
--url "$$LIBRETRANSLATE_URL" \
|
||||||
|
--api-key "$$LIBRETRANSLATE_API_KEY"
|
||||||
|
|
||||||
|
.PHONY: lint-actions
|
||||||
|
lint-actions:
|
||||||
|
# Lint GitHub Actions workflows.
|
||||||
|
docker run --rm -v "$$PWD:/repo" -w /repo rhysd/actionlint:latest
|
||||||
|
|
||||||
|
.PHONY: lint-python
|
||||||
|
lint-python: install-dev
|
||||||
|
# Run Python lint and format checks.
|
||||||
|
$(PYTHON) -m ruff check .
|
||||||
|
$(PYTHON) -m ruff format --check .
|
||||||
|
|
||||||
|
.PHONY: lint-docker
|
||||||
|
lint-docker:
|
||||||
|
# Lint the Dockerfile.
|
||||||
|
docker run --rm -i hadolint/hadolint < Dockerfile
|
||||||
|
|
||||||
|
.PHONY: lint-yaml
|
||||||
|
lint-yaml: install-dev
|
||||||
|
# Lint YAML for duplicate keys and ambiguous scalars (see .yamllint).
|
||||||
|
$(PYTHON) -m yamllint --strict .
|
||||||
|
|
||||||
|
.PHONY: lint-js
|
||||||
|
lint-js: node-deps
|
||||||
|
# Lint the browser and Cypress JavaScript.
|
||||||
|
cd app && env -u ELECTRON_RUN_AS_NODE npx eslint .
|
||||||
|
|
||||||
|
.PHONY: lint-shell
|
||||||
|
lint-shell:
|
||||||
|
# Lint the shell scripts.
|
||||||
|
docker run --rm -v "$$PWD:/mnt" -w /mnt koalaman/shellcheck:stable scripts/*.sh
|
||||||
|
|
||||||
|
.PHONY: test-lint
|
||||||
|
test-lint:
|
||||||
|
# Run lint guardrail tests.
|
||||||
|
$(PYTHON) -m unittest discover -s tests/lint -t .
|
||||||
|
|
||||||
|
.PHONY: test-integration
|
||||||
|
test-integration: install
|
||||||
|
# Run repository integration tests.
|
||||||
|
$(PYTHON) -m unittest discover -s tests/integration -t .
|
||||||
|
|
||||||
|
.PHONY: test-unit
|
||||||
|
test-unit: install
|
||||||
|
# Run repository unit tests.
|
||||||
|
$(PYTHON) -m unittest discover -s tests/unit -t .
|
||||||
|
|
||||||
|
.PHONY: test-security
|
||||||
|
test-security: install
|
||||||
|
# Run repository security guardrail tests.
|
||||||
|
$(PYTHON) -m unittest discover -s tests/security -t .
|
||||||
|
|
||||||
|
.PHONY: lint
|
||||||
|
lint: lint-actions lint-python lint-yaml lint-js lint-docker lint-shell test-lint
|
||||||
|
# Run the full lint suite.
|
||||||
|
|
||||||
|
.PHONY: security
|
||||||
|
security: install-dev test-security
|
||||||
|
# Run security checks.
|
||||||
|
$(PYTHON) -m bandit -q -ll -ii -r app main.py
|
||||||
|
$(PYTHON) utils/export_runtime_requirements.py > /tmp/portfolio-runtime-requirements.txt
|
||||||
|
$(PYTHON) -m pip_audit -r /tmp/portfolio-runtime-requirements.txt
|
||||||
|
|
||||||
|
.PHONY: node-deps
|
||||||
|
node-deps:
|
||||||
|
# Install the Cypress binary and the browser vendor assets into app/.
|
||||||
|
cd app && npm install
|
||||||
|
|
||||||
|
.PHONY: test-e2e
|
||||||
|
test-e2e: env config node-deps
|
||||||
|
# Run Cypress against a locally started Flask app — no act, no runner image.
|
||||||
|
@$(call _require_env,PORT); \
|
||||||
|
PORT="$$PORT" PYTHON="$(PYTHON)" scripts/run-e2e.sh
|
||||||
|
|
||||||
|
.PHONY: test-e2e-act
|
||||||
|
test-e2e-act:
|
||||||
|
# Run the CI end-to-end job through act (stop portfolio container to free port first).
|
||||||
|
-docker stop portfolio 2>/dev/null || true
|
||||||
|
$(ACT) workflow_dispatch -W .github/workflows/tests.yml -j e2e
|
||||||
|
-docker start portfolio 2>/dev/null || true
|
||||||
|
|
||||||
|
.PHONY: test-workflow
|
||||||
|
test-workflow:
|
||||||
|
# Run the GitHub test workflow locally via act.
|
||||||
|
$(ACT) workflow_dispatch -W .github/workflows/tests.yml
|
||||||
|
|
||||||
|
.PHONY: lint-workflow
|
||||||
|
lint-workflow:
|
||||||
|
# Run the GitHub lint workflow locally via act.
|
||||||
|
$(ACT) workflow_dispatch -W .github/workflows/lint.yml
|
||||||
|
|
||||||
|
.PHONY: quality
|
||||||
|
quality: lint-workflow test-workflow
|
||||||
|
# Run the GitHub lint and test workflows locally via act.
|
||||||
|
|
||||||
|
.PHONY: ci
|
||||||
|
ci: lint security test-unit test-integration test-e2e
|
||||||
|
# Run the local CI suite.
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test: ci
|
||||||
|
# Run the full validation suite.
|
||||||
217
README.md
217
README.md
@@ -1,86 +1,99 @@
|
|||||||
# Portfolio CMS: Flask-based Portfolio Management 🚀
|
# PortUI 🖥️✨
|
||||||
|
|
||||||
[](https://github.com/sponsors/kevinveenbirkenbach) [](https://www.patreon.com/c/kevinveenbirkenbach) [](https://buymeacoffee.com/kevinveenbirkenbach) [](https://s.veen.world/paypaldonate)
|
[](https://github.com/sponsors/kevinveenbirkenbach) [](https://www.patreon.com/c/kevinveenbirkenbach) [](https://buymeacoffee.com/kevinveenbirkenbach) [](https://s.veen.world/paypaldonate)
|
||||||
|
|
||||||
This software allows individuals and institutions to set up an easy portfolio/landingpage/homepage to showcase their projects and online presence. It is highly customizable via a YAML configuration file.
|
A lightweight, Docker-powered portfolio/landing-page generator—fully customizable via YAML! Showcase your projects, skills, and online presence in minutes.
|
||||||
|
|
||||||
## Features ✨
|

|
||||||
|
|
||||||
- **Dynamic Navigation:** Easily create dropdown menus and nested links.
|
> 🚀 You can also pair PortUI with JavaScript for sleek, web-based desktop-style interfaces.
|
||||||
- **Customizable Cards:** Showcase your skills, projects, or services.
|
> 💻 Example in action: [CyMaIS.Cloud](https://cymais.cloud/) (demo)
|
||||||
- **Cache Management:** Optimize your assets with automatic caching.
|
> 🌐 Another live example: [veen.world](https://www.veen.world/) (Kevin’s personal site)
|
||||||
- **Responsive Design:** Beautiful on any device with Bootstrap.
|
|
||||||
- **Easy Configuration:** Update content using a YAML file.
|
|
||||||
- **Command Line Interface:** Manage Docker containers with the `portfolio` CLI.
|
|
||||||
|
|
||||||
## Access 🌐
|
---
|
||||||
|
|
||||||
### Local Access
|
## ✨ Key Features
|
||||||
Access the application locally at [http://127.0.0.1:5000](http://127.0.0.1:5000).
|
|
||||||
|
|
||||||
## Getting Started 🏁
|
- **Dynamic Navigation**
|
||||||
|
Create dropdowns & nested menus with ease.
|
||||||
|
- **Customizable Cards**
|
||||||
|
Highlight skills, projects, or services—with icons, titles, and links.
|
||||||
|
- **Smart Cache Management**
|
||||||
|
Auto-cache assets for lightning-fast loading.
|
||||||
|
- **Responsive Design**
|
||||||
|
Built on Bootstrap; looks great on desktop, tablet & mobile.
|
||||||
|
- **184 Languages**
|
||||||
|
Every ISO 639-1 code, browser-negotiated, RTL-aware, with machine translation for your own content.
|
||||||
|
- **YAML-Driven**
|
||||||
|
All content & structure defined in a simple `config.yaml`.
|
||||||
|
- **CLI Control**
|
||||||
|
Manage Docker containers via the `portfolio` command.
|
||||||
|
|
||||||
### Prerequisites 📋
|
---
|
||||||
|
|
||||||
- Docker and Docker Compose installed on your system.
|
## 🌐 Quick Access
|
||||||
- Basic knowledge of Python and YAML for configuration.
|
|
||||||
|
|
||||||
### Installation 🛠️
|
- **Local Preview:**
|
||||||
|
[http://127.0.0.1:5000](http://127.0.0.1:5000)
|
||||||
|
|
||||||
#### Installation via git clone
|
---
|
||||||
|
|
||||||
1. **Clone the repository:**
|
## 🏁 Getting Started
|
||||||
|
|
||||||
|
### 🔧 Prerequisites
|
||||||
|
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- Basic Python & YAML knowledge
|
||||||
|
|
||||||
|
### 🛠️ Installation via Git
|
||||||
|
|
||||||
|
1. **Clone & enter repo**
|
||||||
```bash
|
```bash
|
||||||
git clone <repository_url>
|
git clone <repository_url>
|
||||||
cd <repository_directory>
|
cd <repository_directory>
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Update the configuration:**
|
2. **Configure**
|
||||||
Create a `config.yaml` file. You can use `config.sample.yaml` as an example (see below for details on the configuration).
|
Copy `config.sample.yaml` → `config.yaml` & customize.
|
||||||
|
3. **Build & run**
|
||||||
|
|
||||||
3. **Build and run the Docker container:**
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up --build
|
docker-compose up --build
|
||||||
```
|
```
|
||||||
|
4. **Browse**
|
||||||
|
Open [http://localhost:5000](http://localhost:5000)
|
||||||
|
|
||||||
4. **Access your portfolio:**
|
### 📦 Installation via Kevin’s Package Manager
|
||||||
Open your browser and navigate to [http://localhost:5000](http://localhost:5000).
|
|
||||||
|
|
||||||
### Installation via Kevin's Package Manager
|
|
||||||
|
|
||||||
You can install the `portfolio` CLI using [Kevin's package manager](https://github.com/kevinveenbirkenbach/package-manager). Simply run:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pkgmgr install portfolio
|
pkgmgr install portui
|
||||||
```
|
```
|
||||||
|
|
||||||
This will install the CLI tool, making it available system-wide.
|
Once installed, the `portui` CLI is available system-wide.
|
||||||
|
|
||||||
### Available Commands
|
---
|
||||||
|
|
||||||
After installation, you can access the help information for the CLI by running:
|
## 🖥️ CLI Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
portfolio --help
|
portui --help
|
||||||
```
|
```
|
||||||
|
|
||||||
This command displays detailed instructions on how to use the following commands:
|
* `build` Build the Docker image
|
||||||
|
* `up` Start containers (with build)
|
||||||
|
* `down` Stop & remove containers
|
||||||
|
* `run-dev` Dev mode (hot-reload)
|
||||||
|
* `run-prod` Production mode
|
||||||
|
* `logs` View container logs
|
||||||
|
* `dev` Docker-Compose dev environment
|
||||||
|
* `prod` Docker-Compose prod environment
|
||||||
|
* `cleanup` Prune stopped containers
|
||||||
|
|
||||||
- **build:** Build the Docker image for the portfolio application.
|
---
|
||||||
- **up:** Start the application using docker-compose (with build).
|
|
||||||
- **down:** Stop and remove the Docker container.
|
|
||||||
- **run-dev:** Run the container in development mode with hot-reloading.
|
|
||||||
- **run-prod:** Run the container in production mode.
|
|
||||||
- **logs:** Display the logs of the running container.
|
|
||||||
- **dev:** Start the application in development mode using docker-compose.
|
|
||||||
- **prod:** Start the application in production mode using docker-compose.
|
|
||||||
- **cleanup:** Remove all stopped Docker containers to clean up your Docker environment.
|
|
||||||
|
|
||||||
## YAML Configuration Guide 🔧
|
## 🔧 YAML Configuration Guide
|
||||||
|
|
||||||
The portfolio is powered by a YAML configuration file (`config.yaml`). This file allows you to define the structure and content of your site, including cards, navigation, and company details.
|
Define your site’s structure in `config.yaml`:
|
||||||
|
|
||||||
### YAML Configuration Example 📄
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
accounts:
|
accounts:
|
||||||
@@ -94,17 +107,12 @@ accounts:
|
|||||||
icon:
|
icon:
|
||||||
class: fas fa-newspaper
|
class: fas fa-newspaper
|
||||||
children:
|
children:
|
||||||
- name: Microblogs
|
- name: Mastodon
|
||||||
description: Stay updated with my microblog posts.
|
description: Follow me on Mastodon.
|
||||||
icon:
|
icon:
|
||||||
class: fa-solid fa-pen-nib
|
class: fa-brands fa-mastodon
|
||||||
children:
|
url: https://microblog.veen.world/@kevinveenbirkenbach
|
||||||
- name: Mastodon
|
identifier: "@kevinveenbirkenbach@microblog.veen.world"
|
||||||
description: Follow my updates on Mastodon.
|
|
||||||
icon:
|
|
||||||
class: fa-brands fa-mastodon
|
|
||||||
url: https://microblog.veen.world/@kevinveenbirkenbach
|
|
||||||
identifier: "@kevinveenbirkenbach@microblog.veen.world"
|
|
||||||
cards:
|
cards:
|
||||||
- icon:
|
- icon:
|
||||||
source: https://cloud.veen.world/s/logo_agile_coach_512x512/download
|
source: https://cloud.veen.world/s/logo_agile_coach_512x512/download
|
||||||
@@ -112,9 +120,10 @@ accounts:
|
|||||||
text: I lead agile transformations and improve team dynamics through Scrum and Agile Coaching.
|
text: I lead agile transformations and improve team dynamics through Scrum and Agile Coaching.
|
||||||
url: https://www.agile-coach.world
|
url: https://www.agile-coach.world
|
||||||
link_text: www.agile-coach.world
|
link_text: www.agile-coach.world
|
||||||
|
|
||||||
company:
|
company:
|
||||||
titel: Kevin Veen-Birkenbach
|
title: Kevin Veen-Birkenbach
|
||||||
subtitel: Consulting and Coaching Solutions
|
subtitle: Consulting & Coaching Solutions
|
||||||
logo:
|
logo:
|
||||||
source: https://cloud.veen.world/s/logo_face_512x512/download
|
source: https://cloud.veen.world/s/logo_face_512x512/download
|
||||||
favicon:
|
favicon:
|
||||||
@@ -127,26 +136,90 @@ company:
|
|||||||
imprint_url: https://s.veen.world/imprint
|
imprint_url: https://s.veen.world/imprint
|
||||||
```
|
```
|
||||||
|
|
||||||
### Understanding the `children` Key 🔍
|
* **`children`** enables multi-level menus.
|
||||||
|
* **`link`** references other YAML paths to avoid duplication.
|
||||||
|
|
||||||
The `children` key allows hierarchical nesting of elements. Each child can itself have children, enabling the creation of multi-level navigation menus or grouped content.
|
---
|
||||||
|
|
||||||
### Understanding the `link` Key 🔗
|
## 🌍 Languages
|
||||||
|
|
||||||
The `link` key allows you to reference another part of the YAML configuration by its path, which helps avoid duplication and maintain consistency.
|
Every ISO 639-1 language — all 184 two-letter codes — has a URL, a display
|
||||||
|
name in its own script and a writing direction. The interface ships translated
|
||||||
|
for 29 of them; the rest fall back to English string by string until a
|
||||||
|
catalogue is filled. `/` serves the best match for the visitor's
|
||||||
|
`Accept-Language` header, `/<code>/` forces one, and a switcher in the navbar
|
||||||
|
lists them all. The ten right-to-left languages get `dir="rtl"` and Bootstrap's RTL
|
||||||
|
stylesheet automatically.
|
||||||
|
|
||||||
## Deployment 🚢
|
Translations live in two catalogues, both keyed by the English source string:
|
||||||
|
|
||||||
For production deployment, ensure to:
|
| Path | Tracked | Holds |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `app/i18n/ui/<code>.yaml` | yes | Interface strings. Shipped for 29 languages; English is the source and has no file. |
|
||||||
|
| `app/i18n/content/<code>.yaml` | no | Your `config.yaml` prose, generated per deployment. |
|
||||||
|
|
||||||
- Use a reverse proxy like NGINX or Apache.
|
A string with no catalogue entry falls back to English, so a half-filled
|
||||||
- Secure your site with SSL/TLS.
|
catalogue degrades instead of breaking.
|
||||||
- Use a production-ready database if required.
|
|
||||||
|
|
||||||
## License 📜
|
Fill the content catalogues from a [LibreTranslate](https://libretranslate.com/)
|
||||||
|
instance — set `LIBRETRANSLATE_URL` in `.env`, then:
|
||||||
|
|
||||||
This project is licensed under the GNU Affero General Public License Version 3. See the [LICENSE](./LICENSE) file for details.
|
```bash
|
||||||
|
make i18n
|
||||||
|
```
|
||||||
|
|
||||||
## Author ✍️
|
This fills the interface strings of the languages that ship no catalogue as
|
||||||
|
well. Existing entries are never overwritten, and a string the shipped
|
||||||
|
catalogue already covers is never requested, so corrections you make by hand
|
||||||
|
survive later runs.
|
||||||
|
|
||||||
This software was created by [Kevin Veen-Birkenbach](https://www.veen.world/).
|
`name`, `title`, `description`, `text`, `warning`, `info` and `subtitel` are
|
||||||
|
translated; `url`, `link_text`, `identifier` and icon classes never are.
|
||||||
|
|
||||||
|
A machine cannot tell the menu label "Pictures" from the brand "Mastodon", so
|
||||||
|
list the brands in `app/i18n/keep.txt`, one per line — they are then stored as
|
||||||
|
themselves in every language and cost no request:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Strings utils/i18n_sync.py stores as themselves instead of translating.
|
||||||
|
Mastodon
|
||||||
|
Nextcloud
|
||||||
|
freelancermap.de
|
||||||
|
```
|
||||||
|
|
||||||
|
Add one-off entries with `--keep Foo Bar`, or point somewhere else with
|
||||||
|
`--keep-file`. A protected string never replaces an entry you already wrote by
|
||||||
|
hand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚢 Production Deployment
|
||||||
|
|
||||||
|
* Use a reverse proxy (NGINX/Apache).
|
||||||
|
* Secure with SSL/TLS.
|
||||||
|
* Swap to a production database if needed.
|
||||||
|
|
||||||
|
Because every page carries a canonical URL and 184 `hreflang` alternates, two
|
||||||
|
details of the proxy setup now matter:
|
||||||
|
|
||||||
|
* **Set `TRUSTED_HOSTS`** in `.env` to your public hostname(s), comma-separated.
|
||||||
|
Left empty, the app reflects whatever `Host` header arrives into its canonical,
|
||||||
|
`hreflang` and redirect URLs — so a shared cache in front of it can be made to
|
||||||
|
store a redirect pointing somewhere else.
|
||||||
|
* **Have the proxy send `X-Forwarded-Proto`.** Without it the app cannot know TLS
|
||||||
|
terminated upstream and every canonical URL claims `http://`. `X-Forwarded-Host`
|
||||||
|
is deliberately *not* trusted; set `Host` to the public name instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📜 License
|
||||||
|
|
||||||
|
Licensed under **GNU AGPLv3**. See [LICENSE](./LICENSE) for details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✍️ Author
|
||||||
|
|
||||||
|
Created by [Kevin Veen-Birkenbach](https://www.veen.world/)
|
||||||
|
|
||||||
|
Enjoy building your portfolio! 🌟
|
||||||
|
|||||||
2
app/.gitignore
vendored
Normal file
2
app/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Portfolio UI web application package."""
|
||||||
179
app/app.py
179
app/app.py
@@ -1,9 +1,34 @@
|
|||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from flask import Flask, render_template
|
|
||||||
|
import requests
|
||||||
import yaml
|
import yaml
|
||||||
from utils.configuration_resolver import ConfigurationResolver
|
from flask import Flask, current_app, make_response, render_template, request, url_for
|
||||||
from utils.cache_manager import CacheManager
|
from markupsafe import Markup
|
||||||
from utils.compute_card_classes import compute_card_classes
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.utils import i18n
|
||||||
|
from app.utils.asset_resolver import asset_src, resolve_asset_cache
|
||||||
|
from app.utils.cache_manager import CacheManager
|
||||||
|
from app.utils.compute_card_classes import compute_card_classes
|
||||||
|
from app.utils.configuration_resolver import ConfigurationResolver
|
||||||
|
except ImportError: # pragma: no cover - supports running from the app/ directory.
|
||||||
|
from utils.asset_resolver import asset_src, resolve_asset_cache
|
||||||
|
from utils.cache_manager import CacheManager
|
||||||
|
from utils.compute_card_classes import compute_card_classes
|
||||||
|
from utils.configuration_resolver import ConfigurationResolver
|
||||||
|
|
||||||
|
from utils import i18n
|
||||||
|
|
||||||
|
TRANSLATED_SECTIONS = ("cards", "company", "navigation", "platform")
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
|
FLASK_ENV = os.getenv("FLASK_ENV", "production")
|
||||||
|
FLASK_HOST = os.getenv("FLASK_HOST", "127.0.0.1")
|
||||||
|
FLASK_PORT = int(os.getenv("FLASK_PORT", os.getenv("PORT", 5000)))
|
||||||
|
print(f"Starting app on {FLASK_HOST}:{FLASK_PORT}, FLASK_ENV={FLASK_ENV}")
|
||||||
|
|
||||||
# Initialize the CacheManager
|
# Initialize the CacheManager
|
||||||
cache_manager = CacheManager()
|
cache_manager = CacheManager()
|
||||||
@@ -11,36 +36,78 @@ cache_manager = CacheManager()
|
|||||||
# Clear cache on startup
|
# Clear cache on startup
|
||||||
cache_manager.clear_cache()
|
cache_manager.clear_cache()
|
||||||
|
|
||||||
|
|
||||||
def load_config(app):
|
def load_config(app):
|
||||||
"""Load and resolve the configuration from config.yaml."""
|
"""Load and resolve the configuration from config.yaml."""
|
||||||
with open("config.yaml", "r") as f:
|
with open("config.yaml", "r", encoding="utf-8") as handle:
|
||||||
config = yaml.safe_load(f)
|
config = yaml.safe_load(handle)
|
||||||
|
|
||||||
|
if config.get("nasa_api_key"):
|
||||||
|
app.config["NASA_API_KEY"] = config["nasa_api_key"]
|
||||||
|
|
||||||
resolver = ConfigurationResolver(config)
|
resolver = ConfigurationResolver(config)
|
||||||
resolver.resolve_links()
|
resolver.resolve_links()
|
||||||
app.config.update(resolver.get_config())
|
app.config.update(resolver.get_config())
|
||||||
|
app.config["TRANSLATED_CONFIG"] = {}
|
||||||
|
i18n.clear_catalogs()
|
||||||
|
|
||||||
|
|
||||||
def cache_icons_and_logos(app):
|
def cache_icons_and_logos(app):
|
||||||
"""Cache all icons and logos to local files."""
|
"""Resolve every icon/logo/favicon to either a local cache path or
|
||||||
|
an external URL (see ``resolve_asset_cache``)."""
|
||||||
for card in app.config["cards"]:
|
for card in app.config["cards"]:
|
||||||
icon = card.get("icon", {})
|
icon = card.get("icon")
|
||||||
if icon.get("source"):
|
if icon:
|
||||||
icon["cache"] = cache_manager.cache_file(icon["source"])
|
resolve_asset_cache(icon, cache_manager)
|
||||||
|
|
||||||
app.config["company"]["logo"]["cache"] = cache_manager.cache_file(app.config["company"]["logo"]["source"])
|
resolve_asset_cache(app.config["company"]["logo"], cache_manager)
|
||||||
app.config["platform"]["favicon"]["cache"] = cache_manager.cache_file(app.config["platform"]["favicon"]["source"])
|
resolve_asset_cache(app.config["platform"]["favicon"], cache_manager)
|
||||||
app.config["platform"]["logo"]["cache"] = cache_manager.cache_file(app.config["platform"]["logo"]["source"])
|
resolve_asset_cache(app.config["platform"]["logo"], cache_manager)
|
||||||
|
|
||||||
# Get the environment variable FLASK_ENV or set a default value
|
|
||||||
FLASK_ENV = os.getenv("FLASK_ENV", "production")
|
|
||||||
|
|
||||||
# Initialize Flask app
|
# Initialize Flask app
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
app.jinja_options = {**app.jinja_options, "autoescape": True}
|
||||||
|
|
||||||
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=0, x_proto=1)
|
||||||
|
|
||||||
|
|
||||||
|
def trusted_hosts(raw):
|
||||||
|
"""Parse a comma-separated host list, or None when nothing is configured.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw: the ``TRUSTED_HOSTS`` value, possibly empty.
|
||||||
|
"""
|
||||||
|
hosts = [host.strip() for host in raw.split(",") if host.strip()]
|
||||||
|
return hosts or None
|
||||||
|
|
||||||
|
|
||||||
|
app.config["TRUSTED_HOSTS"] = trusted_hosts(os.getenv("TRUSTED_HOSTS", ""))
|
||||||
|
|
||||||
# Load configuration and cache assets on startup
|
# Load configuration and cache assets on startup
|
||||||
load_config(app)
|
load_config(app)
|
||||||
cache_icons_and_logos(app)
|
cache_icons_and_logos(app)
|
||||||
|
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def utility_processor():
|
||||||
|
def include_svg(path):
|
||||||
|
full_path = os.path.join(current_app.root_path, "static", path)
|
||||||
|
try:
|
||||||
|
with open(full_path, "r", encoding="utf-8") as handle:
|
||||||
|
svg = handle.read()
|
||||||
|
# Trusted local SVG asset shipped with the application package.
|
||||||
|
return Markup(svg) # nosec B704
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def template_asset_src(asset):
|
||||||
|
return asset_src(asset, lambda filename: url_for("static", filename=filename))
|
||||||
|
|
||||||
|
return dict(include_svg=include_svg, asset_src=template_asset_src)
|
||||||
|
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def reload_config_in_dev():
|
def reload_config_in_dev():
|
||||||
"""Reload config and recache icons before each request in development mode."""
|
"""Reload config and recache icons before each request in development mode."""
|
||||||
@@ -48,20 +115,84 @@ def reload_config_in_dev():
|
|||||||
load_config(app)
|
load_config(app)
|
||||||
cache_icons_and_logos(app)
|
cache_icons_and_logos(app)
|
||||||
|
|
||||||
@app.route('/')
|
|
||||||
def index():
|
def translated_config(lang):
|
||||||
"""Render the main index page."""
|
"""Return the configuration sections translated into ``lang``, memoized.
|
||||||
cards = app.config["cards"]
|
|
||||||
|
The memo is dropped by ``load_config``, so a development reload picks up
|
||||||
|
edited content on the next request.
|
||||||
|
"""
|
||||||
|
memo = app.config["TRANSLATED_CONFIG"]
|
||||||
|
if lang not in memo:
|
||||||
|
source = {section: app.config[section] for section in TRANSLATED_SECTIONS}
|
||||||
|
memo[lang] = i18n.translate_tree(source, lang)
|
||||||
|
return memo[lang]
|
||||||
|
|
||||||
|
|
||||||
|
def apod_background():
|
||||||
|
"""Return today's NASA APOD image URL, or None when unavailable."""
|
||||||
|
api_key = app.config.get("NASA_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
"https://api.nasa.gov/planetary/apod",
|
||||||
|
params={"api_key": api_key},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except requests.RequestException:
|
||||||
|
logging.warning("APOD lookup failed", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not resp.ok:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
return data.get("url") if data.get("media_type") == "image" else None
|
||||||
|
|
||||||
|
|
||||||
|
def render_index(lang):
|
||||||
|
"""Render the index page in ``lang``."""
|
||||||
|
config = translated_config(lang)
|
||||||
|
cards = config["cards"]
|
||||||
lg_classes, md_classes = compute_card_classes(cards)
|
lg_classes, md_classes = compute_card_classes(cards)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"pages/index.html.j2",
|
"pages/index.html.j2",
|
||||||
cards=cards,
|
cards=cards,
|
||||||
company=app.config["company"],
|
company=config["company"],
|
||||||
navigation=app.config["navigation"],
|
navigation=config["navigation"],
|
||||||
platform=app.config["platform"],
|
platform=config["platform"],
|
||||||
lg_classes=lg_classes,
|
lg_classes=lg_classes,
|
||||||
md_classes=md_classes
|
md_classes=md_classes,
|
||||||
|
apod_bg=apod_background(),
|
||||||
|
lang=lang,
|
||||||
|
lang_dir=i18n.direction(lang),
|
||||||
|
languages=i18n.LANGUAGES,
|
||||||
|
ui_strings=i18n.ui_strings(lang),
|
||||||
|
t=lambda source: i18n.catalog(lang).get(source, source),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
"""Render the index page in the language the browser asks for."""
|
||||||
|
response = make_response(render_index(i18n.negotiate(request.accept_languages)))
|
||||||
|
response.headers["Vary"] = "Accept-Language"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.route(f"/<any({','.join(i18n.LANGUAGES)}):lang>/")
|
||||||
|
def localized_index(lang):
|
||||||
|
"""Render the index page in an explicitly requested language."""
|
||||||
|
return render_index(lang)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(debug=(FLASK_ENV == "development"), host="0.0.0.0", port=5000)
|
app.run(
|
||||||
|
debug=(FLASK_ENV == "development"),
|
||||||
|
host=FLASK_HOST,
|
||||||
|
port=FLASK_PORT,
|
||||||
|
use_reloader=False,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
accounts:
|
accounts:
|
||||||
|
nasa_api_key: YOUR_REAL_KEY_HERE
|
||||||
name: Online Presence
|
name: Online Presence
|
||||||
description: Discover my online presence.
|
description: Discover my online presence.
|
||||||
icon:
|
icon:
|
||||||
@@ -197,6 +198,15 @@ accounts:
|
|||||||
url: https://s.veen.world/cloud
|
url: https://s.veen.world/cloud
|
||||||
|
|
||||||
cards:
|
cards:
|
||||||
|
- icon:
|
||||||
|
source: https://file.infinito.nexus/assets/img/logo.png
|
||||||
|
title: Infinito.Nexus
|
||||||
|
text: Open-source self-hosting stack — one-click deployable FOSS web apps with
|
||||||
|
shared identity, asset, and observability services. The platform that hosts
|
||||||
|
this site, including the dashboard you are looking at.
|
||||||
|
url: https://infinito.nexus
|
||||||
|
link_text: infinito.nexus
|
||||||
|
iframe: true
|
||||||
- icon:
|
- icon:
|
||||||
source: https://cloud.veen.world/s/logo_agile_coach_512x512/download
|
source: https://cloud.veen.world/s/logo_agile_coach_512x512/download
|
||||||
title: Agile Coach
|
title: Agile Coach
|
||||||
@@ -647,4 +657,36 @@ navigation:
|
|||||||
class: fa-solid fa-scale-balanced
|
class: fa-solid fa-scale-balanced
|
||||||
url: https://s.veen.world/imprint
|
url: https://s.veen.world/imprint
|
||||||
iframe: true
|
iframe: true
|
||||||
|
- name: Settings
|
||||||
|
description: Application settings
|
||||||
|
icon:
|
||||||
|
class: fa-solid fa-cog
|
||||||
|
children:
|
||||||
|
- name: Toggle Fullscreen
|
||||||
|
description: Enter or exit fullscreen mode
|
||||||
|
icon:
|
||||||
|
class: fa-solid fa-expand-arrows-alt
|
||||||
|
onclick: "toggleFullscreen()"
|
||||||
|
- name: Toggle Full Width
|
||||||
|
description: Switch between normal and full-width layout
|
||||||
|
icon:
|
||||||
|
class: fa-solid fa-arrows-left-right
|
||||||
|
onclick: "setFullWidth(!initFullWidthFromUrl())"
|
||||||
|
- name: Open in new tab
|
||||||
|
description: Open the currently embedded iframe URL in a fresh browser tab
|
||||||
|
icon:
|
||||||
|
class: fa-solid fa-up-right-from-square
|
||||||
|
onclick: openIframeInNewTab()
|
||||||
|
- name: Print
|
||||||
|
description: Print the current view
|
||||||
|
icon:
|
||||||
|
class: fa-solid fa-print
|
||||||
|
onclick: window.print()
|
||||||
|
- name: Zoom +
|
||||||
|
icon:
|
||||||
|
class: fa-solid fa-search-plus
|
||||||
|
onclick: zoomPage(1.1)
|
||||||
|
- name: Zoom –
|
||||||
|
icon:
|
||||||
|
class: fa-solid fa-search-minus
|
||||||
|
onclick: zoomPage(0.9)
|
||||||
|
|||||||
19
app/cypress.config.js
Normal file
19
app/cypress.config.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// cypress.config.js
|
||||||
|
const { defineConfig } = require('cypress');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
e2e: {
|
||||||
|
// your app under test must already be running on this port
|
||||||
|
baseUrl: `http://localhost:${process.env.PORT || 5001}`,
|
||||||
|
defaultCommandTimeout: 60000,
|
||||||
|
pageLoadTimeout: 60000,
|
||||||
|
requestTimeout: 1500,
|
||||||
|
responseTimeout: 15000,
|
||||||
|
specPattern: 'cypress/e2e/**/*.spec.js',
|
||||||
|
supportFile: false,
|
||||||
|
setupNodeEvents(on, config) {
|
||||||
|
// here you could hook into events, but we don’t need anything special
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
90
app/cypress/e2e/container.spec.js
Normal file
90
app/cypress/e2e/container.spec.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// cypress/e2e/container.spec.js
|
||||||
|
|
||||||
|
describe('Custom Scroll & Container Resizing', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Assumes your app is running at baseUrl, and container.js is loaded on “/”
|
||||||
|
cy.visit('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on load, the scroll-container gets a positive height and proper overflow', () => {
|
||||||
|
// wait for our JS to run
|
||||||
|
cy.window().should('have.property', 'adjustScrollContainerHeight');
|
||||||
|
|
||||||
|
// Grab the inline style of .scroll-container
|
||||||
|
cy.get('.scroll-container')
|
||||||
|
.should('have.attr', 'style')
|
||||||
|
.then(style => {
|
||||||
|
// height:<number>px must be present
|
||||||
|
const m = style.match(/height:\s*(\d+(?:\.\d+)?)px/);
|
||||||
|
expect(m, 'height set').to.not.be.null;
|
||||||
|
expect(parseFloat(m[1]), 'height > 0').to.be.greaterThan(0);
|
||||||
|
|
||||||
|
// overflow shorthand should include both hidden & auto (order-insensitive)
|
||||||
|
expect(style).to.include('overflow:');
|
||||||
|
expect(style).to.match(/overflow:\s*(hidden\s+auto|auto\s+hidden)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on window resize, scroll-container height updates', () => {
|
||||||
|
// record original height
|
||||||
|
cy.get('.scroll-container')
|
||||||
|
.invoke('css', 'height')
|
||||||
|
.then(orig => {
|
||||||
|
// resize to a smaller viewport
|
||||||
|
cy.viewport(320, 480);
|
||||||
|
cy.wait(100); // allow resize handler to fire
|
||||||
|
|
||||||
|
cy.get('.scroll-container')
|
||||||
|
.invoke('css', 'height')
|
||||||
|
.then(newH => {
|
||||||
|
expect(parseFloat(newH), 'height changed on resize').to.not.equal(parseFloat(orig));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
context('custom scrollbar thumb', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// inject tall content to force scrolling
|
||||||
|
cy.get('.scroll-container').then($sc => {
|
||||||
|
$sc[0].innerHTML = '<div style="height:2000px">long</div>';
|
||||||
|
});
|
||||||
|
// re-run scrollbar setup
|
||||||
|
cy.window().invoke('updateCustomScrollbar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a thumb with reasonable size & position', () => {
|
||||||
|
cy.get('#custom-scrollbar').should('have.css', 'opacity', '1');
|
||||||
|
|
||||||
|
cy.get('#scroll-thumb')
|
||||||
|
.should('have.css', 'height')
|
||||||
|
.then(h => {
|
||||||
|
const hh = parseFloat(h);
|
||||||
|
expect(hh).to.be.at.least(20);
|
||||||
|
// ensure thumb is smaller than container
|
||||||
|
cy.get('#custom-scrollbar')
|
||||||
|
.invoke('css', 'height')
|
||||||
|
.then(ch => {
|
||||||
|
expect(hh).to.be.lessThan(parseFloat(ch));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// scroll a bit and verify thumb.top changes
|
||||||
|
cy.get('.scroll-container').scrollTo(0, 200);
|
||||||
|
cy.wait(50);
|
||||||
|
cy.get('#scroll-thumb')
|
||||||
|
.invoke('css', 'top')
|
||||||
|
.then(t => {
|
||||||
|
expect(parseFloat(t)).to.be.greaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides scrollbar when content fits', () => {
|
||||||
|
// remove overflow
|
||||||
|
cy.get('.scroll-container').then($sc => {
|
||||||
|
$sc[0].innerHTML = '<div style="height:10px">tiny</div>';
|
||||||
|
});
|
||||||
|
cy.window().invoke('updateCustomScrollbar');
|
||||||
|
cy.get('#custom-scrollbar').should('have.css', 'opacity', '0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
85
app/cypress/e2e/fullscreen.spec.js
Normal file
85
app/cypress/e2e/fullscreen.spec.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// cypress/e2e/fullscreen.spec.js
|
||||||
|
|
||||||
|
describe('Fullscreen Toggle', () => {
|
||||||
|
const ROOT = '/';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit(ROOT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to normal mode when no fullscreen param is present', () => {
|
||||||
|
// Body should not have fullscreen class
|
||||||
|
cy.get('body').should('not.have.class', 'fullscreen');
|
||||||
|
|
||||||
|
// URL should not include `fullscreen`
|
||||||
|
cy.url().should('not.include', 'fullscreen=');
|
||||||
|
|
||||||
|
// Header and footer should be visible (max-height > 0)
|
||||||
|
cy.get('header').should('have.css', 'max-height').and(value => {
|
||||||
|
expect(parseFloat(value)).to.be.greaterThan(0);
|
||||||
|
});
|
||||||
|
cy.get('footer').should('have.css', 'max-height').and(value => {
|
||||||
|
expect(parseFloat(value)).to.be.greaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initFullscreenFromUrl() picks up ?fullscreen=1 on load', () => {
|
||||||
|
cy.visit(`${ROOT}?fullscreen=1`);
|
||||||
|
|
||||||
|
cy.get('body').should('have.class', 'fullscreen');
|
||||||
|
cy.url().should('include', 'fullscreen=1');
|
||||||
|
|
||||||
|
// Header and footer should be collapsed (max-height == 0)
|
||||||
|
cy.get('header').should('have.css', 'max-height', '0px');
|
||||||
|
cy.get('footer').should('have.css', 'max-height', '0px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enterFullscreen() adds fullscreen class, sets full width, and updates URL', () => {
|
||||||
|
cy.window().then(win => {
|
||||||
|
win.exitFullscreen(); // ensure starting state
|
||||||
|
win.enterFullscreen();
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get('body').should('have.class', 'fullscreen');
|
||||||
|
cy.url().should('include', 'fullscreen=1');
|
||||||
|
cy.get('.container, .container-fluid')
|
||||||
|
.should('have.class', 'container-fluid');
|
||||||
|
|
||||||
|
cy.get('header').should('have.css', 'max-height', '0px');
|
||||||
|
cy.get('footer').should('have.css', 'max-height', '0px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exitFullscreen() removes fullscreen class, resets width, and URL param', () => {
|
||||||
|
// start in fullscreen
|
||||||
|
cy.window().invoke('enterFullscreen');
|
||||||
|
|
||||||
|
// then exit
|
||||||
|
cy.window().invoke('exitFullscreen');
|
||||||
|
|
||||||
|
cy.get('body').should('not.have.class', 'fullscreen');
|
||||||
|
cy.url().should('not.include', 'fullscreen=');
|
||||||
|
cy.get('.container, .container-fluid')
|
||||||
|
.should('have.class', 'container')
|
||||||
|
.and('not.have.class', 'container-fluid');
|
||||||
|
|
||||||
|
// Header and footer should be expanded again
|
||||||
|
cy.get('header').should('have.css', 'max-height').and(value => {
|
||||||
|
expect(parseFloat(value)).to.be.greaterThan(0);
|
||||||
|
});
|
||||||
|
cy.get('footer').should('have.css', 'max-height').and(value => {
|
||||||
|
expect(parseFloat(value)).to.be.greaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggleFullscreen() toggles into and out of fullscreen', () => {
|
||||||
|
// Toggle into fullscreen
|
||||||
|
cy.window().invoke('toggleFullscreen');
|
||||||
|
cy.get('body').should('have.class', 'fullscreen');
|
||||||
|
cy.url().should('include', 'fullscreen=1');
|
||||||
|
|
||||||
|
// Toggle back
|
||||||
|
cy.window().invoke('toggleFullscreen');
|
||||||
|
cy.get('body').should('not.have.class', 'fullscreen');
|
||||||
|
cy.url().should('not.include', 'fullscreen=');
|
||||||
|
});
|
||||||
|
});
|
||||||
61
app/cypress/e2e/fullwidth.spec.js
Normal file
61
app/cypress/e2e/fullwidth.spec.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// cypress/e2e/fullwidth.spec.js
|
||||||
|
|
||||||
|
describe('Full-width Toggle', () => {
|
||||||
|
// test page must include your <div class="container"> wrapper
|
||||||
|
const ROOT = '/';
|
||||||
|
|
||||||
|
it('defaults to .container when no param is present', () => {
|
||||||
|
cy.visit(ROOT);
|
||||||
|
cy.get('.container, .container-fluid')
|
||||||
|
.should('have.class', 'container')
|
||||||
|
.and('not.have.class', 'container-fluid');
|
||||||
|
|
||||||
|
// URL should not include `fullwidth`
|
||||||
|
cy.url().should('not.include', 'fullwidth=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initFullWidthFromUrl() picks up ?fullwidth=1 on load', () => {
|
||||||
|
cy.visit(`${ROOT}?fullwidth=1`);
|
||||||
|
cy.get('.container, .container-fluid')
|
||||||
|
.should('have.class', 'container-fluid')
|
||||||
|
.and('not.have.class', 'container');
|
||||||
|
cy.url().should('include', 'fullwidth=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setFullWidth(true) switches to container-fluid and updates URL', () => {
|
||||||
|
cy.visit(ROOT);
|
||||||
|
|
||||||
|
// call your global function
|
||||||
|
cy.window().invoke('setFullWidth', true);
|
||||||
|
|
||||||
|
cy.get('.container, .container-fluid')
|
||||||
|
.should('have.class', 'container-fluid')
|
||||||
|
.and('not.have.class', 'container');
|
||||||
|
|
||||||
|
cy.url().should('include', 'fullwidth=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setFullWidth(false) reverts to container and removes URL param', () => {
|
||||||
|
cy.visit(`${ROOT}?fullwidth=1`);
|
||||||
|
|
||||||
|
// now reset
|
||||||
|
cy.window().invoke('setFullWidth', false);
|
||||||
|
|
||||||
|
cy.get('.container, .container-fluid')
|
||||||
|
.should('have.class', 'container')
|
||||||
|
.and('not.have.class', 'container-fluid');
|
||||||
|
|
||||||
|
cy.url().should('not.include', 'fullwidth=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateUrlFullWidth() toggles the query param without changing layout', () => {
|
||||||
|
cy.visit(ROOT);
|
||||||
|
|
||||||
|
// manually toggle URL only
|
||||||
|
cy.window().invoke('updateUrlFullWidth', true);
|
||||||
|
cy.url().should('include', 'fullwidth=1');
|
||||||
|
|
||||||
|
cy.window().invoke('updateUrlFullWidth', false);
|
||||||
|
cy.url().should('not.include', 'fullwidth=');
|
||||||
|
});
|
||||||
|
});
|
||||||
243
app/cypress/e2e/i18n.spec.js
Normal file
243
app/cypress/e2e/i18n.spec.js
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
// cypress/e2e/i18n.spec.js
|
||||||
|
|
||||||
|
const GERMAN_BROWSER = { headers: { 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8' } };
|
||||||
|
|
||||||
|
describe('Language negotiation', () => {
|
||||||
|
it('serves English to an English browser', () => {
|
||||||
|
cy.visit('/', { headers: { 'Accept-Language': 'en-US,en;q=0.9' } });
|
||||||
|
cy.get('html').should('have.attr', 'lang', 'en');
|
||||||
|
cy.get('footer.footer a.iframe-link').should('contain.text', 'Imprint');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves German to a German browser', () => {
|
||||||
|
cy.visit('/', GERMAN_BROWSER);
|
||||||
|
cy.get('html').should('have.attr', 'lang', 'de');
|
||||||
|
cy.get('footer.footer a.iframe-link').should('contain.text', 'Impressum');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to English for an unsupported browser language', () => {
|
||||||
|
cy.visit('/', { headers: { 'Accept-Language': 'xx-XX' } });
|
||||||
|
cy.get('html').should('have.attr', 'lang', 'en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the URL prefix override the browser language', () => {
|
||||||
|
cy.visit('/fr/', GERMAN_BROWSER);
|
||||||
|
cy.get('html').should('have.attr', 'lang', 'fr');
|
||||||
|
cy.get('footer.footer a.iframe-link').should('contain.text', 'Mentions légales');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unsupported language code', () => {
|
||||||
|
cy.request({ url: '/xx/', failOnStatusCode: false })
|
||||||
|
.its('status')
|
||||||
|
.should('eq', 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not turn unrelated single-segment paths into redirects', () => {
|
||||||
|
['/robots.txt', '/favicon.ico', '/sitemap.xml'].forEach(path => {
|
||||||
|
cy.request({ url: path, failOnStatusCode: false, followRedirect: false })
|
||||||
|
.its('status')
|
||||||
|
.should('eq', 404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the negotiated route as varying by language', () => {
|
||||||
|
cy.request('/')
|
||||||
|
.its('headers.vary')
|
||||||
|
.should('contain', 'Accept-Language');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Language switcher', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.viewport(1280, 720);
|
||||||
|
cy.visit('/en/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the active language and offers every ISO 639-1 code', () => {
|
||||||
|
cy.get('#navbarDropdownLanguage')
|
||||||
|
.should('have.attr', 'data-bs-toggle', 'dropdown')
|
||||||
|
.and('contain.text', 'English');
|
||||||
|
|
||||||
|
cy.get('#navbarDropdownLanguage')
|
||||||
|
.parent('.nav-item')
|
||||||
|
.find('> .dropdown-menu a.dropdown-item')
|
||||||
|
.should('have.length', 184);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the active language', () => {
|
||||||
|
cy.get('#navbarDropdownLanguage').click();
|
||||||
|
cy.get('.dropdown-menu a.dropdown-item.active[hreflang="en"]').should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to the chosen language', () => {
|
||||||
|
cy.get('#navbarDropdownLanguage').click();
|
||||||
|
cy.get('.dropdown-menu a.dropdown-item[hreflang="de"]')
|
||||||
|
.should('have.text', 'Deutsch')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
cy.url().should('match', /\/de\/$/);
|
||||||
|
cy.get('html').should('have.attr', 'lang', 'de');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Translated interface strings', () => {
|
||||||
|
it('translates the strings rendered by the templates', () => {
|
||||||
|
cy.visit('/de/');
|
||||||
|
cy.get('#dynamicCopyButton').should('have.text', 'Kopieren');
|
||||||
|
cy.get('#dynamicChildrenSection h6').should('have.text', 'Optionen:');
|
||||||
|
cy.get('#dynamicAlternativesSection h6').should('have.text', 'Alternativen:');
|
||||||
|
cy.get('.modal-footer button').should('have.text', 'Schließen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the catalogue to client-side code', () => {
|
||||||
|
cy.visit('/de/');
|
||||||
|
cy.window().its('I18N').should('deep.include', {
|
||||||
|
Open: 'Öffnen',
|
||||||
|
'Open Link': 'Link öffnen',
|
||||||
|
'Identifier copied to clipboard!': 'Kennung in die Zwischenablage kopiert!',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the catalogue untranslated in the source language', () => {
|
||||||
|
cy.visit('/en/');
|
||||||
|
cy.window().its('I18N').should('deep.include', { Open: 'Open' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Strings translated by modal.js', () => {
|
||||||
|
const item = {
|
||||||
|
name: 'Test Item',
|
||||||
|
identifier: 'ABC123',
|
||||||
|
icon: { class: 'fa fa-test' },
|
||||||
|
alternatives: [
|
||||||
|
{ name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit('/de/');
|
||||||
|
cy.window().then(win => {
|
||||||
|
cy.stub(win.navigator.clipboard, 'writeText').resolves();
|
||||||
|
cy.stub(win, 'alert');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('translates the button of a list entry', () => {
|
||||||
|
cy.window().invoke('openDynamicPopup', item);
|
||||||
|
cy.get('#dynamicAlternativesList button').should('have.text', 'Öffnen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('translates the link label when the entry has no description', () => {
|
||||||
|
cy.window().invoke('openDynamicPopup', {
|
||||||
|
...item,
|
||||||
|
url: 'https://example.com',
|
||||||
|
description: null,
|
||||||
|
});
|
||||||
|
cy.get('#dynamicModalLinkHref').should('have.text', 'Link öffnen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('translates the clipboard confirmation', () => {
|
||||||
|
cy.window().invoke('openDynamicPopup', item);
|
||||||
|
cy.get('#dynamicCopyButton').click();
|
||||||
|
cy.window()
|
||||||
|
.its('alert')
|
||||||
|
.should('have.been.calledWith', 'Kennung in die Zwischenablage kopiert!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Right-to-left languages', () => {
|
||||||
|
it('flips the document and loads the RTL stylesheet', () => {
|
||||||
|
cy.visit('/ar/');
|
||||||
|
cy.get('html').should('have.attr', 'dir', 'rtl');
|
||||||
|
cy.get('link[href*="bootstrap.rtl.min.css"]').should('exist');
|
||||||
|
cy.get('link[href*="vendor/bootstrap/css/bootstrap.min.css"]').should('not.exist');
|
||||||
|
cy.get('body').should('have.css', 'direction', 'rtl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps left-to-right languages on the default stylesheet', () => {
|
||||||
|
cy.visit('/en/');
|
||||||
|
cy.get('html').should('have.attr', 'dir', 'ltr');
|
||||||
|
cy.get('link[href*="bootstrap.rtl.min.css"]').should('not.exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('actually serves the RTL stylesheet it links to', () => {
|
||||||
|
cy.visit('/ar/');
|
||||||
|
cy.get('link[href*="bootstrap.rtl.min.css"]')
|
||||||
|
.should('have.attr', 'href')
|
||||||
|
.then(href => {
|
||||||
|
cy.request(href).its('status').should('eq', 200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Translated interface details', () => {
|
||||||
|
it('translates the strings only a screen reader sees', () => {
|
||||||
|
cy.visit('/de/');
|
||||||
|
|
||||||
|
cy.get('#dynamicModal .btn-close').should('have.attr', 'aria-label', 'Schließen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('translates the alert headings', () => {
|
||||||
|
cy.visit('/fr/');
|
||||||
|
|
||||||
|
cy.get('#dynamicModalWarning h5').should('contain.text', 'Avertissement');
|
||||||
|
cy.get('#dynamicModalInfo h5').should('contain.text', 'Informations');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('translates the language switcher tooltip', () => {
|
||||||
|
cy.visit('/de/');
|
||||||
|
|
||||||
|
cy.get('#navbarDropdownLanguage').should('have.attr', 'title', 'Sprache');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags each switcher entry with its own language', () => {
|
||||||
|
cy.visit('/en/');
|
||||||
|
|
||||||
|
cy.get('.dropdown-menu a.dropdown-item[hreflang="ja"]').should(
|
||||||
|
'have.attr',
|
||||||
|
'lang',
|
||||||
|
'ja',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrolls inside the language menu instead of past the page', () => {
|
||||||
|
cy.viewport(1280, 720);
|
||||||
|
cy.visit('/en/');
|
||||||
|
cy.get('#navbarDropdownLanguage').click();
|
||||||
|
|
||||||
|
cy.get('.dropdown-menu.language-menu').should($menu => {
|
||||||
|
const menu = $menu[0];
|
||||||
|
expect(menu.scrollHeight, 'taller than it shows').to.be.greaterThan(
|
||||||
|
menu.clientHeight,
|
||||||
|
);
|
||||||
|
expect(menu.getBoundingClientRect().height).to.be.lessThan(720);
|
||||||
|
expect(getComputedStyle(menu).overflowY).to.eq('auto');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers the switcher in the header only', () => {
|
||||||
|
cy.viewport(1280, 720);
|
||||||
|
cy.visit('/en/');
|
||||||
|
|
||||||
|
cy.get('#navbarNavheader #navbarDropdownLanguage').should('exist');
|
||||||
|
cy.get('#navbarNavfooter #navbarDropdownLanguage').should('not.exist');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Search engine metadata', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit('/de/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declares an alternate for every language plus a default', () => {
|
||||||
|
cy.get('link[rel="alternate"][hreflang]').should('have.length', 185);
|
||||||
|
cy.get('link[rel="alternate"][hreflang="x-default"]').should('exist');
|
||||||
|
cy.get('link[rel="alternate"][hreflang="ja"]')
|
||||||
|
.should('have.attr', 'href')
|
||||||
|
.and('match', /\/ja\/$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('points the canonical URL at the language actually served', () => {
|
||||||
|
cy.get('link[rel="canonical"]').should('have.attr', 'href').and('match', /\/de\/$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
app/cypress/e2e/iframe.spec.js
Normal file
46
app/cypress/e2e/iframe.spec.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// cypress/e2e/iframe.spec.js
|
||||||
|
|
||||||
|
describe('Iframe integration', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Visit the app’s base URL (configured in cypress.config.js)
|
||||||
|
cy.visit('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the iframe when an .iframe-link is clicked', () => {
|
||||||
|
// Find the first iframe-link on the page
|
||||||
|
cy.get('.iframe-link').first().then($link => {
|
||||||
|
const href = $link.prop('href');
|
||||||
|
|
||||||
|
// Click it
|
||||||
|
cy.wrap($link).click();
|
||||||
|
|
||||||
|
// The URL should now include ?iframe=<encoded href>
|
||||||
|
cy.url().should('include', 'iframe=' + encodeURIComponent(href));
|
||||||
|
|
||||||
|
// The <body> should have the "fullscreen" class
|
||||||
|
cy.get('body').should('have.class', 'fullscreen');
|
||||||
|
|
||||||
|
// And the <main> should contain a visible <iframe src="<href>">
|
||||||
|
cy.get('main iframe')
|
||||||
|
.should('have.attr', 'src', href)
|
||||||
|
.and('be.visible');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the original content when a .js-restore element is clicked', () => {
|
||||||
|
// First open the iframe
|
||||||
|
cy.get('.iframe-link').first().click();
|
||||||
|
|
||||||
|
// Then click the first .js-restore element (e.g. header or logo)
|
||||||
|
cy.get('.js-restore').first().click();
|
||||||
|
|
||||||
|
// The URL must no longer include the iframe parameter
|
||||||
|
cy.url().should('not.include', 'iframe=');
|
||||||
|
|
||||||
|
// The <body> should no longer have the "fullscreen" class
|
||||||
|
cy.get('body').should('not.have.class', 'fullscreen');
|
||||||
|
|
||||||
|
// And no <iframe> should remain inside <main>
|
||||||
|
cy.get('main iframe').should('not.exist');
|
||||||
|
});
|
||||||
|
});
|
||||||
263
app/cypress/e2e/injection.spec.js
Normal file
263
app/cypress/e2e/injection.spec.js
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
// cypress/e2e/injection.spec.js
|
||||||
|
|
||||||
|
describe('Untrusted content in the modal', () => {
|
||||||
|
const base = {
|
||||||
|
name: 'Test Item',
|
||||||
|
identifier: 'ABC123',
|
||||||
|
icon: { class: 'fa fa-test' },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit('/');
|
||||||
|
cy.window().then(win => {
|
||||||
|
cy.stub(win.navigator.clipboard, 'writeText').resolves();
|
||||||
|
cy.stub(win, 'alert');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function open(item = {}) {
|
||||||
|
cy.window().invoke('openDynamicPopup', { ...base, ...item });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('markdown rendered into innerHTML', () => {
|
||||||
|
it('strips a plain script URL', () => {
|
||||||
|
open({
|
||||||
|
warning: '[click me](javascript:window.__xss = true)',
|
||||||
|
info: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get('#dynamicModalWarningText').find('a').should('not.exist');
|
||||||
|
cy.get('#dynamicModalWarningText').should('contain.text', 'click me');
|
||||||
|
cy.get('#dynamicModalInfoText').find('img').should('not.exist');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips a script URL hidden behind character references', () => {
|
||||||
|
open({
|
||||||
|
warning:
|
||||||
|
'[a](javascript:window.__xss=1) [b](javascript:window.__xss=1)',
|
||||||
|
info: '[c](java	script:window.__xss=1) [d](java
script:window.__xss=1)',
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get('#dynamicModalWarningText').find('a').should('not.exist');
|
||||||
|
cy.get('#dynamicModalInfoText').find('a').should('not.exist');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips a script URL written as a reference-style link', () => {
|
||||||
|
open({
|
||||||
|
warning: '[click me][ref]\n\n[ref]: javascript:window.__xss=1',
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get('#dynamicModalWarningText').find('a').should('not.exist');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('neutralises raw HTML', () => {
|
||||||
|
open({
|
||||||
|
warning: '<img src=x onerror="window.__xss = true">',
|
||||||
|
info: '<a href="javascript:window.__xss = true">x</a>',
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get('#dynamicModalWarningText').find('img').should('not.exist');
|
||||||
|
cy.get('#dynamicModalWarningText').should('contain.text', 'onerror');
|
||||||
|
cy.get('#dynamicModalInfoText').find('a').should('not.exist');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('neutralises raw HTML used as the text of a stripped link', () => {
|
||||||
|
open({ warning: '[<img src=x onerror="window.__xss = true">](javascript:bad)' });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalWarningText').find('img').should('not.exist');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a relative link', () => {
|
||||||
|
open({ warning: 'See [the notes](/#anchor)' });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalWarningText')
|
||||||
|
.find('a')
|
||||||
|
.should('have.attr', 'href', '/#anchor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the text of a link it strips', () => {
|
||||||
|
open({ warning: '[read this](javascript:window.__xss=1)' });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalWarningText').should('contain.text', 'read this');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps ordinary markdown', () => {
|
||||||
|
open({ warning: 'See [Matrix](https://matrix.org/) and **mind** this' });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalWarningText')
|
||||||
|
.find('a')
|
||||||
|
.should('have.attr', 'href', 'https://matrix.org/');
|
||||||
|
cy.get('#dynamicModalWarningText').find('strong').should('have.text', 'mind');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('values interpolated outside markdown', () => {
|
||||||
|
it('does not treat an interface string as markup', () => {
|
||||||
|
cy.window().then(win => {
|
||||||
|
win.I18N.Open = '<img src=x onerror="window.__xss = true">';
|
||||||
|
});
|
||||||
|
open({
|
||||||
|
alternatives: [
|
||||||
|
{ name: 'Alt', identifier: 'A', icon: { class: 'fa-alt' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get('#dynamicAlternativesList').find('img').should('not.exist');
|
||||||
|
cy.get('#dynamicAlternativesList').should('contain.text', 'onerror');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the English source when a string is missing', () => {
|
||||||
|
cy.window().then(win => {
|
||||||
|
delete win.I18N;
|
||||||
|
});
|
||||||
|
open({
|
||||||
|
alternatives: [
|
||||||
|
{ name: 'Alt', identifier: 'A', icon: { class: 'fa-alt' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get('#dynamicAlternativesList button').should('have.text', 'Open');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no placeholder for a missing name', () => {
|
||||||
|
open({ name: undefined });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalLabel').should('not.contain.text', 'undefined');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat the name or the icon class as markup', () => {
|
||||||
|
open({
|
||||||
|
name: '<img src=x onerror="window.__xss = true">',
|
||||||
|
icon: { class: 'fa" onmouseover="window.__xss = true' },
|
||||||
|
alternatives: [
|
||||||
|
{
|
||||||
|
name: '<img src=y onerror="window.__xss = true">',
|
||||||
|
identifier: 'ALT1',
|
||||||
|
icon: { class: 'fa-alt' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get('#dynamicModalLabel').find('img').should('not.exist');
|
||||||
|
cy.get('#dynamicModalLabel').should('contain.text', 'onerror');
|
||||||
|
cy.get('#dynamicAlternativesList').find('img').should('not.exist');
|
||||||
|
cy.get('#dynamicAlternativesList').should('contain.text', 'onerror');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the link the modal offers', () => {
|
||||||
|
it('drops a URL that uses an unsafe scheme', () => {
|
||||||
|
open({ url: 'javascript:window.__xss = true', description: 'Bad' });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalLinkHref').should('not.have.attr', 'href');
|
||||||
|
cy.get('#dynamicModalLinkHref').should('have.text', 'Bad');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an ordinary URL', () => {
|
||||||
|
open({ url: 'https://example.com', description: 'Good' });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalLinkHref').should(
|
||||||
|
'have.attr',
|
||||||
|
'href',
|
||||||
|
'https://example.com',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a URL that carries surrounding whitespace', () => {
|
||||||
|
open({ url: ' https://example.com ', description: 'Good' });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalLinkHref').should('have.attr', 'href');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a mailto URL', () => {
|
||||||
|
open({ url: 'mailto:kevin@veen.world', description: 'Write' });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalLinkHref').should(
|
||||||
|
'have.attr',
|
||||||
|
'href',
|
||||||
|
'mailto:kevin@veen.world',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the link after a popup whose URL was dropped', () => {
|
||||||
|
open({ url: 'javascript:window.__xss = true', description: 'Bad' });
|
||||||
|
cy.get('#dynamicModalLinkHref').should('not.have.attr', 'href');
|
||||||
|
|
||||||
|
open({ url: 'https://example.com', description: 'Good' });
|
||||||
|
cy.get('#dynamicModalLinkHref').should(
|
||||||
|
'have.attr',
|
||||||
|
'href',
|
||||||
|
'https://example.com',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let one popup iframe handler outlive it', () => {
|
||||||
|
open({ url: 'https://a.test/', description: 'A', iframe: true });
|
||||||
|
cy.get('#dynamicModalLinkHref').should('have.class', 'iframe');
|
||||||
|
|
||||||
|
open({ url: 'https://b.test/', description: 'B' });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalLinkHref').should('not.have.class', 'iframe');
|
||||||
|
cy.get('#dynamicModalLinkHref').should($anchor => {
|
||||||
|
expect($anchor[0].onclick, 'stale click handler').to.equal(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the current popup URL, not an earlier one', () => {
|
||||||
|
open({ url: 'https://a.test/', description: 'A', iframe: true });
|
||||||
|
open({ url: 'https://b.test/', description: 'B', iframe: true });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalLinkHref').click();
|
||||||
|
|
||||||
|
cy.get('#main')
|
||||||
|
.find('iframe', { timeout: 4000 })
|
||||||
|
.should('have.attr', 'src', 'https://b.test/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Untrusted content reaching the iframe', () => {
|
||||||
|
const AFTER_THE_FADE = 3000;
|
||||||
|
|
||||||
|
it('refuses to open a script URL handed over by the modal', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
cy.window().invoke('openDynamicPopup', {
|
||||||
|
name: 'Bad',
|
||||||
|
icon: { class: 'fa fa-test' },
|
||||||
|
url: 'javascript:window.__xss = true',
|
||||||
|
description: 'Watch',
|
||||||
|
iframe: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get('#dynamicModalLinkHref').click({ force: true });
|
||||||
|
|
||||||
|
cy.wait(AFTER_THE_FADE);
|
||||||
|
cy.get('#main').find('iframe').should('not.exist');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a script URL supplied through the query string', () => {
|
||||||
|
cy.visit('/?iframe=javascript:window.__xss%20%3D%20true');
|
||||||
|
|
||||||
|
cy.wait(AFTER_THE_FADE);
|
||||||
|
cy.get('#main').find('iframe').should('not.exist');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still opens an ordinary URL from the query string', () => {
|
||||||
|
cy.visit('/?iframe=https://example.com/');
|
||||||
|
|
||||||
|
cy.get('#main')
|
||||||
|
.find('iframe', { timeout: AFTER_THE_FADE })
|
||||||
|
.should('have.attr', 'src', 'https://example.com/');
|
||||||
|
});
|
||||||
|
});
|
||||||
196
app/cypress/e2e/menu.spec.js
Normal file
196
app/cypress/e2e/menu.spec.js
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
// cypress/e2e/menu.spec.js
|
||||||
|
|
||||||
|
describe('Navigation dropdowns', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.viewport(1280, 720);
|
||||||
|
cy.visit('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens top-level dropdowns with explicit Bootstrap instances', () => {
|
||||||
|
cy.get('#navbarNavheader .nav-item.dropdown > .nav-link.dropdown-toggle')
|
||||||
|
.first()
|
||||||
|
.as('toggle')
|
||||||
|
.should('have.attr', 'data-bs-toggle', 'dropdown')
|
||||||
|
.and('have.attr', 'aria-expanded', 'false');
|
||||||
|
|
||||||
|
cy.get('@toggle').then($toggle => {
|
||||||
|
cy.window().then(win => {
|
||||||
|
expect(win.bootstrap.Dropdown.getInstance($toggle[0])).to.exist;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get('@toggle')
|
||||||
|
.parent('.nav-item')
|
||||||
|
.find('> .dropdown-menu')
|
||||||
|
.as('menu')
|
||||||
|
.should('not.have.class', 'show')
|
||||||
|
.should('not.be.visible');
|
||||||
|
|
||||||
|
cy.get('@toggle').click();
|
||||||
|
cy.get('@toggle').should('have.attr', 'aria-expanded', 'true');
|
||||||
|
cy.get('@toggle').parent('.nav-item').should('have.class', 'dropdown');
|
||||||
|
cy.get('@menu')
|
||||||
|
.should('have.class', 'show')
|
||||||
|
.and('be.visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flips footer dropdowns upward where there is more space above', () => {
|
||||||
|
cy.get('#navbarNavfooter .nav-item .nav-link.dropdown-toggle')
|
||||||
|
.first()
|
||||||
|
.as('toggle');
|
||||||
|
|
||||||
|
cy.get('@toggle')
|
||||||
|
.parent('.nav-item')
|
||||||
|
.as('item')
|
||||||
|
.find('> .dropdown-menu')
|
||||||
|
.as('menu')
|
||||||
|
.should('not.have.class', 'show');
|
||||||
|
|
||||||
|
// Make sure the footer sits at the bottom of the viewport before clicking
|
||||||
|
// — otherwise the toggle could land near the top and chooseDirection would
|
||||||
|
// keep .dropdown (more space below than above).
|
||||||
|
// ensureScrollable:false because on short pages the body isn't scrollable
|
||||||
|
// and the footer is already in view (which is fine for this test).
|
||||||
|
cy.scrollTo('bottom', { ensureScrollable: false });
|
||||||
|
cy.get('@toggle').then($toggle => {
|
||||||
|
const rect = $toggle[0].getBoundingClientRect();
|
||||||
|
expect(rect.top, 'toggle is in the lower half of the viewport')
|
||||||
|
.to.be.greaterThan(Cypress.config('viewportHeight') / 2);
|
||||||
|
});
|
||||||
|
cy.get('@toggle').click({ scrollBehavior: false });
|
||||||
|
cy.get('@item').should('have.class', 'dropup');
|
||||||
|
cy.get('@item').should('not.have.class', 'dropdown');
|
||||||
|
cy.get('@menu')
|
||||||
|
.should('have.class', 'show')
|
||||||
|
.and('be.visible');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dynamic Popup', () => {
|
||||||
|
const base = {
|
||||||
|
name: 'Test Item',
|
||||||
|
identifier: 'ABC123',
|
||||||
|
description: 'A simple description',
|
||||||
|
warning: '**Be careful!**',
|
||||||
|
info: '_Some info_',
|
||||||
|
url: null,
|
||||||
|
iframe: false,
|
||||||
|
icon: { class: 'fa fa-test' },
|
||||||
|
alternatives: [
|
||||||
|
{ name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } }
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
{ name: 'Child One', identifier: 'CH1', icon: { class: 'fa fa-child1' } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit('/');
|
||||||
|
cy.window().then(win => {
|
||||||
|
cy.stub(win.navigator.clipboard, 'writeText').resolves();
|
||||||
|
cy.stub(win, 'alert');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function open(item = {}) {
|
||||||
|
cy.window().invoke('openDynamicPopup', { ...base, ...item });
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders title with icon and text', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.find('i.fa.fa-test')
|
||||||
|
.should('exist');
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.should('contain.text', 'Test Item');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to plain text when no icon', () => {
|
||||||
|
open({ icon: null });
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.find('i')
|
||||||
|
.should('not.exist');
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.should('have.text', 'Test Item');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows identifier when provided and populates input', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicIdentifierBox').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalContent').should('have.value', 'ABC123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides identifier box when none', () => {
|
||||||
|
open({ identifier: null });
|
||||||
|
cy.get('#dynamicIdentifierBox').should('have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalContent').should('have.value', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders warning and info via marked', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicModalWarning')
|
||||||
|
.should('not.have.class', 'd-none')
|
||||||
|
.find('#dynamicModalWarningText')
|
||||||
|
.should('contain.html', '<strong>Be careful!</strong>');
|
||||||
|
cy.get('#dynamicModalInfo')
|
||||||
|
.should('not.have.class', 'd-none')
|
||||||
|
.find('#dynamicModalInfoText')
|
||||||
|
.should('contain.html', '<em>Some info</em>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides warning/info when none provided', () => {
|
||||||
|
open({ warning: null, info: null });
|
||||||
|
cy.get('#dynamicModalWarning').should('have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalInfo').should('have.class', 'd-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows description when no URL', () => {
|
||||||
|
open({ url: null, description: 'Only desc' });
|
||||||
|
cy.get('#dynamicDescriptionText')
|
||||||
|
.should('not.have.class', 'd-none')
|
||||||
|
.and('have.text', 'Only desc');
|
||||||
|
cy.get('#dynamicModalLink').should('have.class', 'd-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows link when URL is provided', () => {
|
||||||
|
open({ url: 'https://example.com', description: 'Click me' });
|
||||||
|
cy.get('#dynamicModalLink').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalLinkHref')
|
||||||
|
.should('have.attr', 'href', 'https://example.com')
|
||||||
|
.and('have.text', 'Click me');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('populates alternatives and children lists', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicAlternativesSection').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicAlternativesList li')
|
||||||
|
.should('have.length', 1)
|
||||||
|
.first().contains('Alt One');
|
||||||
|
cy.get('#dynamicChildrenSection').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicChildrenList li')
|
||||||
|
.should('have.length', 1)
|
||||||
|
.first().contains('Child One');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides sections when no items', () => {
|
||||||
|
open({ alternatives: [], children: [] });
|
||||||
|
cy.get('#dynamicAlternativesSection').should('have.class', 'd-none');
|
||||||
|
cy.get('#dynamicChildrenSection').should('have.class', 'd-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking an “Open” in list re-opens popup with that item', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicAlternativesList button').click();
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.should('contain.text', 'Alt One');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copy button selects & copies identifier', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicCopyButton').click();
|
||||||
|
cy.window().its('navigator.clipboard.writeText')
|
||||||
|
.should('have.been.calledWith', 'ABC123');
|
||||||
|
cy.window().its('alert')
|
||||||
|
.should('have.been.calledWith', 'Identifier copied to clipboard!');
|
||||||
|
});
|
||||||
|
});
|
||||||
130
app/cypress/e2e/modal.spec.js
Normal file
130
app/cypress/e2e/modal.spec.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// cypress/e2e/dynamic_popup.spec.js
|
||||||
|
|
||||||
|
describe('Dynamic Popup', () => {
|
||||||
|
const base = {
|
||||||
|
name: 'Test Item',
|
||||||
|
identifier: 'ABC123',
|
||||||
|
description: 'A simple description',
|
||||||
|
warning: '**Be careful!**',
|
||||||
|
info: '_Some info_',
|
||||||
|
url: null,
|
||||||
|
iframe: false,
|
||||||
|
icon: { class: 'fa fa-test' },
|
||||||
|
alternatives: [
|
||||||
|
{ name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } }
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
{ name: 'Child One', identifier: 'CH1', icon: { class: 'fa fa-child1' } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit('/');
|
||||||
|
cy.window().then(win => {
|
||||||
|
cy.stub(win.navigator.clipboard, 'writeText').resolves();
|
||||||
|
cy.stub(win, 'alert');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function open(item = {}) {
|
||||||
|
cy.window().invoke('openDynamicPopup', { ...base, ...item });
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders title with icon and text', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.find('i.fa.fa-test')
|
||||||
|
.should('exist');
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.should('contain.text', 'Test Item');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to plain text when no icon', () => {
|
||||||
|
open({ icon: null });
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.find('i')
|
||||||
|
.should('not.exist');
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.should('have.text', 'Test Item');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows identifier when provided and populates input', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicIdentifierBox').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalContent').should('have.value', 'ABC123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides identifier box when none', () => {
|
||||||
|
open({ identifier: null });
|
||||||
|
cy.get('#dynamicIdentifierBox').should('have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalContent').should('have.value', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders warning and info via marked', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicModalWarning')
|
||||||
|
.should('not.have.class', 'd-none')
|
||||||
|
.find('#dynamicModalWarningText')
|
||||||
|
.should('contain.html', '<strong>Be careful!</strong>');
|
||||||
|
cy.get('#dynamicModalInfo')
|
||||||
|
.should('not.have.class', 'd-none')
|
||||||
|
.find('#dynamicModalInfoText')
|
||||||
|
.should('contain.html', '<em>Some info</em>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides warning/info when none provided', () => {
|
||||||
|
open({ warning: null, info: null });
|
||||||
|
cy.get('#dynamicModalWarning').should('have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalInfo').should('have.class', 'd-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows description when no URL', () => {
|
||||||
|
open({ url: null, description: 'Only desc' });
|
||||||
|
cy.get('#dynamicDescriptionText')
|
||||||
|
.should('not.have.class', 'd-none')
|
||||||
|
.and('have.text', 'Only desc');
|
||||||
|
cy.get('#dynamicModalLink').should('have.class', 'd-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows link when URL is provided', () => {
|
||||||
|
open({ url: 'https://example.com', description: 'Click me' });
|
||||||
|
cy.get('#dynamicModalLink').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalLinkHref')
|
||||||
|
.should('have.attr', 'href', 'https://example.com')
|
||||||
|
.and('have.text', 'Click me');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('populates alternatives and children lists', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicAlternativesSection').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicAlternativesList li')
|
||||||
|
.should('have.length', 1)
|
||||||
|
.first().contains('Alt One');
|
||||||
|
cy.get('#dynamicChildrenSection').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicChildrenList li')
|
||||||
|
.should('have.length', 1)
|
||||||
|
.first().contains('Child One');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides sections when no items', () => {
|
||||||
|
open({ alternatives: [], children: [] });
|
||||||
|
cy.get('#dynamicAlternativesSection').should('have.class', 'd-none');
|
||||||
|
cy.get('#dynamicChildrenSection').should('have.class', 'd-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking an “Open” in list re-opens popup with that item', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicAlternativesList button').click();
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.should('contain.text', 'Alt One');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copy button selects & copies identifier', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicCopyButton').click();
|
||||||
|
cy.window().its('navigator.clipboard.writeText')
|
||||||
|
.should('have.been.calledWith', 'ABC123');
|
||||||
|
cy.window().its('alert')
|
||||||
|
.should('have.been.calledWith', 'Identifier copied to clipboard!');
|
||||||
|
});
|
||||||
|
});
|
||||||
32
app/cypress/e2e/navbar_logo_visibility.spec.js
Normal file
32
app/cypress/e2e/navbar_logo_visibility.spec.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
describe('Navbar Logo Visibility', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have #navbar_logo present in the DOM', () => {
|
||||||
|
cy.get('#navbar_logo').should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be invisible (opacity 0) by default', () => {
|
||||||
|
cy.get('#navbar_logo')
|
||||||
|
.should('exist')
|
||||||
|
.and('have.css', 'opacity', '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should become visible (opacity 1) after entering fullscreen', () => {
|
||||||
|
cy.window().then(win => {
|
||||||
|
win.enterFullscreen();
|
||||||
|
});
|
||||||
|
cy.get('#navbar_logo', { timeout: 4000 })
|
||||||
|
.should('have.css', 'opacity', '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should become invisible again (opacity 0) after exiting fullscreen', () => {
|
||||||
|
cy.window().then(win => {
|
||||||
|
win.enterFullscreen();
|
||||||
|
win.exitFullscreen();
|
||||||
|
});
|
||||||
|
cy.get('#navbar_logo', { timeout: 4000 })
|
||||||
|
.should('have.css', 'opacity', '0');
|
||||||
|
});
|
||||||
|
});
|
||||||
130
app/cypress/e2e/tooltips.spec.js
Normal file
130
app/cypress/e2e/tooltips.spec.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// cypress/e2e/dynamic_popup.spec.js
|
||||||
|
|
||||||
|
describe('Dynamic Popup', () => {
|
||||||
|
const base = {
|
||||||
|
name: 'Test Item',
|
||||||
|
identifier: 'ABC123',
|
||||||
|
description: 'A simple description',
|
||||||
|
warning: '**Be careful!**',
|
||||||
|
info: '_Some info_',
|
||||||
|
url: null,
|
||||||
|
iframe: false,
|
||||||
|
icon: { class: 'fa fa-test' },
|
||||||
|
alternatives: [
|
||||||
|
{ name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } }
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
{ name: 'Child One', identifier: 'CH1', icon: { class: 'fa fa-child1' } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit('/');
|
||||||
|
cy.window().then(win => {
|
||||||
|
cy.stub(win.navigator.clipboard, 'writeText').resolves();
|
||||||
|
cy.stub(win, 'alert');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function open(item = {}) {
|
||||||
|
cy.window().invoke('openDynamicPopup', { ...base, ...item });
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders title with icon and text', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.find('i.fa.fa-test')
|
||||||
|
.should('exist');
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.should('contain.text', 'Test Item');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to plain text when no icon', () => {
|
||||||
|
open({ icon: null });
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.find('i')
|
||||||
|
.should('not.exist');
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.should('have.text', 'Test Item');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows identifier when provided and populates input', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicIdentifierBox').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalContent').should('have.value', 'ABC123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides identifier box when none', () => {
|
||||||
|
open({ identifier: null });
|
||||||
|
cy.get('#dynamicIdentifierBox').should('have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalContent').should('have.value', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders warning and info via marked', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicModalWarning')
|
||||||
|
.should('not.have.class', 'd-none')
|
||||||
|
.find('#dynamicModalWarningText')
|
||||||
|
.should('contain.html', '<strong>Be careful!</strong>');
|
||||||
|
cy.get('#dynamicModalInfo')
|
||||||
|
.should('not.have.class', 'd-none')
|
||||||
|
.find('#dynamicModalInfoText')
|
||||||
|
.should('contain.html', '<em>Some info</em>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides warning/info when none provided', () => {
|
||||||
|
open({ warning: null, info: null });
|
||||||
|
cy.get('#dynamicModalWarning').should('have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalInfo').should('have.class', 'd-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows description when no URL', () => {
|
||||||
|
open({ url: null, description: 'Only desc' });
|
||||||
|
cy.get('#dynamicDescriptionText')
|
||||||
|
.should('not.have.class', 'd-none')
|
||||||
|
.and('have.text', 'Only desc');
|
||||||
|
cy.get('#dynamicModalLink').should('have.class', 'd-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows link when URL is provided', () => {
|
||||||
|
open({ url: 'https://example.com', description: 'Click me' });
|
||||||
|
cy.get('#dynamicModalLink').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicModalLinkHref')
|
||||||
|
.should('have.attr', 'href', 'https://example.com')
|
||||||
|
.and('have.text', 'Click me');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('populates alternatives and children lists', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicAlternativesSection').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicAlternativesList li')
|
||||||
|
.should('have.length', 1)
|
||||||
|
.first().contains('Alt One');
|
||||||
|
cy.get('#dynamicChildrenSection').should('not.have.class', 'd-none');
|
||||||
|
cy.get('#dynamicChildrenList li')
|
||||||
|
.should('have.length', 1)
|
||||||
|
.first().contains('Child One');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides sections when no items', () => {
|
||||||
|
open({ alternatives: [], children: [] });
|
||||||
|
cy.get('#dynamicAlternativesSection').should('have.class', 'd-none');
|
||||||
|
cy.get('#dynamicChildrenSection').should('have.class', 'd-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking an “Open” in list re-opens popup with that item', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicAlternativesList button').click();
|
||||||
|
cy.get('#dynamicModalLabel')
|
||||||
|
.should('contain.text', 'Alt One');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copy button selects & copies identifier', () => {
|
||||||
|
open();
|
||||||
|
cy.get('#dynamicCopyButton').click();
|
||||||
|
cy.window().its('navigator.clipboard.writeText')
|
||||||
|
.should('have.been.calledWith', 'ABC123');
|
||||||
|
cy.window().its('alert')
|
||||||
|
.should('have.been.calledWith', 'Identifier copied to clipboard!');
|
||||||
|
});
|
||||||
|
});
|
||||||
78
app/eslint.config.js
Normal file
78
app/eslint.config.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Correctness only — eslint's recommended set, no stylistic rules.
|
||||||
|
*
|
||||||
|
* The browser scripts are plain <script> tags sharing one global scope: each
|
||||||
|
* file declares some functions and calls others declared elsewhere. That is why
|
||||||
|
* they are listed as globals and why no-redeclare is off — the declaring file
|
||||||
|
* would otherwise be reported for defining its own function.
|
||||||
|
*/
|
||||||
|
const js = require('@eslint/js');
|
||||||
|
const globals = require('globals');
|
||||||
|
|
||||||
|
// Vendored libraries plus the functions static/js files call across each other.
|
||||||
|
// Keep this to names that really cross a file boundary. Every superfluous
|
||||||
|
// entry is a permanent no-undef blind spot for a typo of that name.
|
||||||
|
const SHARED = {
|
||||||
|
bootstrap: 'readonly',
|
||||||
|
marked: 'readonly',
|
||||||
|
$: 'readonly',
|
||||||
|
jQuery: 'readonly',
|
||||||
|
openDynamicPopup: 'readonly',
|
||||||
|
closeAllModals: 'readonly',
|
||||||
|
isSafeUrl: 'readonly',
|
||||||
|
openIframe: 'readonly',
|
||||||
|
enterFullscreen: 'readonly',
|
||||||
|
exitFullscreen: 'readonly',
|
||||||
|
setFullWidth: 'readonly',
|
||||||
|
initFullWidthFromUrl: 'readonly',
|
||||||
|
adjustScrollContainerHeight: 'readonly',
|
||||||
|
updateCustomScrollbar: 'readonly',
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
{ ignores: ['node_modules/**', 'static/vendor/**', 'cypress/screenshots/**'] },
|
||||||
|
{
|
||||||
|
files: ['static/js/**/*.js'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'script',
|
||||||
|
globals: { ...globals.browser, ...SHARED },
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...js.configs.recommended.rules,
|
||||||
|
'no-redeclare': 'off',
|
||||||
|
// vars: 'local' — a top-level function here is the API other files and
|
||||||
|
// the templates call, so only unused locals are a defect.
|
||||||
|
'no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ vars: 'local', args: 'none', caughtErrors: 'none' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['cypress/**/*.js'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'script',
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.mocha,
|
||||||
|
cy: 'readonly',
|
||||||
|
Cypress: 'readonly',
|
||||||
|
expect: 'readonly',
|
||||||
|
assert: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: js.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['scripts/**/*.js', 'cypress.config.js', 'eslint.config.js'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
globals: globals.node,
|
||||||
|
},
|
||||||
|
rules: js.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
];
|
||||||
46
app/i18n/keep.txt
Normal file
46
app/i18n/keep.txt
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Strings utils/i18n_sync.py stores as themselves instead of translating.
|
||||||
|
# One per line; blank lines and lines starting with # are ignored.
|
||||||
|
|
||||||
|
Baserow
|
||||||
|
Big Blue Button
|
||||||
|
Bluesky
|
||||||
|
Buy me a Coffee
|
||||||
|
Discourse
|
||||||
|
Duolingo
|
||||||
|
Eversports
|
||||||
|
Facebook
|
||||||
|
Friendica
|
||||||
|
Garmin
|
||||||
|
Gitea
|
||||||
|
GitHub
|
||||||
|
GitHub Sponsors
|
||||||
|
Infinito.Nexus
|
||||||
|
Instagram
|
||||||
|
Keycloak
|
||||||
|
LDAP
|
||||||
|
LinkedIn
|
||||||
|
Mailu
|
||||||
|
Mastodon
|
||||||
|
Matomo
|
||||||
|
Matrix
|
||||||
|
Nextcloud
|
||||||
|
Open Project
|
||||||
|
Patreon
|
||||||
|
PayPal
|
||||||
|
Peertube
|
||||||
|
Pixelfed
|
||||||
|
Signal
|
||||||
|
Snipe IT
|
||||||
|
Spotify
|
||||||
|
Taiga
|
||||||
|
Telegram
|
||||||
|
Twitter
|
||||||
|
WhatsApp
|
||||||
|
XING
|
||||||
|
YouTube
|
||||||
|
Yourls
|
||||||
|
Zoom
|
||||||
|
freelancermap.de
|
||||||
|
malt
|
||||||
|
phpMyAdmin
|
||||||
|
upwork.com
|
||||||
11
app/i18n/ui/ar.yaml
Normal file
11
app/i18n/ui/ar.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: بدائل
|
||||||
|
Close: إغلاق
|
||||||
|
Copy: نسخ
|
||||||
|
Identifier copied to clipboard!: تم نسخ المعرّف إلى الحافظة!
|
||||||
|
Imprint: معلومات قانونية
|
||||||
|
Information: معلومات
|
||||||
|
Language: اللغة
|
||||||
|
Open: فتح
|
||||||
|
Open Link: فتح الرابط
|
||||||
|
Options: خيارات
|
||||||
|
Warning: تحذير
|
||||||
11
app/i18n/ui/bn.yaml
Normal file
11
app/i18n/ui/bn.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: অন্যান্য বিকল্প
|
||||||
|
Close: বন্ধ করুন
|
||||||
|
Copy: অনুলিপি
|
||||||
|
Identifier copied to clipboard!: শনাক্তকারী ক্লিপবোর্ডে অনুলিপি করা হয়েছে!
|
||||||
|
Imprint: আইনি তথ্য
|
||||||
|
Information: তথ্য
|
||||||
|
Language: ভাষা
|
||||||
|
Open: খুলুন
|
||||||
|
Open Link: লিঙ্ক খুলুন
|
||||||
|
Options: বিকল্প
|
||||||
|
Warning: সতর্কতা
|
||||||
11
app/i18n/ui/cs.yaml
Normal file
11
app/i18n/ui/cs.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternativy
|
||||||
|
Close: Zavřít
|
||||||
|
Copy: Kopírovat
|
||||||
|
Identifier copied to clipboard!: Identifikátor zkopírován do schránky!
|
||||||
|
Imprint: Tiráž
|
||||||
|
Information: Informace
|
||||||
|
Language: Jazyk
|
||||||
|
Open: Otevřít
|
||||||
|
Open Link: Otevřít odkaz
|
||||||
|
Options: Možnosti
|
||||||
|
Warning: Upozornění
|
||||||
11
app/i18n/ui/da.yaml
Normal file
11
app/i18n/ui/da.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternativer
|
||||||
|
Close: Luk
|
||||||
|
Copy: Kopiér
|
||||||
|
Identifier copied to clipboard!: Identifikator kopieret til udklipsholder!
|
||||||
|
Imprint: Juridisk information
|
||||||
|
Information: Information
|
||||||
|
Language: Sprog
|
||||||
|
Open: Åbn
|
||||||
|
Open Link: Åbn link
|
||||||
|
Options: Valgmuligheder
|
||||||
|
Warning: Advarsel
|
||||||
11
app/i18n/ui/de.yaml
Normal file
11
app/i18n/ui/de.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternativen
|
||||||
|
Close: Schließen
|
||||||
|
Copy: Kopieren
|
||||||
|
Identifier copied to clipboard!: Kennung in die Zwischenablage kopiert!
|
||||||
|
Imprint: Impressum
|
||||||
|
Information: Information
|
||||||
|
Language: Sprache
|
||||||
|
Open: Öffnen
|
||||||
|
Open Link: Link öffnen
|
||||||
|
Options: Optionen
|
||||||
|
Warning: Warnung
|
||||||
11
app/i18n/ui/el.yaml
Normal file
11
app/i18n/ui/el.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Εναλλακτικές
|
||||||
|
Close: Κλείσιμο
|
||||||
|
Copy: Αντιγραφή
|
||||||
|
Identifier copied to clipboard!: Το αναγνωριστικό αντιγράφηκε στο πρόχειρο!
|
||||||
|
Imprint: Νομικές πληροφορίες
|
||||||
|
Information: Πληροφορίες
|
||||||
|
Language: Γλώσσα
|
||||||
|
Open: Άνοιγμα
|
||||||
|
Open Link: Άνοιγμα συνδέσμου
|
||||||
|
Options: Επιλογές
|
||||||
|
Warning: Προειδοποίηση
|
||||||
11
app/i18n/ui/es.yaml
Normal file
11
app/i18n/ui/es.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternativas
|
||||||
|
Close: Cerrar
|
||||||
|
Copy: Copiar
|
||||||
|
Identifier copied to clipboard!: ¡Identificador copiado al portapapeles!
|
||||||
|
Imprint: Aviso legal
|
||||||
|
Information: Información
|
||||||
|
Language: Idioma
|
||||||
|
Open: Abrir
|
||||||
|
Open Link: Abrir enlace
|
||||||
|
Options: Opciones
|
||||||
|
Warning: Advertencia
|
||||||
11
app/i18n/ui/fa.yaml
Normal file
11
app/i18n/ui/fa.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: جایگزینها
|
||||||
|
Close: بستن
|
||||||
|
Copy: کپی
|
||||||
|
Identifier copied to clipboard!: شناسه در کلیپبورد کپی شد!
|
||||||
|
Imprint: اطلاعات حقوقی
|
||||||
|
Information: اطلاعات
|
||||||
|
Language: زبان
|
||||||
|
Open: باز کردن
|
||||||
|
Open Link: باز کردن پیوند
|
||||||
|
Options: گزینهها
|
||||||
|
Warning: هشدار
|
||||||
11
app/i18n/ui/fi.yaml
Normal file
11
app/i18n/ui/fi.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Vaihtoehdot
|
||||||
|
Close: Sulje
|
||||||
|
Copy: Kopioi
|
||||||
|
Identifier copied to clipboard!: Tunniste kopioitu leikepöydälle!
|
||||||
|
Imprint: Oikeudelliset tiedot
|
||||||
|
Information: Tiedot
|
||||||
|
Language: Kieli
|
||||||
|
Open: Avaa
|
||||||
|
Open Link: Avaa linkki
|
||||||
|
Options: Valinnat
|
||||||
|
Warning: Varoitus
|
||||||
11
app/i18n/ui/fr.yaml
Normal file
11
app/i18n/ui/fr.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternatives
|
||||||
|
Close: Fermer
|
||||||
|
Copy: Copier
|
||||||
|
Identifier copied to clipboard!: Identifiant copié dans le presse-papiers !
|
||||||
|
Imprint: Mentions légales
|
||||||
|
Information: Informations
|
||||||
|
Language: Langue
|
||||||
|
Open: Ouvrir
|
||||||
|
Open Link: Ouvrir le lien
|
||||||
|
Options: Options
|
||||||
|
Warning: Avertissement
|
||||||
11
app/i18n/ui/he.yaml
Normal file
11
app/i18n/ui/he.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: חלופות
|
||||||
|
Close: סגור
|
||||||
|
Copy: העתק
|
||||||
|
Identifier copied to clipboard!: המזהה הועתק ללוח!
|
||||||
|
Imprint: מידע משפטי
|
||||||
|
Information: מידע
|
||||||
|
Language: שפה
|
||||||
|
Open: פתח
|
||||||
|
Open Link: פתח קישור
|
||||||
|
Options: אפשרויות
|
||||||
|
Warning: אזהרה
|
||||||
11
app/i18n/ui/hi.yaml
Normal file
11
app/i18n/ui/hi.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: अन्य विकल्प
|
||||||
|
Close: बंद करें
|
||||||
|
Copy: कॉपी करें
|
||||||
|
Identifier copied to clipboard!: पहचानकर्ता क्लिपबोर्ड पर कॉपी हो गया!
|
||||||
|
Imprint: कानूनी सूचना
|
||||||
|
Information: जानकारी
|
||||||
|
Language: भाषा
|
||||||
|
Open: खोलें
|
||||||
|
Open Link: लिंक खोलें
|
||||||
|
Options: विकल्प
|
||||||
|
Warning: चेतावनी
|
||||||
11
app/i18n/ui/hu.yaml
Normal file
11
app/i18n/ui/hu.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternatívák
|
||||||
|
Close: Bezárás
|
||||||
|
Copy: Másolás
|
||||||
|
Identifier copied to clipboard!: Az azonosító a vágólapra másolva!
|
||||||
|
Imprint: Impresszum
|
||||||
|
Information: Információ
|
||||||
|
Language: Nyelv
|
||||||
|
Open: Megnyitás
|
||||||
|
Open Link: Hivatkozás megnyitása
|
||||||
|
Options: Lehetőségek
|
||||||
|
Warning: Figyelmeztetés
|
||||||
11
app/i18n/ui/id.yaml
Normal file
11
app/i18n/ui/id.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternatif
|
||||||
|
Close: Tutup
|
||||||
|
Copy: Salin
|
||||||
|
Identifier copied to clipboard!: Pengenal disalin ke papan klip!
|
||||||
|
Imprint: Informasi hukum
|
||||||
|
Information: Informasi
|
||||||
|
Language: Bahasa
|
||||||
|
Open: Buka
|
||||||
|
Open Link: Buka tautan
|
||||||
|
Options: Opsi
|
||||||
|
Warning: Peringatan
|
||||||
11
app/i18n/ui/it.yaml
Normal file
11
app/i18n/ui/it.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternative
|
||||||
|
Close: Chiudi
|
||||||
|
Copy: Copia
|
||||||
|
Identifier copied to clipboard!: Identificatore copiato negli appunti!
|
||||||
|
Imprint: Note legali
|
||||||
|
Information: Informazioni
|
||||||
|
Language: Lingua
|
||||||
|
Open: Apri
|
||||||
|
Open Link: Apri collegamento
|
||||||
|
Options: Opzioni
|
||||||
|
Warning: Avviso
|
||||||
11
app/i18n/ui/ja.yaml
Normal file
11
app/i18n/ui/ja.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: 代替
|
||||||
|
Close: 閉じる
|
||||||
|
Copy: コピー
|
||||||
|
Identifier copied to clipboard!: 識別子をクリップボードにコピーしました!
|
||||||
|
Imprint: 法的情報
|
||||||
|
Information: 情報
|
||||||
|
Language: 言語
|
||||||
|
Open: 開く
|
||||||
|
Open Link: リンクを開く
|
||||||
|
Options: オプション
|
||||||
|
Warning: 警告
|
||||||
11
app/i18n/ui/ko.yaml
Normal file
11
app/i18n/ui/ko.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: 대안
|
||||||
|
Close: 닫기
|
||||||
|
Copy: 복사
|
||||||
|
Identifier copied to clipboard!: 식별자를 클립보드에 복사했습니다!
|
||||||
|
Imprint: 법적 고지
|
||||||
|
Information: 정보
|
||||||
|
Language: 언어
|
||||||
|
Open: 열기
|
||||||
|
Open Link: 링크 열기
|
||||||
|
Options: 옵션
|
||||||
|
Warning: 경고
|
||||||
11
app/i18n/ui/nl.yaml
Normal file
11
app/i18n/ui/nl.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternatieven
|
||||||
|
Close: Sluiten
|
||||||
|
Copy: Kopiëren
|
||||||
|
Identifier copied to clipboard!: Identificatie gekopieerd naar klembord!
|
||||||
|
Imprint: Colofon
|
||||||
|
Information: Informatie
|
||||||
|
Language: Taal
|
||||||
|
Open: Openen
|
||||||
|
Open Link: Link openen
|
||||||
|
Options: Opties
|
||||||
|
Warning: Waarschuwing
|
||||||
11
app/i18n/ui/pl.yaml
Normal file
11
app/i18n/ui/pl.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternatywy
|
||||||
|
Close: Zamknij
|
||||||
|
Copy: Kopiuj
|
||||||
|
Identifier copied to clipboard!: Identyfikator skopiowany do schowka!
|
||||||
|
Imprint: Nota prawna
|
||||||
|
Information: Informacja
|
||||||
|
Language: Język
|
||||||
|
Open: Otwórz
|
||||||
|
Open Link: Otwórz link
|
||||||
|
Options: Opcje
|
||||||
|
Warning: Ostrzeżenie
|
||||||
11
app/i18n/ui/pt.yaml
Normal file
11
app/i18n/ui/pt.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternativas
|
||||||
|
Close: Fechar
|
||||||
|
Copy: Copiar
|
||||||
|
Identifier copied to clipboard!: Identificador copiado para a área de transferência!
|
||||||
|
Imprint: Aviso legal
|
||||||
|
Information: Informação
|
||||||
|
Language: Idioma
|
||||||
|
Open: Abrir
|
||||||
|
Open Link: Abrir link
|
||||||
|
Options: Opções
|
||||||
|
Warning: Aviso
|
||||||
11
app/i18n/ui/ro.yaml
Normal file
11
app/i18n/ui/ro.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternative
|
||||||
|
Close: Închide
|
||||||
|
Copy: Copiază
|
||||||
|
Identifier copied to clipboard!: Identificator copiat în clipboard!
|
||||||
|
Imprint: Notă legală
|
||||||
|
Information: Informații
|
||||||
|
Language: Limbă
|
||||||
|
Open: Deschide
|
||||||
|
Open Link: Deschide linkul
|
||||||
|
Options: Opțiuni
|
||||||
|
Warning: Avertisment
|
||||||
11
app/i18n/ui/ru.yaml
Normal file
11
app/i18n/ui/ru.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Альтернативы
|
||||||
|
Close: Закрыть
|
||||||
|
Copy: Копировать
|
||||||
|
Identifier copied to clipboard!: Идентификатор скопирован в буфер обмена!
|
||||||
|
Imprint: Выходные данные
|
||||||
|
Information: Информация
|
||||||
|
Language: Язык
|
||||||
|
Open: Открыть
|
||||||
|
Open Link: Открыть ссылку
|
||||||
|
Options: Варианты
|
||||||
|
Warning: Предупреждение
|
||||||
11
app/i18n/ui/sv.yaml
Normal file
11
app/i18n/ui/sv.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternativ
|
||||||
|
Close: Stäng
|
||||||
|
Copy: Kopiera
|
||||||
|
Identifier copied to clipboard!: Identifierare kopierad till urklipp!
|
||||||
|
Imprint: Juridisk information
|
||||||
|
Information: Information
|
||||||
|
Language: Språk
|
||||||
|
Open: Öppna
|
||||||
|
Open Link: Öppna länk
|
||||||
|
Options: Val
|
||||||
|
Warning: Varning
|
||||||
11
app/i18n/ui/th.yaml
Normal file
11
app/i18n/ui/th.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: ทางเลือกอื่น
|
||||||
|
Close: ปิด
|
||||||
|
Copy: คัดลอก
|
||||||
|
Identifier copied to clipboard!: คัดลอกตัวระบุไปยังคลิปบอร์ดแล้ว!
|
||||||
|
Imprint: ข้อมูลทางกฎหมาย
|
||||||
|
Information: ข้อมูล
|
||||||
|
Language: ภาษา
|
||||||
|
Open: เปิด
|
||||||
|
Open Link: เปิดลิงก์
|
||||||
|
Options: ตัวเลือก
|
||||||
|
Warning: คำเตือน
|
||||||
11
app/i18n/ui/tr.yaml
Normal file
11
app/i18n/ui/tr.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternatifler
|
||||||
|
Close: Kapat
|
||||||
|
Copy: Kopyala
|
||||||
|
Identifier copied to clipboard!: Tanımlayıcı panoya kopyalandı!
|
||||||
|
Imprint: Künye
|
||||||
|
Information: Bilgi
|
||||||
|
Language: Dil
|
||||||
|
Open: Aç
|
||||||
|
Open Link: Bağlantıyı aç
|
||||||
|
Options: Seçenekler
|
||||||
|
Warning: Uyarı
|
||||||
11
app/i18n/ui/uk.yaml
Normal file
11
app/i18n/ui/uk.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Альтернативи
|
||||||
|
Close: Закрити
|
||||||
|
Copy: Копіювати
|
||||||
|
Identifier copied to clipboard!: Ідентифікатор скопійовано в буфер обміну!
|
||||||
|
Imprint: Вихідні дані
|
||||||
|
Information: Інформація
|
||||||
|
Language: Мова
|
||||||
|
Open: Відкрити
|
||||||
|
Open Link: Відкрити посилання
|
||||||
|
Options: Варіанти
|
||||||
|
Warning: Попередження
|
||||||
11
app/i18n/ui/ur.yaml
Normal file
11
app/i18n/ui/ur.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: متبادل
|
||||||
|
Close: بند کریں
|
||||||
|
Copy: نقل کریں
|
||||||
|
Identifier copied to clipboard!: شناخت کنندہ کلپ بورڈ پر نقل ہو گیا!
|
||||||
|
Imprint: قانونی معلومات
|
||||||
|
Information: معلومات
|
||||||
|
Language: زبان
|
||||||
|
Open: کھولیں
|
||||||
|
Open Link: لنک کھولیں
|
||||||
|
Options: اختیارات
|
||||||
|
Warning: انتباہ
|
||||||
11
app/i18n/ui/vi.yaml
Normal file
11
app/i18n/ui/vi.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Lựa chọn thay thế
|
||||||
|
Close: Đóng
|
||||||
|
Copy: Sao chép
|
||||||
|
Identifier copied to clipboard!: Đã sao chép mã định danh vào bộ nhớ tạm!
|
||||||
|
Imprint: Thông tin pháp lý
|
||||||
|
Information: Thông tin
|
||||||
|
Language: Ngôn ngữ
|
||||||
|
Open: Mở
|
||||||
|
Open Link: Mở liên kết
|
||||||
|
Options: Tùy chọn
|
||||||
|
Warning: Cảnh báo
|
||||||
11
app/i18n/ui/zh.yaml
Normal file
11
app/i18n/ui/zh.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: 替代方案
|
||||||
|
Close: 关闭
|
||||||
|
Copy: 复制
|
||||||
|
Identifier copied to clipboard!: 标识符已复制到剪贴板!
|
||||||
|
Imprint: 法律声明
|
||||||
|
Information: 信息
|
||||||
|
Language: 语言
|
||||||
|
Open: 打开
|
||||||
|
Open Link: 打开链接
|
||||||
|
Options: 选项
|
||||||
|
Warning: 警告
|
||||||
19
app/package.json
Normal file
19
app/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||||
|
"bootstrap": "5.2.2",
|
||||||
|
"bootstrap-icons": "1.9.1",
|
||||||
|
"jquery": "3.6.0",
|
||||||
|
"marked": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"cypress": "^14.5.1",
|
||||||
|
"eslint": "^10.9.0",
|
||||||
|
"globals": "^17.11.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "node scripts/copy-vendor.js",
|
||||||
|
"postinstall": "node scripts/copy-vendor.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
flask
|
|
||||||
requests
|
|
||||||
pyyaml
|
|
||||||
75
app/scripts/copy-vendor.js
Normal file
75
app/scripts/copy-vendor.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Copies third-party browser assets from node_modules into static/vendor/
|
||||||
|
* so Flask can serve them without any CDN dependency.
|
||||||
|
* Runs automatically via the "postinstall" npm hook.
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const NM = path.join(__dirname, '..', 'node_modules');
|
||||||
|
const VENDOR = path.join(__dirname, '..', 'static', 'vendor');
|
||||||
|
|
||||||
|
function copyFile(src, dest) {
|
||||||
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
fs.copyFileSync(src, dest);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyDir(src, dest) {
|
||||||
|
fs.mkdirSync(dest, { recursive: true });
|
||||||
|
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||||
|
const s = path.join(src, entry.name);
|
||||||
|
const d = path.join(dest, entry.name);
|
||||||
|
entry.isDirectory() ? copyDir(s, d) : fs.copyFileSync(s, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap CSS + JS bundle
|
||||||
|
copyFile(
|
||||||
|
path.join(NM, 'bootstrap', 'dist', 'css', 'bootstrap.min.css'),
|
||||||
|
path.join(VENDOR, 'bootstrap', 'css', 'bootstrap.min.css')
|
||||||
|
);
|
||||||
|
copyFile(
|
||||||
|
path.join(NM, 'bootstrap', 'dist', 'css', 'bootstrap.rtl.min.css'),
|
||||||
|
path.join(VENDOR, 'bootstrap', 'css', 'bootstrap.rtl.min.css')
|
||||||
|
);
|
||||||
|
copyFile(
|
||||||
|
path.join(NM, 'bootstrap', 'dist', 'js', 'bootstrap.bundle.min.js'),
|
||||||
|
path.join(VENDOR, 'bootstrap', 'js', 'bootstrap.bundle.min.js')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bootstrap Icons CSS + embedded fonts
|
||||||
|
copyFile(
|
||||||
|
path.join(NM, 'bootstrap-icons', 'font', 'bootstrap-icons.css'),
|
||||||
|
path.join(VENDOR, 'bootstrap-icons', 'font', 'bootstrap-icons.css')
|
||||||
|
);
|
||||||
|
copyDir(
|
||||||
|
path.join(NM, 'bootstrap-icons', 'font', 'fonts'),
|
||||||
|
path.join(VENDOR, 'bootstrap-icons', 'font', 'fonts')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Font Awesome Free CSS + webfonts
|
||||||
|
copyFile(
|
||||||
|
path.join(NM, '@fortawesome', 'fontawesome-free', 'css', 'all.min.css'),
|
||||||
|
path.join(VENDOR, 'fontawesome', 'css', 'all.min.css')
|
||||||
|
);
|
||||||
|
copyDir(
|
||||||
|
path.join(NM, '@fortawesome', 'fontawesome-free', 'webfonts'),
|
||||||
|
path.join(VENDOR, 'fontawesome', 'webfonts')
|
||||||
|
);
|
||||||
|
|
||||||
|
// marked – browser UMD build (path varies by version)
|
||||||
|
const markedCandidates = [
|
||||||
|
path.join(NM, 'marked', 'marked.min.js'), // v4.x
|
||||||
|
path.join(NM, 'marked', 'lib', 'marked.umd.min.js'), // v5.x
|
||||||
|
path.join(NM, 'marked', 'dist', 'marked.min.js'), // v9+
|
||||||
|
];
|
||||||
|
const markedSrc = markedCandidates.find(p => fs.existsSync(p));
|
||||||
|
if (!markedSrc) throw new Error('marked: no browser UMD build found in node_modules');
|
||||||
|
copyFile(markedSrc, path.join(VENDOR, 'marked', 'marked.min.js'));
|
||||||
|
|
||||||
|
// jQuery
|
||||||
|
copyFile(
|
||||||
|
path.join(NM, 'jquery', 'dist', 'jquery.min.js'),
|
||||||
|
path.join(VENDOR, 'jquery', 'jquery.min.js')
|
||||||
|
);
|
||||||
@@ -38,6 +38,18 @@ a {
|
|||||||
background-color: #f9f9f9;
|
background-color: #f9f9f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
transition: background-color 1s ease, transform 1s ease;
|
||||||
|
transition: color 1s ease, transform 1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
/* invert everything inside the card */
|
||||||
|
filter: invert(0.8) hue-rotate(144deg);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.card-body {
|
.card-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -70,7 +82,6 @@ h3.card-title {
|
|||||||
|
|
||||||
/* Footer styles */
|
/* Footer styles */
|
||||||
.footer {
|
.footer {
|
||||||
margin-top: 12px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.7em;
|
font-size: 0.7em;
|
||||||
}
|
}
|
||||||
@@ -85,8 +96,11 @@ h3.footer-title {
|
|||||||
font-size: 1.3em;
|
font-size: 1.3em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-img-top i {
|
.card-img-top i, .card-img-top svg{
|
||||||
font-size: 100px;
|
font-size: 100px;
|
||||||
|
fill: currentColor;
|
||||||
|
width: 100px;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
div#navbarNavheader li.nav-item {
|
div#navbarNavheader li.nav-item {
|
||||||
@@ -97,7 +111,22 @@ div#navbarNavfooter li.nav-item {
|
|||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
/* Prevent nav items from wrapping to a second line.
|
||||||
|
overflow is intentionally NOT set here — overflow-x:auto would
|
||||||
|
implicitly clip overflow-y too and hide dropdown menus that open
|
||||||
|
below the navbar. */
|
||||||
|
div#navbarNavheader .navbar-nav,
|
||||||
|
div#navbarNavfooter .navbar-nav {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu.language-menu {
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
main, footer, header, nav {
|
||||||
position: relative;
|
position: relative;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
/* Inner shadow */
|
/* Inner shadow */
|
||||||
@@ -108,3 +137,82 @@ main {
|
|||||||
-10px 0 10px -10px rgba(0, 0, 0, 0.3); /* Left outer shadow */
|
-10px 0 10px -10px rgba(0, 0, 0, 0.3); /* Left outer shadow */
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
header{
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header,
|
||||||
|
footer {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
top: 0;
|
||||||
|
margin: 0;
|
||||||
|
z-index: 1030;
|
||||||
|
background-color: var(--bs-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* at the end of default.css */
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe{
|
||||||
|
margin-bottom: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-fluid {
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--anim-duration: 3s; /* Basis-Dauer */
|
||||||
|
}
|
||||||
|
|
||||||
|
.container,
|
||||||
|
.container-fluid {
|
||||||
|
transition:
|
||||||
|
max-width var(--anim-duration) ease-in-out,
|
||||||
|
padding-left var(--anim-duration) ease-in-out,
|
||||||
|
padding-right var(--anim-duration) ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navbar_logo {
|
||||||
|
opacity: 0;
|
||||||
|
max-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: opacity var(--anim-duration) ease-in-out,
|
||||||
|
max-width var(--anim-duration) ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navbar_logo.visible {
|
||||||
|
opacity: 1 !important;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* 1. Make sure headers and footers can collapse */
|
||||||
|
header,
|
||||||
|
footer {
|
||||||
|
/* choose a max-height that’s >= your tallest header/footer */
|
||||||
|
max-height: 200px;
|
||||||
|
padding: 1rem;
|
||||||
|
transition:
|
||||||
|
max-height var(--anim-duration) ease-in-out,
|
||||||
|
padding var(--anim-duration) ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. In fullscreen mode, collapse them. overflow: hidden is scoped here
|
||||||
|
so dropdown menus can escape the header in normal mode. */
|
||||||
|
body.fullscreen header,
|
||||||
|
body.fullscreen footer {
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
/* Top-level dropdown menu */
|
/* Top-level dropdown menu — direction toggled by JS via .dropdown / .dropup */
|
||||||
.nav-item .dropdown-menu {
|
.nav-item.dropdown > .dropdown-menu,
|
||||||
position: absolute; /* Important for positioning */
|
.nav-item.dropup > .dropdown-menu {
|
||||||
top: 100%; /* Default opening direction: downwards */
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 1050; /* Ensures the menu appears above other elements */
|
z-index: 1050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.dropdown > .dropdown-menu {
|
||||||
|
top: 100%;
|
||||||
|
bottom: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.dropup > .dropdown-menu {
|
||||||
|
top: auto;
|
||||||
|
bottom: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Submenu position */
|
/* Submenu position */
|
||||||
@@ -19,12 +29,6 @@
|
|||||||
transition: all 0.3s ease-in-out;
|
transition: all 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav.navbar.menu-header {
|
nav.navbar {
|
||||||
border-bottom-left-radius: 0;
|
border-radius: 0;
|
||||||
border-bottom-right-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav.navbar.menu-footer {
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function adjustScrollContainerHeight() {
|
|||||||
|
|
||||||
// Calculate the available height for the scroll area
|
// Calculate the available height for the scroll area
|
||||||
const availableHeight = window.innerHeight - siblingsHeight;
|
const availableHeight = window.innerHeight - siblingsHeight;
|
||||||
scrollContainer.style.maxHeight = availableHeight + 'px';
|
scrollContainer.style.height = availableHeight + 'px';
|
||||||
scrollContainer.style.overflowY = 'auto';
|
scrollContainer.style.overflowY = 'auto';
|
||||||
scrollContainer.style.overflowX = 'hidden';
|
scrollContainer.style.overflowX = 'hidden';
|
||||||
|
|
||||||
110
app/static/js/fullscreen.js
Normal file
110
app/static/js/fullscreen.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Add or remove the `fullscreen=1` URL parameter.
|
||||||
|
* @param {boolean} enabled
|
||||||
|
*/
|
||||||
|
function updateUrlFullscreen(enabled) {
|
||||||
|
var url = new URL(window.location);
|
||||||
|
if (enabled) url.searchParams.set('fullscreen', '1');
|
||||||
|
else url.searchParams.delete('fullscreen');
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a requestAnimationFrame loop that calls your recalc methods,
|
||||||
|
* and stops automatically when the header’s max-height transition ends.
|
||||||
|
*/
|
||||||
|
function recalcWhileCollapsing() {
|
||||||
|
const header = document.querySelector('header');
|
||||||
|
if (!header) return;
|
||||||
|
|
||||||
|
// 1) Start the RAF loop
|
||||||
|
let rafId;
|
||||||
|
const step = () => {
|
||||||
|
adjustScrollContainerHeight();
|
||||||
|
updateCustomScrollbar();
|
||||||
|
rafId = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
step();
|
||||||
|
|
||||||
|
// 2) Listen for the end of the max-height transition
|
||||||
|
function onEnd(e) {
|
||||||
|
if (e.propertyName === 'max-height') {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
header.removeEventListener('transitionend', onEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header.addEventListener('transitionend', onEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterFullscreen() {
|
||||||
|
document.body.classList.add('fullscreen');
|
||||||
|
setFullWidth(true);
|
||||||
|
updateUrlFullscreen(true);
|
||||||
|
|
||||||
|
// Nur jetzt sichtbar machen
|
||||||
|
const logo = document.getElementById('navbar_logo');
|
||||||
|
if (logo) {
|
||||||
|
logo.classList.add('visible');
|
||||||
|
}
|
||||||
|
recalcWhileCollapsing();
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitFullscreen() {
|
||||||
|
document.body.classList.remove('fullscreen');
|
||||||
|
setFullWidth(false);
|
||||||
|
updateUrlFullscreen(false);
|
||||||
|
|
||||||
|
// Jetzt wieder verstecken
|
||||||
|
const logo = document.getElementById('navbar_logo');
|
||||||
|
if (logo) {
|
||||||
|
logo.classList.remove('visible');
|
||||||
|
}
|
||||||
|
recalcWhileCollapsing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle between enter and exit fullscreen.
|
||||||
|
*/
|
||||||
|
function toggleFullscreen() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const isFull = params.get('fullscreen') === '1';
|
||||||
|
|
||||||
|
if (isFull) exitFullscreen();
|
||||||
|
else enterFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read `fullscreen` flag from URL on load.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function initFullscreenFromUrl() {
|
||||||
|
return new URLSearchParams(window.location.search).get('fullscreen') === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
// On page load: apply fullwidth & fullscreen flags
|
||||||
|
window.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// first fullwidth
|
||||||
|
var wasFullWidth = initFullWidthFromUrl();
|
||||||
|
setFullWidth(wasFullWidth);
|
||||||
|
|
||||||
|
// now fullscreen
|
||||||
|
if (initFullscreenFromUrl()) {
|
||||||
|
enterFullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mirror native F11/fullscreen API events
|
||||||
|
document.addEventListener('fullscreenchange', function() {
|
||||||
|
if (document.fullscreenElement) enterFullscreen();
|
||||||
|
else exitFullscreen();
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', function() {
|
||||||
|
var isUiFs = Math.abs(window.innerHeight - screen.height) < 2;
|
||||||
|
if (isUiFs) enterFullscreen();
|
||||||
|
else exitFullscreen();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expose globally
|
||||||
|
window.fullscreen = enterFullscreen;
|
||||||
|
window.exitFullscreen = exitFullscreen;
|
||||||
|
window.toggleFullscreen = toggleFullscreen;
|
||||||
42
app/static/js/fullwidth.js
Normal file
42
app/static/js/fullwidth.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Toggles the .container class between .container and .container-fluid.
|
||||||
|
* @param {boolean} enabled – true = full width, false = normal.
|
||||||
|
*/
|
||||||
|
function setFullWidth(enabled) {
|
||||||
|
var el = document.querySelector('.container, .container-fluid');
|
||||||
|
if (!el) return;
|
||||||
|
if (enabled) {
|
||||||
|
el.classList.replace('container', 'container-fluid');
|
||||||
|
updateUrlFullWidth(true);
|
||||||
|
} else {
|
||||||
|
el.classList.replace('container-fluid', 'container');
|
||||||
|
updateUrlFullWidth(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the URL parameter `fullwidth` and applies full width if it's set.
|
||||||
|
* @returns {boolean} – current full‐width state
|
||||||
|
*/
|
||||||
|
function initFullWidthFromUrl() {
|
||||||
|
var isFull = new URLSearchParams(window.location.search).get('fullwidth') === '1';
|
||||||
|
setFullWidth(isFull);
|
||||||
|
return isFull;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds or removes the `fullwidth=1` URL parameter.
|
||||||
|
* @param {boolean} enabled
|
||||||
|
*/
|
||||||
|
function updateUrlFullWidth(enabled) {
|
||||||
|
var url = new URL(window.location);
|
||||||
|
if (enabled) url.searchParams.set('fullwidth', '1');
|
||||||
|
else url.searchParams.delete('fullwidth');
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose globally
|
||||||
|
window.setFullWidth = setFullWidth;
|
||||||
|
window.initFullWidthFromUrl = initFullWidthFromUrl;
|
||||||
|
window.updateUrlFullWidth = updateUrlFullWidth;
|
||||||
@@ -1,101 +1,193 @@
|
|||||||
// Global variables to store elements and original state
|
// Global variables to store elements and original state
|
||||||
let mainElement, originalContent, originalMainStyle, container, customScrollbar;
|
let mainElement, originalContent, originalMainStyle, container, customScrollbar, scrollbarContainer;
|
||||||
|
let currentIframeUrl = null;
|
||||||
|
|
||||||
// Function to open a URL in an iframe using global variables
|
// === Auto-open iframe if URL parameter is present ===
|
||||||
function openIframe(url) {
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
// Set a fixed height for the main element if not already set
|
const paramUrl = new URLSearchParams(window.location.search).get('iframe');
|
||||||
if (!mainElement.style.height) {
|
if (paramUrl) {
|
||||||
mainElement.style.height = `${mainElement.clientHeight}px`;
|
currentIframeUrl = paramUrl;
|
||||||
}
|
enterFullscreen();
|
||||||
|
openIframe(paramUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Replace the container class with container-fluid if not already applied
|
// Synchronize the height of the iframe to match the scroll-container or main element
|
||||||
if (container && !container.classList.contains("container-fluid")) {
|
function syncIframeHeight() {
|
||||||
container.classList.replace("container", "container-fluid");
|
const iframe = mainElement.querySelector("iframe");
|
||||||
}
|
if (iframe) {
|
||||||
|
if (scrollbarContainer) {
|
||||||
// Hide the custom scrollbar
|
// Prefer inline height, otherwise inline max-height
|
||||||
if (customScrollbar) {
|
const inlineHeight = scrollbarContainer.style.height;
|
||||||
customScrollbar.style.display = "none";
|
const inlineMax = scrollbarContainer.style.maxHeight;
|
||||||
}
|
const target = inlineHeight || inlineMax;
|
||||||
|
if (target) {
|
||||||
// Check if an iframe already exists in the main element
|
iframe.style.height = target;
|
||||||
let iframe = mainElement.querySelector("iframe");
|
|
||||||
if (!iframe) {
|
|
||||||
// Create a new iframe element
|
|
||||||
iframe = document.createElement("iframe");
|
|
||||||
iframe.width = "100%";
|
|
||||||
iframe.style.border = "none";
|
|
||||||
iframe.style.height = mainElement.style.height; // Apply fixed height
|
|
||||||
iframe.style.overflow = "auto"; // Enable internal scrollbar
|
|
||||||
iframe.scrolling = "auto"; // Ensure scrollability
|
|
||||||
|
|
||||||
// Clear the main content before appending the iframe
|
|
||||||
mainElement.innerHTML = "";
|
|
||||||
mainElement.appendChild(iframe);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the URL of the iframe
|
|
||||||
iframe.src = url;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
|
||||||
// Initialize global variables
|
|
||||||
mainElement = document.querySelector("main");
|
|
||||||
originalContent = mainElement.innerHTML;
|
|
||||||
originalMainStyle = mainElement.getAttribute("style"); // might be null if no inline style exists
|
|
||||||
|
|
||||||
container = document.querySelector(".container");
|
|
||||||
customScrollbar = document.getElementById("custom-scrollbar");
|
|
||||||
|
|
||||||
// Get all links that should open in an iframe
|
|
||||||
const links = document.querySelectorAll(".iframe-link");
|
|
||||||
|
|
||||||
// Add click event listener to each iframe link
|
|
||||||
links.forEach(link => {
|
|
||||||
link.addEventListener("click", function (event) {
|
|
||||||
event.preventDefault(); // Prevent default link behavior
|
|
||||||
const url = this.getAttribute("href");
|
|
||||||
openIframe(url);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add click event listener to header h1 to restore the original main content and style
|
|
||||||
const headerH1 = document.querySelector("header h1");
|
|
||||||
if (headerH1) {
|
|
||||||
headerH1.style.cursor = "pointer";
|
|
||||||
headerH1.addEventListener("click", function () {
|
|
||||||
// Restore the original content of the main element (removing the iframe)
|
|
||||||
mainElement.innerHTML = originalContent;
|
|
||||||
|
|
||||||
// Restore the original inline style of the main element
|
|
||||||
if (originalMainStyle !== null) {
|
|
||||||
mainElement.setAttribute("style", originalMainStyle);
|
|
||||||
} else {
|
} else {
|
||||||
mainElement.removeAttribute("style");
|
iframe.style.height = mainElement.style.height;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
// Optionally revert the container class back to "container" if needed
|
|
||||||
if (container && container.classList.contains("container-fluid")) {
|
|
||||||
container.classList.replace("container-fluid", "container");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optionally show the custom scrollbar again
|
|
||||||
if (customScrollbar) {
|
|
||||||
customScrollbar.style.display = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust scroll container height if that function exists
|
|
||||||
if (typeof adjustScrollContainerHeight === "function") {
|
|
||||||
adjustScrollContainerHeight();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust iframe height on window resize (optional, to keep it responsive)
|
|
||||||
window.addEventListener("resize", function () {
|
|
||||||
const iframe = mainElement.querySelector("iframe");
|
|
||||||
if (iframe) {
|
|
||||||
iframe.style.height = mainElement.style.height;
|
iframe.style.height = mainElement.style.height;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to open a URL in an iframe (jQuery version mit 1500 ms Fade)
|
||||||
|
function openIframe(url) {
|
||||||
|
if (!isSafeUrl(url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var $container = scrollbarContainer ? $(scrollbarContainer) : null;
|
||||||
|
var $customScroll = customScrollbar ? $(customScrollbar) : null;
|
||||||
|
var $main = $(mainElement);
|
||||||
|
|
||||||
|
// Original-Content ausblenden mit 1500 ms
|
||||||
|
var promises = [];
|
||||||
|
if ($container) promises.push($container.fadeOut(1500).promise());
|
||||||
|
if ($customScroll) promises.push($customScroll.fadeOut(1500).promise());
|
||||||
|
|
||||||
|
$.when.apply($, promises).done(function() {
|
||||||
|
// now that scroll areas are hidden, go fullscreen
|
||||||
|
enterFullscreen();
|
||||||
|
// create iframe if it doesn’t exist yet
|
||||||
|
var $iframe = $main.find('iframe');
|
||||||
|
if ($iframe.length === 0) {
|
||||||
|
originalMainStyle = $main.attr('style') || null;
|
||||||
|
$iframe = $('<iframe>', {
|
||||||
|
width: '100%',
|
||||||
|
frameborder: 0,
|
||||||
|
scrolling: 'auto'
|
||||||
|
}).css({ overflow: 'auto', display: 'none' });
|
||||||
|
$main.append($iframe);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quelle setzen und mit 1500 ms einblenden
|
||||||
|
$iframe
|
||||||
|
.attr('src', url)
|
||||||
|
.fadeIn(1500, function() {
|
||||||
|
syncIframeHeight();
|
||||||
|
observeIframeNavigation();
|
||||||
|
});
|
||||||
|
|
||||||
|
// URL-State pushen
|
||||||
|
var newUrl = new URL(window.location);
|
||||||
|
newUrl.searchParams.set('iframe', url);
|
||||||
|
window.history.pushState({ iframe: url }, '', newUrl);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore the original <main> content and exit fullscreen.
|
||||||
|
*/
|
||||||
|
function restoreOriginal() {
|
||||||
|
// Exit fullscreen (collapse header/footer and run recalcs)
|
||||||
|
exitFullscreen();
|
||||||
|
|
||||||
|
// Replace <main> innerHTML with the snapshot we took on load
|
||||||
|
mainElement.innerHTML = originalContent;
|
||||||
|
|
||||||
|
// Reset any inline styles on mainElement
|
||||||
|
if (originalMainStyle !== null) {
|
||||||
|
mainElement.setAttribute('style', originalMainStyle);
|
||||||
|
} else {
|
||||||
|
mainElement.removeAttribute('style');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-run height adjustments for scroll container & thumb
|
||||||
|
adjustScrollContainerHeight();
|
||||||
|
updateCustomScrollbar();
|
||||||
|
|
||||||
|
// Clear iframe state and URL param
|
||||||
|
currentIframeUrl = null;
|
||||||
|
history.replaceState(null, '', window.location.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize event listeners after DOM content is loaded
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
// Cache references to elements and original state
|
||||||
|
mainElement = document.querySelector("main");
|
||||||
|
originalContent = mainElement.innerHTML;
|
||||||
|
originalMainStyle = mainElement.getAttribute("style"); // may be null
|
||||||
|
container = document.querySelector(".container");
|
||||||
|
customScrollbar = document.getElementById("custom-scrollbar");
|
||||||
|
scrollbarContainer = container.querySelector(".scroll-container")
|
||||||
|
|
||||||
|
document.querySelectorAll(".js-restore").forEach(el => {
|
||||||
|
el.style.cursor = "pointer";
|
||||||
|
el.addEventListener("click", restoreOriginal);
|
||||||
|
});
|
||||||
|
|
||||||
|
// === Close iframe & exit fullscreen when any .js-restore is clicked ===
|
||||||
|
document.querySelectorAll('.js-restore').forEach(el => {
|
||||||
|
el.style.cursor = 'pointer';
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
// first collapse header/footer and recalc container
|
||||||
|
exitFullscreen();
|
||||||
|
// then fade out and remove the iframe, fade content back
|
||||||
|
restoreOriginal();
|
||||||
|
// clear stored URL and reset browser address
|
||||||
|
currentIframeUrl = null;
|
||||||
|
history.replaceState(null, '', window.location.pathname);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the current iframe URL in a new browser tab.
|
||||||
|
*/
|
||||||
|
function openIframeInNewTab() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const iframeUrl = params.get('iframe');
|
||||||
|
if (iframeUrl) {
|
||||||
|
window.open(iframeUrl, '_blank');
|
||||||
|
} else {
|
||||||
|
alert('No iframe is currently open.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// expose globally so your template’s onclick can find it
|
||||||
|
window.openIframeInNewTab = openIframeInNewTab;
|
||||||
|
|
||||||
|
// Adjust iframe height on window resize
|
||||||
|
window.addEventListener('resize', syncIframeHeight);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observe iframe location changes (Same-Origin only).
|
||||||
|
*/
|
||||||
|
function observeIframeNavigation() {
|
||||||
|
const iframe = mainElement.querySelector("iframe");
|
||||||
|
if (!iframe || !iframe.contentWindow) return;
|
||||||
|
|
||||||
|
let lastUrl = iframe.contentWindow.location.href;
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
try {
|
||||||
|
const currentUrl = iframe.contentWindow.location.href;
|
||||||
|
if (currentUrl !== lastUrl) {
|
||||||
|
lastUrl = currentUrl;
|
||||||
|
const newUrl = new URL(window.location);
|
||||||
|
newUrl.searchParams.set('iframe', currentUrl);
|
||||||
|
window.history.replaceState({}, '', newUrl);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Cross-origin – ignore
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remember, open iframe, enter fullscreen, AND set the URL param immediately
|
||||||
|
document.querySelectorAll(".iframe-link").forEach(link => {
|
||||||
|
link.addEventListener("click", function(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
currentIframeUrl = this.href;
|
||||||
|
|
||||||
|
enterFullscreen();
|
||||||
|
openIframe(currentIframeUrl);
|
||||||
|
|
||||||
|
// Update the browser URL right away
|
||||||
|
const newUrl = new URL(window.location);
|
||||||
|
newUrl.searchParams.set('iframe', currentIframeUrl);
|
||||||
|
window.history.replaceState({ iframe: currentIframeUrl }, '', newUrl);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,48 @@
|
|||||||
|
function t(source) {
|
||||||
|
return (window.I18N || {})[source] || source;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAFE_URL_SCHEMES = ['http:', 'https:', 'mailto:'];
|
||||||
|
|
||||||
|
function isSafeUrl(url) {
|
||||||
|
const probe = document.createElement('a');
|
||||||
|
probe.href = String(url == null ? '' : url);
|
||||||
|
return SAFE_URL_SCHEMES.includes(probe.protocol);
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconAndName(item) {
|
||||||
|
const nodes = [];
|
||||||
|
if (item.icon && item.icon.class) {
|
||||||
|
const icon = document.createElement('i');
|
||||||
|
icon.className = item.icon.class;
|
||||||
|
nodes.push(icon, document.createTextNode(' '));
|
||||||
|
}
|
||||||
|
nodes.push(document.createTextNode(item.name == null ? '' : item.name));
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(content) {
|
||||||
|
const escaped = String(content).replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const parsed = new DOMParser().parseFromString(marked.parse(escaped), 'text/html');
|
||||||
|
|
||||||
|
parsed.querySelectorAll('a[href]').forEach((anchor) => {
|
||||||
|
if (!SAFE_URL_SCHEMES.includes(anchor.protocol)) {
|
||||||
|
anchor.replaceWith(...anchor.childNodes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
parsed.querySelectorAll('img[src]').forEach((image) => {
|
||||||
|
if (!SAFE_URL_SCHEMES.includes(image.protocol)) {
|
||||||
|
image.replaceWith(image.alt || '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return parsed.body.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
function openDynamicPopup(subitem) {
|
function openDynamicPopup(subitem) {
|
||||||
closeAllModals();
|
closeAllModals();
|
||||||
const modalTitle = document.getElementById('dynamicModalLabel');
|
const modalTitle = document.getElementById('dynamicModalLabel');
|
||||||
if (subitem.icon && subitem.icon.class) {
|
modalTitle.replaceChildren(...iconAndName(subitem));
|
||||||
modalTitle.innerHTML = `<i class="${subitem.icon.class}"></i> ${subitem.name}`;
|
|
||||||
} else {
|
|
||||||
modalTitle.innerText = subitem.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
const identifierBox = document.getElementById('dynamicIdentifierBox');
|
const identifierBox = document.getElementById('dynamicIdentifierBox');
|
||||||
const modalContent = document.getElementById('dynamicModalContent');
|
const modalContent = document.getElementById('dynamicModalContent');
|
||||||
@@ -21,7 +58,7 @@ function openDynamicPopup(subitem) {
|
|||||||
const box = document.getElementById(boxId);
|
const box = document.getElementById(boxId);
|
||||||
if (content) {
|
if (content) {
|
||||||
box.classList.remove('d-none');
|
box.classList.remove('d-none');
|
||||||
document.getElementById(textId).innerHTML = marked.parse(content);
|
document.getElementById(textId).innerHTML = renderMarkdown(content);
|
||||||
} else {
|
} else {
|
||||||
box.classList.add('d-none');
|
box.classList.add('d-none');
|
||||||
}
|
}
|
||||||
@@ -44,16 +81,19 @@ function openDynamicPopup(subitem) {
|
|||||||
if (subitem.url) {
|
if (subitem.url) {
|
||||||
linkBox.classList.remove('d-none');
|
linkBox.classList.remove('d-none');
|
||||||
linkHref.href = subitem.url;
|
linkHref.href = subitem.url;
|
||||||
linkHref.innerText = subitem.description || "Open Link";
|
if (!isSafeUrl(subitem.url)) {
|
||||||
|
linkHref.removeAttribute('href');
|
||||||
|
}
|
||||||
|
linkHref.innerText = subitem.description || t("Open Link");
|
||||||
|
linkHref.classList.remove('iframe');
|
||||||
|
linkHref.onclick = null;
|
||||||
if (subitem.iframe) {
|
if (subitem.iframe) {
|
||||||
linkHref.classList.add('iframe');
|
linkHref.classList.add('iframe');
|
||||||
// Attach an event listener that prevents the default behavior and
|
linkHref.onclick = function(event) {
|
||||||
// opens the URL in an iframe when clicked.
|
|
||||||
linkHref.addEventListener('click', function(event) {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
openIframe(subitem.url);
|
openIframe(subitem.url);
|
||||||
closeAllModals()
|
closeAllModals();
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
linkBox.classList.add('d-none');
|
linkBox.classList.add('d-none');
|
||||||
@@ -69,13 +109,13 @@ function openDynamicPopup(subitem) {
|
|||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
const listItem = document.createElement('li');
|
const listItem = document.createElement('li');
|
||||||
listItem.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'align-items-center');
|
listItem.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'align-items-center');
|
||||||
listItem.innerHTML = `
|
const label = document.createElement('span');
|
||||||
<span>
|
label.replaceChildren(...iconAndName(item));
|
||||||
<i class="${item.icon.class}"></i> ${item.name}
|
const button = document.createElement('button');
|
||||||
</span>
|
button.className = 'btn btn-outline-secondary btn-sm';
|
||||||
<button class="btn btn-outline-secondary btn-sm">Open</button>
|
button.textContent = t('Open');
|
||||||
`;
|
listItem.replaceChildren(label, button);
|
||||||
listItem.querySelector('button').addEventListener('click', () => onClickHandler(item));
|
button.addEventListener('click', () => onClickHandler(item));
|
||||||
list.appendChild(listItem);
|
list.appendChild(listItem);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -90,7 +130,7 @@ function openDynamicPopup(subitem) {
|
|||||||
copyButton.onclick = () => {
|
copyButton.onclick = () => {
|
||||||
modalContent.select();
|
modalContent.select();
|
||||||
navigator.clipboard.writeText(modalContent.value).then(() => {
|
navigator.clipboard.writeText(modalContent.value).then(() => {
|
||||||
alert('Identifier copied to clipboard!');
|
alert(t('Identifier copied to clipboard!'));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,58 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const menuItems = document.querySelectorAll('.nav-item.dropdown');
|
function getDirectChildByClass(item, className) {
|
||||||
const subMenuItems = document.querySelectorAll('.dropdown-submenu');
|
return Array.from(item.children).find(child => child.classList?.contains(className));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMenu(item) {
|
||||||
|
return getDirectChildByClass(item, 'dropdown-menu');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToggle(item) {
|
||||||
|
return getDirectChildByClass(item, 'dropdown-toggle');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTopLevelDropdown(item) {
|
||||||
|
return (
|
||||||
|
item.classList.contains('nav-item') &&
|
||||||
|
(item.classList.contains('dropdown') || item.classList.contains('dropup'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseDirection(item) {
|
||||||
|
const rect = item.getBoundingClientRect();
|
||||||
|
const spaceAbove = rect.top;
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
|
if (spaceAbove > spaceBelow) {
|
||||||
|
item.classList.add('dropup');
|
||||||
|
item.classList.remove('dropdown');
|
||||||
|
} else {
|
||||||
|
item.classList.add('dropdown');
|
||||||
|
item.classList.remove('dropup');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDropdownInstances(root = document) {
|
||||||
|
const scope = root && root.querySelectorAll ? root : document;
|
||||||
|
scope
|
||||||
|
.querySelectorAll('.nav-item.dropdown > .dropdown-toggle, .nav-item.dropup > .dropdown-toggle')
|
||||||
|
.forEach(toggle => {
|
||||||
|
toggle.setAttribute('data-bs-toggle', 'dropdown');
|
||||||
|
if (!toggle.hasAttribute('aria-expanded')) {
|
||||||
|
toggle.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
if (window.bootstrap?.Dropdown) {
|
||||||
|
// Use Popper strategy: 'fixed' so the menu is positioned relative
|
||||||
|
// to the viewport and escapes ancestors with overflow:hidden
|
||||||
|
// (e.g. <header> which clips for the fullscreen-collapse animation).
|
||||||
|
window.bootstrap.Dropdown.getInstance(toggle)?.dispose();
|
||||||
|
new window.bootstrap.Dropdown(toggle, {
|
||||||
|
popperConfig(defaultBsPopperConfig) {
|
||||||
|
return { ...defaultBsPopperConfig, strategy: 'fixed' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function addMenuEventListeners(items, isTopLevel) {
|
function addMenuEventListeners(items, isTopLevel) {
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
@@ -8,7 +60,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
function onMouseEnter() {
|
function onMouseEnter() {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
openMenu(item, isTopLevel);
|
openMenu(item, isTopLevel, 'hover');
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMouseLeave() {
|
function onMouseLeave() {
|
||||||
@@ -25,7 +77,29 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// Open and adjust position on click
|
// Open and adjust position on click
|
||||||
item.addEventListener('click', (e) => {
|
item.addEventListener('click', (e) => {
|
||||||
|
const toggle = getToggle(item);
|
||||||
|
const clickedToggle = toggle && (e.target === toggle || toggle.contains(e.target));
|
||||||
|
|
||||||
|
if (isTopLevel && !clickedToggle) {
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
e.stopPropagation(); // Prevents menus from closing when clicking inside
|
e.stopPropagation(); // Prevents menus from closing when clicking inside
|
||||||
|
if (isTopLevel) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (window.bootstrap?.Dropdown) {
|
||||||
|
if (item.dataset.openedBy === 'click') {
|
||||||
|
closeMenu(item);
|
||||||
|
} else if (getMenu(item)) {
|
||||||
|
item.dataset.openedBy = 'click';
|
||||||
|
item.classList.add('open');
|
||||||
|
chooseDirection(item);
|
||||||
|
window.bootstrap.Dropdown.getOrCreateInstance(toggle).show();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (item.classList.contains('open')) {
|
if (item.classList.contains('open')) {
|
||||||
closeMenu(item);
|
closeMenu(item);
|
||||||
} else {
|
} else {
|
||||||
@@ -35,39 +109,59 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TOP_LEVEL_SELECTOR = '.nav-item.dropdown, .nav-item.dropup';
|
||||||
|
|
||||||
function addAllMenuEventListeners() {
|
function addAllMenuEventListeners() {
|
||||||
const updatedMenuItems = document.querySelectorAll('.nav-item.dropdown');
|
const updatedMenuItems = document.querySelectorAll(TOP_LEVEL_SELECTOR);
|
||||||
const updatedSubMenuItems = document.querySelectorAll('.dropdown-submenu');
|
const updatedSubMenuItems = document.querySelectorAll('.dropdown-submenu');
|
||||||
addMenuEventListeners(updatedMenuItems, true);
|
addMenuEventListeners(updatedMenuItems, true);
|
||||||
addMenuEventListeners(updatedSubMenuItems, false);
|
addMenuEventListeners(updatedSubMenuItems, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureDropdownInstances();
|
||||||
addAllMenuEventListeners();
|
addAllMenuEventListeners();
|
||||||
|
|
||||||
// Global click listener to close menus when clicking outside
|
// Global click listener to close menus when clicking outside
|
||||||
document.addEventListener('click', () => {
|
document.addEventListener('click', () => {
|
||||||
|
const menuItems = document.querySelectorAll(TOP_LEVEL_SELECTOR);
|
||||||
|
const subMenuItems = document.querySelectorAll('.dropdown-submenu');
|
||||||
[...menuItems, ...subMenuItems].forEach(item => closeMenu(item));
|
[...menuItems, ...subMenuItems].forEach(item => closeMenu(item));
|
||||||
});
|
});
|
||||||
|
|
||||||
function openMenu(item, isTopLevel) {
|
function openMenu(item, isTopLevel, openedBy = 'script') {
|
||||||
item.classList.add('open');
|
item.classList.add('open');
|
||||||
const submenu = item.querySelector('.dropdown-menu');
|
const submenu = getMenu(item);
|
||||||
if (submenu) {
|
if (!submenu) return;
|
||||||
submenu.style.display = 'block';
|
if (isTopLevel) {
|
||||||
submenu.style.opacity = '1';
|
item.dataset.openedBy = openedBy;
|
||||||
submenu.style.visibility = 'visible';
|
const toggle = getToggle(item);
|
||||||
adjustMenuPosition(submenu, item, isTopLevel);
|
if (toggle && window.bootstrap?.Dropdown) {
|
||||||
|
chooseDirection(item);
|
||||||
|
window.bootstrap.Dropdown.getOrCreateInstance(toggle).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
submenu.style.display = 'block';
|
||||||
|
submenu.style.opacity = '1';
|
||||||
|
submenu.style.visibility = 'visible';
|
||||||
|
adjustMenuPosition(submenu, item, isTopLevel);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeMenu(item) {
|
function closeMenu(item) {
|
||||||
item.classList.remove('open');
|
item.classList.remove('open');
|
||||||
const submenu = item.querySelector('.dropdown-menu');
|
delete item.dataset.openedBy;
|
||||||
if (submenu) {
|
const submenu = getMenu(item);
|
||||||
submenu.style.display = 'none';
|
if (!submenu) return;
|
||||||
submenu.style.opacity = '0';
|
if (isTopLevelDropdown(item)) {
|
||||||
submenu.style.visibility = 'hidden';
|
const toggle = getToggle(item);
|
||||||
|
if (toggle && window.bootstrap?.Dropdown) {
|
||||||
|
window.bootstrap.Dropdown.getOrCreateInstance(toggle).hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
submenu.style.display = 'none';
|
||||||
|
submenu.style.opacity = '0';
|
||||||
|
submenu.style.visibility = 'hidden';
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSmallScreen() {
|
function isSmallScreen() {
|
||||||
@@ -75,7 +169,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function adjustMenuPosition(submenu, parent, isTopLevel) {
|
function adjustMenuPosition(submenu, parent, isTopLevel) {
|
||||||
const rect = submenu.getBoundingClientRect();
|
|
||||||
const parentRect = parent.getBoundingClientRect();
|
const parentRect = parent.getBoundingClientRect();
|
||||||
|
|
||||||
const spaceAbove = parentRect.top;
|
const spaceAbove = parentRect.top;
|
||||||
|
|||||||
@@ -1,29 +1,51 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="{{ lang }}" dir="{{ lang_dir }}">
|
||||||
<head>
|
<head>
|
||||||
<title>{{platform.titel}}</title>
|
<title>{{platform.titel}}</title>
|
||||||
<meta charset="utf-8" >
|
<meta charset="utf-8" >
|
||||||
<link rel="icon" type="image/x-icon" href="{{platform.favicon.cache}}">
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/x-icon"
|
||||||
|
href="{{ asset_src(platform.favicon) }}"
|
||||||
|
>
|
||||||
|
<link rel="canonical" href="{{ url_for('localized_index', lang=lang, _external=True) }}">
|
||||||
|
{% for code in languages %}
|
||||||
|
<link rel="alternate" hreflang="{{ code }}" href="{{ url_for('localized_index', lang=code, _external=True) }}">
|
||||||
|
{% endfor %}
|
||||||
|
<link rel="alternate" hreflang="x-default" href="{{ url_for('index', _external=True) }}">
|
||||||
<!-- Bootstrap CSS only -->
|
<!-- Bootstrap CSS only -->
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-Zenh87qX5JnK2Jl0vWa8Ck2rdkQ2Bzep5IDxbcnCeuOxjzrPF/et3URy9Bv1WTRi" crossorigin="anonymous">
|
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap' ~ ('.rtl' if lang_dir == 'rtl' else '') ~ '.min.css') }}" rel="stylesheet">
|
||||||
<!-- Bootstrap JavaScript Bundle with Popper -->
|
<!-- Bootstrap JavaScript Bundle with Popper -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-OERcA2EqjJCMA+/3y+gxIOqMEjwtxJY7qPCqsdltbNJuaOe923+mo//f6V8Qbsw3" crossorigin="anonymous"></script>
|
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
|
||||||
<!-- Bootstrap Icons -->
|
<!-- Bootstrap Icons -->
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.9.1/font/bootstrap-icons.css">
|
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/font/bootstrap-icons.css') }}">
|
||||||
<!-- Fontawesome -->
|
<!-- Fontawesome -->
|
||||||
<script src="https://kit.fontawesome.com/56f96da298.js" crossorigin="anonymous"></script>
|
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/fontawesome/css/all.min.css') }}">
|
||||||
<!-- Markdown -->
|
<!-- Markdown -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
<script src="{{ url_for('static', filename='vendor/marked/marked.min.js') }}"></script>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/default.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/default.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom_scrollbar.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom_scrollbar.css') }}">
|
||||||
|
<!-- JQuery -->
|
||||||
|
<script src="{{ url_for('static', filename='vendor/jquery/jquery.min.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body
|
||||||
|
{% if apod_bg %}
|
||||||
|
style="
|
||||||
|
background-image: url('{{ apod_bg }}');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-attachment: fixed;
|
||||||
|
"
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header class="header">
|
<header class="header js-restore">
|
||||||
<img src="{{platform.logo.cache}}" alt="logo"/>
|
<img
|
||||||
|
src="{{ asset_src(platform.logo) }}"
|
||||||
|
alt="logo"
|
||||||
|
/>
|
||||||
<h1>{{platform.titel}}</h1>
|
<h1>{{platform.titel}}</h1>
|
||||||
<h2>{{platform.subtitel}}</h2>
|
<h2>{{platform.subtitel}}</h2>
|
||||||
<br />
|
|
||||||
</header>
|
</header>
|
||||||
{% set menu_type = "header" %}
|
{% set menu_type = "header" %}
|
||||||
{% include "moduls/navigation.html.j2"%}
|
{% include "moduls/navigation.html.j2"%}
|
||||||
@@ -43,16 +65,23 @@
|
|||||||
<p itemprop="name">{{ company.titel }} <br />
|
<p itemprop="name">{{ company.titel }} <br />
|
||||||
{{ company.subtitel }}</p>
|
{{ company.subtitel }}</p>
|
||||||
<span><i class="fa-solid fa-location-dot"></i> {{ company.address.values() | join(", ") }}</span>
|
<span><i class="fa-solid fa-location-dot"></i> {{ company.address.values() | join(", ") }}</span>
|
||||||
<p><a href="{{company.imprint_url}}" class="iframe-link"><i class="fa-solid fa-scale-balanced"></i> Imprint</a></p>
|
<p><a href="{{company.imprint_url}}" class="iframe-link"><i class="fa-solid fa-scale-balanced"></i> {{ t('Imprint') }}</a></p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<!-- Include modal -->
|
<!-- Include modal -->
|
||||||
{% include "moduls/modal.html.j2" %}
|
{% include "moduls/modal.html.j2" %}
|
||||||
<script src="{{ url_for('static', filename='js/modal.js') }}"></script>
|
<script>window.I18N = {{ ui_strings | tojson }};</script>
|
||||||
<script src="{{ url_for('static', filename='js/navigation.js') }}"></script>
|
{% for name in [
|
||||||
<script src="{{ url_for('static', filename='js/tooltip.js') }}"></script>
|
'modal',
|
||||||
<script src="{{ url_for('static', filename='js/custom_scrollbar.js') }}"></script>
|
'navigation',
|
||||||
<script src="{{ url_for('static', filename='js/iframe.js') }}"></script>
|
'tooltip',
|
||||||
|
'container',
|
||||||
|
'fullwidth',
|
||||||
|
'fullscreen',
|
||||||
|
'iframe',
|
||||||
|
] %}
|
||||||
|
<script src="{{ url_for('static', filename='js/' ~ name ~ '.js') }}"></script>
|
||||||
|
{% endfor %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,23 +1,34 @@
|
|||||||
<div class="card-column {{ lg_class }} {{ md_class }} col-12">
|
<div class="card-column {{ lg_class }} {{ md_class }} col-12">
|
||||||
<div class="card h-100 d-flex flex-column">
|
<div class="card h-100 d-flex flex-column">
|
||||||
<div class="card-body d-flex flex-column">
|
<div class="card-body d-flex flex-column">
|
||||||
<div class="card-img-top">
|
<div class="card-img-top">
|
||||||
{% if card.icon.class %}
|
{% if card.icon.cache and card.icon.cache.endswith('.svg') %}
|
||||||
<i class="{{ card.icon.class }}"></i>
|
{{ include_svg(card.icon.cache) }}
|
||||||
{% else %}
|
{% elif card.icon.cache or card.icon.external_url %}
|
||||||
<img src="{{ card.icon.cache }}" alt="{{ card.title }}" style="width: 100px; height: auto;">
|
<img
|
||||||
{% endif %}
|
src="{{ asset_src(card.icon) }}"
|
||||||
</div>
|
alt="{{ card.title }}"
|
||||||
<hr />
|
style="width:100px; height:auto;"
|
||||||
<h3 class="card-title">{{ card.title }}</h3>
|
onerror="this.style.display='none'; this.nextElementSibling?.style.display='inline-block';">
|
||||||
<p class="card-text">{{ card.text }}</p>
|
{% if card.icon.class %}
|
||||||
{% if card.url %}
|
<i class="{{ card.icon.class }}" style="display:none;"></i>
|
||||||
<a href="{{ card.url }}" class="mt-auto btn btn-light stretched-link {% if card.iframe %}iframe-link{% endif %}">
|
{% endif %}
|
||||||
<i class="fa-solid fa-globe"></i> {{ card.link_text }}
|
{% elif card.icon.class %}
|
||||||
</a>
|
<i class="{{ card.icon.class }}"></i>
|
||||||
{% else %}
|
{% endif %}
|
||||||
<i class="fa-solid fa-hourglass"></i> {{ card.link_text }}
|
</div>
|
||||||
{% endif %}
|
<hr />
|
||||||
</div>
|
<h3 class="card-title">{{ card.title }}</h3>
|
||||||
|
<p class="card-text">{{ card.text }}</p>
|
||||||
|
{% if card.url %}
|
||||||
|
<a
|
||||||
|
href="{{ card.url }}"
|
||||||
|
class="mt-auto btn btn-light stretched-link {% if card.iframe %}iframe-link{% endif %}">
|
||||||
|
<i class="fa-solid fa-globe"></i> {{ card.link_text }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<i class="fa-solid fa-hourglass"></i> {{ card.link_text }}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" id="dynamicModalLabel"></h5>
|
<h5 class="modal-title" id="dynamicModalLabel"></h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ t('Close') }}"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<!-- Warning box with Markdown -->
|
<!-- Warning box with Markdown -->
|
||||||
{{ alert_box('dynamicModalWarning', 'alert-warning', 'fa-solid fa-triangle-exclamation', 'Warning', 'dynamicModalWarningText') }}
|
{{ alert_box('dynamicModalWarning', 'alert-warning', 'fa-solid fa-triangle-exclamation', t('Warning'), 'dynamicModalWarningText') }}
|
||||||
|
|
||||||
<!-- Info box with Markdown -->
|
<!-- Info box with Markdown -->
|
||||||
{{ alert_box('dynamicModalInfo', 'alert-info', 'fa-solid fa-circle-info', 'Information', 'dynamicModalInfoText') }}
|
{{ alert_box('dynamicModalInfo', 'alert-info', 'fa-solid fa-circle-info', t('Information'), 'dynamicModalInfoText') }}
|
||||||
|
|
||||||
<!-- Description text -->
|
<!-- Description text -->
|
||||||
<div id="dynamicDescriptionText" class="mt-2 d-none"></div>
|
<div id="dynamicDescriptionText" class="mt-2 d-none"></div>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<!-- Input box for Identifier -->
|
<!-- Input box for Identifier -->
|
||||||
<div id="dynamicIdentifierBox" class="input-group mt-2 d-none">
|
<div id="dynamicIdentifierBox" class="input-group mt-2 d-none">
|
||||||
<input type="text" id="dynamicModalContent" class="form-control" readonly>
|
<input type="text" id="dynamicModalContent" class="form-control" readonly>
|
||||||
<button class="btn btn-outline-secondary" type="button" id="dynamicCopyButton">Copy</button>
|
<button class="btn btn-outline-secondary" type="button" id="dynamicCopyButton">{{ t('Copy') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Link -->
|
<!-- Link -->
|
||||||
@@ -40,13 +40,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Options -->
|
<!-- Options -->
|
||||||
{{ list_section('dynamicChildrenSection', 'Options', 'dynamicChildrenList') }}
|
{{ list_section('dynamicChildrenSection', t('Options'), 'dynamicChildrenList') }}
|
||||||
|
|
||||||
<!-- Alternatives -->
|
<!-- Alternatives -->
|
||||||
{{ list_section('dynamicAlternativesSection', 'Alternatives', 'dynamicAlternativesList') }}
|
{{ list_section('dynamicAlternativesSection', t('Alternatives'), 'dynamicAlternativesList') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" onclick="closeAllModals()">Close</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" onclick="closeAllModals()">{{ t('Close') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,65 +8,113 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
<!-- Template for children -->
|
<!-- Template for children -->
|
||||||
{% macro render_children(children) %}
|
{% macro render_children(children) %}
|
||||||
{% for children in children %}
|
{% for child in children %}
|
||||||
{% if children.children %}
|
{% if child.children %}
|
||||||
<li class="dropdown-submenu position-relative">
|
<li class="dropdown-submenu position-relative">
|
||||||
<a class="dropdown-item dropdown-toggle" title="{{ children.description }}">
|
<a class="dropdown-item dropdown-toggle" title="{{ child.description }}">
|
||||||
{{ render_icon_and_name(children) }}
|
{{ render_icon_and_name(child) }}
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
{{ render_children(children.children) }}
|
{{ render_children(child.children) }}
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
{% elif children.identifier or children.warning or children.info %}
|
|
||||||
<li>
|
{% elif child.identifier or child.warning or child.info %}
|
||||||
<a class="dropdown-item" onclick='openDynamicPopup({{ children|tojson|safe }})' data-bs-toggle="tooltip" title="{{ children.description }}">
|
<li>
|
||||||
{{ render_icon_and_name(children) }}
|
<a class="dropdown-item"
|
||||||
</a>
|
onclick='openDynamicPopup({{ child|tojson|safe }})'
|
||||||
</li>
|
data-bs-toggle="tooltip"
|
||||||
{% else %}
|
title="{{ child.description }}">
|
||||||
<li>
|
{{ render_icon_and_name(child) }}
|
||||||
<a class="dropdown-item {% if children.iframe %}iframe-link{% endif %}" href="{{ children.url }}" target="{{ children.target|default('_blank') }}" data-bs-toggle="tooltip" title="{{ children.description }}">
|
</a>
|
||||||
{{ render_icon_and_name(children) }}
|
</li>
|
||||||
</a>
|
|
||||||
</li>
|
{% else %}
|
||||||
{% endif %}
|
<li>
|
||||||
{% endfor %}
|
<a class="dropdown-item {% if child.iframe %}iframe-link{% endif %}"
|
||||||
|
{% if child.onclick %}
|
||||||
|
onclick="{{ child.onclick }}"
|
||||||
|
{% else %}
|
||||||
|
href="{{ child.url }}"
|
||||||
|
{% endif %}
|
||||||
|
target="{{ child.target|default('_blank') }}"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
title="{{ child.description }}">
|
||||||
|
{{ render_icon_and_name(child) }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
<!-- Navigation Bar -->
|
<!-- Navigation Bar -->
|
||||||
<nav class="navbar navbar-expand-lg navbar-light bg-light menu-{{menu_type}}">
|
<nav class="navbar navbar-expand-lg navbar-light bg-light menu-{{menu_type}} mb-0">
|
||||||
<div class="container-fluid">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav{{menu_type}}" aria-controls="navbarNav{{menu_type}}" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav{{menu_type}}" aria-controls="navbarNav{{menu_type}}" aria-expanded="false" aria-label="Toggle navigation">
|
<span class="navbar-toggler-icon"></span>
|
||||||
<span class="navbar-toggler-icon"></span>
|
</button>
|
||||||
</button>
|
<div class="collapse navbar-collapse" id="navbarNav{{menu_type}}">
|
||||||
<div class="collapse navbar-collapse" id="navbarNav{{menu_type}}">
|
{% if menu_type == "header" %}
|
||||||
<ul class="navbar-nav {% if menu_type == 'header' %}ms-auto{% endif %} btn-group">
|
<a class="navbar-brand align-items-center d-flex js-restore" id="navbar_logo" href="#">
|
||||||
{% for item in navigation[menu_type].children %}
|
<img
|
||||||
{% if item.url %}
|
src="{{ asset_src(platform.logo) }}"
|
||||||
<!-- Single Item -->
|
alt="{{ platform.titel }}"
|
||||||
<li class="nav-item">
|
class="d-inline-block align-text-top"
|
||||||
<a class="nav-link btn btn-light {% if item.iframe %}iframe-link{% endif %}" href="{{ item.url }}" target="{{ item.target|default('_blank') }}" data-bs-toggle="tooltip" title="{{ item.description }}">
|
style="height:2rem">
|
||||||
|
<div class="ms-2 d-flex flex-column">
|
||||||
|
<span class="fs-4 fw-bold mb-0">{{ platform.titel }}</span>
|
||||||
|
{# <small class="fs-7 text-muted">{{ platform.subtitel }}</small> #}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
<ul class="navbar-nav {% if menu_type == 'header' %}ms-auto{% endif %} btn-group">
|
||||||
|
{% for item in navigation[menu_type].children %}
|
||||||
|
{% if item.url or item.onclick %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link btn btn-light {% if item.iframe %}iframe-link{% endif %}"
|
||||||
|
{% if item.onclick %}
|
||||||
|
onclick="{{ item.onclick }}"
|
||||||
|
{% else %}
|
||||||
|
href="{{ item.url }}"
|
||||||
|
{% endif %}
|
||||||
|
target="{{ item.target|default('_blank') }}"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
title="{{ item.description }}">
|
||||||
|
{{ render_icon_and_name(item) }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<!-- Dropdown Menu -->
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle btn btn-light" id="navbarDropdown{{ loop.index }}" role="button" data-bs-toggle="dropdown" data-bs-display="dynamic" aria-expanded="false">
|
||||||
|
{% if item.icon is defined and item.icon.class is defined %}
|
||||||
{{ render_icon_and_name(item) }}
|
{{ render_icon_and_name(item) }}
|
||||||
</a>
|
{% else %}
|
||||||
</li>
|
<p>Missing icon in item: {{ item }}</p>
|
||||||
{% else %}
|
{% endif %}
|
||||||
<!-- Dropdown Menu -->
|
</a>
|
||||||
<li class="nav-item dropdown">
|
<ul class="dropdown-menu">
|
||||||
<a class="nav-link dropdown-toggle btn btn-light" id="navbarDropdown{{ loop.index }}" role="button" data-bs-display="dynamic" aria-expanded="false">
|
{{ render_children(item.children) }}
|
||||||
{% if item.icon is defined and item.icon.class is defined %}
|
</ul>
|
||||||
{{ render_icon_and_name(item) }}
|
</li>
|
||||||
{% else %}
|
{% endif %}
|
||||||
<p>Missing icon in item: {{ item }}</p>
|
{% endfor %}
|
||||||
{% endif %}
|
{% if menu_type == "header" %}
|
||||||
</a>
|
<li class="nav-item dropdown">
|
||||||
<ul class="dropdown-menu">
|
<a class="nav-link dropdown-toggle btn btn-light" id="navbarDropdownLanguage" role="button" data-bs-toggle="dropdown" data-bs-display="dynamic" aria-expanded="false" title="{{ t('Language') }}">
|
||||||
{{ render_children(item.children) }}
|
<i class="fa-solid fa-language"></i> {{ languages[lang] }}
|
||||||
</ul>
|
</a>
|
||||||
</li>
|
<ul class="dropdown-menu language-menu">
|
||||||
{% endif %}
|
{% for code, endonym in languages.items() %}
|
||||||
{% endfor %}
|
<li>
|
||||||
</ul>
|
<a class="dropdown-item {% if code == lang %}active{% endif %}"
|
||||||
</div>
|
href="{{ url_for('localized_index', lang=code) }}"
|
||||||
|
hreflang="{{ code }}"
|
||||||
|
lang="{{ code }}">{{ endonym }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
1
app/utils/__init__.py
Normal file
1
app/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Utilities used by the Portfolio UI web application."""
|
||||||
89
app/utils/asset_resolver.py
Normal file
89
app/utils/asset_resolver.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""Split asset resolution into distinct fields so the template can
|
||||||
|
render the right URL.
|
||||||
|
|
||||||
|
Strategy
|
||||||
|
--------
|
||||||
|
|
||||||
|
1. **Probe** the source URL with a HEAD request. If it responds with an
|
||||||
|
``image/*`` content type, embed it directly via ``external_url``
|
||||||
|
(no download necessary — the browser fetches it itself).
|
||||||
|
2. **Cache** as a fallback: when the probe fails (404, non-image,
|
||||||
|
network error, …) try ``cache_manager.cache_file()`` to download
|
||||||
|
and serve via ``/static/cache/<file>``.
|
||||||
|
3. **External fallback**: if the download fails too, still expose the
|
||||||
|
source URL via ``external_url`` so the browser shows at least the
|
||||||
|
alt-text or broken-image icon instead of an empty ``src``.
|
||||||
|
|
||||||
|
Type safety
|
||||||
|
-----------
|
||||||
|
|
||||||
|
``cache`` only ever holds a relative path the static handler can serve;
|
||||||
|
absolute URLs land in ``external_url`` and are rendered as-is. The
|
||||||
|
previous fallback ``asset['cache'] = cached or asset['source']`` mixed
|
||||||
|
both shapes — and the template's ``url_for('static', filename=...)``
|
||||||
|
wrapper produced broken ``/static/https://…`` URLs when the source
|
||||||
|
was an absolute URL the container couldn't fetch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
_PROBE_TIMEOUT_SECONDS = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_image_url(url: str) -> bool:
|
||||||
|
"""Return True if a HEAD request to ``url`` succeeds with an
|
||||||
|
``image/*`` content type, suggesting the URL is safe to embed
|
||||||
|
directly as ``<img src>``."""
|
||||||
|
try:
|
||||||
|
resp = requests.head(url, allow_redirects=True, timeout=_PROBE_TIMEOUT_SECONDS)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except requests.RequestException:
|
||||||
|
return False
|
||||||
|
content_type = resp.headers.get("Content-Type", "").lower()
|
||||||
|
return content_type.split(";", 1)[0].strip().startswith("image/")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_asset_cache(
|
||||||
|
asset: dict,
|
||||||
|
cache_manager,
|
||||||
|
probe: Callable[[str], bool] = _probe_image_url,
|
||||||
|
) -> None:
|
||||||
|
"""Set ``asset['cache']`` (local, served via /static/) or
|
||||||
|
``asset['external_url']`` (rendered as-is). Never both.
|
||||||
|
|
||||||
|
Tries the cheap direct-embed path first (``probe``) before falling
|
||||||
|
back to downloading via ``cache_manager``. See module docstring."""
|
||||||
|
source = asset.get("source")
|
||||||
|
asset["cache"] = None
|
||||||
|
asset["external_url"] = None
|
||||||
|
if not source:
|
||||||
|
return
|
||||||
|
if probe(source):
|
||||||
|
asset["external_url"] = source
|
||||||
|
return
|
||||||
|
cached = cache_manager.cache_file(source)
|
||||||
|
if cached:
|
||||||
|
asset["cache"] = cached
|
||||||
|
else:
|
||||||
|
asset["external_url"] = source
|
||||||
|
|
||||||
|
|
||||||
|
def asset_src(asset: Optional[dict], url_for_static: Callable[[str], str]) -> str:
|
||||||
|
"""Return the final URL for an asset dict prepared by
|
||||||
|
:func:`resolve_asset_cache`. ``url_for_static`` is a callable that
|
||||||
|
maps a relative cache path to its served URL (typically a partial
|
||||||
|
of Flask's ``url_for('static', filename=...)``)."""
|
||||||
|
if not asset:
|
||||||
|
return ""
|
||||||
|
external = asset.get("external_url")
|
||||||
|
if external:
|
||||||
|
return external
|
||||||
|
cached = asset.get("cache")
|
||||||
|
if cached:
|
||||||
|
return url_for_static(cached)
|
||||||
|
return ""
|
||||||
@@ -1,66 +1,47 @@
|
|||||||
import os
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
||||||
class CacheManager:
|
class CacheManager:
|
||||||
"""
|
|
||||||
A class to manage caching of files, including creating temporary directories
|
|
||||||
and caching files locally with hashed filenames.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, cache_dir="static/cache"):
|
def __init__(self, cache_dir="static/cache"):
|
||||||
"""
|
|
||||||
Initialize the CacheManager with a cache directory.
|
|
||||||
|
|
||||||
:param cache_dir: The directory where cached files will be stored.
|
|
||||||
"""
|
|
||||||
self.cache_dir = cache_dir
|
self.cache_dir = cache_dir
|
||||||
self._ensure_cache_dir_exists()
|
self._ensure_cache_dir_exists()
|
||||||
|
|
||||||
def _ensure_cache_dir_exists(self):
|
def _ensure_cache_dir_exists(self):
|
||||||
"""
|
os.makedirs(self.cache_dir, exist_ok=True)
|
||||||
Ensure the cache directory exists. If it doesn't, create it.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(self.cache_dir):
|
|
||||||
os.makedirs(self.cache_dir)
|
|
||||||
|
|
||||||
def clear_cache(self):
|
def clear_cache(self):
|
||||||
"""
|
|
||||||
Clear all files in the cache directory.
|
|
||||||
"""
|
|
||||||
if os.path.exists(self.cache_dir):
|
if os.path.exists(self.cache_dir):
|
||||||
for filename in os.listdir(self.cache_dir):
|
for filename in os.listdir(self.cache_dir):
|
||||||
file_path = os.path.join(self.cache_dir, filename)
|
path = os.path.join(self.cache_dir, filename)
|
||||||
if os.path.isfile(file_path):
|
if os.path.isfile(path):
|
||||||
os.remove(file_path)
|
os.remove(path)
|
||||||
|
|
||||||
def cache_file(self, file_url):
|
def cache_file(self, file_url):
|
||||||
"""
|
hash_suffix = hashlib.blake2s(
|
||||||
Download a file and store it locally in the cache directory with a hashed filename.
|
file_url.encode("utf-8"),
|
||||||
|
digest_size=8,
|
||||||
|
).hexdigest()
|
||||||
|
parts = file_url.rstrip("/").split("/")
|
||||||
|
base = parts[-2] if parts[-1] == "download" else parts[-1]
|
||||||
|
|
||||||
:param file_url: The URL of the file to cache.
|
try:
|
||||||
:return: The local path of the cached file.
|
resp = requests.get(file_url, stream=True, timeout=5)
|
||||||
"""
|
resp.raise_for_status()
|
||||||
# Generate a hashed filename based on the URL
|
except requests.RequestException:
|
||||||
hash_object = hashlib.blake2s(file_url.encode('utf-8'), digest_size=8)
|
return None
|
||||||
hash_suffix = hash_object.hexdigest()
|
|
||||||
|
|
||||||
# Determine the base name for the file
|
content_type = resp.headers.get("Content-Type", "")
|
||||||
splitted_file_url = file_url.split("/")
|
ext = mimetypes.guess_extension(content_type.split(";")[0].strip()) or ".png"
|
||||||
base_name = splitted_file_url[-2] if splitted_file_url[-1] == "download" else splitted_file_url[-1]
|
filename = f"{base}_{hash_suffix}{ext}"
|
||||||
|
|
||||||
# Construct the full path for the cached file
|
|
||||||
filename = f"{base_name}_{hash_suffix}.png"
|
|
||||||
full_path = os.path.join(self.cache_dir, filename)
|
full_path = os.path.join(self.cache_dir, filename)
|
||||||
|
|
||||||
# If the file already exists, return the cached path
|
if not os.path.exists(full_path):
|
||||||
if os.path.exists(full_path):
|
with open(full_path, "wb") as f:
|
||||||
return full_path
|
for chunk in resp.iter_content(1024):
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
# Download the file and save it locally
|
return f"cache/{filename}"
|
||||||
response = requests.get(file_url, stream=True)
|
|
||||||
if response.status_code == 200:
|
|
||||||
with open(full_path, "wb") as file:
|
|
||||||
for chunk in response.iter_content(1024):
|
|
||||||
file.write(chunk)
|
|
||||||
return full_path
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def compute_card_classes(cards):
|
|||||||
lg_classes.append("col-lg-6")
|
lg_classes.append("col-lg-6")
|
||||||
else:
|
else:
|
||||||
lg_classes.append("col-lg-4")
|
lg_classes.append("col-lg-4")
|
||||||
# md classes: If the number of cards is even or if not the last card, otherwise "col-md-12"
|
# Use a full-width last card on medium screens only when the total count is odd.
|
||||||
md_classes = []
|
md_classes = []
|
||||||
for i in range(num_cards):
|
for i in range(num_cards):
|
||||||
if num_cards % 2 == 0 or i < num_cards - 1:
|
if num_cards % 2 == 0 or i < num_cards - 1:
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from pprint import pprint
|
|
||||||
class ConfigurationResolver:
|
class ConfigurationResolver:
|
||||||
"""
|
"""
|
||||||
A class to resolve `link` entries in a nested configuration structure.
|
A class to resolve `link` entries in a nested configuration structure.
|
||||||
@@ -14,22 +13,9 @@ class ConfigurationResolver:
|
|||||||
"""
|
"""
|
||||||
self._recursive_resolve(self.config, self.config)
|
self._recursive_resolve(self.config, self.config)
|
||||||
|
|
||||||
def __load_children(self,path):
|
|
||||||
"""
|
|
||||||
Check if explicitly children should be loaded and not parent
|
|
||||||
"""
|
|
||||||
return path.split('.').pop() == "children"
|
|
||||||
|
|
||||||
def _replace_in_dict_by_dict(self, dict_origine, old_key, new_dict):
|
|
||||||
if old_key in dict_origine:
|
|
||||||
# Entferne den alten Key
|
|
||||||
old_value = dict_origine.pop(old_key)
|
|
||||||
# Füge die neuen Key-Value-Paare hinzu
|
|
||||||
dict_origine.update(new_dict)
|
|
||||||
|
|
||||||
def _replace_in_list_by_list(self, list_origine, old_element, new_elements):
|
def _replace_in_list_by_list(self, list_origine, old_element, new_elements):
|
||||||
index = list_origine.index(old_element)
|
index = list_origine.index(old_element)
|
||||||
list_origine[index:index+1] = new_elements
|
list_origine[index : index + 1] = new_elements
|
||||||
|
|
||||||
def _replace_element_in_list(self, list_origine, old_element, new_element):
|
def _replace_element_in_list(self, list_origine, old_element, new_element):
|
||||||
index = list_origine.index(old_element)
|
index = list_origine.index(old_element)
|
||||||
@@ -43,27 +29,43 @@ class ConfigurationResolver:
|
|||||||
for key, value in list(current_config.items()):
|
for key, value in list(current_config.items()):
|
||||||
if key == "children":
|
if key == "children":
|
||||||
if value is None or not isinstance(value, list):
|
if value is None or not isinstance(value, list):
|
||||||
raise ValueError(f"Expected 'children' to be a list, but got {type(value).__name__} instead.")
|
raise ValueError(
|
||||||
|
"Expected 'children' to be a list, but got "
|
||||||
|
f"{type(value).__name__} instead."
|
||||||
|
)
|
||||||
for item in value:
|
for item in value:
|
||||||
if "link" in item:
|
if "link" in item:
|
||||||
loaded_link = self._find_entry(root_config, self._mapped_key(item['link']), False)
|
loaded_link = self._find_entry(
|
||||||
|
root_config,
|
||||||
|
self._mapped_key(item["link"]),
|
||||||
|
False,
|
||||||
|
)
|
||||||
if isinstance(loaded_link, list):
|
if isinstance(loaded_link, list):
|
||||||
self._replace_in_list_by_list(value,item,loaded_link)
|
self._replace_in_list_by_list(value, item, loaded_link)
|
||||||
else:
|
else:
|
||||||
self._replace_element_in_list(value,item,loaded_link)
|
self._replace_element_in_list(value, item, loaded_link)
|
||||||
else:
|
else:
|
||||||
self._recursive_resolve(value, root_config)
|
self._recursive_resolve(value, root_config)
|
||||||
elif key == "link":
|
elif key == "link":
|
||||||
try:
|
try:
|
||||||
loaded = self._find_entry(root_config, self._mapped_key(value), False)
|
loaded = self._find_entry(
|
||||||
|
root_config, self._mapped_key(value), False
|
||||||
|
)
|
||||||
if isinstance(loaded, list) and len(loaded) > 2:
|
if isinstance(loaded, list) and len(loaded) > 2:
|
||||||
loaded = self._find_entry(root_config, self._mapped_key(value), False)
|
loaded = self._find_entry(
|
||||||
|
root_config, self._mapped_key(value), False
|
||||||
|
)
|
||||||
current_config.clear()
|
current_config.clear()
|
||||||
current_config.update(loaded)
|
current_config.update(loaded)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Error resolving link '{value}': {str(e)}. "
|
f"Error resolving link '{value}': {str(e)}. "
|
||||||
f"Current part: {key}, Current config: {current_config}" + (f", Loaded: {loaded}" if 'loaded' in locals() or 'loaded' in globals() else "")
|
f"Current part: {key}, Current config: {current_config}"
|
||||||
|
+ (
|
||||||
|
f", Loaded: {loaded}"
|
||||||
|
if "loaded" in locals() or "loaded" in globals()
|
||||||
|
else ""
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._recursive_resolve(value, root_config)
|
self._recursive_resolve(value, root_config)
|
||||||
@@ -71,69 +73,74 @@ class ConfigurationResolver:
|
|||||||
for item in current_config:
|
for item in current_config:
|
||||||
self._recursive_resolve(item, root_config)
|
self._recursive_resolve(item, root_config)
|
||||||
|
|
||||||
def _get_children(self,current):
|
def _get_children(self, current):
|
||||||
if isinstance(current, dict) and ("children" in current and current["children"]):
|
if isinstance(current, dict) and (
|
||||||
|
"children" in current and current["children"]
|
||||||
|
):
|
||||||
current = current["children"]
|
current = current["children"]
|
||||||
return current
|
return current
|
||||||
|
|
||||||
def _mapped_key(self,name):
|
def _mapped_key(self, name):
|
||||||
return name.replace(" ", "").lower()
|
return name.replace(" ", "").lower()
|
||||||
|
|
||||||
def _find_by_name(self,current, part):
|
def _find_by_name(self, current, part):
|
||||||
return next(
|
return next(
|
||||||
(item for item in current if isinstance(item, dict) and self._mapped_key(item.get("name", "")) == part),
|
(
|
||||||
None
|
item
|
||||||
)
|
for item in current
|
||||||
|
if isinstance(item, dict)
|
||||||
|
and self._mapped_key(item.get("name", "")) == part
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
def _find_entry(self, config, path, children):
|
def _find_entry(self, config, path, children):
|
||||||
"""
|
"""
|
||||||
Finds an entry in the configuration by a dot-separated path.
|
Finds an entry in the configuration by a dot-separated path.
|
||||||
Supports both dictionaries and lists with `children` navigation.
|
Supports both dictionaries and lists with `children` navigation.
|
||||||
"""
|
"""
|
||||||
parts = path.split('.')
|
parts = path.split(".")
|
||||||
current = config
|
current = config
|
||||||
for part in parts:
|
for part in parts:
|
||||||
if isinstance(current, list):
|
if isinstance(current, list):
|
||||||
# If children explicit declared just load children
|
|
||||||
if part != "children":
|
if part != "children":
|
||||||
# Look for a matching name in the list
|
found = self._find_by_name(current, part)
|
||||||
found = self._find_by_name(current,part)
|
|
||||||
if found:
|
if found:
|
||||||
current = found
|
current = found
|
||||||
print(
|
print(
|
||||||
f"Matching entry for '{part}' in list. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
|
f"Matching entry for '{part}' in list. Path so far: "
|
||||||
|
f"{' > '.join(parts[: parts.index(part) + 1])}. "
|
||||||
f"Current list: {current}"
|
f"Current list: {current}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"No matching entry for '{part}' in list. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
|
f"No matching entry for '{part}' in list. Path so far: "
|
||||||
|
f"{' > '.join(parts[: parts.index(part) + 1])}. "
|
||||||
f"Current list: {current}"
|
f"Current list: {current}"
|
||||||
)
|
)
|
||||||
elif isinstance(current, dict):
|
elif isinstance(current, dict):
|
||||||
# Case-insensitive dictionary lookup
|
|
||||||
key = next((k for k in current if self._mapped_key(k) == part), None)
|
key = next((k for k in current if self._mapped_key(k) == part), None)
|
||||||
# If no fitting key was found search in the children
|
|
||||||
if key is None:
|
if key is None:
|
||||||
if "children" not in current:
|
if "children" not in current:
|
||||||
raise KeyError(
|
raise KeyError(
|
||||||
f"No 'children' found in current dictionary. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
|
"No 'children' found in current dictionary. Path so far: "
|
||||||
f"Current dictionary: {current}"
|
f"{' > '.join(parts[: parts.index(part) + 1])}. "
|
||||||
)
|
f"Current dictionary: {current}"
|
||||||
# The following line seems buggy; Why is children loaded allways and not just when children is set?
|
)
|
||||||
current = self._find_by_name(current["children"],part)
|
current = self._find_by_name(current["children"], part)
|
||||||
|
|
||||||
if not current:
|
if not current:
|
||||||
raise KeyError(
|
raise KeyError(
|
||||||
f"Key '{part}' not found in dictionary. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
|
f"Key '{part}' not found in dictionary. Path so far: "
|
||||||
|
f"{' > '.join(parts[: parts.index(part) + 1])}. "
|
||||||
f"Current dictionary: {current}"
|
f"Current dictionary: {current}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
current = current[key]
|
current = current[key]
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Invalid path segment '{part}'. Current type: {type(current)}. "
|
f"Invalid path segment '{part}'. Current type: {type(current)}. "
|
||||||
f"Path so far: {' > '.join(parts[:parts.index(part)+1])}"
|
f"Path so far: {' > '.join(parts[: parts.index(part) + 1])}"
|
||||||
)
|
)
|
||||||
if children:
|
if children:
|
||||||
current = self._get_children(current)
|
current = self._get_children(current)
|
||||||
|
|||||||
134
app/utils/i18n.py
Normal file
134
app/utils/i18n.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""Language negotiation and translation of the resolved configuration tree.
|
||||||
|
|
||||||
|
Translation is catalogue-driven: a string is replaced only when the target
|
||||||
|
language's catalogue holds an entry for the exact English source string.
|
||||||
|
Anything unknown falls through to English, so a partially filled catalogue
|
||||||
|
degrades instead of breaking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.utils.languages import LANGUAGES, RTL_LANGUAGES
|
||||||
|
except ImportError: # pragma: no cover - supports running from the app/ directory.
|
||||||
|
from utils.languages import LANGUAGES, RTL_LANGUAGES
|
||||||
|
|
||||||
|
I18N_DIR = Path(__file__).resolve().parent.parent / "i18n"
|
||||||
|
UI_DIR = I18N_DIR / "ui"
|
||||||
|
CONTENT_DIR = I18N_DIR / "content"
|
||||||
|
|
||||||
|
SOURCE_LANGUAGE = "en"
|
||||||
|
|
||||||
|
|
||||||
|
TRANSLATABLE_KEYS = frozenset(
|
||||||
|
{"description", "info", "name", "subtitel", "text", "title", "warning"}
|
||||||
|
)
|
||||||
|
|
||||||
|
UI_STRINGS = (
|
||||||
|
"Alternatives",
|
||||||
|
"Close",
|
||||||
|
"Copy",
|
||||||
|
"Identifier copied to clipboard!",
|
||||||
|
"Imprint",
|
||||||
|
"Information",
|
||||||
|
"Language",
|
||||||
|
"Open",
|
||||||
|
"Open Link",
|
||||||
|
"Options",
|
||||||
|
"Warning",
|
||||||
|
)
|
||||||
|
|
||||||
|
_catalogs: dict[str, dict[str, str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def direction(code):
|
||||||
|
"""Return the writing direction of ``code`` as an HTML ``dir`` value."""
|
||||||
|
return "rtl" if code in RTL_LANGUAGES else "ltr"
|
||||||
|
|
||||||
|
|
||||||
|
def read_catalog(path):
|
||||||
|
"""Return the catalogue at ``path``, or an empty one if it is unusable.
|
||||||
|
|
||||||
|
Catalogues are hand-edited and machine-written, so a stray character must
|
||||||
|
degrade that language to English rather than take every page down with a
|
||||||
|
parse error. Non-string entries are dropped for the same reason: they would
|
||||||
|
otherwise reach the templates and render as ``42`` or ``null``.
|
||||||
|
"""
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeDecodeError, yaml.YAMLError):
|
||||||
|
logging.warning("Ignoring unreadable translation catalogue: %s", path)
|
||||||
|
return {}
|
||||||
|
if not isinstance(loaded, dict):
|
||||||
|
logging.warning(
|
||||||
|
"Ignoring translation catalogue that is not a mapping: %s", path
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
key: value
|
||||||
|
for key, value in loaded.items()
|
||||||
|
if isinstance(key, str) and isinstance(value, str)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def clear_catalogs():
|
||||||
|
"""Drop the memoized catalogues so edited files are picked up."""
|
||||||
|
_catalogs.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def catalog(code):
|
||||||
|
"""Return the merged UI and content catalogue for ``code``."""
|
||||||
|
if code not in _catalogs:
|
||||||
|
_catalogs[code] = {
|
||||||
|
**read_catalog(UI_DIR / f"{code}.yaml"),
|
||||||
|
**read_catalog(CONTENT_DIR / f"{code}.yaml"),
|
||||||
|
}
|
||||||
|
return _catalogs[code]
|
||||||
|
|
||||||
|
|
||||||
|
def negotiate(accepted, default=SOURCE_LANGUAGE):
|
||||||
|
"""Pick the best supported language from ``Accept-Language`` pairs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
accepted: iterable of ``(tag, quality)`` as produced by
|
||||||
|
``flask.request.accept_languages``.
|
||||||
|
default: language returned when no tag is supported.
|
||||||
|
|
||||||
|
Werkzeug's own ``best_match`` returns an exact match before it considers
|
||||||
|
primary-tag fallbacks, so ``de-DE,en;q=0.8`` resolves to English. Matching
|
||||||
|
on the primary subtag up front avoids that.
|
||||||
|
"""
|
||||||
|
best, best_quality = default, 0.0
|
||||||
|
for tag, quality in accepted:
|
||||||
|
code = tag.replace("_", "-").split("-")[0].lower()
|
||||||
|
if code in LANGUAGES and quality > best_quality:
|
||||||
|
best, best_quality = code, quality
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def translate_tree(node, code, key=None):
|
||||||
|
"""Return a copy of ``node`` with translatable leaves swapped for ``code``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
node: the resolved configuration tree, or any subtree of it.
|
||||||
|
code: target language code.
|
||||||
|
key: the mapping key ``node`` was reached through.
|
||||||
|
"""
|
||||||
|
if isinstance(node, dict):
|
||||||
|
return {name: translate_tree(value, code, name) for name, value in node.items()}
|
||||||
|
if isinstance(node, list):
|
||||||
|
return [translate_tree(item, code, key) for item in node]
|
||||||
|
if isinstance(node, str) and key in TRANSLATABLE_KEYS:
|
||||||
|
return catalog(code).get(node, node)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def ui_strings(code):
|
||||||
|
"""Return the interface strings for ``code``, keyed by their English source."""
|
||||||
|
entries = catalog(code)
|
||||||
|
return {source: entries.get(source, source) for source in UI_STRINGS}
|
||||||
196
app/utils/languages.py
Normal file
196
app/utils/languages.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"""ISO 639-1 languages, their display names and writing direction.
|
||||||
|
|
||||||
|
Generated by utils/generate_languages.py — edit that script, not this file.
|
||||||
|
|
||||||
|
Display names are CLDR endonyms where CLDR covers the language and the English
|
||||||
|
ISO 639-1 name for the 25 codes it does not.
|
||||||
|
"""
|
||||||
|
|
||||||
|
LANGUAGES = {
|
||||||
|
"en": "English",
|
||||||
|
"aa": "Qafar",
|
||||||
|
"ab": "Аԥсшәа",
|
||||||
|
"ae": "Avestan",
|
||||||
|
"af": "Afrikaans",
|
||||||
|
"ak": "Akan",
|
||||||
|
"am": "አማርኛ",
|
||||||
|
"an": "aragonés",
|
||||||
|
"ar": "العربية",
|
||||||
|
"as": "অসমীয়া",
|
||||||
|
"av": "Avaric",
|
||||||
|
"ay": "Aymara",
|
||||||
|
"az": "azərbaycan",
|
||||||
|
"ba": "башҡорт теле",
|
||||||
|
"be": "беларуская",
|
||||||
|
"bg": "български",
|
||||||
|
"bi": "Bislama",
|
||||||
|
"bm": "bamanakan",
|
||||||
|
"bn": "বাংলা",
|
||||||
|
"bo": "བོད་སྐད་",
|
||||||
|
"br": "brezhoneg",
|
||||||
|
"bs": "bosanski",
|
||||||
|
"ca": "català",
|
||||||
|
"ce": "нохчийн",
|
||||||
|
"ch": "Chamorro",
|
||||||
|
"co": "corsu",
|
||||||
|
"cr": "Cree",
|
||||||
|
"cs": "čeština",
|
||||||
|
"cu": "церковнослове́нскїй",
|
||||||
|
"cv": "чӑваш",
|
||||||
|
"cy": "Cymraeg",
|
||||||
|
"da": "dansk",
|
||||||
|
"de": "Deutsch",
|
||||||
|
"dv": "ދިވެހިބަސް",
|
||||||
|
"dz": "རྫོང་ཁ",
|
||||||
|
"ee": "eʋegbe",
|
||||||
|
"el": "Ελληνικά",
|
||||||
|
"eo": "Esperanto",
|
||||||
|
"es": "español",
|
||||||
|
"et": "eesti",
|
||||||
|
"eu": "euskara",
|
||||||
|
"fa": "فارسی",
|
||||||
|
"ff": "Pulaar",
|
||||||
|
"fi": "suomi",
|
||||||
|
"fj": "Fijian",
|
||||||
|
"fo": "føroyskt",
|
||||||
|
"fr": "français",
|
||||||
|
"fy": "Frysk",
|
||||||
|
"ga": "Gaeilge",
|
||||||
|
"gd": "Gàidhlig",
|
||||||
|
"gl": "galego",
|
||||||
|
"gn": "avañe’ẽ",
|
||||||
|
"gu": "ગુજરાતી",
|
||||||
|
"gv": "Gaelg",
|
||||||
|
"ha": "Hausa",
|
||||||
|
"he": "עברית",
|
||||||
|
"hi": "हिन्दी",
|
||||||
|
"ho": "Hiri Motu",
|
||||||
|
"hr": "hrvatski",
|
||||||
|
"ht": "Kreyòl Ayisyen",
|
||||||
|
"hu": "magyar",
|
||||||
|
"hy": "հայերեն",
|
||||||
|
"hz": "Herero",
|
||||||
|
"ia": "interlingua",
|
||||||
|
"id": "Indonesia",
|
||||||
|
"ie": "Interlingue",
|
||||||
|
"ig": "Igbo",
|
||||||
|
"ii": "ꆈꌠꉙ",
|
||||||
|
"ik": "Inupiaq",
|
||||||
|
"io": "Ido",
|
||||||
|
"is": "íslenska",
|
||||||
|
"it": "italiano",
|
||||||
|
"iu": "ᐃᓄᒃᑎᑐᑦ",
|
||||||
|
"ja": "日本語",
|
||||||
|
"jv": "Jawa",
|
||||||
|
"ka": "ქართული",
|
||||||
|
"kg": "Kongo",
|
||||||
|
"ki": "Gikuyu",
|
||||||
|
"kj": "Kuanyama",
|
||||||
|
"kk": "қазақ тілі",
|
||||||
|
"kl": "kalaallisut",
|
||||||
|
"km": "ខ្មែរ",
|
||||||
|
"kn": "ಕನ್ನಡ",
|
||||||
|
"ko": "한국어",
|
||||||
|
"kr": "Kanuri",
|
||||||
|
"ks": "کٲشُر",
|
||||||
|
"ku": "kurdî (kurmancî)",
|
||||||
|
"kv": "Komi",
|
||||||
|
"kw": "kernewek",
|
||||||
|
"ky": "кыргызча",
|
||||||
|
"la": "Latina",
|
||||||
|
"lb": "Lëtzebuergesch",
|
||||||
|
"lg": "Luganda",
|
||||||
|
"li": "Limburgan",
|
||||||
|
"ln": "lingála",
|
||||||
|
"lo": "ລາວ",
|
||||||
|
"lt": "lietuvių",
|
||||||
|
"lu": "Tshiluba",
|
||||||
|
"lv": "latviešu",
|
||||||
|
"mg": "Malagasy",
|
||||||
|
"mh": "Marshallese",
|
||||||
|
"mi": "Māori",
|
||||||
|
"mk": "македонски",
|
||||||
|
"ml": "മലയാളം",
|
||||||
|
"mn": "монгол",
|
||||||
|
"mr": "मराठी",
|
||||||
|
"ms": "Melayu",
|
||||||
|
"mt": "Malti",
|
||||||
|
"my": "မြန်မာ",
|
||||||
|
"na": "Nauru",
|
||||||
|
"nb": "norsk bokmål",
|
||||||
|
"nd": "isiNdebele",
|
||||||
|
"ne": "नेपाली",
|
||||||
|
"ng": "Ndonga",
|
||||||
|
"nl": "Nederlands",
|
||||||
|
"nn": "norsk nynorsk",
|
||||||
|
"no": "norsk",
|
||||||
|
"nr": "isiNdebele",
|
||||||
|
"nv": "Diné Bizaad",
|
||||||
|
"ny": "Nyanja",
|
||||||
|
"oc": "occitan",
|
||||||
|
"oj": "Ojibwa",
|
||||||
|
"om": "Oromoo",
|
||||||
|
"or": "ଓଡ଼ିଆ",
|
||||||
|
"os": "ирон",
|
||||||
|
"pa": "ਪੰਜਾਬੀ",
|
||||||
|
"pi": "Pali",
|
||||||
|
"pl": "polski",
|
||||||
|
"ps": "پښتو",
|
||||||
|
"pt": "português",
|
||||||
|
"qu": "Runasimi",
|
||||||
|
"rm": "rumantsch",
|
||||||
|
"rn": "Ikirundi",
|
||||||
|
"ro": "română",
|
||||||
|
"ru": "русский",
|
||||||
|
"rw": "Ikinyarwanda",
|
||||||
|
"sa": "संस्कृत भाषा",
|
||||||
|
"sc": "sardu",
|
||||||
|
"sd": "سنڌي",
|
||||||
|
"se": "davvisámegiella",
|
||||||
|
"sg": "Sängö",
|
||||||
|
"sh": "Serbo-Croatian",
|
||||||
|
"si": "සිංහල",
|
||||||
|
"sk": "slovenčina",
|
||||||
|
"sl": "slovenščina",
|
||||||
|
"sm": "Samoan",
|
||||||
|
"sn": "chiShona",
|
||||||
|
"so": "Soomaali",
|
||||||
|
"sq": "shqip",
|
||||||
|
"sr": "српски",
|
||||||
|
"ss": "siSwati",
|
||||||
|
"st": "Sesotho",
|
||||||
|
"su": "Basa Sunda",
|
||||||
|
"sv": "svenska",
|
||||||
|
"sw": "Kiswahili",
|
||||||
|
"ta": "தமிழ்",
|
||||||
|
"te": "తెలుగు",
|
||||||
|
"tg": "тоҷикӣ",
|
||||||
|
"th": "ไทย",
|
||||||
|
"ti": "ትግርኛ",
|
||||||
|
"tk": "türkmen dili",
|
||||||
|
"tl": "Tagalog",
|
||||||
|
"tn": "Setswana",
|
||||||
|
"to": "lea fakatonga",
|
||||||
|
"tr": "Türkçe",
|
||||||
|
"ts": "Xitsonga",
|
||||||
|
"tt": "татар",
|
||||||
|
"tw": "Twi",
|
||||||
|
"ty": "Tahitian",
|
||||||
|
"ug": "ئۇيغۇرچە",
|
||||||
|
"uk": "українська",
|
||||||
|
"ur": "اردو",
|
||||||
|
"uz": "o‘zbek",
|
||||||
|
"ve": "Tshivenḓa",
|
||||||
|
"vi": "Tiếng Việt",
|
||||||
|
"vo": "Volapük",
|
||||||
|
"wa": "walon",
|
||||||
|
"wo": "Wolof",
|
||||||
|
"xh": "IsiXhosa",
|
||||||
|
"yi": "ייִדיש",
|
||||||
|
"yo": "Èdè Yorùbá",
|
||||||
|
"za": "Vahcuengh",
|
||||||
|
"zh": "中文",
|
||||||
|
"zu": "isiZulu",
|
||||||
|
}
|
||||||
|
|
||||||
|
RTL_LANGUAGES = frozenset({"ar", "dv", "fa", "he", "ks", "ps", "sd", "ug", "ur", "yi"})
|
||||||
BIN
assets/img/screenshot.png
Normal file
BIN
assets/img/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1024 KiB |
@@ -1,12 +1,26 @@
|
|||||||
services:
|
services:
|
||||||
portfolio:
|
portfolio:
|
||||||
|
# SPOT: IMAGE_NAME comes from .env (see env.example). No default —
|
||||||
|
# an empty tag would have compose fall back to `portfolio-portfolio`
|
||||||
|
# silently, which `make run-dev` would then fail to find.
|
||||||
|
image: ${IMAGE_NAME:?IMAGE_NAME must be set in .env (see env.example)}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: application-portfolio
|
|
||||||
container_name: portfolio
|
container_name: portfolio
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "${PORT:?PORT must be set in .env (see env.example)}:${PORT:?PORT must be set in .env (see env.example)}"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./app:/app
|
- ./app:/app
|
||||||
|
- node_modules:/app/node_modules
|
||||||
|
- vendor:/app/static/vendor
|
||||||
|
# Run `npm install` on every container start so the named volumes
|
||||||
|
# reflect the current package.json (postinstall regenerates vendor/).
|
||||||
|
command: sh -c "npm install --prefix /app --no-audit --no-fund && python app.py"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
node_modules:
|
||||||
|
vendor:
|
||||||
|
|||||||
13
env.example
Normal file
13
env.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
PORT=5001
|
||||||
|
FLASK_ENV=production
|
||||||
|
# Single source of truth for the Docker image tag — read by both the
|
||||||
|
# Makefile (build/run-dev/run-prod) and docker-compose.yml so every
|
||||||
|
# build path produces and consumes the same tag.
|
||||||
|
IMAGE_NAME=portfolio
|
||||||
|
# Comma-separated public hostnames. Leave empty and the app reflects whatever
|
||||||
|
# Host header arrives into its canonical, hreflang and redirect URLs.
|
||||||
|
TRUSTED_HOSTS=
|
||||||
|
# LibreTranslate instance used by `make i18n` to fill app/i18n/content/.
|
||||||
|
# Only needed when generating translations, never at runtime.
|
||||||
|
LIBRETRANSLATE_URL=http://localhost:5002
|
||||||
|
LIBRETRANSLATE_API_KEY=
|
||||||
301
main.py
301
main.py
@@ -1,30 +1,43 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
main.py - A CLI tool for managing the Portfolio CMS Docker application.
|
main.py - Proxy to Makefile targets for managing the Portfolio CMS Docker application.
|
||||||
|
Automatically generates CLI commands based on the Makefile definitions.
|
||||||
This script provides commands to build and run the Docker container for the
|
|
||||||
portfolio application. It mimics the functionality of a Makefile with additional
|
|
||||||
explanatory text using argparse.
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
build - Build the Docker image.
|
|
||||||
up - Start the application using docker-compose (with build).
|
|
||||||
down - Stop and remove the running container.
|
|
||||||
run-dev - Run the container in development mode (with hot-reloading).
|
|
||||||
run-prod - Run the container in production mode.
|
|
||||||
logs - Display the logs of the running container.
|
|
||||||
dev - Start the application in development mode using docker-compose.
|
|
||||||
prod - Start the application in production mode using docker-compose.
|
|
||||||
cleanup - Remove all stopped containers.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import os
|
from pathlib import Path
|
||||||
|
|
||||||
|
MAKEFILE_PATH = Path(__file__).resolve().parent / "Makefile"
|
||||||
|
|
||||||
|
|
||||||
|
def load_targets(makefile_path):
|
||||||
|
"""
|
||||||
|
Parse the Makefile to extract targets and their help comments.
|
||||||
|
Assumes each target is defined as 'name:' and the following line that starts
|
||||||
|
with '\t#' provides its help text.
|
||||||
|
"""
|
||||||
|
targets = []
|
||||||
|
pattern = re.compile(r"^([A-Za-z0-9_\-]+):")
|
||||||
|
with open(makefile_path, "r", encoding="utf-8") as handle:
|
||||||
|
lines = handle.readlines()
|
||||||
|
for idx, line in enumerate(lines):
|
||||||
|
m = pattern.match(line)
|
||||||
|
if m:
|
||||||
|
name = m.group(1)
|
||||||
|
help_text = ""
|
||||||
|
if idx + 1 < len(lines):
|
||||||
|
next_line = lines[idx + 1].lstrip()
|
||||||
|
if next_line.startswith("#"):
|
||||||
|
help_text = next_line.lstrip("# ").strip()
|
||||||
|
targets.append((name, help_text))
|
||||||
|
return targets
|
||||||
|
|
||||||
|
|
||||||
def run_command(command, dry_run=False):
|
def run_command(command, dry_run=False):
|
||||||
"""Utility function to run a shell command."""
|
"""Utility to run shell commands."""
|
||||||
print(f"Executing: {' '.join(command)}")
|
print(f"Executing: {' '.join(command)}")
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print("Dry run enabled: command not executed.")
|
print("Dry run enabled: command not executed.")
|
||||||
@@ -35,262 +48,32 @@ def run_command(command, dry_run=False):
|
|||||||
print(f"Error: Command failed with exit code {e.returncode}")
|
print(f"Error: Command failed with exit code {e.returncode}")
|
||||||
sys.exit(e.returncode)
|
sys.exit(e.returncode)
|
||||||
|
|
||||||
def build(args):
|
|
||||||
"""
|
|
||||||
Build the Docker image for the portfolio application.
|
|
||||||
|
|
||||||
Command:
|
|
||||||
docker build -t application-portfolio .
|
|
||||||
|
|
||||||
This command creates a Docker image named 'application-portfolio'
|
|
||||||
from the Dockerfile in the current directory.
|
|
||||||
"""
|
|
||||||
command = ["docker", "build", "-t", "application-portfolio", "."]
|
|
||||||
run_command(command, args.dry_run)
|
|
||||||
|
|
||||||
def up(args):
|
|
||||||
"""
|
|
||||||
Start the application using docker-compose with build.
|
|
||||||
|
|
||||||
Command:
|
|
||||||
docker-compose up --build
|
|
||||||
|
|
||||||
This command uses docker-compose to build (if necessary) and start
|
|
||||||
all defined services. It is useful for quickly starting your
|
|
||||||
development or production environment.
|
|
||||||
"""
|
|
||||||
command = ["docker-compose", "up", "--build"]
|
|
||||||
run_command(command, args.dry_run)
|
|
||||||
|
|
||||||
def down(args):
|
|
||||||
"""
|
|
||||||
Stop and remove the Docker container named 'portfolio'.
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
docker stop portfolio
|
|
||||||
docker rm portfolio
|
|
||||||
|
|
||||||
These commands stop the running container and remove it from your Docker host.
|
|
||||||
The '-' prefix is used to ignore errors if the container is not running.
|
|
||||||
"""
|
|
||||||
command_stop = ["docker", "stop", "portfolio"]
|
|
||||||
command_rm = ["docker", "rm", "portfolio"]
|
|
||||||
run_command(command_stop, args.dry_run)
|
|
||||||
run_command(command_rm, args.dry_run)
|
|
||||||
|
|
||||||
def run_dev(args):
|
|
||||||
"""
|
|
||||||
Run the container in development mode with hot-reloading.
|
|
||||||
|
|
||||||
Command:
|
|
||||||
docker run -d -p 5000:5000 --name portfolio -v $(pwd)/app/:/app \
|
|
||||||
-e FLASK_APP=app.py -e FLASK_ENV=development application-portfolio
|
|
||||||
|
|
||||||
This command starts the container in detached mode (-d), maps port 5000,
|
|
||||||
mounts the local 'app/' directory into the container, and sets environment
|
|
||||||
variables to enable Flask's development mode.
|
|
||||||
"""
|
|
||||||
current_dir = os.getcwd()
|
|
||||||
volume_mapping = f"{current_dir}/app/:/app"
|
|
||||||
command = [
|
|
||||||
"docker", "run", "-d",
|
|
||||||
"-p", "5000:5000",
|
|
||||||
"--name", "portfolio",
|
|
||||||
"-v", volume_mapping,
|
|
||||||
"-e", "FLASK_APP=app.py",
|
|
||||||
"-e", "FLASK_ENV=development",
|
|
||||||
"application-portfolio"
|
|
||||||
]
|
|
||||||
run_command(command, args.dry_run)
|
|
||||||
|
|
||||||
def run_prod(args):
|
|
||||||
"""
|
|
||||||
Run the container in production mode.
|
|
||||||
|
|
||||||
Command:
|
|
||||||
docker run -d -p 5000:5000 --name portfolio application-portfolio
|
|
||||||
|
|
||||||
This command starts the container in detached mode, mapping port 5000,
|
|
||||||
and runs the production version of the portfolio application.
|
|
||||||
"""
|
|
||||||
command = [
|
|
||||||
"docker", "run", "-d",
|
|
||||||
"-p", "5000:5000",
|
|
||||||
"--name", "portfolio",
|
|
||||||
"application-portfolio"
|
|
||||||
]
|
|
||||||
run_command(command, args.dry_run)
|
|
||||||
|
|
||||||
def logs(args):
|
|
||||||
"""
|
|
||||||
Display the logs of the 'portfolio' container.
|
|
||||||
|
|
||||||
Command:
|
|
||||||
docker logs -f portfolio
|
|
||||||
|
|
||||||
This command follows the logs (using -f) of the running container,
|
|
||||||
which is helpful for debugging and monitoring.
|
|
||||||
"""
|
|
||||||
command = ["docker", "logs", "-f", "portfolio"]
|
|
||||||
run_command(command, args.dry_run)
|
|
||||||
|
|
||||||
def dev(args):
|
|
||||||
"""
|
|
||||||
Run the application in development mode using docker-compose.
|
|
||||||
|
|
||||||
Command:
|
|
||||||
FLASK_ENV=development docker-compose up -d
|
|
||||||
|
|
||||||
This command sets the FLASK_ENV environment variable to 'development'
|
|
||||||
and starts the application using docker-compose, enabling hot-reloading.
|
|
||||||
"""
|
|
||||||
env = os.environ.copy()
|
|
||||||
env["FLASK_ENV"] = "development"
|
|
||||||
command = ["docker-compose", "up", "-d"]
|
|
||||||
print("Setting FLASK_ENV=development")
|
|
||||||
run_command(command, args.dry_run)
|
|
||||||
|
|
||||||
def prod(args):
|
|
||||||
"""
|
|
||||||
Run the application in production mode using docker-compose.
|
|
||||||
|
|
||||||
Command:
|
|
||||||
docker-compose up --build
|
|
||||||
|
|
||||||
This command builds the Docker image if needed and starts the application
|
|
||||||
using docker-compose for a production environment.
|
|
||||||
"""
|
|
||||||
command = ["docker-compose", "up", "--build"]
|
|
||||||
run_command(command, args.dry_run)
|
|
||||||
|
|
||||||
def cleanup(args):
|
|
||||||
"""
|
|
||||||
Remove all stopped Docker containers.
|
|
||||||
|
|
||||||
Command:
|
|
||||||
docker container prune -f
|
|
||||||
|
|
||||||
This command cleans up your Docker environment by forcefully removing
|
|
||||||
all stopped containers. It is useful to reclaim disk space and remove
|
|
||||||
unused containers.
|
|
||||||
"""
|
|
||||||
command = ["docker", "container", "prune", "-f"]
|
|
||||||
run_command(command, args.dry_run)
|
|
||||||
|
|
||||||
def delete_portfolio_container(dry_run=False):
|
|
||||||
"""
|
|
||||||
Force remove the portfolio container if it exists.
|
|
||||||
"""
|
|
||||||
print("Checking if 'portfolio' container exists to delete...")
|
|
||||||
command = ["docker", "rm", "-f", "portfolio"]
|
|
||||||
run_command(command, dry_run)
|
|
||||||
|
|
||||||
def browse(args):
|
|
||||||
"""
|
|
||||||
Open http://localhost:5000 in Chromium browser.
|
|
||||||
|
|
||||||
Command:
|
|
||||||
chromium http://localhost:5000
|
|
||||||
|
|
||||||
This command launches the Chromium browser to view the running application.
|
|
||||||
"""
|
|
||||||
command = ["chromium", "http://localhost:5000"]
|
|
||||||
run_command(command, args.dry_run)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="CLI tool to manage the Portfolio CMS Docker application."
|
description="CLI proxy to Makefile targets for Portfolio CMS Docker app"
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Print the commands without executing them."
|
help="Print the generated Make command without executing it.",
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--delete",
|
|
||||||
action="store_true",
|
|
||||||
help="Delete the existing 'portfolio' container before running the command."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
subparsers = parser.add_subparsers(
|
subparsers = parser.add_subparsers(
|
||||||
title="Commands",
|
title="Available commands",
|
||||||
description="Available commands to manage the application",
|
dest="command",
|
||||||
dest="command"
|
required=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Browse command
|
targets = load_targets(MAKEFILE_PATH)
|
||||||
parser_browse = subparsers.add_parser(
|
for name, help_text in targets:
|
||||||
"browse", help="Open http://localhost:5000 in Chromium browser."
|
sp = subparsers.add_parser(name, help=help_text)
|
||||||
)
|
sp.set_defaults(target=name)
|
||||||
parser_browse.set_defaults(func=browse)
|
|
||||||
|
|
||||||
# Build command
|
|
||||||
parser_build = subparsers.add_parser(
|
|
||||||
"build", help="Build the Docker image."
|
|
||||||
)
|
|
||||||
parser_build.set_defaults(func=build)
|
|
||||||
|
|
||||||
# Up command (docker-compose up)
|
|
||||||
parser_up = subparsers.add_parser(
|
|
||||||
"up", help="Start the application using docker-compose (with build)."
|
|
||||||
)
|
|
||||||
parser_up.set_defaults(func=up)
|
|
||||||
|
|
||||||
# Down command
|
|
||||||
parser_down = subparsers.add_parser(
|
|
||||||
"down", help="Stop and remove the Docker container."
|
|
||||||
)
|
|
||||||
parser_down.set_defaults(func=down)
|
|
||||||
|
|
||||||
# Run-dev command
|
|
||||||
parser_run_dev = subparsers.add_parser(
|
|
||||||
"run-dev", help="Run the container in development mode (with hot-reloading)."
|
|
||||||
)
|
|
||||||
parser_run_dev.set_defaults(func=run_dev)
|
|
||||||
|
|
||||||
# Run-prod command
|
|
||||||
parser_run_prod = subparsers.add_parser(
|
|
||||||
"run-prod", help="Run the container in production mode."
|
|
||||||
)
|
|
||||||
parser_run_prod.set_defaults(func=run_prod)
|
|
||||||
|
|
||||||
# Logs command
|
|
||||||
parser_logs = subparsers.add_parser(
|
|
||||||
"logs", help="Display the logs of the running container."
|
|
||||||
)
|
|
||||||
parser_logs.set_defaults(func=logs)
|
|
||||||
|
|
||||||
# Dev command (docker-compose with FLASK_ENV)
|
|
||||||
parser_dev = subparsers.add_parser(
|
|
||||||
"dev", help="Start the application in development mode using docker-compose."
|
|
||||||
)
|
|
||||||
parser_dev.set_defaults(func=dev)
|
|
||||||
|
|
||||||
# Prod command (docker-compose production)
|
|
||||||
parser_prod = subparsers.add_parser(
|
|
||||||
"prod", help="Start the application in production mode using docker-compose."
|
|
||||||
)
|
|
||||||
parser_prod.set_defaults(func=prod)
|
|
||||||
|
|
||||||
# Cleanup command
|
|
||||||
parser_cleanup = subparsers.add_parser(
|
|
||||||
"cleanup", help="Remove all stopped Docker containers."
|
|
||||||
)
|
|
||||||
parser_cleanup.set_defaults(func=cleanup)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
cmd = ["make", args.target]
|
||||||
|
run_command(cmd, dry_run=args.dry_run)
|
||||||
|
|
||||||
if args.command is None:
|
|
||||||
parser.print_help()
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if args.delete:
|
|
||||||
delete_portfolio_container(args.dry_run)
|
|
||||||
|
|
||||||
# Execute the chosen subcommand function
|
|
||||||
args.func(args)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
51
pyproject.toml
Normal file
51
pyproject.toml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "portfolio-ui"
|
||||||
|
version = "2.1.0"
|
||||||
|
description = "A lightweight YAML-driven portfolio and landing-page generator."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
# 3.1 introduced TRUSTED_HOSTS; an older Flask accepts the config key and
|
||||||
|
# ignores it, which would silently disable the host-forgery check.
|
||||||
|
"flask>=3.1",
|
||||||
|
"pyyaml",
|
||||||
|
"requests",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"babel",
|
||||||
|
"bandit",
|
||||||
|
"pip-audit",
|
||||||
|
"pycountry",
|
||||||
|
"ruff",
|
||||||
|
"yamllint",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
py-modules = ["main"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["app", "app.*"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
app = [
|
||||||
|
"config.sample.yaml",
|
||||||
|
"i18n/ui/*.yaml",
|
||||||
|
"i18n/content/*.yaml",
|
||||||
|
"templates/**/*.j2",
|
||||||
|
"static/css/*.css",
|
||||||
|
"static/js/*.js",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
line-length = 88
|
||||||
|
extend-exclude = ["app/static/cache", "build"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I"]
|
||||||
73
scripts/run-e2e.sh
Executable file
73
scripts/run-e2e.sh
Executable file
@@ -0,0 +1,73 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Run the Cypress suite against a Flask app started by this script.
|
||||||
|
#
|
||||||
|
# Server and browser share one shell, so this works where act cannot reach the
|
||||||
|
# host network. ELECTRON_RUN_AS_NODE is dropped before invoking Cypress because
|
||||||
|
# VS Code exports it to child processes, which makes Cypress' bundled Electron
|
||||||
|
# behave as Node and reject its own launch flags.
|
||||||
|
#
|
||||||
|
# Env:
|
||||||
|
# PORT required, the port Flask binds and Cypress targets
|
||||||
|
# PYTHON interpreter to run app.py with (default: python3)
|
||||||
|
# E2E_LOG where Flask's output goes (default: /tmp/portfolio-e2e-flask.log)
|
||||||
|
set -u
|
||||||
|
|
||||||
|
PORT="${PORT:?PORT must be set (see env.example)}"
|
||||||
|
PYTHON="${PYTHON:-python3}"
|
||||||
|
E2E_LOG="${E2E_LOG:-/tmp/portfolio-e2e-flask.log}"
|
||||||
|
FLASK_HOST=127.0.0.1
|
||||||
|
export PORT FLASK_HOST
|
||||||
|
|
||||||
|
APP_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")/../app" && pwd)"
|
||||||
|
BASE_URL="http://127.0.0.1:$PORT/"
|
||||||
|
|
||||||
|
# cypress.config.js defaults to localhost, which resolves to ::1 first on a
|
||||||
|
# dual-stack host — a different listener from the 127.0.0.1 one Flask binds and
|
||||||
|
# this script probes. Pin all three to the same origin.
|
||||||
|
export CYPRESS_baseUrl="$BASE_URL"
|
||||||
|
|
||||||
|
# A foreign listener would be tested instead of the working tree. The predicate
|
||||||
|
# is "something answers", deliberately without curl's -f: a foreign server that
|
||||||
|
# is still returning 5xx right now would otherwise pass the guard and then be
|
||||||
|
# picked up by the readiness probe once it recovers. --noproxy is what keeps
|
||||||
|
# that same looser predicate from matching an HTTP proxy instead of the port.
|
||||||
|
if curl -s --noproxy '*' --connect-timeout 2 --max-time 10 -o /dev/null "$BASE_URL"; then
|
||||||
|
echo "ERROR: something already serves port $PORT — Cypress would test that"
|
||||||
|
echo " instead of your working tree. Stop it first ('make down' for"
|
||||||
|
echo " the container, otherwise a stray 'python app.py')."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$APP_DIR" || exit 1
|
||||||
|
$PYTHON app.py > "$E2E_LOG" 2>&1 &
|
||||||
|
flask_pid=$!
|
||||||
|
trap 'kill "$flask_pid" 2>/dev/null || true' EXIT INT TERM
|
||||||
|
|
||||||
|
echo "Waiting for $BASE_URL — follow with: tail -f $E2E_LOG"
|
||||||
|
attempt=0
|
||||||
|
while [ "$attempt" -lt 120 ]; do
|
||||||
|
# Liveness first: a server answering while ours is dead means we are about
|
||||||
|
# to hand Cypress somebody else's app.
|
||||||
|
if ! kill -0 "$flask_pid" 2>/dev/null; then
|
||||||
|
echo "ERROR: Flask exited during startup"
|
||||||
|
cat "$E2E_LOG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if curl -sf --noproxy '*' --connect-timeout 2 --max-time 10 -o /dev/null "$BASE_URL"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! curl -sf --noproxy '*' --connect-timeout 2 --max-time 10 -o /dev/null "$BASE_URL"; then
|
||||||
|
echo "ERROR: app never became ready"
|
||||||
|
cat "$E2E_LOG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
env -u ELECTRON_RUN_AS_NODE npx cypress run
|
||||||
|
status=$?
|
||||||
|
|
||||||
|
kill "$flask_pid" 2>/dev/null || true
|
||||||
|
exit "$status"
|
||||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
tests/integration/__init__.py
Normal file
1
tests/integration/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
258
tests/integration/test_app_routes.py
Normal file
258
tests/integration/test_app_routes.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
app_module = None
|
||||||
|
flask_app = None
|
||||||
|
i18n = None
|
||||||
|
_origin = None
|
||||||
|
_workdir = None
|
||||||
|
|
||||||
|
|
||||||
|
def setUpModule():
|
||||||
|
"""Import the app with a disposable configuration as the working directory.
|
||||||
|
|
||||||
|
``app.app`` reads ``config.yaml`` relative to the process working directory
|
||||||
|
at import time, so the chdir has to happen before the import.
|
||||||
|
"""
|
||||||
|
global app_module, flask_app, i18n, _origin, _workdir
|
||||||
|
|
||||||
|
_origin = os.getcwd()
|
||||||
|
_workdir = tempfile.mkdtemp(prefix="portfolio-routes-")
|
||||||
|
shutil.copy(
|
||||||
|
REPO_ROOT / "app" / "config.sample.yaml", Path(_workdir) / "config.yaml"
|
||||||
|
)
|
||||||
|
os.chdir(_workdir)
|
||||||
|
|
||||||
|
from app import app as imported_module
|
||||||
|
from app.utils import i18n as imported_i18n
|
||||||
|
|
||||||
|
app_module = imported_module
|
||||||
|
flask_app = imported_module.app
|
||||||
|
i18n = imported_i18n
|
||||||
|
flask_app.config["NASA_API_KEY"] = None
|
||||||
|
|
||||||
|
|
||||||
|
def tearDownModule():
|
||||||
|
os.chdir(_origin)
|
||||||
|
shutil.rmtree(_workdir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AppRouteMixin:
|
||||||
|
"""Shared setup. A mixin rather than a TestCase subclass, so that every
|
||||||
|
test class below still names ``unittest.TestCase`` as a direct base — the
|
||||||
|
lint guardrail in tests/lint/ does not resolve inherited aliases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = flask_app.test_client()
|
||||||
|
self.addCleanup(i18n.clear_catalogs)
|
||||||
|
self.addCleanup(flask_app.config["TRANSLATED_CONFIG"].clear)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRouting(AppRouteMixin, unittest.TestCase):
|
||||||
|
def test_unrelated_single_segment_paths_are_not_redirected(self):
|
||||||
|
for path in ("/robots.txt", "/favicon.ico", "/sitemap.xml"):
|
||||||
|
with self.subTest(path=path):
|
||||||
|
self.assertEqual(self.client.get(path).status_code, 404)
|
||||||
|
|
||||||
|
def test_language_path_redirects_to_the_canonical_trailing_slash(self):
|
||||||
|
response = self.client.get("/de")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 308)
|
||||||
|
self.assertTrue(response.headers["Location"].endswith("/de/"))
|
||||||
|
|
||||||
|
def test_supported_language_renders(self):
|
||||||
|
response = self.client.get("/de/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn('<html lang="de"', response.get_data(as_text=True))
|
||||||
|
|
||||||
|
def test_unsupported_language_is_not_found(self):
|
||||||
|
self.assertEqual(self.client.get("/xx/").status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNegotiation(AppRouteMixin, unittest.TestCase):
|
||||||
|
def test_regional_tag_beats_a_lower_ranked_exact_match(self):
|
||||||
|
response = self.client.get("/", headers={"Accept-Language": "de-DE,en;q=0.8"})
|
||||||
|
|
||||||
|
self.assertIn('<html lang="de"', response.get_data(as_text=True))
|
||||||
|
|
||||||
|
def test_negotiated_route_declares_that_it_varies(self):
|
||||||
|
response = self.client.get("/", headers={"Accept-Language": "de-DE"})
|
||||||
|
|
||||||
|
self.assertEqual(response.headers.get("Vary"), "Accept-Language")
|
||||||
|
|
||||||
|
|
||||||
|
class TestEscaping(AppRouteMixin, unittest.TestCase):
|
||||||
|
def test_catalog_content_is_html_escaped(self):
|
||||||
|
i18n._catalogs["de"] = {"Copy": "<script>alert('ui')</script>"}
|
||||||
|
|
||||||
|
body = self.client.get("/de/").get_data(as_text=True)
|
||||||
|
|
||||||
|
self.assertNotIn("<script>alert('ui')</script>", body)
|
||||||
|
self.assertIn("<script>alert('ui')", body)
|
||||||
|
|
||||||
|
def test_configuration_content_is_html_escaped(self):
|
||||||
|
i18n._catalogs["de"] = {"Imprint": "<script>alert('config')</script>"}
|
||||||
|
|
||||||
|
body = self.client.get("/de/").get_data(as_text=True)
|
||||||
|
|
||||||
|
self.assertNotIn("<script>alert('config')</script>", body)
|
||||||
|
self.assertIn("<script>alert('config')", body)
|
||||||
|
|
||||||
|
|
||||||
|
class TestApodBackground(AppRouteMixin, unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.addCleanup(flask_app.config.__setitem__, "NASA_API_KEY", None)
|
||||||
|
flask_app.config["NASA_API_KEY"] = "key"
|
||||||
|
|
||||||
|
def test_no_request_is_made_without_a_key(self):
|
||||||
|
flask_app.config["NASA_API_KEY"] = None
|
||||||
|
|
||||||
|
with patch("app.app.requests.get") as get:
|
||||||
|
self.assertIsNone(app_module.apod_background())
|
||||||
|
|
||||||
|
get.assert_not_called()
|
||||||
|
|
||||||
|
def test_a_transport_failure_costs_the_background_not_the_page(self):
|
||||||
|
with patch(
|
||||||
|
"app.app.requests.get", side_effect=requests.ConnectionError("down")
|
||||||
|
):
|
||||||
|
self.assertIsNone(app_module.apod_background())
|
||||||
|
|
||||||
|
self.assertEqual(self.client.get("/en/").status_code, 200)
|
||||||
|
|
||||||
|
def test_an_error_response_costs_the_background_not_the_page(self):
|
||||||
|
refusal = Mock(ok=False)
|
||||||
|
refusal.json.return_value = {"media_type": "image", "url": "https://i.test/x"}
|
||||||
|
|
||||||
|
with patch("app.app.requests.get", return_value=refusal):
|
||||||
|
self.assertIsNone(app_module.apod_background())
|
||||||
|
|
||||||
|
def test_a_video_of_the_day_is_not_used_as_a_background(self):
|
||||||
|
answer = Mock(ok=True)
|
||||||
|
answer.json.return_value = {"media_type": "video", "url": "https://v.test/x"}
|
||||||
|
|
||||||
|
with patch("app.app.requests.get", return_value=answer):
|
||||||
|
self.assertIsNone(app_module.apod_background())
|
||||||
|
|
||||||
|
def test_an_image_of_the_day_is_used(self):
|
||||||
|
answer = Mock(ok=True)
|
||||||
|
answer.json.return_value = {"media_type": "image", "url": "https://i.test/x"}
|
||||||
|
|
||||||
|
with patch("app.app.requests.get", return_value=answer):
|
||||||
|
self.assertEqual(app_module.apod_background(), "https://i.test/x")
|
||||||
|
|
||||||
|
|
||||||
|
class TestExternalUrls(AppRouteMixin, unittest.TestCase):
|
||||||
|
def test_only_the_forwarded_scheme_is_trusted(self):
|
||||||
|
proxy = flask_app.wsgi_app
|
||||||
|
|
||||||
|
self.assertEqual(proxy.x_proto, 1)
|
||||||
|
self.assertEqual(
|
||||||
|
(proxy.x_for, proxy.x_host, proxy.x_port, proxy.x_prefix), (0, 0, 0, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_forwarded_scheme_reaches_the_canonical_and_alternates(self):
|
||||||
|
body = self.client.get(
|
||||||
|
"/en/",
|
||||||
|
headers={"Host": "portfolio.example.org", "X-Forwarded-Proto": "https"},
|
||||||
|
).get_data(as_text=True)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
'<link rel="canonical" href="https://portfolio.example.org/en/">', body
|
||||||
|
)
|
||||||
|
self.assertIn('hreflang="ja" href="https://portfolio.example.org/ja/"', body)
|
||||||
|
self.assertNotIn("http://portfolio.example.org", body)
|
||||||
|
|
||||||
|
def test_trusted_hosts_are_parsed_from_a_comma_separated_list(self):
|
||||||
|
self.assertEqual(
|
||||||
|
app_module.trusted_hosts("a.test, b.test ,"), ["a.test", "b.test"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_an_unset_trusted_hosts_value_disables_the_check(self):
|
||||||
|
self.assertIsNone(app_module.trusted_hosts(""))
|
||||||
|
self.assertIsNone(app_module.trusted_hosts(" , "))
|
||||||
|
|
||||||
|
def test_a_forged_host_is_rejected_once_trusted_hosts_are_named(self):
|
||||||
|
self.addCleanup(flask_app.config.__setitem__, "TRUSTED_HOSTS", None)
|
||||||
|
flask_app.config["TRUSTED_HOSTS"] = ["portfolio.example.org"]
|
||||||
|
|
||||||
|
forged = self.client.get("/de", headers={"Host": "evil.test"})
|
||||||
|
honest = self.client.get("/de/", headers={"Host": "portfolio.example.org"})
|
||||||
|
|
||||||
|
self.assertEqual(forged.status_code, 400)
|
||||||
|
self.assertEqual(honest.status_code, 200)
|
||||||
|
|
||||||
|
def test_forwarded_host_is_not_trusted(self):
|
||||||
|
response = self.client.get(
|
||||||
|
"/de",
|
||||||
|
headers={"Host": "portfolio.example.org", "X-Forwarded-Host": "evil.test"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotIn("evil.test", response.headers["Location"])
|
||||||
|
|
||||||
|
body = self.client.get(
|
||||||
|
"/en/",
|
||||||
|
headers={"Host": "portfolio.example.org", "X-Forwarded-Host": "evil.test"},
|
||||||
|
).get_data(as_text=True)
|
||||||
|
|
||||||
|
self.assertNotIn("evil.test", body)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigurationReload(AppRouteMixin, unittest.TestCase):
|
||||||
|
def test_reloading_the_configuration_drops_the_catalog_memo(self):
|
||||||
|
i18n.catalog("de")
|
||||||
|
self.assertIn("de", i18n._catalogs)
|
||||||
|
|
||||||
|
app_module.load_config(flask_app)
|
||||||
|
|
||||||
|
self.assertEqual(i18n._catalogs, {})
|
||||||
|
|
||||||
|
def test_reloading_the_configuration_drops_the_translation_memo(self):
|
||||||
|
self.client.get("/de/")
|
||||||
|
self.assertIn("de", flask_app.config["TRANSLATED_CONFIG"])
|
||||||
|
|
||||||
|
app_module.load_config(flask_app)
|
||||||
|
|
||||||
|
self.assertEqual(flask_app.config["TRANSLATED_CONFIG"], {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestTrustedHostsWiring(unittest.TestCase):
|
||||||
|
def test_the_environment_reaches_the_flask_configuration(self):
|
||||||
|
script = (
|
||||||
|
"import json, sys;"
|
||||||
|
f"sys.path.insert(0, {str(REPO_ROOT)!r});"
|
||||||
|
"from app.app import app;"
|
||||||
|
"print('TRUSTED=' + json.dumps(app.config['TRUSTED_HOSTS']))"
|
||||||
|
)
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-c", script],
|
||||||
|
cwd=_workdir,
|
||||||
|
env={**os.environ, "TRUSTED_HOSTS": "a.test, b.test"},
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
reported = [
|
||||||
|
line for line in result.stdout.splitlines() if line.startswith("TRUSTED=")
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
json.loads(reported[-1][len("TRUSTED=") :]), ["a.test", "b.test"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
84
tests/integration/test_asset_resolver_live.py
Normal file
84
tests/integration/test_asset_resolver_live.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""Live network verification of the probe-first asset resolution path.
|
||||||
|
|
||||||
|
The unit tests in ``tests/unit/test_asset_resolver.py`` cover every
|
||||||
|
branch with mocks. This integration test exercises the real
|
||||||
|
``https://file.infinito.nexus/assets/img/logo.png`` endpoint that
|
||||||
|
``app/config.sample.yaml`` references for the Infinito.Nexus card. A
|
||||||
|
green run proves the production resolver behaviour end-to-end:
|
||||||
|
|
||||||
|
* probe (HEAD ``image/*``) succeeds against the canonical Infinito.Nexus
|
||||||
|
asset host, so ``external_url`` is populated directly and **no**
|
||||||
|
download is performed (the cache directory stays empty).
|
||||||
|
|
||||||
|
The download-fallback branch (probe fails → ``cache_manager.cache_file``
|
||||||
|
serves the asset locally) is intentionally not exercised against a live
|
||||||
|
endpoint here — it is covered by mocked unit tests because every
|
||||||
|
candidate "HEAD-fails, GET-succeeds" public URL we tried was flaky
|
||||||
|
(Nextcloud's ``/download`` endpoint sporadically times out on HEAD even
|
||||||
|
when GET works), which would make this guard test non-deterministic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from app.utils.asset_resolver import resolve_asset_cache
|
||||||
|
from app.utils.cache_manager import CacheManager
|
||||||
|
|
||||||
|
INFINITO_LOGO_URL = "https://file.infinito.nexus/assets/img/logo.png"
|
||||||
|
|
||||||
|
_SKIP_LIVE_NETWORK = os.environ.get("PORTFOLIO_SKIP_LIVE_NETWORK_TESTS") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def _reachable(url: str) -> bool:
|
||||||
|
try:
|
||||||
|
return requests.head(url, allow_redirects=True, timeout=5).ok
|
||||||
|
except requests.RequestException:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipIf(
|
||||||
|
_SKIP_LIVE_NETWORK,
|
||||||
|
"PORTFOLIO_SKIP_LIVE_NETWORK_TESTS=1 — skipping live network tests",
|
||||||
|
)
|
||||||
|
class TestAssetResolverLive(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
if not _reachable(INFINITO_LOGO_URL):
|
||||||
|
raise unittest.SkipTest(
|
||||||
|
f"{urlparse(INFINITO_LOGO_URL).netloc} unreachable from this runner"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_infinito_nexus_logo_resolves_via_external_url_without_download(self):
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
cache_dir = os.path.join(tmp, "cache")
|
||||||
|
cache_manager = CacheManager(cache_dir)
|
||||||
|
asset = {"source": INFINITO_LOGO_URL}
|
||||||
|
|
||||||
|
resolve_asset_cache(asset, cache_manager)
|
||||||
|
|
||||||
|
self.assertIsNone(
|
||||||
|
asset["cache"],
|
||||||
|
f"Expected probe-first path to skip download for {INFINITO_LOGO_URL}",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
asset["external_url"],
|
||||||
|
INFINITO_LOGO_URL,
|
||||||
|
"Probe-success URL must land in external_url so the template "
|
||||||
|
"embeds it directly instead of routing through /static/.",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
os.listdir(cache_dir),
|
||||||
|
[],
|
||||||
|
"Cache directory must stay empty when probe succeeds — "
|
||||||
|
"downloading would waste bandwidth.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
72
tests/integration/test_python_packaging.py
Normal file
72
tests/integration/test_python_packaging.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import re
|
||||||
|
import tomllib
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def distributions(requirements):
|
||||||
|
"""Return the distribution names of PEP 508 requirement strings."""
|
||||||
|
return {
|
||||||
|
re.split(r"[<>=!~\[; ]", requirement, maxsplit=1)[0]
|
||||||
|
for requirement in requirements
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestPythonPackaging(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.repo_root = Path(__file__).resolve().parents[2]
|
||||||
|
self.pyproject_path = self.repo_root / "pyproject.toml"
|
||||||
|
|
||||||
|
with self.pyproject_path.open("rb") as handle:
|
||||||
|
self.pyproject = tomllib.load(handle)
|
||||||
|
|
||||||
|
def test_pyproject_defines_build_system_and_runtime_dependencies(self):
|
||||||
|
build_system = self.pyproject["build-system"]
|
||||||
|
project = self.pyproject["project"]
|
||||||
|
|
||||||
|
self.assertEqual(build_system["build-backend"], "setuptools.build_meta")
|
||||||
|
self.assertIn("setuptools>=69", build_system["requires"])
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
distributions(project["dependencies"]),
|
||||||
|
{"flask", "pyyaml", "requests"},
|
||||||
|
)
|
||||||
|
self.assertEqual(project["requires-python"], ">=3.12")
|
||||||
|
|
||||||
|
def test_flask_is_pinned_to_a_version_that_honours_trusted_hosts(self):
|
||||||
|
requirement = next(
|
||||||
|
item
|
||||||
|
for item in self.pyproject["project"]["dependencies"]
|
||||||
|
if item.startswith("flask")
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn(">=3.1", requirement)
|
||||||
|
|
||||||
|
def test_pyproject_defines_dev_dependencies_and_package_contents(self):
|
||||||
|
project = self.pyproject["project"]
|
||||||
|
setuptools_config = self.pyproject["tool"]["setuptools"]
|
||||||
|
package_find = setuptools_config["packages"]["find"]
|
||||||
|
package_data = setuptools_config["package-data"]["app"]
|
||||||
|
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
distributions(project["optional-dependencies"]["dev"]),
|
||||||
|
{"bandit", "pip-audit", "ruff", "yamllint"},
|
||||||
|
)
|
||||||
|
self.assertEqual(setuptools_config["py-modules"], ["main"])
|
||||||
|
self.assertEqual(package_find["include"], ["app", "app.*"])
|
||||||
|
self.assertIn("config.sample.yaml", package_data)
|
||||||
|
self.assertIn("templates/**/*.j2", package_data)
|
||||||
|
self.assertIn("static/css/*.css", package_data)
|
||||||
|
self.assertIn("static/js/*.js", package_data)
|
||||||
|
|
||||||
|
def test_legacy_requirements_files_are_removed(self):
|
||||||
|
self.assertFalse((self.repo_root / "requirements.txt").exists())
|
||||||
|
self.assertFalse((self.repo_root / "requirements-dev.txt").exists())
|
||||||
|
self.assertFalse((self.repo_root / "app" / "requirements.txt").exists())
|
||||||
|
|
||||||
|
def test_package_init_files_exist(self):
|
||||||
|
self.assertTrue((self.repo_root / "app" / "__init__.py").is_file())
|
||||||
|
self.assertTrue((self.repo_root / "app" / "utils" / "__init__.py").is_file())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
43
tests/integration/test_yaml_syntax.py
Normal file
43
tests/integration/test_yaml_syntax.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
SKIP_DIR_NAMES = {".git", ".ruff_cache", "__pycache__", "node_modules"}
|
||||||
|
SKIP_FILES = {"app/config.yaml"}
|
||||||
|
YAML_SUFFIXES = {".yml", ".yaml"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestYamlSyntax(unittest.TestCase):
|
||||||
|
def test_all_repository_yaml_files_are_valid(self):
|
||||||
|
repo_root = Path(__file__).resolve().parents[2]
|
||||||
|
invalid_files = []
|
||||||
|
|
||||||
|
for path in repo_root.rglob("*"):
|
||||||
|
if not path.is_file() or path.suffix not in YAML_SUFFIXES:
|
||||||
|
continue
|
||||||
|
|
||||||
|
relative_path = path.relative_to(repo_root).as_posix()
|
||||||
|
if relative_path in SKIP_FILES:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if any(part in SKIP_DIR_NAMES for part in path.parts):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
yaml.safe_load(handle)
|
||||||
|
except yaml.YAMLError as error:
|
||||||
|
invalid_files.append((relative_path, str(error).splitlines()[0]))
|
||||||
|
except Exception as error:
|
||||||
|
invalid_files.append((relative_path, f"Unexpected error: {error}"))
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
invalid_files,
|
||||||
|
"Found invalid YAML files:\n"
|
||||||
|
+ "\n".join(f"- {path}: {error}" for path, error in invalid_files),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
1
tests/lint/__init__.py
Normal file
1
tests/lint/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
90
tests/lint/test_all_test_files_have_tests.py
Normal file
90
tests/lint/test_all_test_files_have_tests.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import ast
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class TestTestFilesContainUnittestTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.repo_root = Path(__file__).resolve().parents[2]
|
||||||
|
self.tests_dir = self.repo_root / "tests"
|
||||||
|
self.assertTrue(
|
||||||
|
self.tests_dir.is_dir(),
|
||||||
|
f"'tests' directory not found at: {self.tests_dir}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _iter_test_files(self) -> list[Path]:
|
||||||
|
return sorted(self.tests_dir.rglob("test_*.py"))
|
||||||
|
|
||||||
|
def _file_contains_runnable_unittest_test(self, path: Path) -> bool:
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = ast.parse(source, filename=str(path))
|
||||||
|
except SyntaxError as error:
|
||||||
|
raise AssertionError(f"SyntaxError in {path}: {error}") from error
|
||||||
|
|
||||||
|
testcase_aliases = {"TestCase"}
|
||||||
|
unittest_aliases = {"unittest"}
|
||||||
|
|
||||||
|
for node in tree.body:
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
for import_name in node.names:
|
||||||
|
if import_name.name == "unittest":
|
||||||
|
unittest_aliases.add(import_name.asname or "unittest")
|
||||||
|
elif isinstance(node, ast.ImportFrom) and node.module == "unittest":
|
||||||
|
for import_name in node.names:
|
||||||
|
if import_name.name == "TestCase":
|
||||||
|
testcase_aliases.add(import_name.asname or "TestCase")
|
||||||
|
|
||||||
|
def is_testcase_base(base: ast.expr) -> bool:
|
||||||
|
if isinstance(base, ast.Name) and base.id in testcase_aliases:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if isinstance(base, ast.Attribute) and base.attr == "TestCase":
|
||||||
|
return (
|
||||||
|
isinstance(base.value, ast.Name)
|
||||||
|
and base.value.id in unittest_aliases
|
||||||
|
)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
for node in tree.body:
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and (
|
||||||
|
node.name.startswith("test_")
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
for node in tree.body:
|
||||||
|
if not isinstance(node, ast.ClassDef):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not any(is_testcase_base(base) for base in node.bases):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for item in node.body:
|
||||||
|
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and (
|
||||||
|
item.name.startswith("test_")
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def test_all_test_py_files_contain_runnable_tests(self) -> None:
|
||||||
|
test_files = self._iter_test_files()
|
||||||
|
self.assertTrue(test_files, "No test_*.py files found under tests/")
|
||||||
|
|
||||||
|
offenders = []
|
||||||
|
for path in test_files:
|
||||||
|
if not self._file_contains_runnable_unittest_test(path):
|
||||||
|
offenders.append(path.relative_to(self.repo_root).as_posix())
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
offenders,
|
||||||
|
"These test_*.py files do not define any unittest-runnable tests:\n"
|
||||||
|
+ "\n".join(f"- {path}" for path in offenders),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
25
tests/lint/test_test_file_naming.py
Normal file
25
tests/lint/test_test_file_naming.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class TestTestFileNaming(unittest.TestCase):
|
||||||
|
def test_all_python_files_use_test_prefix(self):
|
||||||
|
tests_root = Path(__file__).resolve().parents[1]
|
||||||
|
invalid_files = []
|
||||||
|
|
||||||
|
for path in tests_root.rglob("*.py"):
|
||||||
|
if path.name == "__init__.py":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not path.name.startswith("test_"):
|
||||||
|
invalid_files.append(path.relative_to(tests_root).as_posix())
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
invalid_files,
|
||||||
|
"The following Python files do not start with 'test_':\n"
|
||||||
|
+ "\n".join(f"- {path}" for path in invalid_files),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
157
tests/lint/test_tooling_configuration.py
Normal file
157
tests/lint/test_tooling_configuration.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"""Guards for configuration that no other test observes.
|
||||||
|
|
||||||
|
Each assertion here stands for a defect that was found by deleting the line it
|
||||||
|
checks: the deletion is invisible to every suite, and its effect only shows up
|
||||||
|
in production or in a fresh checkout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import tomllib
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
class TestYamllintConfiguration(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.config = yaml.safe_load(
|
||||||
|
(REPO_ROOT / ".yamllint").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_duplicate_keys_are_an_error(self):
|
||||||
|
self.assertEqual(self.config["rules"]["key-duplicates"], "enable")
|
||||||
|
|
||||||
|
def test_the_directories_that_collect_foreign_yaml_are_ignored(self):
|
||||||
|
ignored = self.config["ignore"].split()
|
||||||
|
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
set(ignored),
|
||||||
|
{".git/", ".venv/", "node_modules/", "app/node_modules/"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunTargets(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
|
||||||
|
self.recipes = {
|
||||||
|
name: body
|
||||||
|
for name, body in re.findall(
|
||||||
|
r"^(run-dev|run-prod):[^\n]*\n((?:\t[^\n]*\n)+)",
|
||||||
|
self.makefile,
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_both_run_targets_exist(self):
|
||||||
|
self.assertEqual(set(self.recipes), {"run-dev", "run-prod"})
|
||||||
|
|
||||||
|
def test_the_container_is_told_which_hosts_are_trusted(self):
|
||||||
|
for name, body in self.recipes.items():
|
||||||
|
with self.subTest(target=name):
|
||||||
|
self.assertIn("TRUSTED_HOSTS", body)
|
||||||
|
|
||||||
|
def test_the_container_is_told_which_port_to_bind(self):
|
||||||
|
for name, body in self.recipes.items():
|
||||||
|
with self.subTest(target=name):
|
||||||
|
self.assertIn('-e PORT="$$PORT"', body)
|
||||||
|
|
||||||
|
def test_the_whole_env_file_is_not_handed_to_the_web_container(self):
|
||||||
|
for name, body in self.recipes.items():
|
||||||
|
with self.subTest(target=name):
|
||||||
|
self.assertNotIn("--env-file", body)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLintCoverage(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_the_lint_target_runs_every_linter(self):
|
||||||
|
prerequisites = re.search(r"^lint: (.+)$", self.makefile, re.MULTILINE).group(1)
|
||||||
|
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
set(prerequisites.split()),
|
||||||
|
{"lint-actions", "lint-python", "lint-yaml", "lint-js", "lint-shell"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_every_linter_has_a_ci_job(self):
|
||||||
|
workflow = yaml.safe_load(
|
||||||
|
(REPO_ROOT / ".github" / "workflows" / "lint.yml").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
set(workflow["jobs"]),
|
||||||
|
{"lint-actions", "lint-python", "lint-yaml", "lint-js", "lint-shell"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_javascript_linter_is_declared(self):
|
||||||
|
package = json.loads(
|
||||||
|
(REPO_ROOT / "app" / "package.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
set(package["devDependencies"]), {"eslint", "@eslint/js", "globals"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_documented_environment_keys_exist(self):
|
||||||
|
example = (REPO_ROOT / "env.example").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
for key in ("PORT", "IMAGE_NAME", "TRUSTED_HOSTS", "LIBRETRANSLATE_URL"):
|
||||||
|
with self.subTest(key=key):
|
||||||
|
self.assertRegex(example, rf"(?m)^{key}=")
|
||||||
|
|
||||||
|
|
||||||
|
class TestEndToEndRunner(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.script = (REPO_ROOT / "scripts" / "run-e2e.sh").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_a_foreign_listener_stops_the_run(self):
|
||||||
|
self.assertIn("already serves port", self.script)
|
||||||
|
|
||||||
|
def test_cypress_is_pinned_to_the_origin_flask_binds(self):
|
||||||
|
self.assertIn("CYPRESS_baseUrl", self.script)
|
||||||
|
self.assertIn("127.0.0.1", self.script)
|
||||||
|
|
||||||
|
def test_the_electron_node_flag_is_dropped(self):
|
||||||
|
self.assertIn("env -u ELECTRON_RUN_AS_NODE", self.script)
|
||||||
|
|
||||||
|
def test_every_probe_bypasses_a_proxy_and_is_bounded(self):
|
||||||
|
probes = [line for line in self.script.splitlines() if "curl " in line]
|
||||||
|
|
||||||
|
self.assertTrue(probes)
|
||||||
|
for probe in probes:
|
||||||
|
with self.subTest(probe=probe.strip()):
|
||||||
|
self.assertIn("--noproxy", probe)
|
||||||
|
self.assertIn("--max-time", probe)
|
||||||
|
|
||||||
|
|
||||||
|
class TestVendoredAssets(unittest.TestCase):
|
||||||
|
def test_the_right_to_left_stylesheet_is_vendored(self):
|
||||||
|
script = (REPO_ROOT / "app" / "scripts" / "copy-vendor.js").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
script.count("bootstrap.rtl.min.css"),
|
||||||
|
2,
|
||||||
|
"the RTL stylesheet needs both a source and a destination path",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPackagedCatalogs(unittest.TestCase):
|
||||||
|
def test_the_interface_catalogs_are_declared_as_package_data(self):
|
||||||
|
with (REPO_ROOT / "pyproject.toml").open("rb") as handle:
|
||||||
|
pyproject = tomllib.load(handle)
|
||||||
|
|
||||||
|
package_data = pyproject["tool"]["setuptools"]["package-data"]["app"]
|
||||||
|
|
||||||
|
self.assertIn("i18n/ui/*.yaml", package_data)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user