mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-09-11 21:46:47 +00:00
Compare commits
60 Commits
ab8ea0dbd6
...
v2.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
| f76794df78 | |||
| c6667f3b05 | |||
| b8891fe6fd | |||
| 2c7f0a23d1 | |||
| 330881260c | |||
| 857c470c9e | |||
| 793e2de899 | |||
| f830153cf4 | |||
| 308741346e | |||
| d7149b83e9 | |||
| e00ff9437c | |||
| 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 |
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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
app/node_modules/
|
||||||
|
app/static/vendor/
|
||||||
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@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
|
||||||
|
- name: Login to GHCR
|
||||||
|
if: steps.semver.outputs.found == 'true'
|
||||||
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
|
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@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
|
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 }}
|
||||||
199
.github/workflows/tests.yml
vendored
Normal file
199
.github/workflows/tests.yml
vendored
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
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: Install lint test dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install --ignore-installed .
|
||||||
|
|
||||||
|
- 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@f790eee7a50d9505912f50c2095510be7de06aa7 # v6.10.9
|
||||||
|
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,4 +1,15 @@
|
|||||||
app/config.yaml
|
app/config.yaml
|
||||||
|
app/i18n/content/
|
||||||
*__pycache__*
|
*__pycache__*
|
||||||
app/static/cache/*
|
app/static/cache/*
|
||||||
.env
|
.env
|
||||||
|
app/cypress/screenshots/*
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
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.
|
||||||
62
CHANGELOG.md
Normal file
62
CHANGELOG.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [2.1.2] - 2026-09-10
|
||||||
|
|
||||||
|
* CI: lint tests install the project first, so the image publishes again
|
||||||
|
* Tooling: make test runs every local suite in .venv and keeps going
|
||||||
|
* Image: multi-stage build ships only runtime assets, no node or dev packages
|
||||||
|
* Security: CodeQL findings fixed; iframe links only open configured origins
|
||||||
|
* i18n: interface strings translated for all 184 languages, test-enforced
|
||||||
|
|
||||||
|
## [2.1.1] - 2026-09-10
|
||||||
|
|
||||||
|
* Accessibility: header icons are *aria-hidden*, links read by label alone
|
||||||
|
* Test coverage: unit test requires *aria-hidden* on every header icon
|
||||||
|
|
||||||
|
## [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.
|
||||||
32
Dockerfile
32
Dockerfile
@@ -1,14 +1,32 @@
|
|||||||
# Base image for Python
|
FROM node:22-slim AS assets
|
||||||
FROM python:slim
|
|
||||||
|
|
||||||
# Set the working directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY app/package.json ./
|
||||||
|
COPY app/scripts ./scripts
|
||||||
|
RUN npm install --omit=dev --no-audit --no-fund
|
||||||
|
|
||||||
# Copy and install dependencies
|
FROM python:3.12-slim AS base
|
||||||
COPY app/requirements.txt requirements.txt
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy application code
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
FLASK_HOST=0.0.0.0
|
||||||
|
|
||||||
|
WORKDIR /tmp/build
|
||||||
|
|
||||||
|
COPY pyproject.toml README.md main.py ./
|
||||||
|
COPY app ./app
|
||||||
|
RUN python -m pip install --no-cache-dir .
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
COPY app/ .
|
COPY app/ .
|
||||||
|
|
||||||
CMD ["python", "app.py"]
|
CMD ["python", "app.py"]
|
||||||
|
|
||||||
|
FROM base AS dev
|
||||||
|
|
||||||
|
# hadolint ignore=DL3008
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
FROM base AS runtime
|
||||||
|
|
||||||
|
COPY --from=assets /app/static/vendor ./static/vendor
|
||||||
|
|||||||
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
|
||||||
|
|
||||||
261
Makefile
Normal file
261
Makefile
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
# 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
|
||||||
|
TEST_VENV := $(CURDIR)/.venv
|
||||||
|
TEST_PYTHON ?= $(TEST_VENV)/bin/python
|
||||||
|
|
||||||
|
# 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: install
|
||||||
|
# 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:
|
||||||
|
# Run every local suite in a project virtualenv, since PEP 668 refuses pip on
|
||||||
|
# a system Python, and keep going so each failing suite reports.
|
||||||
|
@[ -x "$(TEST_PYTHON)" ] || python3 -m venv "$(TEST_VENV)"
|
||||||
|
$(MAKE) --keep-going ci PYTHON="$(TEST_PYTHON)"
|
||||||
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."""
|
||||||
189
app/app.py
189
app/app.py
@@ -1,18 +1,33 @@
|
|||||||
import os
|
|
||||||
from flask import Flask, render_template
|
|
||||||
import yaml
|
|
||||||
import requests
|
|
||||||
from utils.configuration_resolver import ConfigurationResolver
|
|
||||||
from utils.cache_manager import CacheManager
|
|
||||||
from utils.compute_card_classes import compute_card_classes
|
|
||||||
import logging
|
import logging
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
import os
|
||||||
FLASK_ENV = os.getenv("FLASK_ENV", "production")
|
|
||||||
FLASK_PORT = int(os.getenv("PORT", 5000))
|
|
||||||
print(f"🔧 Starting app on port {FLASK_PORT}, FLASK_ENV={FLASK_ENV}")
|
|
||||||
|
|
||||||
from flask import Flask, render_template, current_app
|
import requests
|
||||||
|
import yaml
|
||||||
|
from flask import Flask, current_app, make_response, render_template, request, url_for
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
|
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)))
|
||||||
|
|
||||||
# Initialize the CacheManager
|
# Initialize the CacheManager
|
||||||
cache_manager = CacheManager()
|
cache_manager = CacheManager()
|
||||||
@@ -20,10 +35,11 @@ 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"):
|
if config.get("nasa_api_key"):
|
||||||
app.config["NASA_API_KEY"] = config["nasa_api_key"]
|
app.config["NASA_API_KEY"] = config["nasa_api_key"]
|
||||||
@@ -31,36 +47,65 @@ def load_config(app):
|
|||||||
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)
|
||||||
|
|
||||||
|
resolve_asset_cache(app.config["company"]["logo"], cache_manager)
|
||||||
|
resolve_asset_cache(app.config["platform"]["favicon"], cache_manager)
|
||||||
|
resolve_asset_cache(app.config["platform"]["logo"], cache_manager)
|
||||||
|
|
||||||
app.config["company"]["logo"]["cache"] = cache_manager.cache_file(app.config["company"]["logo"]["source"])
|
|
||||||
app.config["platform"]["favicon"]["cache"] = cache_manager.cache_file(app.config["platform"]["favicon"]["source"])
|
|
||||||
app.config["platform"]["logo"]["cache"] = cache_manager.cache_file(app.config["platform"]["logo"]["source"])
|
|
||||||
|
|
||||||
# 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
|
@app.context_processor
|
||||||
def utility_processor():
|
def utility_processor():
|
||||||
def include_svg(path):
|
def include_svg(path):
|
||||||
full_path = os.path.join(current_app.root_path, 'static', path)
|
full_path = os.path.join(current_app.root_path, "static", path)
|
||||||
try:
|
try:
|
||||||
with open(full_path, 'r', encoding='utf-8') as f:
|
with open(full_path, "r", encoding="utf-8") as handle:
|
||||||
svg = f.read()
|
svg = handle.read()
|
||||||
return Markup(svg)
|
# Trusted local SVG asset shipped with the application package.
|
||||||
except IOError:
|
return Markup(svg) # nosec B704
|
||||||
return Markup(f'<!-- SVG not found: {path} -->')
|
except OSError:
|
||||||
return dict(include_svg=include_svg)
|
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():
|
||||||
@@ -69,35 +114,85 @@ 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"]
|
|
||||||
lg_classes, md_classes = compute_card_classes(cards)
|
The memo is dropped by ``load_config``, so a development reload picks up
|
||||||
# fetch NASA APOD URL only if key present
|
edited content on the next request.
|
||||||
apod_bg = None
|
"""
|
||||||
|
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")
|
api_key = app.config.get("NASA_API_KEY")
|
||||||
if api_key:
|
if not api_key:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
"https://api.nasa.gov/planetary/apod",
|
"https://api.nasa.gov/planetary/apod",
|
||||||
params={"api_key": api_key}
|
params={"api_key": api_key},
|
||||||
|
timeout=10,
|
||||||
)
|
)
|
||||||
if resp.ok:
|
except requests.RequestException:
|
||||||
data = resp.json()
|
logging.warning("APOD lookup failed", exc_info=True)
|
||||||
# only use if it's an image
|
return None
|
||||||
if data.get("media_type") == "image":
|
|
||||||
apod_bg = data.get("url")
|
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)
|
||||||
|
|
||||||
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_bg
|
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=FLASK_PORT)
|
print(f"Starting app on {FLASK_HOST}:{FLASK_PORT}, FLASK_ENV={FLASK_ENV}")
|
||||||
|
app.run(
|
||||||
|
debug=(FLASK_ENV == "development"),
|
||||||
|
host=FLASK_HOST,
|
||||||
|
port=FLASK_PORT,
|
||||||
|
use_reloader=False,
|
||||||
|
)
|
||||||
|
|||||||
@@ -198,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
|
||||||
|
|||||||
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
285
app/cypress/e2e/injection.spec.js
Normal file
285
app/cypress/e2e/injection.spec.js
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
// 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 a configured iframe target from the query string', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
cy.get('a.iframe-link').first().invoke('prop', 'href').then((href) => {
|
||||||
|
cy.visit(`/?iframe=${encodeURIComponent(href)}`);
|
||||||
|
|
||||||
|
cy.get('#main')
|
||||||
|
.find('iframe', { timeout: AFTER_THE_FADE })
|
||||||
|
.should('have.attr', 'src', href);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a foreign origin supplied through the query string', () => {
|
||||||
|
cy.visit('/?iframe=https://example.com/');
|
||||||
|
|
||||||
|
cy.wait(AFTER_THE_FADE);
|
||||||
|
cy.get('#main').find('iframe').should('not.exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not open a foreign query-string origin in a new tab', () => {
|
||||||
|
cy.visit('/?iframe=https://example.com/', {
|
||||||
|
onBeforeLoad(win) {
|
||||||
|
cy.stub(win, 'open').as('open');
|
||||||
|
cy.stub(win, 'alert');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.window().then((win) => win.openIframeInNewTab());
|
||||||
|
cy.get('@open').should('not.have.been.called');
|
||||||
|
});
|
||||||
|
});
|
||||||
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/aa.yaml
Normal file
11
app/i18n/ui/aa.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Tan doorit
|
||||||
|
Close: Alif
|
||||||
|
Copy: Garraab
|
||||||
|
Identifier copied to clipboard!: Astooti tixi rakaakayih culte!
|
||||||
|
Imprint: Qaanuunih warsa
|
||||||
|
Information: Warsa
|
||||||
|
Language: Af
|
||||||
|
Open: Fak
|
||||||
|
Open Link: Tixi fak
|
||||||
|
Options: Doorit
|
||||||
|
Warning: Kassiisa
|
||||||
11
app/i18n/ui/ab.yaml
Normal file
11
app/i18n/ui/ab.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/ae.yaml
Normal file
11
app/i18n/ui/ae.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Aniiā
|
||||||
|
Close: Pairi.dāraiia
|
||||||
|
Copy: Paitiš.kərənu
|
||||||
|
Identifier copied to clipboard!: Nāman paitiš.kərətəm!
|
||||||
|
Imprint: Dātō.xvarənah
|
||||||
|
Information: Vaēdiia
|
||||||
|
Language: Hizuuā
|
||||||
|
Open: Vīuuāraiia
|
||||||
|
Open Link: Paθō vīuuāraiia
|
||||||
|
Options: Vərəθra
|
||||||
|
Warning: Aiβi.saŋha
|
||||||
11
app/i18n/ui/af.yaml
Normal file
11
app/i18n/ui/af.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternatiewe
|
||||||
|
Close: Maak toe
|
||||||
|
Copy: Kopieer
|
||||||
|
Identifier copied to clipboard!: Identifiseerder na knipbord gekopieer!
|
||||||
|
Imprint: Regskennisgewing
|
||||||
|
Information: Inligting
|
||||||
|
Language: Taal
|
||||||
|
Open: Maak oop
|
||||||
|
Open Link: Maak skakel oop
|
||||||
|
Options: Opsies
|
||||||
|
Warning: Waarskuwing
|
||||||
11
app/i18n/ui/ak.yaml
Normal file
11
app/i18n/ui/ak.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Nneɛma foforɔ
|
||||||
|
Close: To mu
|
||||||
|
Copy: Twa
|
||||||
|
Identifier copied to clipboard!: Wɔatwa nsɛnkyerɛnneɛ no akɔ clipboard so!
|
||||||
|
Imprint: Mmara ho nsɛm
|
||||||
|
Information: Nsɛm
|
||||||
|
Language: Kasa
|
||||||
|
Open: Bue
|
||||||
|
Open Link: Bue link no
|
||||||
|
Options: Nhyehyɛe
|
||||||
|
Warning: Kɔkɔbɔ
|
||||||
11
app/i18n/ui/am.yaml
Normal file
11
app/i18n/ui/am.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/an.yaml
Normal file
11
app/i18n/ui/an.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternativas
|
||||||
|
Close: Zarrar
|
||||||
|
Copy: Copiar
|
||||||
|
Identifier copied to clipboard!: Identificador copiau en o portafuellas!
|
||||||
|
Imprint: Aviso legal
|
||||||
|
Information: Información
|
||||||
|
Language: Idioma
|
||||||
|
Open: Ubrir
|
||||||
|
Open Link: Ubrir o vinclo
|
||||||
|
Options: Opcions
|
||||||
|
Warning: Alvertencia
|
||||||
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/as.yaml
Normal file
11
app/i18n/ui/as.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/av.yaml
Normal file
11
app/i18n/ui/av.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/ay.yaml
Normal file
11
app/i18n/ui/ay.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Yaqha ajlliwinaka
|
||||||
|
Close: Jist'antaña
|
||||||
|
Copy: Qillqaña
|
||||||
|
Identifier copied to clipboard!: Uñt'ayiri clipboard ukar qillqatawa!
|
||||||
|
Imprint: Kamachi yatiyawi
|
||||||
|
Information: Yatiyawi
|
||||||
|
Language: Aru
|
||||||
|
Open: Jist'araña
|
||||||
|
Open Link: Link jist'araña
|
||||||
|
Options: Ajlliwinaka
|
||||||
|
Warning: Iwxt'awi
|
||||||
11
app/i18n/ui/az.yaml
Normal file
11
app/i18n/ui/az.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternativlər
|
||||||
|
Close: Bağla
|
||||||
|
Copy: Kopyala
|
||||||
|
Identifier copied to clipboard!: İdentifikator mübadilə buferinə kopyalandı!
|
||||||
|
Imprint: Hüquqi məlumat
|
||||||
|
Information: Məlumat
|
||||||
|
Language: Dil
|
||||||
|
Open: Aç
|
||||||
|
Open Link: Keçidi aç
|
||||||
|
Options: Seçimlər
|
||||||
|
Warning: Xəbərdarlıq
|
||||||
11
app/i18n/ui/ba.yaml
Normal file
11
app/i18n/ui/ba.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/be.yaml
Normal file
11
app/i18n/ui/be.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/bg.yaml
Normal file
11
app/i18n/ui/bg.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/bi.yaml
Normal file
11
app/i18n/ui/bi.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Narafala jois
|
||||||
|
Close: Klosem
|
||||||
|
Copy: Kopi
|
||||||
|
Identifier copied to clipboard!: Namba blong aedentifikesen i kopi finis!
|
||||||
|
Imprint: Loa infomesen
|
||||||
|
Information: Infomesen
|
||||||
|
Language: Lanwis
|
||||||
|
Open: Openem
|
||||||
|
Open Link: Openem link
|
||||||
|
Options: Ol jois
|
||||||
|
Warning: Woning
|
||||||
11
app/i18n/ui/bm.yaml
Normal file
11
app/i18n/ui/bm.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Sugandili wɛrɛw
|
||||||
|
Close: A datugu
|
||||||
|
Copy: A kopi
|
||||||
|
Identifier copied to clipboard!: Tɔgɔ taamasiyɛn kopilen don!
|
||||||
|
Imprint: Sariya kunnafoni
|
||||||
|
Information: Kunnafoni
|
||||||
|
Language: Kan
|
||||||
|
Open: A dayɛlɛ
|
||||||
|
Open Link: Jɛɲɔgɔnya dayɛlɛ
|
||||||
|
Options: Sugandiliw
|
||||||
|
Warning: Lasɔmini
|
||||||
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/bo.yaml
Normal file
11
app/i18n/ui/bo.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/br.yaml
Normal file
11
app/i18n/ui/br.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Dibaboù all
|
||||||
|
Close: Serriñ
|
||||||
|
Copy: Eilañ
|
||||||
|
Identifier copied to clipboard!: Eilet eo bet an anaouder er golver!
|
||||||
|
Imprint: Titouroù lezennel
|
||||||
|
Information: Titouroù
|
||||||
|
Language: Yezh
|
||||||
|
Open: Digeriñ
|
||||||
|
Open Link: Digeriñ al liamm
|
||||||
|
Options: Dibarzhioù
|
||||||
|
Warning: Diwall
|
||||||
11
app/i18n/ui/bs.yaml
Normal file
11
app/i18n/ui/bs.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternative
|
||||||
|
Close: Zatvori
|
||||||
|
Copy: Kopiraj
|
||||||
|
Identifier copied to clipboard!: Identifikator je kopiran u međuspremnik!
|
||||||
|
Imprint: Pravne informacije
|
||||||
|
Information: Informacije
|
||||||
|
Language: Jezik
|
||||||
|
Open: Otvori
|
||||||
|
Open Link: Otvori link
|
||||||
|
Options: Opcije
|
||||||
|
Warning: Upozorenje
|
||||||
11
app/i18n/ui/ca.yaml
Normal file
11
app/i18n/ui/ca.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternatives
|
||||||
|
Close: Tanca
|
||||||
|
Copy: Copia
|
||||||
|
Identifier copied to clipboard!: S'ha copiat l'identificador al porta-retalls!
|
||||||
|
Imprint: Avís legal
|
||||||
|
Information: Informació
|
||||||
|
Language: Idioma
|
||||||
|
Open: Obre
|
||||||
|
Open Link: Obre l'enllaç
|
||||||
|
Options: Opcions
|
||||||
|
Warning: Advertiment
|
||||||
11
app/i18n/ui/ce.yaml
Normal file
11
app/i18n/ui/ce.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/ch.yaml
Normal file
11
app/i18n/ui/ch.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Otro na ayek
|
||||||
|
Close: Huchom
|
||||||
|
Copy: Kopia
|
||||||
|
Identifier copied to clipboard!: Ma kopia i identifier gi clipboard!
|
||||||
|
Imprint: Infotmasion lai
|
||||||
|
Information: Infotmasion
|
||||||
|
Language: Lengguahi
|
||||||
|
Open: Baba
|
||||||
|
Open Link: Baba i link
|
||||||
|
Options: Ayek
|
||||||
|
Warning: Adbietensia
|
||||||
11
app/i18n/ui/co.yaml
Normal file
11
app/i18n/ui/co.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternative
|
||||||
|
Close: Chjode
|
||||||
|
Copy: Cupià
|
||||||
|
Identifier copied to clipboard!: L'identificatore hè statu cupiatu in u preme-papers!
|
||||||
|
Imprint: Menzioni legali
|
||||||
|
Information: Infurmazione
|
||||||
|
Language: Lingua
|
||||||
|
Open: Apre
|
||||||
|
Open Link: Apre u ligame
|
||||||
|
Options: Uzzione
|
||||||
|
Warning: Avertimentu
|
||||||
11
app/i18n/ui/cr.yaml
Normal file
11
app/i18n/ui/cr.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: kotakak
|
||||||
|
Close: kipahamok
|
||||||
|
Copy: ayisinahikêwin
|
||||||
|
Identifier copied to clipboard!: kiskinowâcihcikan kî-ayisinahikâtêw!
|
||||||
|
Imprint: wiyasiwêwin kiskêyihtamowin
|
||||||
|
Information: kiskêyihtamowin
|
||||||
|
Language: pîkiskwêwin
|
||||||
|
Open: yôhtênamok
|
||||||
|
Open Link: yôhtêna mâmawikan
|
||||||
|
Options: nawasônamowina
|
||||||
|
Warning: kitahamâkêwin
|
||||||
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/cu.yaml
Normal file
11
app/i18n/ui/cu.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/cv.yaml
Normal file
11
app/i18n/ui/cv.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/cy.yaml
Normal file
11
app/i18n/ui/cy.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Dewisiadau eraill
|
||||||
|
Close: Cau
|
||||||
|
Copy: Copïo
|
||||||
|
Identifier copied to clipboard!: Wedi copïo'r dynodwr i'r clipfwrdd!
|
||||||
|
Imprint: Gwybodaeth gyfreithiol
|
||||||
|
Information: Gwybodaeth
|
||||||
|
Language: Iaith
|
||||||
|
Open: Agor
|
||||||
|
Open Link: Agor y ddolen
|
||||||
|
Options: Opsiynau
|
||||||
|
Warning: Rhybudd
|
||||||
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/dv.yaml
Normal file
11
app/i18n/ui/dv.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/dz.yaml
Normal file
11
app/i18n/ui/dz.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/ee.yaml
Normal file
11
app/i18n/ui/ee.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Tiatia bubuwo
|
||||||
|
Close: Tu
|
||||||
|
Copy: Kɔpi
|
||||||
|
Identifier copied to clipboard!: Wokɔpi dzesidede ɖe clipboard dzi!
|
||||||
|
Imprint: Se ŋuti nyatakakawo
|
||||||
|
Information: Nyatakaka
|
||||||
|
Language: Gbe
|
||||||
|
Open: Ʋu
|
||||||
|
Open Link: Ʋu kadodoa
|
||||||
|
Options: Tiatiawo
|
||||||
|
Warning: Nuxlɔ̃ame
|
||||||
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/eo.yaml
Normal file
11
app/i18n/ui/eo.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternativoj
|
||||||
|
Close: Fermi
|
||||||
|
Copy: Kopii
|
||||||
|
Identifier copied to clipboard!: Identigilo kopiita al la tondujo!
|
||||||
|
Imprint: Jura informo
|
||||||
|
Information: Informo
|
||||||
|
Language: Lingvo
|
||||||
|
Open: Malfermi
|
||||||
|
Open Link: Malfermi ligilon
|
||||||
|
Options: Opcioj
|
||||||
|
Warning: Averto
|
||||||
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/et.yaml
Normal file
11
app/i18n/ui/et.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternatiivid
|
||||||
|
Close: Sulge
|
||||||
|
Copy: Kopeeri
|
||||||
|
Identifier copied to clipboard!: Identifikaator kopeeriti lõikelauale!
|
||||||
|
Imprint: Õiguslik teave
|
||||||
|
Information: Teave
|
||||||
|
Language: Keel
|
||||||
|
Open: Ava
|
||||||
|
Open Link: Ava link
|
||||||
|
Options: Valikud
|
||||||
|
Warning: Hoiatus
|
||||||
11
app/i18n/ui/eu.yaml
Normal file
11
app/i18n/ui/eu.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternatibak
|
||||||
|
Close: Itxi
|
||||||
|
Copy: Kopiatu
|
||||||
|
Identifier copied to clipboard!: Identifikatzailea arbelean kopiatu da!
|
||||||
|
Imprint: Lege-oharra
|
||||||
|
Information: Informazioa
|
||||||
|
Language: Hizkuntza
|
||||||
|
Open: Ireki
|
||||||
|
Open Link: Ireki esteka
|
||||||
|
Options: Aukerak
|
||||||
|
Warning: Abisua
|
||||||
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/ff.yaml
Normal file
11
app/i18n/ui/ff.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Suɓaaji goɗɗi
|
||||||
|
Close: Uddu
|
||||||
|
Copy: Natto
|
||||||
|
Identifier copied to clipboard!: Innde heɓtirde natta!
|
||||||
|
Imprint: Humpito laawol
|
||||||
|
Information: Humpito
|
||||||
|
Language: Ɗemngal
|
||||||
|
Open: Uddit
|
||||||
|
Open Link: Uddit jokkorde
|
||||||
|
Options: Suɓaaji
|
||||||
|
Warning: Reentino
|
||||||
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/fj.yaml
Normal file
11
app/i18n/ui/fj.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Na ka tale e so
|
||||||
|
Close: Sogota
|
||||||
|
Copy: Kopitaka
|
||||||
|
Identifier copied to clipboard!: Sa kopitaki na ivakatakilakila!
|
||||||
|
Imprint: iTukutuku vakalawa
|
||||||
|
Information: iTukutuku
|
||||||
|
Language: Vosa
|
||||||
|
Open: Dolava
|
||||||
|
Open Link: Dolava na isema
|
||||||
|
Options: Digidigi
|
||||||
|
Warning: iVakasala
|
||||||
11
app/i18n/ui/fo.yaml
Normal file
11
app/i18n/ui/fo.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternativ
|
||||||
|
Close: Lat aftur
|
||||||
|
Copy: Avrita
|
||||||
|
Identifier copied to clipboard!: Eyðmerki avritað í setiborðið!
|
||||||
|
Imprint: Løgfrøðilig kunning
|
||||||
|
Information: Kunning
|
||||||
|
Language: Mál
|
||||||
|
Open: Lat upp
|
||||||
|
Open Link: Lat leinkju upp
|
||||||
|
Options: Valmøguleikar
|
||||||
|
Warning: Ávaring
|
||||||
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/fy.yaml
Normal file
11
app/i18n/ui/fy.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternativen
|
||||||
|
Close: Slute
|
||||||
|
Copy: Kopiearje
|
||||||
|
Identifier copied to clipboard!: Identifikaasje nei it klamboerd kopiearre!
|
||||||
|
Imprint: Kolofon
|
||||||
|
Information: Ynformaasje
|
||||||
|
Language: Taal
|
||||||
|
Open: Iepenje
|
||||||
|
Open Link: Keppeling iepenje
|
||||||
|
Options: Opsjes
|
||||||
|
Warning: Warskôging
|
||||||
11
app/i18n/ui/ga.yaml
Normal file
11
app/i18n/ui/ga.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Roghanna eile
|
||||||
|
Close: Dún
|
||||||
|
Copy: Cóipeáil
|
||||||
|
Identifier copied to clipboard!: Cóipeáladh an t-aitheantóir chuig an ghearrthaisce!
|
||||||
|
Imprint: Fógra dlíthiúil
|
||||||
|
Information: Faisnéis
|
||||||
|
Language: Teanga
|
||||||
|
Open: Oscail
|
||||||
|
Open Link: Oscail an nasc
|
||||||
|
Options: Roghanna
|
||||||
|
Warning: Rabhadh
|
||||||
11
app/i18n/ui/gd.yaml
Normal file
11
app/i18n/ui/gd.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Roghainnean eile
|
||||||
|
Close: Dùin
|
||||||
|
Copy: Dèan lethbhreac
|
||||||
|
Identifier copied to clipboard!: Chaidh lethbhreac dhen aithnichear a chur air an stòr-bhòrd!
|
||||||
|
Imprint: Fiosrachadh laghail
|
||||||
|
Information: Fiosrachadh
|
||||||
|
Language: Cànan
|
||||||
|
Open: Fosgail
|
||||||
|
Open Link: Fosgail an ceangal
|
||||||
|
Options: Roghainnean
|
||||||
|
Warning: Rabhadh
|
||||||
11
app/i18n/ui/gl.yaml
Normal file
11
app/i18n/ui/gl.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternativas
|
||||||
|
Close: Pechar
|
||||||
|
Copy: Copiar
|
||||||
|
Identifier copied to clipboard!: Identificador copiado no portapapeis!
|
||||||
|
Imprint: Aviso legal
|
||||||
|
Information: Información
|
||||||
|
Language: Idioma
|
||||||
|
Open: Abrir
|
||||||
|
Open Link: Abrir ligazón
|
||||||
|
Options: Opcións
|
||||||
|
Warning: Advertencia
|
||||||
11
app/i18n/ui/gn.yaml
Normal file
11
app/i18n/ui/gn.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Ambue jeporavorã
|
||||||
|
Close: Mboty
|
||||||
|
Copy: Monguatia
|
||||||
|
Identifier copied to clipboard!: Oñemonguatia pe mboheraguapy!
|
||||||
|
Imprint: Léi marandu
|
||||||
|
Information: Marandu
|
||||||
|
Language: Ñe'ẽ
|
||||||
|
Open: Mbojuruja
|
||||||
|
Open Link: Mbojuruja joajuha
|
||||||
|
Options: Jeporavorã
|
||||||
|
Warning: Ñemomarandu
|
||||||
11
app/i18n/ui/gu.yaml
Normal file
11
app/i18n/ui/gu.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/gv.yaml
Normal file
11
app/i18n/ui/gv.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Reihghyn elley
|
||||||
|
Close: Dooney
|
||||||
|
Copy: Jean lhiasaghey
|
||||||
|
Identifier copied to clipboard!: Ta'n enmyssagh er ny lhiasaghey!
|
||||||
|
Imprint: Fys leighoil
|
||||||
|
Information: Fys
|
||||||
|
Language: Çhengey
|
||||||
|
Open: Foshil
|
||||||
|
Open Link: Foshil y kiangley
|
||||||
|
Options: Reihghyn
|
||||||
|
Warning: Raaue
|
||||||
11
app/i18n/ui/ha.yaml
Normal file
11
app/i18n/ui/ha.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Madadai
|
||||||
|
Close: Rufe
|
||||||
|
Copy: Kwafi
|
||||||
|
Identifier copied to clipboard!: An kwafi mai ganowa zuwa allon rubutu!
|
||||||
|
Imprint: Bayanin doka
|
||||||
|
Information: Bayani
|
||||||
|
Language: Harshe
|
||||||
|
Open: Buɗe
|
||||||
|
Open Link: Buɗe mahaɗi
|
||||||
|
Options: Zaɓuɓɓuka
|
||||||
|
Warning: Gargaɗi
|
||||||
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/ho.yaml
Normal file
11
app/i18n/ui/ho.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Ma haida
|
||||||
|
Close: Koua
|
||||||
|
Copy: Kopi
|
||||||
|
Identifier copied to clipboard!: Ladana ia kopi vadaeni!
|
||||||
|
Imprint: Taravatu sivaraina
|
||||||
|
Information: Sivaraina
|
||||||
|
Language: Gado
|
||||||
|
Open: Kehoa
|
||||||
|
Open Link: Link kehoa
|
||||||
|
Options: Abia hidi
|
||||||
|
Warning: Hadibaia
|
||||||
11
app/i18n/ui/hr.yaml
Normal file
11
app/i18n/ui/hr.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternative
|
||||||
|
Close: Zatvori
|
||||||
|
Copy: Kopiraj
|
||||||
|
Identifier copied to clipboard!: Identifikator je kopiran u međuspremnik!
|
||||||
|
Imprint: Pravne informacije
|
||||||
|
Information: Informacije
|
||||||
|
Language: Jezik
|
||||||
|
Open: Otvori
|
||||||
|
Open Link: Otvori poveznicu
|
||||||
|
Options: Mogućnosti
|
||||||
|
Warning: Upozorenje
|
||||||
11
app/i18n/ui/ht.yaml
Normal file
11
app/i18n/ui/ht.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Lòt chwa
|
||||||
|
Close: Fèmen
|
||||||
|
Copy: Kopye
|
||||||
|
Identifier copied to clipboard!: Idantifyan an kopye nan klipbòd la!
|
||||||
|
Imprint: Enfòmasyon legal
|
||||||
|
Information: Enfòmasyon
|
||||||
|
Language: Lang
|
||||||
|
Open: Louvri
|
||||||
|
Open Link: Louvri lyen an
|
||||||
|
Options: Opsyon
|
||||||
|
Warning: Avètisman
|
||||||
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/hy.yaml
Normal file
11
app/i18n/ui/hy.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/hz.yaml
Normal file
11
app/i18n/ui/hz.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Ovikeṱa oviṱjaṱa
|
||||||
|
Close: Pata
|
||||||
|
Copy: Hiṱa
|
||||||
|
Identifier copied to clipboard!: Ondjiviro ya hiṱwa!
|
||||||
|
Imprint: Omambo woveta
|
||||||
|
Information: Omambo
|
||||||
|
Language: Ombango
|
||||||
|
Open: Paturura
|
||||||
|
Open Link: Paturura omukambo
|
||||||
|
Options: Ovikeṱa
|
||||||
|
Warning: Omaṱoororo
|
||||||
11
app/i18n/ui/ia.yaml
Normal file
11
app/i18n/ui/ia.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternativas
|
||||||
|
Close: Clauder
|
||||||
|
Copy: Copiar
|
||||||
|
Identifier copied to clipboard!: Identificator copiate al area de transferentia!
|
||||||
|
Imprint: Information legal
|
||||||
|
Information: Information
|
||||||
|
Language: Lingua
|
||||||
|
Open: Aperir
|
||||||
|
Open Link: Aperir ligamine
|
||||||
|
Options: Optiones
|
||||||
|
Warning: Advertimento
|
||||||
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/ie.yaml
Normal file
11
app/i18n/ui/ie.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternatives
|
||||||
|
Close: Clúder
|
||||||
|
Copy: Copiar
|
||||||
|
Identifier copied to clipboard!: Identificator copiat al paperiere!
|
||||||
|
Imprint: Information legal
|
||||||
|
Information: Information
|
||||||
|
Language: Lingue
|
||||||
|
Open: Aperter
|
||||||
|
Open Link: Aperter li ligament
|
||||||
|
Options: Optiones
|
||||||
|
Warning: Avise
|
||||||
11
app/i18n/ui/ig.yaml
Normal file
11
app/i18n/ui/ig.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Nhọrọ ndị ọzọ
|
||||||
|
Close: Mechie
|
||||||
|
Copy: Detuo
|
||||||
|
Identifier copied to clipboard!: Edepụtara njirimara na klipbọọdụ!
|
||||||
|
Imprint: Ozi gbasara iwu
|
||||||
|
Information: Ozi
|
||||||
|
Language: Asụsụ
|
||||||
|
Open: Mepee
|
||||||
|
Open Link: Mepee njikọ
|
||||||
|
Options: Nhọrọ
|
||||||
|
Warning: Ịdọ aka na ntị
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user