mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-08-24 13:14:32 +00:00
Compare commits
11 Commits
v1.2.0
...
d115fc99b2
| Author | SHA1 | Date | |
|---|---|---|---|
| d115fc99b2 | |||
| e96e8684e6 | |||
| 747ce379cc | |||
| ef1c8ff09a | |||
| 0c67f999f6 | |||
| efabbd3b2e | |||
| 2a35b2910a | |||
| a42930a699 | |||
| 03b7cef90e | |||
| 4b29448b33 | |||
| 3f30621630 |
55
.github/workflows/lint.yml
vendored
55
.github/workflows/lint.yml
vendored
@@ -19,6 +19,61 @@ jobs:
|
|||||||
- name: Run actionlint
|
- name: Run actionlint
|
||||||
run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest
|
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:
|
lint-python:
|
||||||
name: Lint Python
|
name: Lint Python
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
app/config.yaml
|
app/config.yaml
|
||||||
|
app/i18n/content/
|
||||||
*__pycache__*
|
*__pycache__*
|
||||||
app/static/cache/*
|
app/static/cache/*
|
||||||
.env
|
.env
|
||||||
@@ -10,3 +11,4 @@ hadolint-results.sarif
|
|||||||
build/
|
build/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
app/core.*
|
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
|
||||||
26
CHANGELOG.md
26
CHANGELOG.md
@@ -1,3 +1,29 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [2.1.0] - 2026-08-22
|
||||||
|
|
||||||
|
* Multilingual site: every ISO 639-1 language has its own URL, */* follows the visitor's browser language, and a switcher in the navbar lists all 184 in their own script
|
||||||
|
* Right-to-left layouts: Arabic, Hebrew, Persian, Urdu and six more mirror the page automatically
|
||||||
|
* Machine translation: *make i18n* fills your card and menu texts from a LibreTranslate instance, *make i18n-ui* the interface strings; hand-corrected entries are never overwritten
|
||||||
|
* Brand protection: list your product names in *app/i18n/keep.txt* and they stay untranslated in every language
|
||||||
|
* Search engines: each page declares a canonical URL and an *hreflang* alternate per language
|
||||||
|
* Security: configuration and translations are HTML-escaped, script URLs are stripped from links, images and the iframe view — set *TRUSTED_HOSTS* in *.env* to pin your public hostname
|
||||||
|
* Reliability: a slow NASA APOD lookup no longer takes the page down
|
||||||
|
* Developer tooling: *make test-e2e* runs Cypress directly instead of through act, and YAML, JavaScript and shell now have linters in CI
|
||||||
|
|
||||||
|
## [2.0.0] - 2026-05-18
|
||||||
|
|
||||||
|
* * Asset resolution: new probe-first resolver tries a HEAD request and embeds reachable image URLs directly via a new external_url field, falling back to the cache-download path only when the probe fails; broken /static/https://... URLs no longer appear when the source cannot be downloaded
|
||||||
|
* Template integration: an asset_src context processor in app.py picks external_url first and url_for(static, cache) second, so base.html.j2, navigation.html.j2, and card.html.j2 never wrap an absolute URL in the static prefix
|
||||||
|
* Test coverage: 16 new unit tests cover every probe, cache, and fallback branch; a live integration test exercises https://file.infinito.nexus/assets/img/logo.png to prove the probe-first path works end-to-end without writing to the cache directory
|
||||||
|
* Sample configuration: new Infinito.Nexus card in app/config.sample.yaml driven by the canonical file.infinito.nexus asset URL
|
||||||
|
* SPOT for build variables: env.example is the single source of truth for IMAGE_NAME and PORT; the Makefile and docker-compose.yml reference both with no defaults and fail loudly when either variable is missing
|
||||||
|
* Bootstrap targets: make env and make config materialise .env and app/config.yaml from their checked-in templates without overwriting existing files; build, build-no-cache, up, run-dev, run-prod, dev, prod, and browse depend on both so a fresh checkout runs in a single make invocation
|
||||||
|
* Recipe sourcing: Makefile recipes load .env at recipe-execution time via a shared _require_env helper, so a freshly bootstrapped .env is picked up in the same make invocation that created it
|
||||||
|
* README: PortUI screenshot added under the title
|
||||||
|
* Lint: removed an unused sys import in the live integration test
|
||||||
|
|
||||||
|
|
||||||
## [1.2.0] - 2026-05-11
|
## [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
|
* * 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
|
||||||
|
|||||||
145
Makefile
145
Makefile
@@ -5,24 +5,61 @@ ifneq (,$(wildcard .env))
|
|||||||
export $(shell sed 's/=.*//' .env)
|
export $(shell sed 's/=.*//' .env)
|
||||||
endif
|
endif
|
||||||
|
|
||||||
# Default port (can be overridden with PORT env var)
|
|
||||||
PORT ?= 5000
|
|
||||||
PYTHON ?= python3
|
PYTHON ?= python3
|
||||||
ACT ?= act
|
ACT ?= act
|
||||||
|
|
||||||
# Default port (can be overridden with PORT env var)
|
# 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
|
.PHONY: build
|
||||||
build:
|
build: env config
|
||||||
# Build the Docker image.
|
# Build the Docker image.
|
||||||
docker build -t application-portfolio .
|
@$(call _require_env,IMAGE_NAME); \
|
||||||
|
docker build -t "$$IMAGE_NAME" .
|
||||||
|
|
||||||
.PHONY: build-no-cache
|
.PHONY: build-no-cache
|
||||||
build-no-cache:
|
build-no-cache: env config
|
||||||
# Build the Docker image without cache.
|
# Build the Docker image without cache.
|
||||||
docker build --no-cache -t application-portfolio .
|
@$(call _require_env,IMAGE_NAME); \
|
||||||
|
docker build --no-cache -t "$$IMAGE_NAME" .
|
||||||
|
|
||||||
.PHONY: up
|
.PHONY: up
|
||||||
up:
|
up: env config
|
||||||
# Start the application using docker-compose with build.
|
# Start the application using docker-compose with build.
|
||||||
docker-compose up -d --build --force-recreate
|
docker-compose up -d --build --force-recreate
|
||||||
|
|
||||||
@@ -34,23 +71,29 @@ down:
|
|||||||
- docker-compose down
|
- docker-compose down
|
||||||
|
|
||||||
.PHONY: run-dev
|
.PHONY: run-dev
|
||||||
run-dev:
|
run-dev: env config config
|
||||||
# Run the container in development mode (hot-reload).
|
# Run the container in development mode (hot-reload).
|
||||||
docker run -d \
|
@$(call _require_env,IMAGE_NAME PORT); \
|
||||||
-p $(PORT):$(PORT) \
|
docker run -d \
|
||||||
--name portfolio \
|
-p "$$PORT:$$PORT" \
|
||||||
-v $(PWD)/app/:/app \
|
--name portfolio \
|
||||||
-e FLASK_APP=app.py \
|
-v "$(PWD)/app/:/app" \
|
||||||
-e FLASK_ENV=development \
|
-e PORT="$$PORT" \
|
||||||
application-portfolio
|
-e TRUSTED_HOSTS="$$TRUSTED_HOSTS" \
|
||||||
|
-e FLASK_APP=app.py \
|
||||||
|
-e FLASK_ENV=development \
|
||||||
|
"$$IMAGE_NAME"
|
||||||
|
|
||||||
.PHONY: run-prod
|
.PHONY: run-prod
|
||||||
run-prod:
|
run-prod: env config config
|
||||||
# Run the container in production mode.
|
# Run the container in production mode.
|
||||||
docker run -d \
|
@$(call _require_env,IMAGE_NAME PORT); \
|
||||||
-p $(PORT):$(PORT) \
|
docker run -d \
|
||||||
--name portfolio \
|
-p "$$PORT:$$PORT" \
|
||||||
application-portfolio
|
--name portfolio \
|
||||||
|
-e PORT="$$PORT" \
|
||||||
|
-e TRUSTED_HOSTS="$$TRUSTED_HOSTS" \
|
||||||
|
"$$IMAGE_NAME"
|
||||||
|
|
||||||
.PHONY: logs
|
.PHONY: logs
|
||||||
logs:
|
logs:
|
||||||
@@ -58,12 +101,12 @@ logs:
|
|||||||
docker logs -f portfolio
|
docker logs -f portfolio
|
||||||
|
|
||||||
.PHONY: dev
|
.PHONY: dev
|
||||||
dev:
|
dev: env config
|
||||||
# Start the application in development mode using docker-compose.
|
# Start the application in development mode using docker-compose.
|
||||||
FLASK_ENV=development docker-compose up -d
|
FLASK_ENV=development docker-compose up -d
|
||||||
|
|
||||||
.PHONY: prod
|
.PHONY: prod
|
||||||
prod:
|
prod: env config
|
||||||
# Start the application in production mode using docker-compose (with build).
|
# Start the application in production mode using docker-compose (with build).
|
||||||
docker-compose up -d --build
|
docker-compose up -d --build
|
||||||
|
|
||||||
@@ -78,9 +121,10 @@ delete:
|
|||||||
- docker rm -f portfolio
|
- docker rm -f portfolio
|
||||||
|
|
||||||
.PHONY: browse
|
.PHONY: browse
|
||||||
browse:
|
browse: env
|
||||||
# Open the application in the browser at http://localhost:$(PORT)
|
# Open the application in the browser at http://localhost:$$PORT
|
||||||
chromium http://localhost:$(PORT)
|
@$(call _require_env,PORT); \
|
||||||
|
chromium "http://localhost:$$PORT"
|
||||||
|
|
||||||
.PHONY: install
|
.PHONY: install
|
||||||
install:
|
install:
|
||||||
@@ -92,6 +136,23 @@ install-dev:
|
|||||||
# Install runtime and developer dependencies from pyproject.toml.
|
# Install runtime and developer dependencies from pyproject.toml.
|
||||||
$(PYTHON) -m pip install -e ".[dev]"
|
$(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
|
.PHONY: lint-actions
|
||||||
lint-actions:
|
lint-actions:
|
||||||
# Lint GitHub Actions workflows.
|
# Lint GitHub Actions workflows.
|
||||||
@@ -108,6 +169,21 @@ lint-docker:
|
|||||||
# Lint the Dockerfile.
|
# Lint the Dockerfile.
|
||||||
docker run --rm -i hadolint/hadolint < 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
|
.PHONY: test-lint
|
||||||
test-lint:
|
test-lint:
|
||||||
# Run lint guardrail tests.
|
# Run lint guardrail tests.
|
||||||
@@ -129,7 +205,7 @@ test-security: install
|
|||||||
$(PYTHON) -m unittest discover -s tests/security -t .
|
$(PYTHON) -m unittest discover -s tests/security -t .
|
||||||
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
lint: lint-actions lint-python lint-docker test-lint
|
lint: lint-actions lint-python lint-yaml lint-js lint-docker lint-shell test-lint
|
||||||
# Run the full lint suite.
|
# Run the full lint suite.
|
||||||
|
|
||||||
.PHONY: security
|
.PHONY: security
|
||||||
@@ -139,9 +215,20 @@ security: install-dev test-security
|
|||||||
$(PYTHON) utils/export_runtime_requirements.py > /tmp/portfolio-runtime-requirements.txt
|
$(PYTHON) utils/export_runtime_requirements.py > /tmp/portfolio-runtime-requirements.txt
|
||||||
$(PYTHON) -m pip_audit -r /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
|
.PHONY: test-e2e
|
||||||
test-e2e:
|
test-e2e: env config node-deps
|
||||||
# Run Cypress end-to-end tests via act (stop portfolio container to free port first).
|
# 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
|
-docker stop portfolio 2>/dev/null || true
|
||||||
$(ACT) workflow_dispatch -W .github/workflows/tests.yml -j e2e
|
$(ACT) workflow_dispatch -W .github/workflows/tests.yml -j e2e
|
||||||
-docker start portfolio 2>/dev/null || true
|
-docker start portfolio 2>/dev/null || true
|
||||||
|
|||||||
67
README.md
67
README.md
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
A lightweight, Docker-powered portfolio/landing-page generator—fully customizable via YAML! Showcase your projects, skills, and online presence in minutes.
|
A lightweight, Docker-powered portfolio/landing-page generator—fully customizable via YAML! Showcase your projects, skills, and online presence in minutes.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
> 🚀 You can also pair PortUI with JavaScript for sleek, web-based desktop-style interfaces.
|
> 🚀 You can also pair PortUI with JavaScript for sleek, web-based desktop-style interfaces.
|
||||||
> 💻 Example in action: [CyMaIS.Cloud](https://cymais.cloud/) (demo)
|
> 💻 Example in action: [CyMaIS.Cloud](https://cymais.cloud/) (demo)
|
||||||
> 🌐 Another live example: [veen.world](https://www.veen.world/) (Kevin’s personal site)
|
> 🌐 Another live example: [veen.world](https://www.veen.world/) (Kevin’s personal site)
|
||||||
@@ -20,6 +22,8 @@ A lightweight, Docker-powered portfolio/landing-page generator—fully customiza
|
|||||||
Auto-cache assets for lightning-fast loading.
|
Auto-cache assets for lightning-fast loading.
|
||||||
- **Responsive Design**
|
- **Responsive Design**
|
||||||
Built on Bootstrap; looks great on desktop, tablet & mobile.
|
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**
|
- **YAML-Driven**
|
||||||
All content & structure defined in a simple `config.yaml`.
|
All content & structure defined in a simple `config.yaml`.
|
||||||
- **CLI Control**
|
- **CLI Control**
|
||||||
@@ -137,12 +141,75 @@ company:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🌍 Languages
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Translations live in two catalogues, both keyed by the English source string:
|
||||||
|
|
||||||
|
| 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. |
|
||||||
|
|
||||||
|
A string with no catalogue entry falls back to English, so a half-filled
|
||||||
|
catalogue degrades instead of breaking.
|
||||||
|
|
||||||
|
Fill the content catalogues from a [LibreTranslate](https://libretranslate.com/)
|
||||||
|
instance — set `LIBRETRANSLATE_URL` in `.env`, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make i18n
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
`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
|
## 🚢 Production Deployment
|
||||||
|
|
||||||
* Use a reverse proxy (NGINX/Apache).
|
* Use a reverse proxy (NGINX/Apache).
|
||||||
* Secure with SSL/TLS.
|
* Secure with SSL/TLS.
|
||||||
* Swap to a production database if needed.
|
* 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
|
## 📜 License
|
||||||
|
|||||||
131
app/app.py
131
app/app.py
@@ -3,18 +3,26 @@ import os
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
import yaml
|
import yaml
|
||||||
from flask import Flask, current_app, render_template
|
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:
|
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.cache_manager import CacheManager
|
||||||
from app.utils.compute_card_classes import compute_card_classes
|
from app.utils.compute_card_classes import compute_card_classes
|
||||||
from app.utils.configuration_resolver import ConfigurationResolver
|
from app.utils.configuration_resolver import ConfigurationResolver
|
||||||
except ImportError: # pragma: no cover - supports running from the app/ directory.
|
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.cache_manager import CacheManager
|
||||||
from utils.compute_card_classes import compute_card_classes
|
from utils.compute_card_classes import compute_card_classes
|
||||||
from utils.configuration_resolver import ConfigurationResolver
|
from utils.configuration_resolver import ConfigurationResolver
|
||||||
|
|
||||||
|
from utils import i18n
|
||||||
|
|
||||||
|
TRANSLATED_SECTIONS = ("cards", "company", "navigation", "platform")
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
FLASK_ENV = os.getenv("FLASK_ENV", "production")
|
FLASK_ENV = os.getenv("FLASK_ENV", "production")
|
||||||
@@ -40,32 +48,43 @@ 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, with a source fallback."""
|
"""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:
|
||||||
cached = cache_manager.cache_file(icon["source"])
|
resolve_asset_cache(icon, cache_manager)
|
||||||
icon["cache"] = cached or icon["source"]
|
|
||||||
|
|
||||||
company_logo = app.config["company"]["logo"]
|
resolve_asset_cache(app.config["company"]["logo"], cache_manager)
|
||||||
cached = cache_manager.cache_file(company_logo["source"])
|
resolve_asset_cache(app.config["platform"]["favicon"], cache_manager)
|
||||||
company_logo["cache"] = cached or company_logo["source"]
|
resolve_asset_cache(app.config["platform"]["logo"], cache_manager)
|
||||||
|
|
||||||
favicon = app.config["platform"]["favicon"]
|
|
||||||
cached = cache_manager.cache_file(favicon["source"])
|
|
||||||
favicon["cache"] = cached or favicon["source"]
|
|
||||||
|
|
||||||
platform_logo = app.config["platform"]["logo"]
|
|
||||||
cached = cache_manager.cache_file(platform_logo["source"])
|
|
||||||
platform_logo["cache"] = cached or 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)
|
||||||
@@ -83,7 +102,10 @@ def utility_processor():
|
|||||||
except OSError:
|
except OSError:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
return dict(include_svg=include_svg)
|
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
|
||||||
@@ -94,36 +116,79 @@ def reload_config_in_dev():
|
|||||||
cache_icons_and_logos(app)
|
cache_icons_and_logos(app)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
def translated_config(lang):
|
||||||
def index():
|
"""Return the configuration sections translated into ``lang``, memoized.
|
||||||
"""Render the main index page."""
|
|
||||||
cards = app.config["cards"]
|
The memo is dropped by ``load_config``, so a development reload picks up
|
||||||
lg_classes, md_classes = compute_card_classes(cards)
|
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,
|
timeout=10,
|
||||||
)
|
)
|
||||||
if resp.ok:
|
except requests.RequestException:
|
||||||
data = resp.json()
|
logging.warning("APOD lookup failed", exc_info=True)
|
||||||
if data.get("media_type") == "image":
|
return None
|
||||||
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(
|
app.run(
|
||||||
debug=(FLASK_ENV == "development"),
|
debug=(FLASK_ENV == "development"),
|
||||||
|
|||||||
@@ -198,7 +198,16 @@ accounts:
|
|||||||
url: https://s.veen.world/cloud
|
url: https://s.veen.world/cloud
|
||||||
|
|
||||||
cards:
|
cards:
|
||||||
- icon:
|
- 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:
|
||||||
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
|
||||||
text: I lead agile transformations and improve team dynamics through Scrum, DevOps,
|
text: I lead agile transformations and improve team dynamics through Scrum, DevOps,
|
||||||
|
|||||||
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\/$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
263
app/cypress/e2e/injection.spec.js
Normal file
263
app/cypress/e2e/injection.spec.js
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
// cypress/e2e/injection.spec.js
|
||||||
|
|
||||||
|
describe('Untrusted content in the modal', () => {
|
||||||
|
const base = {
|
||||||
|
name: 'Test Item',
|
||||||
|
identifier: 'ABC123',
|
||||||
|
icon: { class: 'fa fa-test' },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit('/');
|
||||||
|
cy.window().then(win => {
|
||||||
|
cy.stub(win.navigator.clipboard, 'writeText').resolves();
|
||||||
|
cy.stub(win, 'alert');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function open(item = {}) {
|
||||||
|
cy.window().invoke('openDynamicPopup', { ...base, ...item });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('markdown rendered into innerHTML', () => {
|
||||||
|
it('strips a plain script URL', () => {
|
||||||
|
open({
|
||||||
|
warning: '[click me](javascript:window.__xss = true)',
|
||||||
|
info: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get('#dynamicModalWarningText').find('a').should('not.exist');
|
||||||
|
cy.get('#dynamicModalWarningText').should('contain.text', 'click me');
|
||||||
|
cy.get('#dynamicModalInfoText').find('img').should('not.exist');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips a script URL hidden behind character references', () => {
|
||||||
|
open({
|
||||||
|
warning:
|
||||||
|
'[a](javascript:window.__xss=1) [b](javascript:window.__xss=1)',
|
||||||
|
info: '[c](java	script:window.__xss=1) [d](java
script:window.__xss=1)',
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get('#dynamicModalWarningText').find('a').should('not.exist');
|
||||||
|
cy.get('#dynamicModalInfoText').find('a').should('not.exist');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips a script URL written as a reference-style link', () => {
|
||||||
|
open({
|
||||||
|
warning: '[click me][ref]\n\n[ref]: javascript:window.__xss=1',
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get('#dynamicModalWarningText').find('a').should('not.exist');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('neutralises raw HTML', () => {
|
||||||
|
open({
|
||||||
|
warning: '<img src=x onerror="window.__xss = true">',
|
||||||
|
info: '<a href="javascript:window.__xss = true">x</a>',
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get('#dynamicModalWarningText').find('img').should('not.exist');
|
||||||
|
cy.get('#dynamicModalWarningText').should('contain.text', 'onerror');
|
||||||
|
cy.get('#dynamicModalInfoText').find('a').should('not.exist');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('neutralises raw HTML used as the text of a stripped link', () => {
|
||||||
|
open({ warning: '[<img src=x onerror="window.__xss = true">](javascript:bad)' });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalWarningText').find('img').should('not.exist');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a relative link', () => {
|
||||||
|
open({ warning: 'See [the notes](/#anchor)' });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalWarningText')
|
||||||
|
.find('a')
|
||||||
|
.should('have.attr', 'href', '/#anchor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the text of a link it strips', () => {
|
||||||
|
open({ warning: '[read this](javascript:window.__xss=1)' });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalWarningText').should('contain.text', 'read this');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps ordinary markdown', () => {
|
||||||
|
open({ warning: 'See [Matrix](https://matrix.org/) and **mind** this' });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalWarningText')
|
||||||
|
.find('a')
|
||||||
|
.should('have.attr', 'href', 'https://matrix.org/');
|
||||||
|
cy.get('#dynamicModalWarningText').find('strong').should('have.text', 'mind');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('values interpolated outside markdown', () => {
|
||||||
|
it('does not treat an interface string as markup', () => {
|
||||||
|
cy.window().then(win => {
|
||||||
|
win.I18N.Open = '<img src=x onerror="window.__xss = true">';
|
||||||
|
});
|
||||||
|
open({
|
||||||
|
alternatives: [
|
||||||
|
{ name: 'Alt', identifier: 'A', icon: { class: 'fa-alt' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get('#dynamicAlternativesList').find('img').should('not.exist');
|
||||||
|
cy.get('#dynamicAlternativesList').should('contain.text', 'onerror');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the English source when a string is missing', () => {
|
||||||
|
cy.window().then(win => {
|
||||||
|
delete win.I18N;
|
||||||
|
});
|
||||||
|
open({
|
||||||
|
alternatives: [
|
||||||
|
{ name: 'Alt', identifier: 'A', icon: { class: 'fa-alt' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get('#dynamicAlternativesList button').should('have.text', 'Open');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no placeholder for a missing name', () => {
|
||||||
|
open({ name: undefined });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalLabel').should('not.contain.text', 'undefined');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat the name or the icon class as markup', () => {
|
||||||
|
open({
|
||||||
|
name: '<img src=x onerror="window.__xss = true">',
|
||||||
|
icon: { class: 'fa" onmouseover="window.__xss = true' },
|
||||||
|
alternatives: [
|
||||||
|
{
|
||||||
|
name: '<img src=y onerror="window.__xss = true">',
|
||||||
|
identifier: 'ALT1',
|
||||||
|
icon: { class: 'fa-alt' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get('#dynamicModalLabel').find('img').should('not.exist');
|
||||||
|
cy.get('#dynamicModalLabel').should('contain.text', 'onerror');
|
||||||
|
cy.get('#dynamicAlternativesList').find('img').should('not.exist');
|
||||||
|
cy.get('#dynamicAlternativesList').should('contain.text', 'onerror');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the link the modal offers', () => {
|
||||||
|
it('drops a URL that uses an unsafe scheme', () => {
|
||||||
|
open({ url: 'javascript:window.__xss = true', description: 'Bad' });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalLinkHref').should('not.have.attr', 'href');
|
||||||
|
cy.get('#dynamicModalLinkHref').should('have.text', 'Bad');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an ordinary URL', () => {
|
||||||
|
open({ url: 'https://example.com', description: 'Good' });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalLinkHref').should(
|
||||||
|
'have.attr',
|
||||||
|
'href',
|
||||||
|
'https://example.com',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a URL that carries surrounding whitespace', () => {
|
||||||
|
open({ url: ' https://example.com ', description: 'Good' });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalLinkHref').should('have.attr', 'href');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a mailto URL', () => {
|
||||||
|
open({ url: 'mailto:kevin@veen.world', description: 'Write' });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalLinkHref').should(
|
||||||
|
'have.attr',
|
||||||
|
'href',
|
||||||
|
'mailto:kevin@veen.world',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the link after a popup whose URL was dropped', () => {
|
||||||
|
open({ url: 'javascript:window.__xss = true', description: 'Bad' });
|
||||||
|
cy.get('#dynamicModalLinkHref').should('not.have.attr', 'href');
|
||||||
|
|
||||||
|
open({ url: 'https://example.com', description: 'Good' });
|
||||||
|
cy.get('#dynamicModalLinkHref').should(
|
||||||
|
'have.attr',
|
||||||
|
'href',
|
||||||
|
'https://example.com',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let one popup iframe handler outlive it', () => {
|
||||||
|
open({ url: 'https://a.test/', description: 'A', iframe: true });
|
||||||
|
cy.get('#dynamicModalLinkHref').should('have.class', 'iframe');
|
||||||
|
|
||||||
|
open({ url: 'https://b.test/', description: 'B' });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalLinkHref').should('not.have.class', 'iframe');
|
||||||
|
cy.get('#dynamicModalLinkHref').should($anchor => {
|
||||||
|
expect($anchor[0].onclick, 'stale click handler').to.equal(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the current popup URL, not an earlier one', () => {
|
||||||
|
open({ url: 'https://a.test/', description: 'A', iframe: true });
|
||||||
|
open({ url: 'https://b.test/', description: 'B', iframe: true });
|
||||||
|
|
||||||
|
cy.get('#dynamicModalLinkHref').click();
|
||||||
|
|
||||||
|
cy.get('#main')
|
||||||
|
.find('iframe', { timeout: 4000 })
|
||||||
|
.should('have.attr', 'src', 'https://b.test/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Untrusted content reaching the iframe', () => {
|
||||||
|
const AFTER_THE_FADE = 3000;
|
||||||
|
|
||||||
|
it('refuses to open a script URL handed over by the modal', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
cy.window().invoke('openDynamicPopup', {
|
||||||
|
name: 'Bad',
|
||||||
|
icon: { class: 'fa fa-test' },
|
||||||
|
url: 'javascript:window.__xss = true',
|
||||||
|
description: 'Watch',
|
||||||
|
iframe: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get('#dynamicModalLinkHref').click({ force: true });
|
||||||
|
|
||||||
|
cy.wait(AFTER_THE_FADE);
|
||||||
|
cy.get('#main').find('iframe').should('not.exist');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a script URL supplied through the query string', () => {
|
||||||
|
cy.visit('/?iframe=javascript:window.__xss%20%3D%20true');
|
||||||
|
|
||||||
|
cy.wait(AFTER_THE_FADE);
|
||||||
|
cy.get('#main').find('iframe').should('not.exist');
|
||||||
|
cy.window().should('not.have.property', '__xss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still opens an ordinary URL from the query string', () => {
|
||||||
|
cy.visit('/?iframe=https://example.com/');
|
||||||
|
|
||||||
|
cy.get('#main')
|
||||||
|
.find('iframe', { timeout: AFTER_THE_FADE })
|
||||||
|
.should('have.attr', 'src', 'https://example.com/');
|
||||||
|
});
|
||||||
|
});
|
||||||
78
app/eslint.config.js
Normal file
78
app/eslint.config.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Correctness only — eslint's recommended set, no stylistic rules.
|
||||||
|
*
|
||||||
|
* The browser scripts are plain <script> tags sharing one global scope: each
|
||||||
|
* file declares some functions and calls others declared elsewhere. That is why
|
||||||
|
* they are listed as globals and why no-redeclare is off — the declaring file
|
||||||
|
* would otherwise be reported for defining its own function.
|
||||||
|
*/
|
||||||
|
const js = require('@eslint/js');
|
||||||
|
const globals = require('globals');
|
||||||
|
|
||||||
|
// Vendored libraries plus the functions static/js files call across each other.
|
||||||
|
// Keep this to names that really cross a file boundary. Every superfluous
|
||||||
|
// entry is a permanent no-undef blind spot for a typo of that name.
|
||||||
|
const SHARED = {
|
||||||
|
bootstrap: 'readonly',
|
||||||
|
marked: 'readonly',
|
||||||
|
$: 'readonly',
|
||||||
|
jQuery: 'readonly',
|
||||||
|
openDynamicPopup: 'readonly',
|
||||||
|
closeAllModals: 'readonly',
|
||||||
|
isSafeUrl: 'readonly',
|
||||||
|
openIframe: 'readonly',
|
||||||
|
enterFullscreen: 'readonly',
|
||||||
|
exitFullscreen: 'readonly',
|
||||||
|
setFullWidth: 'readonly',
|
||||||
|
initFullWidthFromUrl: 'readonly',
|
||||||
|
adjustScrollContainerHeight: 'readonly',
|
||||||
|
updateCustomScrollbar: 'readonly',
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
{ ignores: ['node_modules/**', 'static/vendor/**', 'cypress/screenshots/**'] },
|
||||||
|
{
|
||||||
|
files: ['static/js/**/*.js'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'script',
|
||||||
|
globals: { ...globals.browser, ...SHARED },
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...js.configs.recommended.rules,
|
||||||
|
'no-redeclare': 'off',
|
||||||
|
// vars: 'local' — a top-level function here is the API other files and
|
||||||
|
// the templates call, so only unused locals are a defect.
|
||||||
|
'no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ vars: 'local', args: 'none', caughtErrors: 'none' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['cypress/**/*.js'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'script',
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.mocha,
|
||||||
|
cy: 'readonly',
|
||||||
|
Cypress: 'readonly',
|
||||||
|
expect: 'readonly',
|
||||||
|
assert: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: js.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['scripts/**/*.js', 'cypress.config.js', 'eslint.config.js'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
globals: globals.node,
|
||||||
|
},
|
||||||
|
rules: js.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
];
|
||||||
46
app/i18n/keep.txt
Normal file
46
app/i18n/keep.txt
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Strings utils/i18n_sync.py stores as themselves instead of translating.
|
||||||
|
# One per line; blank lines and lines starting with # are ignored.
|
||||||
|
|
||||||
|
Baserow
|
||||||
|
Big Blue Button
|
||||||
|
Bluesky
|
||||||
|
Buy me a Coffee
|
||||||
|
Discourse
|
||||||
|
Duolingo
|
||||||
|
Eversports
|
||||||
|
Facebook
|
||||||
|
Friendica
|
||||||
|
Garmin
|
||||||
|
Gitea
|
||||||
|
GitHub
|
||||||
|
GitHub Sponsors
|
||||||
|
Infinito.Nexus
|
||||||
|
Instagram
|
||||||
|
Keycloak
|
||||||
|
LDAP
|
||||||
|
LinkedIn
|
||||||
|
Mailu
|
||||||
|
Mastodon
|
||||||
|
Matomo
|
||||||
|
Matrix
|
||||||
|
Nextcloud
|
||||||
|
Open Project
|
||||||
|
Patreon
|
||||||
|
PayPal
|
||||||
|
Peertube
|
||||||
|
Pixelfed
|
||||||
|
Signal
|
||||||
|
Snipe IT
|
||||||
|
Spotify
|
||||||
|
Taiga
|
||||||
|
Telegram
|
||||||
|
Twitter
|
||||||
|
WhatsApp
|
||||||
|
XING
|
||||||
|
YouTube
|
||||||
|
Yourls
|
||||||
|
Zoom
|
||||||
|
freelancermap.de
|
||||||
|
malt
|
||||||
|
phpMyAdmin
|
||||||
|
upwork.com
|
||||||
11
app/i18n/ui/ar.yaml
Normal file
11
app/i18n/ui/ar.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: بدائل
|
||||||
|
Close: إغلاق
|
||||||
|
Copy: نسخ
|
||||||
|
Identifier copied to clipboard!: تم نسخ المعرّف إلى الحافظة!
|
||||||
|
Imprint: معلومات قانونية
|
||||||
|
Information: معلومات
|
||||||
|
Language: اللغة
|
||||||
|
Open: فتح
|
||||||
|
Open Link: فتح الرابط
|
||||||
|
Options: خيارات
|
||||||
|
Warning: تحذير
|
||||||
11
app/i18n/ui/bn.yaml
Normal file
11
app/i18n/ui/bn.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: অন্যান্য বিকল্প
|
||||||
|
Close: বন্ধ করুন
|
||||||
|
Copy: অনুলিপি
|
||||||
|
Identifier copied to clipboard!: শনাক্তকারী ক্লিপবোর্ডে অনুলিপি করা হয়েছে!
|
||||||
|
Imprint: আইনি তথ্য
|
||||||
|
Information: তথ্য
|
||||||
|
Language: ভাষা
|
||||||
|
Open: খুলুন
|
||||||
|
Open Link: লিঙ্ক খুলুন
|
||||||
|
Options: বিকল্প
|
||||||
|
Warning: সতর্কতা
|
||||||
11
app/i18n/ui/cs.yaml
Normal file
11
app/i18n/ui/cs.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternativy
|
||||||
|
Close: Zavřít
|
||||||
|
Copy: Kopírovat
|
||||||
|
Identifier copied to clipboard!: Identifikátor zkopírován do schránky!
|
||||||
|
Imprint: Tiráž
|
||||||
|
Information: Informace
|
||||||
|
Language: Jazyk
|
||||||
|
Open: Otevřít
|
||||||
|
Open Link: Otevřít odkaz
|
||||||
|
Options: Možnosti
|
||||||
|
Warning: Upozornění
|
||||||
11
app/i18n/ui/da.yaml
Normal file
11
app/i18n/ui/da.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternativer
|
||||||
|
Close: Luk
|
||||||
|
Copy: Kopiér
|
||||||
|
Identifier copied to clipboard!: Identifikator kopieret til udklipsholder!
|
||||||
|
Imprint: Juridisk information
|
||||||
|
Information: Information
|
||||||
|
Language: Sprog
|
||||||
|
Open: Åbn
|
||||||
|
Open Link: Åbn link
|
||||||
|
Options: Valgmuligheder
|
||||||
|
Warning: Advarsel
|
||||||
11
app/i18n/ui/de.yaml
Normal file
11
app/i18n/ui/de.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternativen
|
||||||
|
Close: Schließen
|
||||||
|
Copy: Kopieren
|
||||||
|
Identifier copied to clipboard!: Kennung in die Zwischenablage kopiert!
|
||||||
|
Imprint: Impressum
|
||||||
|
Information: Information
|
||||||
|
Language: Sprache
|
||||||
|
Open: Öffnen
|
||||||
|
Open Link: Link öffnen
|
||||||
|
Options: Optionen
|
||||||
|
Warning: Warnung
|
||||||
11
app/i18n/ui/el.yaml
Normal file
11
app/i18n/ui/el.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Εναλλακτικές
|
||||||
|
Close: Κλείσιμο
|
||||||
|
Copy: Αντιγραφή
|
||||||
|
Identifier copied to clipboard!: Το αναγνωριστικό αντιγράφηκε στο πρόχειρο!
|
||||||
|
Imprint: Νομικές πληροφορίες
|
||||||
|
Information: Πληροφορίες
|
||||||
|
Language: Γλώσσα
|
||||||
|
Open: Άνοιγμα
|
||||||
|
Open Link: Άνοιγμα συνδέσμου
|
||||||
|
Options: Επιλογές
|
||||||
|
Warning: Προειδοποίηση
|
||||||
11
app/i18n/ui/es.yaml
Normal file
11
app/i18n/ui/es.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternativas
|
||||||
|
Close: Cerrar
|
||||||
|
Copy: Copiar
|
||||||
|
Identifier copied to clipboard!: ¡Identificador copiado al portapapeles!
|
||||||
|
Imprint: Aviso legal
|
||||||
|
Information: Información
|
||||||
|
Language: Idioma
|
||||||
|
Open: Abrir
|
||||||
|
Open Link: Abrir enlace
|
||||||
|
Options: Opciones
|
||||||
|
Warning: Advertencia
|
||||||
11
app/i18n/ui/fa.yaml
Normal file
11
app/i18n/ui/fa.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: جایگزینها
|
||||||
|
Close: بستن
|
||||||
|
Copy: کپی
|
||||||
|
Identifier copied to clipboard!: شناسه در کلیپبورد کپی شد!
|
||||||
|
Imprint: اطلاعات حقوقی
|
||||||
|
Information: اطلاعات
|
||||||
|
Language: زبان
|
||||||
|
Open: باز کردن
|
||||||
|
Open Link: باز کردن پیوند
|
||||||
|
Options: گزینهها
|
||||||
|
Warning: هشدار
|
||||||
11
app/i18n/ui/fi.yaml
Normal file
11
app/i18n/ui/fi.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Vaihtoehdot
|
||||||
|
Close: Sulje
|
||||||
|
Copy: Kopioi
|
||||||
|
Identifier copied to clipboard!: Tunniste kopioitu leikepöydälle!
|
||||||
|
Imprint: Oikeudelliset tiedot
|
||||||
|
Information: Tiedot
|
||||||
|
Language: Kieli
|
||||||
|
Open: Avaa
|
||||||
|
Open Link: Avaa linkki
|
||||||
|
Options: Valinnat
|
||||||
|
Warning: Varoitus
|
||||||
11
app/i18n/ui/fr.yaml
Normal file
11
app/i18n/ui/fr.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternatives
|
||||||
|
Close: Fermer
|
||||||
|
Copy: Copier
|
||||||
|
Identifier copied to clipboard!: Identifiant copié dans le presse-papiers !
|
||||||
|
Imprint: Mentions légales
|
||||||
|
Information: Informations
|
||||||
|
Language: Langue
|
||||||
|
Open: Ouvrir
|
||||||
|
Open Link: Ouvrir le lien
|
||||||
|
Options: Options
|
||||||
|
Warning: Avertissement
|
||||||
11
app/i18n/ui/he.yaml
Normal file
11
app/i18n/ui/he.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: חלופות
|
||||||
|
Close: סגור
|
||||||
|
Copy: העתק
|
||||||
|
Identifier copied to clipboard!: המזהה הועתק ללוח!
|
||||||
|
Imprint: מידע משפטי
|
||||||
|
Information: מידע
|
||||||
|
Language: שפה
|
||||||
|
Open: פתח
|
||||||
|
Open Link: פתח קישור
|
||||||
|
Options: אפשרויות
|
||||||
|
Warning: אזהרה
|
||||||
11
app/i18n/ui/hi.yaml
Normal file
11
app/i18n/ui/hi.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: अन्य विकल्प
|
||||||
|
Close: बंद करें
|
||||||
|
Copy: कॉपी करें
|
||||||
|
Identifier copied to clipboard!: पहचानकर्ता क्लिपबोर्ड पर कॉपी हो गया!
|
||||||
|
Imprint: कानूनी सूचना
|
||||||
|
Information: जानकारी
|
||||||
|
Language: भाषा
|
||||||
|
Open: खोलें
|
||||||
|
Open Link: लिंक खोलें
|
||||||
|
Options: विकल्प
|
||||||
|
Warning: चेतावनी
|
||||||
11
app/i18n/ui/hu.yaml
Normal file
11
app/i18n/ui/hu.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternatívák
|
||||||
|
Close: Bezárás
|
||||||
|
Copy: Másolás
|
||||||
|
Identifier copied to clipboard!: Az azonosító a vágólapra másolva!
|
||||||
|
Imprint: Impresszum
|
||||||
|
Information: Információ
|
||||||
|
Language: Nyelv
|
||||||
|
Open: Megnyitás
|
||||||
|
Open Link: Hivatkozás megnyitása
|
||||||
|
Options: Lehetőségek
|
||||||
|
Warning: Figyelmeztetés
|
||||||
11
app/i18n/ui/id.yaml
Normal file
11
app/i18n/ui/id.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternatif
|
||||||
|
Close: Tutup
|
||||||
|
Copy: Salin
|
||||||
|
Identifier copied to clipboard!: Pengenal disalin ke papan klip!
|
||||||
|
Imprint: Informasi hukum
|
||||||
|
Information: Informasi
|
||||||
|
Language: Bahasa
|
||||||
|
Open: Buka
|
||||||
|
Open Link: Buka tautan
|
||||||
|
Options: Opsi
|
||||||
|
Warning: Peringatan
|
||||||
11
app/i18n/ui/it.yaml
Normal file
11
app/i18n/ui/it.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternative
|
||||||
|
Close: Chiudi
|
||||||
|
Copy: Copia
|
||||||
|
Identifier copied to clipboard!: Identificatore copiato negli appunti!
|
||||||
|
Imprint: Note legali
|
||||||
|
Information: Informazioni
|
||||||
|
Language: Lingua
|
||||||
|
Open: Apri
|
||||||
|
Open Link: Apri collegamento
|
||||||
|
Options: Opzioni
|
||||||
|
Warning: Avviso
|
||||||
11
app/i18n/ui/ja.yaml
Normal file
11
app/i18n/ui/ja.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: 代替
|
||||||
|
Close: 閉じる
|
||||||
|
Copy: コピー
|
||||||
|
Identifier copied to clipboard!: 識別子をクリップボードにコピーしました!
|
||||||
|
Imprint: 法的情報
|
||||||
|
Information: 情報
|
||||||
|
Language: 言語
|
||||||
|
Open: 開く
|
||||||
|
Open Link: リンクを開く
|
||||||
|
Options: オプション
|
||||||
|
Warning: 警告
|
||||||
11
app/i18n/ui/ko.yaml
Normal file
11
app/i18n/ui/ko.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: 대안
|
||||||
|
Close: 닫기
|
||||||
|
Copy: 복사
|
||||||
|
Identifier copied to clipboard!: 식별자를 클립보드에 복사했습니다!
|
||||||
|
Imprint: 법적 고지
|
||||||
|
Information: 정보
|
||||||
|
Language: 언어
|
||||||
|
Open: 열기
|
||||||
|
Open Link: 링크 열기
|
||||||
|
Options: 옵션
|
||||||
|
Warning: 경고
|
||||||
11
app/i18n/ui/nl.yaml
Normal file
11
app/i18n/ui/nl.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternatieven
|
||||||
|
Close: Sluiten
|
||||||
|
Copy: Kopiëren
|
||||||
|
Identifier copied to clipboard!: Identificatie gekopieerd naar klembord!
|
||||||
|
Imprint: Colofon
|
||||||
|
Information: Informatie
|
||||||
|
Language: Taal
|
||||||
|
Open: Openen
|
||||||
|
Open Link: Link openen
|
||||||
|
Options: Opties
|
||||||
|
Warning: Waarschuwing
|
||||||
11
app/i18n/ui/pl.yaml
Normal file
11
app/i18n/ui/pl.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternatywy
|
||||||
|
Close: Zamknij
|
||||||
|
Copy: Kopiuj
|
||||||
|
Identifier copied to clipboard!: Identyfikator skopiowany do schowka!
|
||||||
|
Imprint: Nota prawna
|
||||||
|
Information: Informacja
|
||||||
|
Language: Język
|
||||||
|
Open: Otwórz
|
||||||
|
Open Link: Otwórz link
|
||||||
|
Options: Opcje
|
||||||
|
Warning: Ostrzeżenie
|
||||||
11
app/i18n/ui/pt.yaml
Normal file
11
app/i18n/ui/pt.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternativas
|
||||||
|
Close: Fechar
|
||||||
|
Copy: Copiar
|
||||||
|
Identifier copied to clipboard!: Identificador copiado para a área de transferência!
|
||||||
|
Imprint: Aviso legal
|
||||||
|
Information: Informação
|
||||||
|
Language: Idioma
|
||||||
|
Open: Abrir
|
||||||
|
Open Link: Abrir link
|
||||||
|
Options: Opções
|
||||||
|
Warning: Aviso
|
||||||
11
app/i18n/ui/ro.yaml
Normal file
11
app/i18n/ui/ro.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternative
|
||||||
|
Close: Închide
|
||||||
|
Copy: Copiază
|
||||||
|
Identifier copied to clipboard!: Identificator copiat în clipboard!
|
||||||
|
Imprint: Notă legală
|
||||||
|
Information: Informații
|
||||||
|
Language: Limbă
|
||||||
|
Open: Deschide
|
||||||
|
Open Link: Deschide linkul
|
||||||
|
Options: Opțiuni
|
||||||
|
Warning: Avertisment
|
||||||
11
app/i18n/ui/ru.yaml
Normal file
11
app/i18n/ui/ru.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Альтернативы
|
||||||
|
Close: Закрыть
|
||||||
|
Copy: Копировать
|
||||||
|
Identifier copied to clipboard!: Идентификатор скопирован в буфер обмена!
|
||||||
|
Imprint: Выходные данные
|
||||||
|
Information: Информация
|
||||||
|
Language: Язык
|
||||||
|
Open: Открыть
|
||||||
|
Open Link: Открыть ссылку
|
||||||
|
Options: Варианты
|
||||||
|
Warning: Предупреждение
|
||||||
11
app/i18n/ui/sv.yaml
Normal file
11
app/i18n/ui/sv.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternativ
|
||||||
|
Close: Stäng
|
||||||
|
Copy: Kopiera
|
||||||
|
Identifier copied to clipboard!: Identifierare kopierad till urklipp!
|
||||||
|
Imprint: Juridisk information
|
||||||
|
Information: Information
|
||||||
|
Language: Språk
|
||||||
|
Open: Öppna
|
||||||
|
Open Link: Öppna länk
|
||||||
|
Options: Val
|
||||||
|
Warning: Varning
|
||||||
11
app/i18n/ui/th.yaml
Normal file
11
app/i18n/ui/th.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: ทางเลือกอื่น
|
||||||
|
Close: ปิด
|
||||||
|
Copy: คัดลอก
|
||||||
|
Identifier copied to clipboard!: คัดลอกตัวระบุไปยังคลิปบอร์ดแล้ว!
|
||||||
|
Imprint: ข้อมูลทางกฎหมาย
|
||||||
|
Information: ข้อมูล
|
||||||
|
Language: ภาษา
|
||||||
|
Open: เปิด
|
||||||
|
Open Link: เปิดลิงก์
|
||||||
|
Options: ตัวเลือก
|
||||||
|
Warning: คำเตือน
|
||||||
11
app/i18n/ui/tr.yaml
Normal file
11
app/i18n/ui/tr.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Alternatifler
|
||||||
|
Close: Kapat
|
||||||
|
Copy: Kopyala
|
||||||
|
Identifier copied to clipboard!: Tanımlayıcı panoya kopyalandı!
|
||||||
|
Imprint: Künye
|
||||||
|
Information: Bilgi
|
||||||
|
Language: Dil
|
||||||
|
Open: Aç
|
||||||
|
Open Link: Bağlantıyı aç
|
||||||
|
Options: Seçenekler
|
||||||
|
Warning: Uyarı
|
||||||
11
app/i18n/ui/uk.yaml
Normal file
11
app/i18n/ui/uk.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Альтернативи
|
||||||
|
Close: Закрити
|
||||||
|
Copy: Копіювати
|
||||||
|
Identifier copied to clipboard!: Ідентифікатор скопійовано в буфер обміну!
|
||||||
|
Imprint: Вихідні дані
|
||||||
|
Information: Інформація
|
||||||
|
Language: Мова
|
||||||
|
Open: Відкрити
|
||||||
|
Open Link: Відкрити посилання
|
||||||
|
Options: Варіанти
|
||||||
|
Warning: Попередження
|
||||||
11
app/i18n/ui/ur.yaml
Normal file
11
app/i18n/ui/ur.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: متبادل
|
||||||
|
Close: بند کریں
|
||||||
|
Copy: نقل کریں
|
||||||
|
Identifier copied to clipboard!: شناخت کنندہ کلپ بورڈ پر نقل ہو گیا!
|
||||||
|
Imprint: قانونی معلومات
|
||||||
|
Information: معلومات
|
||||||
|
Language: زبان
|
||||||
|
Open: کھولیں
|
||||||
|
Open Link: لنک کھولیں
|
||||||
|
Options: اختیارات
|
||||||
|
Warning: انتباہ
|
||||||
11
app/i18n/ui/vi.yaml
Normal file
11
app/i18n/ui/vi.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: Lựa chọn thay thế
|
||||||
|
Close: Đóng
|
||||||
|
Copy: Sao chép
|
||||||
|
Identifier copied to clipboard!: Đã sao chép mã định danh vào bộ nhớ tạm!
|
||||||
|
Imprint: Thông tin pháp lý
|
||||||
|
Information: Thông tin
|
||||||
|
Language: Ngôn ngữ
|
||||||
|
Open: Mở
|
||||||
|
Open Link: Mở liên kết
|
||||||
|
Options: Tùy chọn
|
||||||
|
Warning: Cảnh báo
|
||||||
11
app/i18n/ui/zh.yaml
Normal file
11
app/i18n/ui/zh.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Alternatives: 替代方案
|
||||||
|
Close: 关闭
|
||||||
|
Copy: 复制
|
||||||
|
Identifier copied to clipboard!: 标识符已复制到剪贴板!
|
||||||
|
Imprint: 法律声明
|
||||||
|
Information: 信息
|
||||||
|
Language: 语言
|
||||||
|
Open: 打开
|
||||||
|
Open Link: 打开链接
|
||||||
|
Options: 选项
|
||||||
|
Warning: 警告
|
||||||
@@ -7,7 +7,10 @@
|
|||||||
"marked": "^4.3.0"
|
"marked": "^4.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"cypress": "^14.5.1"
|
"@eslint/js": "^10.0.1",
|
||||||
|
"cypress": "^14.5.1",
|
||||||
|
"eslint": "^10.9.0",
|
||||||
|
"globals": "^17.11.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node scripts/copy-vendor.js",
|
"build": "node scripts/copy-vendor.js",
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ copyFile(
|
|||||||
path.join(NM, 'bootstrap', 'dist', 'css', 'bootstrap.min.css'),
|
path.join(NM, 'bootstrap', 'dist', 'css', 'bootstrap.min.css'),
|
||||||
path.join(VENDOR, 'bootstrap', 'css', 'bootstrap.min.css')
|
path.join(VENDOR, 'bootstrap', 'css', 'bootstrap.min.css')
|
||||||
);
|
);
|
||||||
|
copyFile(
|
||||||
|
path.join(NM, 'bootstrap', 'dist', 'css', 'bootstrap.rtl.min.css'),
|
||||||
|
path.join(VENDOR, 'bootstrap', 'css', 'bootstrap.rtl.min.css')
|
||||||
|
);
|
||||||
copyFile(
|
copyFile(
|
||||||
path.join(NM, 'bootstrap', 'dist', 'js', 'bootstrap.bundle.min.js'),
|
path.join(NM, 'bootstrap', 'dist', 'js', 'bootstrap.bundle.min.js'),
|
||||||
path.join(VENDOR, 'bootstrap', 'js', 'bootstrap.bundle.min.js')
|
path.join(VENDOR, 'bootstrap', 'js', 'bootstrap.bundle.min.js')
|
||||||
|
|||||||
@@ -120,6 +120,12 @@ div#navbarNavfooter .navbar-nav {
|
|||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-menu.language-menu {
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
main, footer, header, nav {
|
main, footer, header, nav {
|
||||||
position: relative;
|
position: relative;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ function syncIframeHeight() {
|
|||||||
|
|
||||||
// Function to open a URL in an iframe (jQuery version mit 1500 ms Fade)
|
// Function to open a URL in an iframe (jQuery version mit 1500 ms Fade)
|
||||||
function openIframe(url) {
|
function openIframe(url) {
|
||||||
|
if (!isSafeUrl(url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var $container = scrollbarContainer ? $(scrollbarContainer) : null;
|
var $container = scrollbarContainer ? $(scrollbarContainer) : null;
|
||||||
var $customScroll = customScrollbar ? $(customScrollbar) : null;
|
var $customScroll = customScrollbar ? $(customScrollbar) : null;
|
||||||
var $main = $(mainElement);
|
var $main = $(mainElement);
|
||||||
|
|||||||
@@ -1,11 +1,48 @@
|
|||||||
|
function t(source) {
|
||||||
|
return (window.I18N || {})[source] || source;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAFE_URL_SCHEMES = ['http:', 'https:', 'mailto:'];
|
||||||
|
|
||||||
|
function isSafeUrl(url) {
|
||||||
|
const probe = document.createElement('a');
|
||||||
|
probe.href = String(url == null ? '' : url);
|
||||||
|
return SAFE_URL_SCHEMES.includes(probe.protocol);
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconAndName(item) {
|
||||||
|
const nodes = [];
|
||||||
|
if (item.icon && item.icon.class) {
|
||||||
|
const icon = document.createElement('i');
|
||||||
|
icon.className = item.icon.class;
|
||||||
|
nodes.push(icon, document.createTextNode(' '));
|
||||||
|
}
|
||||||
|
nodes.push(document.createTextNode(item.name == null ? '' : item.name));
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(content) {
|
||||||
|
const escaped = String(content).replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const parsed = new DOMParser().parseFromString(marked.parse(escaped), 'text/html');
|
||||||
|
|
||||||
|
parsed.querySelectorAll('a[href]').forEach((anchor) => {
|
||||||
|
if (!SAFE_URL_SCHEMES.includes(anchor.protocol)) {
|
||||||
|
anchor.replaceWith(...anchor.childNodes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
parsed.querySelectorAll('img[src]').forEach((image) => {
|
||||||
|
if (!SAFE_URL_SCHEMES.includes(image.protocol)) {
|
||||||
|
image.replaceWith(image.alt || '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return parsed.body.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
function openDynamicPopup(subitem) {
|
function openDynamicPopup(subitem) {
|
||||||
closeAllModals();
|
closeAllModals();
|
||||||
const modalTitle = document.getElementById('dynamicModalLabel');
|
const modalTitle = document.getElementById('dynamicModalLabel');
|
||||||
if (subitem.icon && subitem.icon.class) {
|
modalTitle.replaceChildren(...iconAndName(subitem));
|
||||||
modalTitle.innerHTML = `<i class="${subitem.icon.class}"></i> ${subitem.name}`;
|
|
||||||
} else {
|
|
||||||
modalTitle.innerText = subitem.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
const identifierBox = document.getElementById('dynamicIdentifierBox');
|
const identifierBox = document.getElementById('dynamicIdentifierBox');
|
||||||
const modalContent = document.getElementById('dynamicModalContent');
|
const modalContent = document.getElementById('dynamicModalContent');
|
||||||
@@ -21,7 +58,7 @@ function openDynamicPopup(subitem) {
|
|||||||
const box = document.getElementById(boxId);
|
const box = document.getElementById(boxId);
|
||||||
if (content) {
|
if (content) {
|
||||||
box.classList.remove('d-none');
|
box.classList.remove('d-none');
|
||||||
document.getElementById(textId).innerHTML = marked.parse(content);
|
document.getElementById(textId).innerHTML = renderMarkdown(content);
|
||||||
} else {
|
} else {
|
||||||
box.classList.add('d-none');
|
box.classList.add('d-none');
|
||||||
}
|
}
|
||||||
@@ -44,16 +81,19 @@ function openDynamicPopup(subitem) {
|
|||||||
if (subitem.url) {
|
if (subitem.url) {
|
||||||
linkBox.classList.remove('d-none');
|
linkBox.classList.remove('d-none');
|
||||||
linkHref.href = subitem.url;
|
linkHref.href = subitem.url;
|
||||||
linkHref.innerText = subitem.description || "Open Link";
|
if (!isSafeUrl(subitem.url)) {
|
||||||
|
linkHref.removeAttribute('href');
|
||||||
|
}
|
||||||
|
linkHref.innerText = subitem.description || t("Open Link");
|
||||||
|
linkHref.classList.remove('iframe');
|
||||||
|
linkHref.onclick = null;
|
||||||
if (subitem.iframe) {
|
if (subitem.iframe) {
|
||||||
linkHref.classList.add('iframe');
|
linkHref.classList.add('iframe');
|
||||||
// Attach an event listener that prevents the default behavior and
|
linkHref.onclick = function(event) {
|
||||||
// opens the URL in an iframe when clicked.
|
|
||||||
linkHref.addEventListener('click', function(event) {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
openIframe(subitem.url);
|
openIframe(subitem.url);
|
||||||
closeAllModals()
|
closeAllModals();
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
linkBox.classList.add('d-none');
|
linkBox.classList.add('d-none');
|
||||||
@@ -69,13 +109,13 @@ function openDynamicPopup(subitem) {
|
|||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
const listItem = document.createElement('li');
|
const listItem = document.createElement('li');
|
||||||
listItem.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'align-items-center');
|
listItem.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'align-items-center');
|
||||||
listItem.innerHTML = `
|
const label = document.createElement('span');
|
||||||
<span>
|
label.replaceChildren(...iconAndName(item));
|
||||||
<i class="${item.icon.class}"></i> ${item.name}
|
const button = document.createElement('button');
|
||||||
</span>
|
button.className = 'btn btn-outline-secondary btn-sm';
|
||||||
<button class="btn btn-outline-secondary btn-sm">Open</button>
|
button.textContent = t('Open');
|
||||||
`;
|
listItem.replaceChildren(label, button);
|
||||||
listItem.querySelector('button').addEventListener('click', () => onClickHandler(item));
|
button.addEventListener('click', () => onClickHandler(item));
|
||||||
list.appendChild(listItem);
|
list.appendChild(listItem);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -90,7 +130,7 @@ function openDynamicPopup(subitem) {
|
|||||||
copyButton.onclick = () => {
|
copyButton.onclick = () => {
|
||||||
modalContent.select();
|
modalContent.select();
|
||||||
navigator.clipboard.writeText(modalContent.value).then(() => {
|
navigator.clipboard.writeText(modalContent.value).then(() => {
|
||||||
alert('Identifier copied to clipboard!');
|
alert(t('Identifier copied to clipboard!'));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -169,7 +169,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function adjustMenuPosition(submenu, parent, isTopLevel) {
|
function adjustMenuPosition(submenu, parent, isTopLevel) {
|
||||||
const rect = submenu.getBoundingClientRect();
|
|
||||||
const parentRect = parent.getBoundingClientRect();
|
const parentRect = parent.getBoundingClientRect();
|
||||||
|
|
||||||
const spaceAbove = parentRect.top;
|
const spaceAbove = parentRect.top;
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="{{ lang }}" dir="{{ lang_dir }}">
|
||||||
<head>
|
<head>
|
||||||
<title>{{platform.titel}}</title>
|
<title>{{platform.titel}}</title>
|
||||||
<meta charset="utf-8" >
|
<meta charset="utf-8" >
|
||||||
<link
|
<link
|
||||||
rel="icon"
|
rel="icon"
|
||||||
type="image/x-icon"
|
type="image/x-icon"
|
||||||
href="{% if platform.favicon.cache %}{{ url_for('static', filename=platform.favicon.cache) }}{% endif %}"
|
href="{{ asset_src(platform.favicon) }}"
|
||||||
>
|
>
|
||||||
|
<link rel="canonical" href="{{ url_for('localized_index', lang=lang, _external=True) }}">
|
||||||
|
{% for code in languages %}
|
||||||
|
<link rel="alternate" hreflang="{{ code }}" href="{{ url_for('localized_index', lang=code, _external=True) }}">
|
||||||
|
{% endfor %}
|
||||||
|
<link rel="alternate" hreflang="x-default" href="{{ url_for('index', _external=True) }}">
|
||||||
<!-- Bootstrap CSS only -->
|
<!-- Bootstrap CSS only -->
|
||||||
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
|
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap' ~ ('.rtl' if lang_dir == 'rtl' else '') ~ '.min.css') }}" rel="stylesheet">
|
||||||
<!-- Bootstrap JavaScript Bundle with Popper -->
|
<!-- Bootstrap JavaScript Bundle with Popper -->
|
||||||
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
|
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
|
||||||
<!-- Bootstrap Icons -->
|
<!-- Bootstrap Icons -->
|
||||||
@@ -36,7 +41,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<header class="header js-restore">
|
<header class="header js-restore">
|
||||||
<img
|
<img
|
||||||
src="{{ url_for('static', filename=platform.logo.cache) }}"
|
src="{{ asset_src(platform.logo) }}"
|
||||||
alt="logo"
|
alt="logo"
|
||||||
/>
|
/>
|
||||||
<h1>{{platform.titel}}</h1>
|
<h1>{{platform.titel}}</h1>
|
||||||
@@ -60,12 +65,13 @@
|
|||||||
<p itemprop="name">{{ company.titel }} <br />
|
<p itemprop="name">{{ company.titel }} <br />
|
||||||
{{ company.subtitel }}</p>
|
{{ company.subtitel }}</p>
|
||||||
<span><i class="fa-solid fa-location-dot"></i> {{ company.address.values() | join(", ") }}</span>
|
<span><i class="fa-solid fa-location-dot"></i> {{ company.address.values() | join(", ") }}</span>
|
||||||
<p><a href="{{company.imprint_url}}" class="iframe-link"><i class="fa-solid fa-scale-balanced"></i> Imprint</a></p>
|
<p><a href="{{company.imprint_url}}" class="iframe-link"><i class="fa-solid fa-scale-balanced"></i> {{ t('Imprint') }}</a></p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<!-- Include modal -->
|
<!-- Include modal -->
|
||||||
{% include "moduls/modal.html.j2" %}
|
{% include "moduls/modal.html.j2" %}
|
||||||
|
<script>window.I18N = {{ ui_strings | tojson }};</script>
|
||||||
{% for name in [
|
{% for name in [
|
||||||
'modal',
|
'modal',
|
||||||
'navigation',
|
'navigation',
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
<div class="card-img-top">
|
<div class="card-img-top">
|
||||||
{% if card.icon.cache and card.icon.cache.endswith('.svg') %}
|
{% if card.icon.cache and card.icon.cache.endswith('.svg') %}
|
||||||
{{ include_svg(card.icon.cache) }}
|
{{ include_svg(card.icon.cache) }}
|
||||||
{% elif card.icon.cache %}
|
{% elif card.icon.cache or card.icon.external_url %}
|
||||||
<img
|
<img
|
||||||
src="{{ url_for('static', filename=card.icon.cache) }}"
|
src="{{ asset_src(card.icon) }}"
|
||||||
alt="{{ card.title }}"
|
alt="{{ card.title }}"
|
||||||
style="width:100px; height:auto;"
|
style="width:100px; height:auto;"
|
||||||
onerror="this.style.display='none'; this.nextElementSibling?.style.display='inline-block';">
|
onerror="this.style.display='none'; this.nextElementSibling?.style.display='inline-block';">
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" id="dynamicModalLabel"></h5>
|
<h5 class="modal-title" id="dynamicModalLabel"></h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ t('Close') }}"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<!-- Warning box with Markdown -->
|
<!-- Warning box with Markdown -->
|
||||||
{{ alert_box('dynamicModalWarning', 'alert-warning', 'fa-solid fa-triangle-exclamation', 'Warning', 'dynamicModalWarningText') }}
|
{{ alert_box('dynamicModalWarning', 'alert-warning', 'fa-solid fa-triangle-exclamation', t('Warning'), 'dynamicModalWarningText') }}
|
||||||
|
|
||||||
<!-- Info box with Markdown -->
|
<!-- Info box with Markdown -->
|
||||||
{{ alert_box('dynamicModalInfo', 'alert-info', 'fa-solid fa-circle-info', 'Information', 'dynamicModalInfoText') }}
|
{{ alert_box('dynamicModalInfo', 'alert-info', 'fa-solid fa-circle-info', t('Information'), 'dynamicModalInfoText') }}
|
||||||
|
|
||||||
<!-- Description text -->
|
<!-- Description text -->
|
||||||
<div id="dynamicDescriptionText" class="mt-2 d-none"></div>
|
<div id="dynamicDescriptionText" class="mt-2 d-none"></div>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<!-- Input box for Identifier -->
|
<!-- Input box for Identifier -->
|
||||||
<div id="dynamicIdentifierBox" class="input-group mt-2 d-none">
|
<div id="dynamicIdentifierBox" class="input-group mt-2 d-none">
|
||||||
<input type="text" id="dynamicModalContent" class="form-control" readonly>
|
<input type="text" id="dynamicModalContent" class="form-control" readonly>
|
||||||
<button class="btn btn-outline-secondary" type="button" id="dynamicCopyButton">Copy</button>
|
<button class="btn btn-outline-secondary" type="button" id="dynamicCopyButton">{{ t('Copy') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Link -->
|
<!-- Link -->
|
||||||
@@ -40,13 +40,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Options -->
|
<!-- Options -->
|
||||||
{{ list_section('dynamicChildrenSection', 'Options', 'dynamicChildrenList') }}
|
{{ list_section('dynamicChildrenSection', t('Options'), 'dynamicChildrenList') }}
|
||||||
|
|
||||||
<!-- Alternatives -->
|
<!-- Alternatives -->
|
||||||
{{ list_section('dynamicAlternativesSection', 'Alternatives', 'dynamicAlternativesList') }}
|
{{ list_section('dynamicAlternativesSection', t('Alternatives'), 'dynamicAlternativesList') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" onclick="closeAllModals()">Close</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" onclick="closeAllModals()">{{ t('Close') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,9 +55,9 @@
|
|||||||
<div class="collapse navbar-collapse" id="navbarNav{{menu_type}}">
|
<div class="collapse navbar-collapse" id="navbarNav{{menu_type}}">
|
||||||
{% if menu_type == "header" %}
|
{% if menu_type == "header" %}
|
||||||
<a class="navbar-brand align-items-center d-flex js-restore" id="navbar_logo" href="#">
|
<a class="navbar-brand align-items-center d-flex js-restore" id="navbar_logo" href="#">
|
||||||
<img
|
<img
|
||||||
src="{{ url_for('static', filename=platform.logo.cache) }}"
|
src="{{ asset_src(platform.logo) }}"
|
||||||
alt="{{ platform.titel }}"
|
alt="{{ platform.titel }}"
|
||||||
class="d-inline-block align-text-top"
|
class="d-inline-block align-text-top"
|
||||||
style="height:2rem">
|
style="height:2rem">
|
||||||
<div class="ms-2 d-flex flex-column">
|
<div class="ms-2 d-flex flex-column">
|
||||||
@@ -98,6 +98,23 @@
|
|||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% if menu_type == "header" %}
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle btn btn-light" id="navbarDropdownLanguage" role="button" data-bs-toggle="dropdown" data-bs-display="dynamic" aria-expanded="false" title="{{ t('Language') }}">
|
||||||
|
<i class="fa-solid fa-language"></i> {{ languages[lang] }}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu language-menu">
|
||||||
|
{% for code, endonym in languages.items() %}
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item {% if code == lang %}active{% endif %}"
|
||||||
|
href="{{ url_for('localized_index', lang=code) }}"
|
||||||
|
hreflang="{{ code }}"
|
||||||
|
lang="{{ code }}">{{ endonym }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
89
app/utils/asset_resolver.py
Normal file
89
app/utils/asset_resolver.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""Split asset resolution into distinct fields so the template can
|
||||||
|
render the right URL.
|
||||||
|
|
||||||
|
Strategy
|
||||||
|
--------
|
||||||
|
|
||||||
|
1. **Probe** the source URL with a HEAD request. If it responds with an
|
||||||
|
``image/*`` content type, embed it directly via ``external_url``
|
||||||
|
(no download necessary — the browser fetches it itself).
|
||||||
|
2. **Cache** as a fallback: when the probe fails (404, non-image,
|
||||||
|
network error, …) try ``cache_manager.cache_file()`` to download
|
||||||
|
and serve via ``/static/cache/<file>``.
|
||||||
|
3. **External fallback**: if the download fails too, still expose the
|
||||||
|
source URL via ``external_url`` so the browser shows at least the
|
||||||
|
alt-text or broken-image icon instead of an empty ``src``.
|
||||||
|
|
||||||
|
Type safety
|
||||||
|
-----------
|
||||||
|
|
||||||
|
``cache`` only ever holds a relative path the static handler can serve;
|
||||||
|
absolute URLs land in ``external_url`` and are rendered as-is. The
|
||||||
|
previous fallback ``asset['cache'] = cached or asset['source']`` mixed
|
||||||
|
both shapes — and the template's ``url_for('static', filename=...)``
|
||||||
|
wrapper produced broken ``/static/https://…`` URLs when the source
|
||||||
|
was an absolute URL the container couldn't fetch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
_PROBE_TIMEOUT_SECONDS = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_image_url(url: str) -> bool:
|
||||||
|
"""Return True if a HEAD request to ``url`` succeeds with an
|
||||||
|
``image/*`` content type, suggesting the URL is safe to embed
|
||||||
|
directly as ``<img src>``."""
|
||||||
|
try:
|
||||||
|
resp = requests.head(url, allow_redirects=True, timeout=_PROBE_TIMEOUT_SECONDS)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except requests.RequestException:
|
||||||
|
return False
|
||||||
|
content_type = resp.headers.get("Content-Type", "").lower()
|
||||||
|
return content_type.split(";", 1)[0].strip().startswith("image/")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_asset_cache(
|
||||||
|
asset: dict,
|
||||||
|
cache_manager,
|
||||||
|
probe: Callable[[str], bool] = _probe_image_url,
|
||||||
|
) -> None:
|
||||||
|
"""Set ``asset['cache']`` (local, served via /static/) or
|
||||||
|
``asset['external_url']`` (rendered as-is). Never both.
|
||||||
|
|
||||||
|
Tries the cheap direct-embed path first (``probe``) before falling
|
||||||
|
back to downloading via ``cache_manager``. See module docstring."""
|
||||||
|
source = asset.get("source")
|
||||||
|
asset["cache"] = None
|
||||||
|
asset["external_url"] = None
|
||||||
|
if not source:
|
||||||
|
return
|
||||||
|
if probe(source):
|
||||||
|
asset["external_url"] = source
|
||||||
|
return
|
||||||
|
cached = cache_manager.cache_file(source)
|
||||||
|
if cached:
|
||||||
|
asset["cache"] = cached
|
||||||
|
else:
|
||||||
|
asset["external_url"] = source
|
||||||
|
|
||||||
|
|
||||||
|
def asset_src(asset: Optional[dict], url_for_static: Callable[[str], str]) -> str:
|
||||||
|
"""Return the final URL for an asset dict prepared by
|
||||||
|
:func:`resolve_asset_cache`. ``url_for_static`` is a callable that
|
||||||
|
maps a relative cache path to its served URL (typically a partial
|
||||||
|
of Flask's ``url_for('static', filename=...)``)."""
|
||||||
|
if not asset:
|
||||||
|
return ""
|
||||||
|
external = asset.get("external_url")
|
||||||
|
if external:
|
||||||
|
return external
|
||||||
|
cached = asset.get("cache")
|
||||||
|
if cached:
|
||||||
|
return url_for_static(cached)
|
||||||
|
return ""
|
||||||
134
app/utils/i18n.py
Normal file
134
app/utils/i18n.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""Language negotiation and translation of the resolved configuration tree.
|
||||||
|
|
||||||
|
Translation is catalogue-driven: a string is replaced only when the target
|
||||||
|
language's catalogue holds an entry for the exact English source string.
|
||||||
|
Anything unknown falls through to English, so a partially filled catalogue
|
||||||
|
degrades instead of breaking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.utils.languages import LANGUAGES, RTL_LANGUAGES
|
||||||
|
except ImportError: # pragma: no cover - supports running from the app/ directory.
|
||||||
|
from utils.languages import LANGUAGES, RTL_LANGUAGES
|
||||||
|
|
||||||
|
I18N_DIR = Path(__file__).resolve().parent.parent / "i18n"
|
||||||
|
UI_DIR = I18N_DIR / "ui"
|
||||||
|
CONTENT_DIR = I18N_DIR / "content"
|
||||||
|
|
||||||
|
SOURCE_LANGUAGE = "en"
|
||||||
|
|
||||||
|
|
||||||
|
TRANSLATABLE_KEYS = frozenset(
|
||||||
|
{"description", "info", "name", "subtitel", "text", "title", "warning"}
|
||||||
|
)
|
||||||
|
|
||||||
|
UI_STRINGS = (
|
||||||
|
"Alternatives",
|
||||||
|
"Close",
|
||||||
|
"Copy",
|
||||||
|
"Identifier copied to clipboard!",
|
||||||
|
"Imprint",
|
||||||
|
"Information",
|
||||||
|
"Language",
|
||||||
|
"Open",
|
||||||
|
"Open Link",
|
||||||
|
"Options",
|
||||||
|
"Warning",
|
||||||
|
)
|
||||||
|
|
||||||
|
_catalogs: dict[str, dict[str, str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def direction(code):
|
||||||
|
"""Return the writing direction of ``code`` as an HTML ``dir`` value."""
|
||||||
|
return "rtl" if code in RTL_LANGUAGES else "ltr"
|
||||||
|
|
||||||
|
|
||||||
|
def read_catalog(path):
|
||||||
|
"""Return the catalogue at ``path``, or an empty one if it is unusable.
|
||||||
|
|
||||||
|
Catalogues are hand-edited and machine-written, so a stray character must
|
||||||
|
degrade that language to English rather than take every page down with a
|
||||||
|
parse error. Non-string entries are dropped for the same reason: they would
|
||||||
|
otherwise reach the templates and render as ``42`` or ``null``.
|
||||||
|
"""
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeDecodeError, yaml.YAMLError):
|
||||||
|
logging.warning("Ignoring unreadable translation catalogue: %s", path)
|
||||||
|
return {}
|
||||||
|
if not isinstance(loaded, dict):
|
||||||
|
logging.warning(
|
||||||
|
"Ignoring translation catalogue that is not a mapping: %s", path
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
key: value
|
||||||
|
for key, value in loaded.items()
|
||||||
|
if isinstance(key, str) and isinstance(value, str)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def clear_catalogs():
|
||||||
|
"""Drop the memoized catalogues so edited files are picked up."""
|
||||||
|
_catalogs.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def catalog(code):
|
||||||
|
"""Return the merged UI and content catalogue for ``code``."""
|
||||||
|
if code not in _catalogs:
|
||||||
|
_catalogs[code] = {
|
||||||
|
**read_catalog(UI_DIR / f"{code}.yaml"),
|
||||||
|
**read_catalog(CONTENT_DIR / f"{code}.yaml"),
|
||||||
|
}
|
||||||
|
return _catalogs[code]
|
||||||
|
|
||||||
|
|
||||||
|
def negotiate(accepted, default=SOURCE_LANGUAGE):
|
||||||
|
"""Pick the best supported language from ``Accept-Language`` pairs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
accepted: iterable of ``(tag, quality)`` as produced by
|
||||||
|
``flask.request.accept_languages``.
|
||||||
|
default: language returned when no tag is supported.
|
||||||
|
|
||||||
|
Werkzeug's own ``best_match`` returns an exact match before it considers
|
||||||
|
primary-tag fallbacks, so ``de-DE,en;q=0.8`` resolves to English. Matching
|
||||||
|
on the primary subtag up front avoids that.
|
||||||
|
"""
|
||||||
|
best, best_quality = default, 0.0
|
||||||
|
for tag, quality in accepted:
|
||||||
|
code = tag.replace("_", "-").split("-")[0].lower()
|
||||||
|
if code in LANGUAGES and quality > best_quality:
|
||||||
|
best, best_quality = code, quality
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def translate_tree(node, code, key=None):
|
||||||
|
"""Return a copy of ``node`` with translatable leaves swapped for ``code``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
node: the resolved configuration tree, or any subtree of it.
|
||||||
|
code: target language code.
|
||||||
|
key: the mapping key ``node`` was reached through.
|
||||||
|
"""
|
||||||
|
if isinstance(node, dict):
|
||||||
|
return {name: translate_tree(value, code, name) for name, value in node.items()}
|
||||||
|
if isinstance(node, list):
|
||||||
|
return [translate_tree(item, code, key) for item in node]
|
||||||
|
if isinstance(node, str) and key in TRANSLATABLE_KEYS:
|
||||||
|
return catalog(code).get(node, node)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def ui_strings(code):
|
||||||
|
"""Return the interface strings for ``code``, keyed by their English source."""
|
||||||
|
entries = catalog(code)
|
||||||
|
return {source: entries.get(source, source) for source in UI_STRINGS}
|
||||||
196
app/utils/languages.py
Normal file
196
app/utils/languages.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"""ISO 639-1 languages, their display names and writing direction.
|
||||||
|
|
||||||
|
Generated by utils/generate_languages.py — edit that script, not this file.
|
||||||
|
|
||||||
|
Display names are CLDR endonyms where CLDR covers the language and the English
|
||||||
|
ISO 639-1 name for the 25 codes it does not.
|
||||||
|
"""
|
||||||
|
|
||||||
|
LANGUAGES = {
|
||||||
|
"en": "English",
|
||||||
|
"aa": "Qafar",
|
||||||
|
"ab": "Аԥсшәа",
|
||||||
|
"ae": "Avestan",
|
||||||
|
"af": "Afrikaans",
|
||||||
|
"ak": "Akan",
|
||||||
|
"am": "አማርኛ",
|
||||||
|
"an": "aragonés",
|
||||||
|
"ar": "العربية",
|
||||||
|
"as": "অসমীয়া",
|
||||||
|
"av": "Avaric",
|
||||||
|
"ay": "Aymara",
|
||||||
|
"az": "azərbaycan",
|
||||||
|
"ba": "башҡорт теле",
|
||||||
|
"be": "беларуская",
|
||||||
|
"bg": "български",
|
||||||
|
"bi": "Bislama",
|
||||||
|
"bm": "bamanakan",
|
||||||
|
"bn": "বাংলা",
|
||||||
|
"bo": "བོད་སྐད་",
|
||||||
|
"br": "brezhoneg",
|
||||||
|
"bs": "bosanski",
|
||||||
|
"ca": "català",
|
||||||
|
"ce": "нохчийн",
|
||||||
|
"ch": "Chamorro",
|
||||||
|
"co": "corsu",
|
||||||
|
"cr": "Cree",
|
||||||
|
"cs": "čeština",
|
||||||
|
"cu": "церковнослове́нскїй",
|
||||||
|
"cv": "чӑваш",
|
||||||
|
"cy": "Cymraeg",
|
||||||
|
"da": "dansk",
|
||||||
|
"de": "Deutsch",
|
||||||
|
"dv": "ދިވެހިބަސް",
|
||||||
|
"dz": "རྫོང་ཁ",
|
||||||
|
"ee": "eʋegbe",
|
||||||
|
"el": "Ελληνικά",
|
||||||
|
"eo": "Esperanto",
|
||||||
|
"es": "español",
|
||||||
|
"et": "eesti",
|
||||||
|
"eu": "euskara",
|
||||||
|
"fa": "فارسی",
|
||||||
|
"ff": "Pulaar",
|
||||||
|
"fi": "suomi",
|
||||||
|
"fj": "Fijian",
|
||||||
|
"fo": "føroyskt",
|
||||||
|
"fr": "français",
|
||||||
|
"fy": "Frysk",
|
||||||
|
"ga": "Gaeilge",
|
||||||
|
"gd": "Gàidhlig",
|
||||||
|
"gl": "galego",
|
||||||
|
"gn": "avañe’ẽ",
|
||||||
|
"gu": "ગુજરાતી",
|
||||||
|
"gv": "Gaelg",
|
||||||
|
"ha": "Hausa",
|
||||||
|
"he": "עברית",
|
||||||
|
"hi": "हिन्दी",
|
||||||
|
"ho": "Hiri Motu",
|
||||||
|
"hr": "hrvatski",
|
||||||
|
"ht": "Kreyòl Ayisyen",
|
||||||
|
"hu": "magyar",
|
||||||
|
"hy": "հայերեն",
|
||||||
|
"hz": "Herero",
|
||||||
|
"ia": "interlingua",
|
||||||
|
"id": "Indonesia",
|
||||||
|
"ie": "Interlingue",
|
||||||
|
"ig": "Igbo",
|
||||||
|
"ii": "ꆈꌠꉙ",
|
||||||
|
"ik": "Inupiaq",
|
||||||
|
"io": "Ido",
|
||||||
|
"is": "íslenska",
|
||||||
|
"it": "italiano",
|
||||||
|
"iu": "ᐃᓄᒃᑎᑐᑦ",
|
||||||
|
"ja": "日本語",
|
||||||
|
"jv": "Jawa",
|
||||||
|
"ka": "ქართული",
|
||||||
|
"kg": "Kongo",
|
||||||
|
"ki": "Gikuyu",
|
||||||
|
"kj": "Kuanyama",
|
||||||
|
"kk": "қазақ тілі",
|
||||||
|
"kl": "kalaallisut",
|
||||||
|
"km": "ខ្មែរ",
|
||||||
|
"kn": "ಕನ್ನಡ",
|
||||||
|
"ko": "한국어",
|
||||||
|
"kr": "Kanuri",
|
||||||
|
"ks": "کٲشُر",
|
||||||
|
"ku": "kurdî (kurmancî)",
|
||||||
|
"kv": "Komi",
|
||||||
|
"kw": "kernewek",
|
||||||
|
"ky": "кыргызча",
|
||||||
|
"la": "Latina",
|
||||||
|
"lb": "Lëtzebuergesch",
|
||||||
|
"lg": "Luganda",
|
||||||
|
"li": "Limburgan",
|
||||||
|
"ln": "lingála",
|
||||||
|
"lo": "ລາວ",
|
||||||
|
"lt": "lietuvių",
|
||||||
|
"lu": "Tshiluba",
|
||||||
|
"lv": "latviešu",
|
||||||
|
"mg": "Malagasy",
|
||||||
|
"mh": "Marshallese",
|
||||||
|
"mi": "Māori",
|
||||||
|
"mk": "македонски",
|
||||||
|
"ml": "മലയാളം",
|
||||||
|
"mn": "монгол",
|
||||||
|
"mr": "मराठी",
|
||||||
|
"ms": "Melayu",
|
||||||
|
"mt": "Malti",
|
||||||
|
"my": "မြန်မာ",
|
||||||
|
"na": "Nauru",
|
||||||
|
"nb": "norsk bokmål",
|
||||||
|
"nd": "isiNdebele",
|
||||||
|
"ne": "नेपाली",
|
||||||
|
"ng": "Ndonga",
|
||||||
|
"nl": "Nederlands",
|
||||||
|
"nn": "norsk nynorsk",
|
||||||
|
"no": "norsk",
|
||||||
|
"nr": "isiNdebele",
|
||||||
|
"nv": "Diné Bizaad",
|
||||||
|
"ny": "Nyanja",
|
||||||
|
"oc": "occitan",
|
||||||
|
"oj": "Ojibwa",
|
||||||
|
"om": "Oromoo",
|
||||||
|
"or": "ଓଡ଼ିଆ",
|
||||||
|
"os": "ирон",
|
||||||
|
"pa": "ਪੰਜਾਬੀ",
|
||||||
|
"pi": "Pali",
|
||||||
|
"pl": "polski",
|
||||||
|
"ps": "پښتو",
|
||||||
|
"pt": "português",
|
||||||
|
"qu": "Runasimi",
|
||||||
|
"rm": "rumantsch",
|
||||||
|
"rn": "Ikirundi",
|
||||||
|
"ro": "română",
|
||||||
|
"ru": "русский",
|
||||||
|
"rw": "Ikinyarwanda",
|
||||||
|
"sa": "संस्कृत भाषा",
|
||||||
|
"sc": "sardu",
|
||||||
|
"sd": "سنڌي",
|
||||||
|
"se": "davvisámegiella",
|
||||||
|
"sg": "Sängö",
|
||||||
|
"sh": "Serbo-Croatian",
|
||||||
|
"si": "සිංහල",
|
||||||
|
"sk": "slovenčina",
|
||||||
|
"sl": "slovenščina",
|
||||||
|
"sm": "Samoan",
|
||||||
|
"sn": "chiShona",
|
||||||
|
"so": "Soomaali",
|
||||||
|
"sq": "shqip",
|
||||||
|
"sr": "српски",
|
||||||
|
"ss": "siSwati",
|
||||||
|
"st": "Sesotho",
|
||||||
|
"su": "Basa Sunda",
|
||||||
|
"sv": "svenska",
|
||||||
|
"sw": "Kiswahili",
|
||||||
|
"ta": "தமிழ்",
|
||||||
|
"te": "తెలుగు",
|
||||||
|
"tg": "тоҷикӣ",
|
||||||
|
"th": "ไทย",
|
||||||
|
"ti": "ትግርኛ",
|
||||||
|
"tk": "türkmen dili",
|
||||||
|
"tl": "Tagalog",
|
||||||
|
"tn": "Setswana",
|
||||||
|
"to": "lea fakatonga",
|
||||||
|
"tr": "Türkçe",
|
||||||
|
"ts": "Xitsonga",
|
||||||
|
"tt": "татар",
|
||||||
|
"tw": "Twi",
|
||||||
|
"ty": "Tahitian",
|
||||||
|
"ug": "ئۇيغۇرچە",
|
||||||
|
"uk": "українська",
|
||||||
|
"ur": "اردو",
|
||||||
|
"uz": "o‘zbek",
|
||||||
|
"ve": "Tshivenḓa",
|
||||||
|
"vi": "Tiếng Việt",
|
||||||
|
"vo": "Volapük",
|
||||||
|
"wa": "walon",
|
||||||
|
"wo": "Wolof",
|
||||||
|
"xh": "IsiXhosa",
|
||||||
|
"yi": "ייִדיש",
|
||||||
|
"yo": "Èdè Yorùbá",
|
||||||
|
"za": "Vahcuengh",
|
||||||
|
"zh": "中文",
|
||||||
|
"zu": "isiZulu",
|
||||||
|
}
|
||||||
|
|
||||||
|
RTL_LANGUAGES = frozenset({"ar", "dv", "fa", "he", "ks", "ps", "sd", "ug", "ur", "yi"})
|
||||||
BIN
assets/img/screenshot.png
Normal file
BIN
assets/img/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1024 KiB |
@@ -1,13 +1,15 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
portfolio:
|
portfolio:
|
||||||
|
# SPOT: IMAGE_NAME comes from .env (see env.example). No default —
|
||||||
|
# an empty tag would have compose fall back to `portfolio-portfolio`
|
||||||
|
# silently, which `make run-dev` would then fail to find.
|
||||||
|
image: ${IMAGE_NAME:?IMAGE_NAME must be set in .env (see env.example)}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: portfolio
|
container_name: portfolio
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-5000}:${PORT:-5000}"
|
- "${PORT:?PORT must be set in .env (see env.example)}:${PORT:?PORT must be set in .env (see env.example)}"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
13
env.example
13
env.example
@@ -1,2 +1,13 @@
|
|||||||
PORT=5001
|
PORT=5001
|
||||||
FLASK_ENV=production
|
FLASK_ENV=production
|
||||||
|
# Single source of truth for the Docker image tag — read by both the
|
||||||
|
# Makefile (build/run-dev/run-prod) and docker-compose.yml so every
|
||||||
|
# build path produces and consumes the same tag.
|
||||||
|
IMAGE_NAME=portfolio
|
||||||
|
# Comma-separated public hostnames. Leave empty and the app reflects whatever
|
||||||
|
# Host header arrives into its canonical, hreflang and redirect URLs.
|
||||||
|
TRUSTED_HOSTS=
|
||||||
|
# LibreTranslate instance used by `make i18n` to fill app/i18n/content/.
|
||||||
|
# Only needed when generating translations, never at runtime.
|
||||||
|
LIBRETRANSLATE_URL=http://localhost:5002
|
||||||
|
LIBRETRANSLATE_API_KEY=
|
||||||
@@ -4,21 +4,26 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "portfolio-ui"
|
name = "portfolio-ui"
|
||||||
version = "1.2.0"
|
version = "2.1.0"
|
||||||
description = "A lightweight YAML-driven portfolio and landing-page generator."
|
description = "A lightweight YAML-driven portfolio and landing-page generator."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"flask",
|
# 3.1 introduced TRUSTED_HOSTS; an older Flask accepts the config key and
|
||||||
|
# ignores it, which would silently disable the host-forgery check.
|
||||||
|
"flask>=3.1",
|
||||||
"pyyaml",
|
"pyyaml",
|
||||||
"requests",
|
"requests",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
"babel",
|
||||||
"bandit",
|
"bandit",
|
||||||
"pip-audit",
|
"pip-audit",
|
||||||
|
"pycountry",
|
||||||
"ruff",
|
"ruff",
|
||||||
|
"yamllint",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
@@ -30,6 +35,8 @@ include = ["app", "app.*"]
|
|||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
app = [
|
app = [
|
||||||
"config.sample.yaml",
|
"config.sample.yaml",
|
||||||
|
"i18n/ui/*.yaml",
|
||||||
|
"i18n/content/*.yaml",
|
||||||
"templates/**/*.j2",
|
"templates/**/*.j2",
|
||||||
"static/css/*.css",
|
"static/css/*.css",
|
||||||
"static/js/*.js",
|
"static/js/*.js",
|
||||||
|
|||||||
73
scripts/run-e2e.sh
Executable file
73
scripts/run-e2e.sh
Executable file
@@ -0,0 +1,73 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Run the Cypress suite against a Flask app started by this script.
|
||||||
|
#
|
||||||
|
# Server and browser share one shell, so this works where act cannot reach the
|
||||||
|
# host network. ELECTRON_RUN_AS_NODE is dropped before invoking Cypress because
|
||||||
|
# VS Code exports it to child processes, which makes Cypress' bundled Electron
|
||||||
|
# behave as Node and reject its own launch flags.
|
||||||
|
#
|
||||||
|
# Env:
|
||||||
|
# PORT required, the port Flask binds and Cypress targets
|
||||||
|
# PYTHON interpreter to run app.py with (default: python3)
|
||||||
|
# E2E_LOG where Flask's output goes (default: /tmp/portfolio-e2e-flask.log)
|
||||||
|
set -u
|
||||||
|
|
||||||
|
PORT="${PORT:?PORT must be set (see env.example)}"
|
||||||
|
PYTHON="${PYTHON:-python3}"
|
||||||
|
E2E_LOG="${E2E_LOG:-/tmp/portfolio-e2e-flask.log}"
|
||||||
|
FLASK_HOST=127.0.0.1
|
||||||
|
export PORT FLASK_HOST
|
||||||
|
|
||||||
|
APP_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")/../app" && pwd)"
|
||||||
|
BASE_URL="http://127.0.0.1:$PORT/"
|
||||||
|
|
||||||
|
# cypress.config.js defaults to localhost, which resolves to ::1 first on a
|
||||||
|
# dual-stack host — a different listener from the 127.0.0.1 one Flask binds and
|
||||||
|
# this script probes. Pin all three to the same origin.
|
||||||
|
export CYPRESS_baseUrl="$BASE_URL"
|
||||||
|
|
||||||
|
# A foreign listener would be tested instead of the working tree. The predicate
|
||||||
|
# is "something answers", deliberately without curl's -f: a foreign server that
|
||||||
|
# is still returning 5xx right now would otherwise pass the guard and then be
|
||||||
|
# picked up by the readiness probe once it recovers. --noproxy is what keeps
|
||||||
|
# that same looser predicate from matching an HTTP proxy instead of the port.
|
||||||
|
if curl -s --noproxy '*' --connect-timeout 2 --max-time 10 -o /dev/null "$BASE_URL"; then
|
||||||
|
echo "ERROR: something already serves port $PORT — Cypress would test that"
|
||||||
|
echo " instead of your working tree. Stop it first ('make down' for"
|
||||||
|
echo " the container, otherwise a stray 'python app.py')."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$APP_DIR" || exit 1
|
||||||
|
$PYTHON app.py > "$E2E_LOG" 2>&1 &
|
||||||
|
flask_pid=$!
|
||||||
|
trap 'kill "$flask_pid" 2>/dev/null || true' EXIT INT TERM
|
||||||
|
|
||||||
|
echo "Waiting for $BASE_URL — follow with: tail -f $E2E_LOG"
|
||||||
|
attempt=0
|
||||||
|
while [ "$attempt" -lt 120 ]; do
|
||||||
|
# Liveness first: a server answering while ours is dead means we are about
|
||||||
|
# to hand Cypress somebody else's app.
|
||||||
|
if ! kill -0 "$flask_pid" 2>/dev/null; then
|
||||||
|
echo "ERROR: Flask exited during startup"
|
||||||
|
cat "$E2E_LOG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if curl -sf --noproxy '*' --connect-timeout 2 --max-time 10 -o /dev/null "$BASE_URL"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! curl -sf --noproxy '*' --connect-timeout 2 --max-time 10 -o /dev/null "$BASE_URL"; then
|
||||||
|
echo "ERROR: app never became ready"
|
||||||
|
cat "$E2E_LOG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
env -u ELECTRON_RUN_AS_NODE npx cypress run
|
||||||
|
status=$?
|
||||||
|
|
||||||
|
kill "$flask_pid" 2>/dev/null || true
|
||||||
|
exit "$status"
|
||||||
258
tests/integration/test_app_routes.py
Normal file
258
tests/integration/test_app_routes.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
app_module = None
|
||||||
|
flask_app = None
|
||||||
|
i18n = None
|
||||||
|
_origin = None
|
||||||
|
_workdir = None
|
||||||
|
|
||||||
|
|
||||||
|
def setUpModule():
|
||||||
|
"""Import the app with a disposable configuration as the working directory.
|
||||||
|
|
||||||
|
``app.app`` reads ``config.yaml`` relative to the process working directory
|
||||||
|
at import time, so the chdir has to happen before the import.
|
||||||
|
"""
|
||||||
|
global app_module, flask_app, i18n, _origin, _workdir
|
||||||
|
|
||||||
|
_origin = os.getcwd()
|
||||||
|
_workdir = tempfile.mkdtemp(prefix="portfolio-routes-")
|
||||||
|
shutil.copy(
|
||||||
|
REPO_ROOT / "app" / "config.sample.yaml", Path(_workdir) / "config.yaml"
|
||||||
|
)
|
||||||
|
os.chdir(_workdir)
|
||||||
|
|
||||||
|
from app import app as imported_module
|
||||||
|
from app.utils import i18n as imported_i18n
|
||||||
|
|
||||||
|
app_module = imported_module
|
||||||
|
flask_app = imported_module.app
|
||||||
|
i18n = imported_i18n
|
||||||
|
flask_app.config["NASA_API_KEY"] = None
|
||||||
|
|
||||||
|
|
||||||
|
def tearDownModule():
|
||||||
|
os.chdir(_origin)
|
||||||
|
shutil.rmtree(_workdir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AppRouteMixin:
|
||||||
|
"""Shared setup. A mixin rather than a TestCase subclass, so that every
|
||||||
|
test class below still names ``unittest.TestCase`` as a direct base — the
|
||||||
|
lint guardrail in tests/lint/ does not resolve inherited aliases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = flask_app.test_client()
|
||||||
|
self.addCleanup(i18n.clear_catalogs)
|
||||||
|
self.addCleanup(flask_app.config["TRANSLATED_CONFIG"].clear)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRouting(AppRouteMixin, unittest.TestCase):
|
||||||
|
def test_unrelated_single_segment_paths_are_not_redirected(self):
|
||||||
|
for path in ("/robots.txt", "/favicon.ico", "/sitemap.xml"):
|
||||||
|
with self.subTest(path=path):
|
||||||
|
self.assertEqual(self.client.get(path).status_code, 404)
|
||||||
|
|
||||||
|
def test_language_path_redirects_to_the_canonical_trailing_slash(self):
|
||||||
|
response = self.client.get("/de")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 308)
|
||||||
|
self.assertTrue(response.headers["Location"].endswith("/de/"))
|
||||||
|
|
||||||
|
def test_supported_language_renders(self):
|
||||||
|
response = self.client.get("/de/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn('<html lang="de"', response.get_data(as_text=True))
|
||||||
|
|
||||||
|
def test_unsupported_language_is_not_found(self):
|
||||||
|
self.assertEqual(self.client.get("/xx/").status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNegotiation(AppRouteMixin, unittest.TestCase):
|
||||||
|
def test_regional_tag_beats_a_lower_ranked_exact_match(self):
|
||||||
|
response = self.client.get("/", headers={"Accept-Language": "de-DE,en;q=0.8"})
|
||||||
|
|
||||||
|
self.assertIn('<html lang="de"', response.get_data(as_text=True))
|
||||||
|
|
||||||
|
def test_negotiated_route_declares_that_it_varies(self):
|
||||||
|
response = self.client.get("/", headers={"Accept-Language": "de-DE"})
|
||||||
|
|
||||||
|
self.assertEqual(response.headers.get("Vary"), "Accept-Language")
|
||||||
|
|
||||||
|
|
||||||
|
class TestEscaping(AppRouteMixin, unittest.TestCase):
|
||||||
|
def test_catalog_content_is_html_escaped(self):
|
||||||
|
i18n._catalogs["de"] = {"Copy": "<script>alert('ui')</script>"}
|
||||||
|
|
||||||
|
body = self.client.get("/de/").get_data(as_text=True)
|
||||||
|
|
||||||
|
self.assertNotIn("<script>alert('ui')</script>", body)
|
||||||
|
self.assertIn("<script>alert('ui')", body)
|
||||||
|
|
||||||
|
def test_configuration_content_is_html_escaped(self):
|
||||||
|
i18n._catalogs["de"] = {"Imprint": "<script>alert('config')</script>"}
|
||||||
|
|
||||||
|
body = self.client.get("/de/").get_data(as_text=True)
|
||||||
|
|
||||||
|
self.assertNotIn("<script>alert('config')</script>", body)
|
||||||
|
self.assertIn("<script>alert('config')", body)
|
||||||
|
|
||||||
|
|
||||||
|
class TestApodBackground(AppRouteMixin, unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.addCleanup(flask_app.config.__setitem__, "NASA_API_KEY", None)
|
||||||
|
flask_app.config["NASA_API_KEY"] = "key"
|
||||||
|
|
||||||
|
def test_no_request_is_made_without_a_key(self):
|
||||||
|
flask_app.config["NASA_API_KEY"] = None
|
||||||
|
|
||||||
|
with patch("app.app.requests.get") as get:
|
||||||
|
self.assertIsNone(app_module.apod_background())
|
||||||
|
|
||||||
|
get.assert_not_called()
|
||||||
|
|
||||||
|
def test_a_transport_failure_costs_the_background_not_the_page(self):
|
||||||
|
with patch(
|
||||||
|
"app.app.requests.get", side_effect=requests.ConnectionError("down")
|
||||||
|
):
|
||||||
|
self.assertIsNone(app_module.apod_background())
|
||||||
|
|
||||||
|
self.assertEqual(self.client.get("/en/").status_code, 200)
|
||||||
|
|
||||||
|
def test_an_error_response_costs_the_background_not_the_page(self):
|
||||||
|
refusal = Mock(ok=False)
|
||||||
|
refusal.json.return_value = {"media_type": "image", "url": "https://i.test/x"}
|
||||||
|
|
||||||
|
with patch("app.app.requests.get", return_value=refusal):
|
||||||
|
self.assertIsNone(app_module.apod_background())
|
||||||
|
|
||||||
|
def test_a_video_of_the_day_is_not_used_as_a_background(self):
|
||||||
|
answer = Mock(ok=True)
|
||||||
|
answer.json.return_value = {"media_type": "video", "url": "https://v.test/x"}
|
||||||
|
|
||||||
|
with patch("app.app.requests.get", return_value=answer):
|
||||||
|
self.assertIsNone(app_module.apod_background())
|
||||||
|
|
||||||
|
def test_an_image_of_the_day_is_used(self):
|
||||||
|
answer = Mock(ok=True)
|
||||||
|
answer.json.return_value = {"media_type": "image", "url": "https://i.test/x"}
|
||||||
|
|
||||||
|
with patch("app.app.requests.get", return_value=answer):
|
||||||
|
self.assertEqual(app_module.apod_background(), "https://i.test/x")
|
||||||
|
|
||||||
|
|
||||||
|
class TestExternalUrls(AppRouteMixin, unittest.TestCase):
|
||||||
|
def test_only_the_forwarded_scheme_is_trusted(self):
|
||||||
|
proxy = flask_app.wsgi_app
|
||||||
|
|
||||||
|
self.assertEqual(proxy.x_proto, 1)
|
||||||
|
self.assertEqual(
|
||||||
|
(proxy.x_for, proxy.x_host, proxy.x_port, proxy.x_prefix), (0, 0, 0, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_forwarded_scheme_reaches_the_canonical_and_alternates(self):
|
||||||
|
body = self.client.get(
|
||||||
|
"/en/",
|
||||||
|
headers={"Host": "portfolio.example.org", "X-Forwarded-Proto": "https"},
|
||||||
|
).get_data(as_text=True)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
'<link rel="canonical" href="https://portfolio.example.org/en/">', body
|
||||||
|
)
|
||||||
|
self.assertIn('hreflang="ja" href="https://portfolio.example.org/ja/"', body)
|
||||||
|
self.assertNotIn("http://portfolio.example.org", body)
|
||||||
|
|
||||||
|
def test_trusted_hosts_are_parsed_from_a_comma_separated_list(self):
|
||||||
|
self.assertEqual(
|
||||||
|
app_module.trusted_hosts("a.test, b.test ,"), ["a.test", "b.test"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_an_unset_trusted_hosts_value_disables_the_check(self):
|
||||||
|
self.assertIsNone(app_module.trusted_hosts(""))
|
||||||
|
self.assertIsNone(app_module.trusted_hosts(" , "))
|
||||||
|
|
||||||
|
def test_a_forged_host_is_rejected_once_trusted_hosts_are_named(self):
|
||||||
|
self.addCleanup(flask_app.config.__setitem__, "TRUSTED_HOSTS", None)
|
||||||
|
flask_app.config["TRUSTED_HOSTS"] = ["portfolio.example.org"]
|
||||||
|
|
||||||
|
forged = self.client.get("/de", headers={"Host": "evil.test"})
|
||||||
|
honest = self.client.get("/de/", headers={"Host": "portfolio.example.org"})
|
||||||
|
|
||||||
|
self.assertEqual(forged.status_code, 400)
|
||||||
|
self.assertEqual(honest.status_code, 200)
|
||||||
|
|
||||||
|
def test_forwarded_host_is_not_trusted(self):
|
||||||
|
response = self.client.get(
|
||||||
|
"/de",
|
||||||
|
headers={"Host": "portfolio.example.org", "X-Forwarded-Host": "evil.test"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotIn("evil.test", response.headers["Location"])
|
||||||
|
|
||||||
|
body = self.client.get(
|
||||||
|
"/en/",
|
||||||
|
headers={"Host": "portfolio.example.org", "X-Forwarded-Host": "evil.test"},
|
||||||
|
).get_data(as_text=True)
|
||||||
|
|
||||||
|
self.assertNotIn("evil.test", body)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigurationReload(AppRouteMixin, unittest.TestCase):
|
||||||
|
def test_reloading_the_configuration_drops_the_catalog_memo(self):
|
||||||
|
i18n.catalog("de")
|
||||||
|
self.assertIn("de", i18n._catalogs)
|
||||||
|
|
||||||
|
app_module.load_config(flask_app)
|
||||||
|
|
||||||
|
self.assertEqual(i18n._catalogs, {})
|
||||||
|
|
||||||
|
def test_reloading_the_configuration_drops_the_translation_memo(self):
|
||||||
|
self.client.get("/de/")
|
||||||
|
self.assertIn("de", flask_app.config["TRANSLATED_CONFIG"])
|
||||||
|
|
||||||
|
app_module.load_config(flask_app)
|
||||||
|
|
||||||
|
self.assertEqual(flask_app.config["TRANSLATED_CONFIG"], {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestTrustedHostsWiring(unittest.TestCase):
|
||||||
|
def test_the_environment_reaches_the_flask_configuration(self):
|
||||||
|
script = (
|
||||||
|
"import json, sys;"
|
||||||
|
f"sys.path.insert(0, {str(REPO_ROOT)!r});"
|
||||||
|
"from app.app import app;"
|
||||||
|
"print('TRUSTED=' + json.dumps(app.config['TRUSTED_HOSTS']))"
|
||||||
|
)
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-c", script],
|
||||||
|
cwd=_workdir,
|
||||||
|
env={**os.environ, "TRUSTED_HOSTS": "a.test, b.test"},
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
reported = [
|
||||||
|
line for line in result.stdout.splitlines() if line.startswith("TRUSTED=")
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
json.loads(reported[-1][len("TRUSTED=") :]), ["a.test", "b.test"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
84
tests/integration/test_asset_resolver_live.py
Normal file
84
tests/integration/test_asset_resolver_live.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""Live network verification of the probe-first asset resolution path.
|
||||||
|
|
||||||
|
The unit tests in ``tests/unit/test_asset_resolver.py`` cover every
|
||||||
|
branch with mocks. This integration test exercises the real
|
||||||
|
``https://file.infinito.nexus/assets/img/logo.png`` endpoint that
|
||||||
|
``app/config.sample.yaml`` references for the Infinito.Nexus card. A
|
||||||
|
green run proves the production resolver behaviour end-to-end:
|
||||||
|
|
||||||
|
* probe (HEAD ``image/*``) succeeds against the canonical Infinito.Nexus
|
||||||
|
asset host, so ``external_url`` is populated directly and **no**
|
||||||
|
download is performed (the cache directory stays empty).
|
||||||
|
|
||||||
|
The download-fallback branch (probe fails → ``cache_manager.cache_file``
|
||||||
|
serves the asset locally) is intentionally not exercised against a live
|
||||||
|
endpoint here — it is covered by mocked unit tests because every
|
||||||
|
candidate "HEAD-fails, GET-succeeds" public URL we tried was flaky
|
||||||
|
(Nextcloud's ``/download`` endpoint sporadically times out on HEAD even
|
||||||
|
when GET works), which would make this guard test non-deterministic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from app.utils.asset_resolver import resolve_asset_cache
|
||||||
|
from app.utils.cache_manager import CacheManager
|
||||||
|
|
||||||
|
INFINITO_LOGO_URL = "https://file.infinito.nexus/assets/img/logo.png"
|
||||||
|
|
||||||
|
_SKIP_LIVE_NETWORK = os.environ.get("PORTFOLIO_SKIP_LIVE_NETWORK_TESTS") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def _reachable(url: str) -> bool:
|
||||||
|
try:
|
||||||
|
return requests.head(url, allow_redirects=True, timeout=5).ok
|
||||||
|
except requests.RequestException:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipIf(
|
||||||
|
_SKIP_LIVE_NETWORK,
|
||||||
|
"PORTFOLIO_SKIP_LIVE_NETWORK_TESTS=1 — skipping live network tests",
|
||||||
|
)
|
||||||
|
class TestAssetResolverLive(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
if not _reachable(INFINITO_LOGO_URL):
|
||||||
|
raise unittest.SkipTest(
|
||||||
|
f"{urlparse(INFINITO_LOGO_URL).netloc} unreachable from this runner"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_infinito_nexus_logo_resolves_via_external_url_without_download(self):
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
cache_dir = os.path.join(tmp, "cache")
|
||||||
|
cache_manager = CacheManager(cache_dir)
|
||||||
|
asset = {"source": INFINITO_LOGO_URL}
|
||||||
|
|
||||||
|
resolve_asset_cache(asset, cache_manager)
|
||||||
|
|
||||||
|
self.assertIsNone(
|
||||||
|
asset["cache"],
|
||||||
|
f"Expected probe-first path to skip download for {INFINITO_LOGO_URL}",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
asset["external_url"],
|
||||||
|
INFINITO_LOGO_URL,
|
||||||
|
"Probe-success URL must land in external_url so the template "
|
||||||
|
"embeds it directly instead of routing through /static/.",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
os.listdir(cache_dir),
|
||||||
|
[],
|
||||||
|
"Cache directory must stay empty when probe succeeds — "
|
||||||
|
"downloading would waste bandwidth.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
|
import re
|
||||||
import tomllib
|
import tomllib
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def distributions(requirements):
|
||||||
|
"""Return the distribution names of PEP 508 requirement strings."""
|
||||||
|
return {
|
||||||
|
re.split(r"[<>=!~\[; ]", requirement, maxsplit=1)[0]
|
||||||
|
for requirement in requirements
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class TestPythonPackaging(unittest.TestCase):
|
class TestPythonPackaging(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.repo_root = Path(__file__).resolve().parents[2]
|
self.repo_root = Path(__file__).resolve().parents[2]
|
||||||
@@ -18,11 +27,20 @@ class TestPythonPackaging(unittest.TestCase):
|
|||||||
self.assertEqual(build_system["build-backend"], "setuptools.build_meta")
|
self.assertEqual(build_system["build-backend"], "setuptools.build_meta")
|
||||||
self.assertIn("setuptools>=69", build_system["requires"])
|
self.assertIn("setuptools>=69", build_system["requires"])
|
||||||
self.assertGreaterEqual(
|
self.assertGreaterEqual(
|
||||||
set(project["dependencies"]),
|
distributions(project["dependencies"]),
|
||||||
{"flask", "pyyaml", "requests"},
|
{"flask", "pyyaml", "requests"},
|
||||||
)
|
)
|
||||||
self.assertEqual(project["requires-python"], ">=3.12")
|
self.assertEqual(project["requires-python"], ">=3.12")
|
||||||
|
|
||||||
|
def test_flask_is_pinned_to_a_version_that_honours_trusted_hosts(self):
|
||||||
|
requirement = next(
|
||||||
|
item
|
||||||
|
for item in self.pyproject["project"]["dependencies"]
|
||||||
|
if item.startswith("flask")
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn(">=3.1", requirement)
|
||||||
|
|
||||||
def test_pyproject_defines_dev_dependencies_and_package_contents(self):
|
def test_pyproject_defines_dev_dependencies_and_package_contents(self):
|
||||||
project = self.pyproject["project"]
|
project = self.pyproject["project"]
|
||||||
setuptools_config = self.pyproject["tool"]["setuptools"]
|
setuptools_config = self.pyproject["tool"]["setuptools"]
|
||||||
@@ -30,8 +48,8 @@ class TestPythonPackaging(unittest.TestCase):
|
|||||||
package_data = setuptools_config["package-data"]["app"]
|
package_data = setuptools_config["package-data"]["app"]
|
||||||
|
|
||||||
self.assertGreaterEqual(
|
self.assertGreaterEqual(
|
||||||
set(project["optional-dependencies"]["dev"]),
|
distributions(project["optional-dependencies"]["dev"]),
|
||||||
{"bandit", "pip-audit", "ruff"},
|
{"bandit", "pip-audit", "ruff", "yamllint"},
|
||||||
)
|
)
|
||||||
self.assertEqual(setuptools_config["py-modules"], ["main"])
|
self.assertEqual(setuptools_config["py-modules"], ["main"])
|
||||||
self.assertEqual(package_find["include"], ["app", "app.*"])
|
self.assertEqual(package_find["include"], ["app", "app.*"])
|
||||||
|
|||||||
157
tests/lint/test_tooling_configuration.py
Normal file
157
tests/lint/test_tooling_configuration.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"""Guards for configuration that no other test observes.
|
||||||
|
|
||||||
|
Each assertion here stands for a defect that was found by deleting the line it
|
||||||
|
checks: the deletion is invisible to every suite, and its effect only shows up
|
||||||
|
in production or in a fresh checkout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import tomllib
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
class TestYamllintConfiguration(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.config = yaml.safe_load(
|
||||||
|
(REPO_ROOT / ".yamllint").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_duplicate_keys_are_an_error(self):
|
||||||
|
self.assertEqual(self.config["rules"]["key-duplicates"], "enable")
|
||||||
|
|
||||||
|
def test_the_directories_that_collect_foreign_yaml_are_ignored(self):
|
||||||
|
ignored = self.config["ignore"].split()
|
||||||
|
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
set(ignored),
|
||||||
|
{".git/", ".venv/", "node_modules/", "app/node_modules/"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunTargets(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
|
||||||
|
self.recipes = {
|
||||||
|
name: body
|
||||||
|
for name, body in re.findall(
|
||||||
|
r"^(run-dev|run-prod):[^\n]*\n((?:\t[^\n]*\n)+)",
|
||||||
|
self.makefile,
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_both_run_targets_exist(self):
|
||||||
|
self.assertEqual(set(self.recipes), {"run-dev", "run-prod"})
|
||||||
|
|
||||||
|
def test_the_container_is_told_which_hosts_are_trusted(self):
|
||||||
|
for name, body in self.recipes.items():
|
||||||
|
with self.subTest(target=name):
|
||||||
|
self.assertIn("TRUSTED_HOSTS", body)
|
||||||
|
|
||||||
|
def test_the_container_is_told_which_port_to_bind(self):
|
||||||
|
for name, body in self.recipes.items():
|
||||||
|
with self.subTest(target=name):
|
||||||
|
self.assertIn('-e PORT="$$PORT"', body)
|
||||||
|
|
||||||
|
def test_the_whole_env_file_is_not_handed_to_the_web_container(self):
|
||||||
|
for name, body in self.recipes.items():
|
||||||
|
with self.subTest(target=name):
|
||||||
|
self.assertNotIn("--env-file", body)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLintCoverage(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_the_lint_target_runs_every_linter(self):
|
||||||
|
prerequisites = re.search(r"^lint: (.+)$", self.makefile, re.MULTILINE).group(1)
|
||||||
|
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
set(prerequisites.split()),
|
||||||
|
{"lint-actions", "lint-python", "lint-yaml", "lint-js", "lint-shell"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_every_linter_has_a_ci_job(self):
|
||||||
|
workflow = yaml.safe_load(
|
||||||
|
(REPO_ROOT / ".github" / "workflows" / "lint.yml").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
set(workflow["jobs"]),
|
||||||
|
{"lint-actions", "lint-python", "lint-yaml", "lint-js", "lint-shell"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_javascript_linter_is_declared(self):
|
||||||
|
package = json.loads(
|
||||||
|
(REPO_ROOT / "app" / "package.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
set(package["devDependencies"]), {"eslint", "@eslint/js", "globals"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_documented_environment_keys_exist(self):
|
||||||
|
example = (REPO_ROOT / "env.example").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
for key in ("PORT", "IMAGE_NAME", "TRUSTED_HOSTS", "LIBRETRANSLATE_URL"):
|
||||||
|
with self.subTest(key=key):
|
||||||
|
self.assertRegex(example, rf"(?m)^{key}=")
|
||||||
|
|
||||||
|
|
||||||
|
class TestEndToEndRunner(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.script = (REPO_ROOT / "scripts" / "run-e2e.sh").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_a_foreign_listener_stops_the_run(self):
|
||||||
|
self.assertIn("already serves port", self.script)
|
||||||
|
|
||||||
|
def test_cypress_is_pinned_to_the_origin_flask_binds(self):
|
||||||
|
self.assertIn("CYPRESS_baseUrl", self.script)
|
||||||
|
self.assertIn("127.0.0.1", self.script)
|
||||||
|
|
||||||
|
def test_the_electron_node_flag_is_dropped(self):
|
||||||
|
self.assertIn("env -u ELECTRON_RUN_AS_NODE", self.script)
|
||||||
|
|
||||||
|
def test_every_probe_bypasses_a_proxy_and_is_bounded(self):
|
||||||
|
probes = [line for line in self.script.splitlines() if "curl " in line]
|
||||||
|
|
||||||
|
self.assertTrue(probes)
|
||||||
|
for probe in probes:
|
||||||
|
with self.subTest(probe=probe.strip()):
|
||||||
|
self.assertIn("--noproxy", probe)
|
||||||
|
self.assertIn("--max-time", probe)
|
||||||
|
|
||||||
|
|
||||||
|
class TestVendoredAssets(unittest.TestCase):
|
||||||
|
def test_the_right_to_left_stylesheet_is_vendored(self):
|
||||||
|
script = (REPO_ROOT / "app" / "scripts" / "copy-vendor.js").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
script.count("bootstrap.rtl.min.css"),
|
||||||
|
2,
|
||||||
|
"the RTL stylesheet needs both a source and a destination path",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPackagedCatalogs(unittest.TestCase):
|
||||||
|
def test_the_interface_catalogs_are_declared_as_package_data(self):
|
||||||
|
with (REPO_ROOT / "pyproject.toml").open("rb") as handle:
|
||||||
|
pyproject = tomllib.load(handle)
|
||||||
|
|
||||||
|
package_data = pyproject["tool"]["setuptools"]["package-data"]["app"]
|
||||||
|
|
||||||
|
self.assertIn("i18n/ui/*.yaml", package_data)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
188
tests/unit/test_asset_resolver.py
Normal file
188
tests/unit/test_asset_resolver.py
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from app.utils.asset_resolver import (
|
||||||
|
_probe_image_url,
|
||||||
|
asset_src,
|
||||||
|
resolve_asset_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _url_for_static(filename):
|
||||||
|
return f"/static/{filename}"
|
||||||
|
|
||||||
|
|
||||||
|
def _always_probes_false(_url):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _always_probes_true(_url):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveAssetCache(unittest.TestCase):
|
||||||
|
def test_probe_success_skips_cache_and_embeds_directly(self):
|
||||||
|
cache_manager = Mock()
|
||||||
|
asset = {"source": "https://example.test/logo.png"}
|
||||||
|
|
||||||
|
resolve_asset_cache(asset, cache_manager, probe=_always_probes_true)
|
||||||
|
|
||||||
|
self.assertIsNone(asset["cache"])
|
||||||
|
self.assertEqual(asset["external_url"], "https://example.test/logo.png")
|
||||||
|
cache_manager.cache_file.assert_not_called()
|
||||||
|
|
||||||
|
def test_local_cache_path_assigned_when_probe_fails_but_download_succeeds(self):
|
||||||
|
cache_manager = Mock()
|
||||||
|
cache_manager.cache_file.return_value = "cache/logo_abc.png"
|
||||||
|
asset = {"source": "https://example.test/logo.png"}
|
||||||
|
|
||||||
|
resolve_asset_cache(asset, cache_manager, probe=_always_probes_false)
|
||||||
|
|
||||||
|
self.assertEqual(asset["cache"], "cache/logo_abc.png")
|
||||||
|
self.assertIsNone(asset["external_url"])
|
||||||
|
cache_manager.cache_file.assert_called_once_with(
|
||||||
|
"https://example.test/logo.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_external_url_fallback_when_probe_and_download_both_fail(self):
|
||||||
|
cache_manager = Mock()
|
||||||
|
cache_manager.cache_file.return_value = None
|
||||||
|
asset = {"source": "https://file.infinito.nexus/assets/img/logo.png"}
|
||||||
|
|
||||||
|
resolve_asset_cache(asset, cache_manager, probe=_always_probes_false)
|
||||||
|
|
||||||
|
self.assertIsNone(asset["cache"])
|
||||||
|
self.assertEqual(
|
||||||
|
asset["external_url"],
|
||||||
|
"https://file.infinito.nexus/assets/img/logo.png",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_source_leaves_both_fields_none(self):
|
||||||
|
cache_manager = Mock()
|
||||||
|
probe = Mock()
|
||||||
|
asset = {"class": "fa-solid fa-link"}
|
||||||
|
|
||||||
|
resolve_asset_cache(asset, cache_manager, probe=probe)
|
||||||
|
|
||||||
|
self.assertIsNone(asset["cache"])
|
||||||
|
self.assertIsNone(asset["external_url"])
|
||||||
|
cache_manager.cache_file.assert_not_called()
|
||||||
|
probe.assert_not_called()
|
||||||
|
|
||||||
|
def test_empty_source_treated_as_missing(self):
|
||||||
|
cache_manager = Mock()
|
||||||
|
probe = Mock()
|
||||||
|
asset = {"source": ""}
|
||||||
|
|
||||||
|
resolve_asset_cache(asset, cache_manager, probe=probe)
|
||||||
|
|
||||||
|
self.assertIsNone(asset["cache"])
|
||||||
|
self.assertIsNone(asset["external_url"])
|
||||||
|
cache_manager.cache_file.assert_not_called()
|
||||||
|
probe.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestProbeImageUrl(unittest.TestCase):
|
||||||
|
def _response(self, content_type, raise_exc=None):
|
||||||
|
resp = Mock()
|
||||||
|
if raise_exc:
|
||||||
|
resp.raise_for_status.side_effect = raise_exc
|
||||||
|
else:
|
||||||
|
resp.raise_for_status.return_value = None
|
||||||
|
resp.headers = {"Content-Type": content_type}
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@patch("app.utils.asset_resolver.requests.head")
|
||||||
|
def test_returns_true_for_image_content_type(self, mock_head):
|
||||||
|
mock_head.return_value = self._response("image/png")
|
||||||
|
|
||||||
|
self.assertTrue(_probe_image_url("https://example.test/logo.png"))
|
||||||
|
|
||||||
|
@patch("app.utils.asset_resolver.requests.head")
|
||||||
|
def test_returns_true_for_svg_with_charset_suffix(self, mock_head):
|
||||||
|
mock_head.return_value = self._response("image/svg+xml; charset=utf-8")
|
||||||
|
|
||||||
|
self.assertTrue(_probe_image_url("https://example.test/logo.svg"))
|
||||||
|
|
||||||
|
@patch("app.utils.asset_resolver.requests.head")
|
||||||
|
def test_returns_false_for_non_image_content_type(self, mock_head):
|
||||||
|
mock_head.return_value = self._response("text/html")
|
||||||
|
|
||||||
|
self.assertFalse(_probe_image_url("https://example.test/404"))
|
||||||
|
|
||||||
|
@patch("app.utils.asset_resolver.requests.head")
|
||||||
|
def test_returns_false_when_http_status_not_ok(self, mock_head):
|
||||||
|
mock_head.return_value = self._response(
|
||||||
|
"image/png",
|
||||||
|
raise_exc=requests.HTTPError("404 Not Found"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(_probe_image_url("https://example.test/missing.png"))
|
||||||
|
|
||||||
|
@patch("app.utils.asset_resolver.requests.head")
|
||||||
|
def test_returns_false_on_network_error(self, mock_head):
|
||||||
|
mock_head.side_effect = requests.ConnectionError("dns failure")
|
||||||
|
|
||||||
|
self.assertFalse(_probe_image_url("https://unreachable.test/logo.png"))
|
||||||
|
|
||||||
|
@patch("app.utils.asset_resolver.requests.head")
|
||||||
|
def test_follows_redirects_with_short_timeout(self, mock_head):
|
||||||
|
mock_head.return_value = self._response("image/png")
|
||||||
|
|
||||||
|
_probe_image_url("https://example.test/logo.png")
|
||||||
|
|
||||||
|
mock_head.assert_called_once_with(
|
||||||
|
"https://example.test/logo.png",
|
||||||
|
allow_redirects=True,
|
||||||
|
timeout=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssetSrc(unittest.TestCase):
|
||||||
|
def test_returns_static_url_for_local_cache_path(self):
|
||||||
|
asset = {"cache": "cache/logo_abc.png", "external_url": None}
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
asset_src(asset, _url_for_static),
|
||||||
|
"/static/cache/logo_abc.png",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_returns_absolute_url_unchanged_for_external_url(self):
|
||||||
|
# Regression: previously the template wrapped this through
|
||||||
|
# url_for('static', ...) and produced /static/https://... — a
|
||||||
|
# URL-in-URL the browser resolved against the dashboard host.
|
||||||
|
asset = {
|
||||||
|
"cache": None,
|
||||||
|
"external_url": "https://file.infinito.nexus/assets/img/logo.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
asset_src(asset, _url_for_static),
|
||||||
|
"https://file.infinito.nexus/assets/img/logo.png",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_external_url_wins_over_cache_when_both_present(self):
|
||||||
|
asset = {
|
||||||
|
"cache": "cache/logo.png",
|
||||||
|
"external_url": "https://override.test/logo.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
asset_src(asset, _url_for_static),
|
||||||
|
"https://override.test/logo.png",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_returns_empty_string_when_neither_field_is_set(self):
|
||||||
|
asset = {"cache": None, "external_url": None}
|
||||||
|
|
||||||
|
self.assertEqual(asset_src(asset, _url_for_static), "")
|
||||||
|
|
||||||
|
def test_returns_empty_string_for_none_or_empty_asset(self):
|
||||||
|
self.assertEqual(asset_src(None, _url_for_static), "")
|
||||||
|
self.assertEqual(asset_src({}, _url_for_static), "")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
230
tests/unit/test_i18n.py
Normal file
230
tests/unit/test_i18n.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.utils import i18n
|
||||||
|
|
||||||
|
|
||||||
|
class TestNegotiate(unittest.TestCase):
|
||||||
|
def test_regional_tag_beats_a_lower_ranked_exact_match(self):
|
||||||
|
self.assertEqual(i18n.negotiate([("de-DE", 1.0), ("en", 0.8)]), "de")
|
||||||
|
|
||||||
|
def test_an_uppercase_tag_is_accepted(self):
|
||||||
|
self.assertEqual(i18n.negotiate([("DE-DE", 1.0)]), "de")
|
||||||
|
|
||||||
|
def test_underscore_separated_tag_is_accepted(self):
|
||||||
|
self.assertEqual(i18n.negotiate([("pt_BR", 1.0)]), "pt")
|
||||||
|
|
||||||
|
def test_highest_quality_supported_tag_wins(self):
|
||||||
|
self.assertEqual(
|
||||||
|
i18n.negotiate([("xx", 1.0), ("fr", 0.9), ("es", 0.5)]),
|
||||||
|
"fr",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unsupported_tags_fall_back_to_the_default(self):
|
||||||
|
self.assertEqual(i18n.negotiate([("xx", 1.0)]), "en")
|
||||||
|
self.assertEqual(i18n.negotiate([], default="de"), "de")
|
||||||
|
|
||||||
|
def test_a_refused_language_is_not_selected(self):
|
||||||
|
self.assertEqual(i18n.negotiate([("de", 0.0)]), "en")
|
||||||
|
|
||||||
|
def test_the_first_of_two_equal_tags_wins(self):
|
||||||
|
self.assertEqual(i18n.negotiate([("fr", 0.9), ("es", 0.9)]), "fr")
|
||||||
|
|
||||||
|
|
||||||
|
class TestDirection(unittest.TestCase):
|
||||||
|
def test_every_right_to_left_language_is_marked(self):
|
||||||
|
for code in ("ar", "dv", "fa", "he", "ks", "ps", "sd", "ug", "ur", "yi"):
|
||||||
|
with self.subTest(code=code):
|
||||||
|
self.assertEqual(i18n.direction(code), "rtl")
|
||||||
|
|
||||||
|
def test_other_languages_are_left_to_right(self):
|
||||||
|
for code in set(i18n.LANGUAGES) - set(i18n.RTL_LANGUAGES):
|
||||||
|
with self.subTest(code=code):
|
||||||
|
self.assertEqual(i18n.direction(code), "ltr")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranslateTree(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.addCleanup(i18n._catalogs.clear)
|
||||||
|
i18n._catalogs["xx"] = {"A card": "Eine Karte", "Pictures": "Bilder"}
|
||||||
|
|
||||||
|
def test_only_translatable_keys_are_replaced(self):
|
||||||
|
tree = {
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"title": "Pictures",
|
||||||
|
"text": "A card",
|
||||||
|
"url": "A card",
|
||||||
|
"icon": {"class": "Pictures"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
translated = i18n.translate_tree(tree, "xx")
|
||||||
|
|
||||||
|
card = translated["cards"][0]
|
||||||
|
self.assertEqual(card["title"], "Bilder")
|
||||||
|
self.assertEqual(card["text"], "Eine Karte")
|
||||||
|
self.assertEqual(card["url"], "A card")
|
||||||
|
self.assertEqual(card["icon"]["class"], "Pictures")
|
||||||
|
|
||||||
|
def test_strings_inside_a_list_are_translated(self):
|
||||||
|
translated = i18n.translate_tree({"text": ["A card", "Pictures"]}, "xx")
|
||||||
|
|
||||||
|
self.assertEqual(translated["text"], ["Eine Karte", "Bilder"])
|
||||||
|
|
||||||
|
def test_unknown_strings_keep_their_source_value(self):
|
||||||
|
translated = i18n.translate_tree({"description": "Untranslated"}, "xx")
|
||||||
|
|
||||||
|
self.assertEqual(translated["description"], "Untranslated")
|
||||||
|
|
||||||
|
def test_source_tree_is_left_untouched(self):
|
||||||
|
tree = {"name": "Pictures"}
|
||||||
|
|
||||||
|
i18n.translate_tree(tree, "xx")
|
||||||
|
|
||||||
|
self.assertEqual(tree["name"], "Pictures")
|
||||||
|
|
||||||
|
def test_non_string_leaves_survive(self):
|
||||||
|
tree = {"name": 1, "text": None, "info": True}
|
||||||
|
|
||||||
|
self.assertEqual(i18n.translate_tree(tree, "xx"), tree)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadCatalog(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.directory = Path(tempfile.mkdtemp())
|
||||||
|
self.addCleanup(shutil.rmtree, self.directory, True)
|
||||||
|
self.path = self.directory / "de.yaml"
|
||||||
|
|
||||||
|
def _read(self, text):
|
||||||
|
self.path.write_text(text, encoding="utf-8")
|
||||||
|
with self.assertLogs(level="WARNING"):
|
||||||
|
return i18n.read_catalog(self.path)
|
||||||
|
|
||||||
|
def test_absent_file_is_an_empty_catalog(self):
|
||||||
|
self.assertEqual(i18n.read_catalog(self.directory / "missing.yaml"), {})
|
||||||
|
|
||||||
|
def test_unparsable_yaml_is_an_empty_catalog(self):
|
||||||
|
self.assertEqual(self._read("Close: [unclosed"), {})
|
||||||
|
|
||||||
|
def test_tab_indentation_is_an_empty_catalog(self):
|
||||||
|
self.assertEqual(self._read("a:\n\tb: 1"), {})
|
||||||
|
|
||||||
|
def test_non_mapping_document_is_an_empty_catalog(self):
|
||||||
|
self.assertEqual(self._read("- a\n- b"), {})
|
||||||
|
|
||||||
|
def test_a_non_utf8_catalog_is_an_empty_catalog(self):
|
||||||
|
self.path.write_bytes(b"\xffClose: Schliessen\n")
|
||||||
|
|
||||||
|
with self.assertLogs(level="WARNING"):
|
||||||
|
self.assertEqual(i18n.read_catalog(self.path), {})
|
||||||
|
|
||||||
|
def test_a_directory_at_the_catalog_path_is_an_empty_catalog(self):
|
||||||
|
(self.directory / "sub.yaml").mkdir()
|
||||||
|
|
||||||
|
with self.assertLogs(level="WARNING"):
|
||||||
|
self.assertEqual(i18n.read_catalog(self.directory / "sub.yaml"), {})
|
||||||
|
|
||||||
|
def test_non_string_entries_are_dropped(self):
|
||||||
|
self.path.write_text(
|
||||||
|
"Close: 42\nOpen:\nCopy: yes\n123: Zahl\nyes: Ja\nImprint: Impressum\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(i18n.read_catalog(self.path), {"Imprint": "Impressum"})
|
||||||
|
|
||||||
|
def test_a_missing_catalog_is_silent(self):
|
||||||
|
with self.assertNoLogs(level="WARNING"):
|
||||||
|
i18n.read_catalog(self.directory / "absent.yaml")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCatalogMerge(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
directory = Path(tempfile.mkdtemp())
|
||||||
|
self.addCleanup(shutil.rmtree, directory, True)
|
||||||
|
(directory / "ui").mkdir()
|
||||||
|
(directory / "content").mkdir()
|
||||||
|
|
||||||
|
self.addCleanup(setattr, i18n, "UI_DIR", i18n.UI_DIR)
|
||||||
|
self.addCleanup(setattr, i18n, "CONTENT_DIR", i18n.CONTENT_DIR)
|
||||||
|
self.addCleanup(i18n.clear_catalogs)
|
||||||
|
i18n.UI_DIR = directory / "ui"
|
||||||
|
i18n.CONTENT_DIR = directory / "content"
|
||||||
|
i18n.clear_catalogs()
|
||||||
|
|
||||||
|
def test_the_content_catalog_overrides_the_shipped_interface_string(self):
|
||||||
|
(i18n.UI_DIR / "de.yaml").write_text(
|
||||||
|
"Close: Schliessen\nImprint: Impressum\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(i18n.CONTENT_DIR / "de.yaml").write_text("Close: Zumachen\n", encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
i18n.catalog("de"), {"Close": "Zumachen", "Imprint": "Impressum"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestShippedCatalogs(unittest.TestCase):
|
||||||
|
def test_every_iso_639_1_language_is_offered(self):
|
||||||
|
self.assertEqual(len(i18n.LANGUAGES), 184)
|
||||||
|
self.assertEqual(i18n.SOURCE_LANGUAGE, next(iter(i18n.LANGUAGES)))
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
set(i18n.LANGUAGES), {"en", "de", "zh", "ar", "he", "vo", "za", "cu"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_every_display_name_is_filled_in(self):
|
||||||
|
blank = [code for code, name in i18n.LANGUAGES.items() if not name.strip()]
|
||||||
|
|
||||||
|
self.assertFalse(blank, f"No display name for: {blank}")
|
||||||
|
|
||||||
|
def test_every_code_is_usable_in_the_route_converter(self):
|
||||||
|
offenders = [
|
||||||
|
code for code in i18n.LANGUAGES if not re.fullmatch(r"[a-z]+", code)
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertFalse(offenders, f"Not usable in the route converter: {offenders}")
|
||||||
|
|
||||||
|
def test_every_shipped_catalog_names_a_known_language(self):
|
||||||
|
stray = [
|
||||||
|
path.stem
|
||||||
|
for path in i18n.UI_DIR.glob("*.yaml")
|
||||||
|
if path.stem not in i18n.LANGUAGES or path.stem == i18n.SOURCE_LANGUAGE
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertFalse(stray, f"Catalogue for an unknown language: {stray}")
|
||||||
|
|
||||||
|
def test_ui_catalogs_cover_exactly_the_interface_strings(self):
|
||||||
|
expected = set(i18n.UI_STRINGS)
|
||||||
|
mismatched = {}
|
||||||
|
|
||||||
|
for path in sorted(i18n.UI_DIR.glob("*.yaml")):
|
||||||
|
code = path.stem
|
||||||
|
entries = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
if set(entries) != expected:
|
||||||
|
mismatched[code] = {
|
||||||
|
"missing": sorted(expected - set(entries)),
|
||||||
|
"unexpected": sorted(set(entries) - expected),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertFalse(mismatched, f"UI catalogues out of sync: {mismatched}")
|
||||||
|
|
||||||
|
def test_ui_strings_of_the_source_language_are_the_source(self):
|
||||||
|
self.assertEqual(
|
||||||
|
i18n.ui_strings("en"),
|
||||||
|
{source: source for source in i18n.UI_STRINGS},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ui_strings_are_translated_for_a_shipped_language(self):
|
||||||
|
strings = i18n.ui_strings("de")
|
||||||
|
|
||||||
|
self.assertEqual(strings["Close"], "Schließen")
|
||||||
|
self.assertEqual(strings["Imprint"], "Impressum")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
454
tests/unit/test_i18n_sync.py
Normal file
454
tests/unit/test_i18n_sync.py
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.utils import i18n
|
||||||
|
from utils import i18n_sync
|
||||||
|
|
||||||
|
|
||||||
|
class TestCollectSources(unittest.TestCase):
|
||||||
|
def test_prose_keys_are_collected_from_nested_structures(self):
|
||||||
|
config = {
|
||||||
|
"cards": [
|
||||||
|
{"title": "Agile Coach", "text": "I lead transformations."},
|
||||||
|
{"text": "Another card."},
|
||||||
|
],
|
||||||
|
"navigation": {
|
||||||
|
"header": {
|
||||||
|
"children": [
|
||||||
|
{"name": "Apps", "description": "Application menu"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"platform": {"titel": "Someone", "subtitel": "A tagline"},
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
i18n_sync.collect_sources(config),
|
||||||
|
{
|
||||||
|
"Agile Coach",
|
||||||
|
"I lead transformations.",
|
||||||
|
"Another card.",
|
||||||
|
"Apps",
|
||||||
|
"Application menu",
|
||||||
|
"A tagline",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_labels_are_collected_too(self):
|
||||||
|
config = {"name": "Pictures", "title": "Agile Coach"}
|
||||||
|
|
||||||
|
self.assertEqual(i18n_sync.collect_sources(config), {"Pictures", "Agile Coach"})
|
||||||
|
|
||||||
|
def test_structural_keys_are_left_alone(self):
|
||||||
|
config = {
|
||||||
|
"url": "https://example.test",
|
||||||
|
"link_text": "www.example.test",
|
||||||
|
"identifier": "@someone@example.test",
|
||||||
|
"icon": {"class": "fa-solid fa-users"},
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(i18n_sync.collect_sources(config), set())
|
||||||
|
|
||||||
|
def test_blank_values_are_ignored(self):
|
||||||
|
self.assertEqual(i18n_sync.collect_sources({"text": " "}), set())
|
||||||
|
|
||||||
|
def test_a_list_of_prose_is_collected(self):
|
||||||
|
self.assertEqual(
|
||||||
|
i18n_sync.collect_sources({"text": ["one", "two"]}), {"one", "two"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranslate(unittest.TestCase):
|
||||||
|
def _session(self, ok, payload=None, status=200, text=""):
|
||||||
|
response = Mock(ok=ok, status_code=status, text=text)
|
||||||
|
response.json.return_value = payload or {}
|
||||||
|
return Mock(post=Mock(return_value=response))
|
||||||
|
|
||||||
|
def test_successful_response_returns_the_translation(self):
|
||||||
|
session = self._session(True, {"translatedText": "Hallo"})
|
||||||
|
|
||||||
|
result = i18n_sync.translate(session, "http://lt", "", "Hello", "de")
|
||||||
|
|
||||||
|
self.assertEqual(result, "Hallo")
|
||||||
|
|
||||||
|
def test_api_key_is_only_sent_when_configured(self):
|
||||||
|
session = self._session(True, {"translatedText": "Hallo"})
|
||||||
|
|
||||||
|
i18n_sync.translate(session, "http://lt", "secret", "Hello", "de")
|
||||||
|
|
||||||
|
self.assertEqual(session.post.call_args.kwargs["data"]["api_key"], "secret")
|
||||||
|
|
||||||
|
def test_no_api_key_is_sent_when_none_is_configured(self):
|
||||||
|
session = self._session(True, {"translatedText": "Hallo"})
|
||||||
|
|
||||||
|
i18n_sync.translate(session, "http://lt", "", "Hello", "de")
|
||||||
|
|
||||||
|
self.assertNotIn("api_key", session.post.call_args.kwargs["data"])
|
||||||
|
|
||||||
|
def test_failed_response_returns_none(self):
|
||||||
|
session = self._session(
|
||||||
|
False, {"translatedText": "SHOULD NOT BE USED"}, status=403, text="denied"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNone(i18n_sync.translate(session, "http://lt", "", "Hi", "de"))
|
||||||
|
|
||||||
|
def test_the_request_asks_for_plain_text_from_the_source_language(self):
|
||||||
|
session = self._session(True, {"translatedText": "Hallo"})
|
||||||
|
|
||||||
|
i18n_sync.translate(session, "http://lt", "", "Hello", "de")
|
||||||
|
|
||||||
|
data = session.post.call_args.kwargs["data"]
|
||||||
|
self.assertEqual(data["format"], "text")
|
||||||
|
self.assertEqual(data["source"], i18n.SOURCE_LANGUAGE)
|
||||||
|
self.assertEqual(data["target"], "de")
|
||||||
|
|
||||||
|
def test_the_request_is_bounded_by_a_timeout(self):
|
||||||
|
session = self._session(True, {"translatedText": "Hallo"})
|
||||||
|
|
||||||
|
i18n_sync.translate(session, "http://lt", "", "Hello", "de")
|
||||||
|
|
||||||
|
timeout = session.post.call_args.kwargs["timeout"]
|
||||||
|
self.assertIsInstance(timeout, (int, float))
|
||||||
|
self.assertGreater(timeout, 0)
|
||||||
|
|
||||||
|
def test_an_empty_translation_is_refused(self):
|
||||||
|
session = self._session(True, {"translatedText": ""})
|
||||||
|
|
||||||
|
self.assertIsNone(i18n_sync.translate(session, "http://lt", "", "Hi", "de"))
|
||||||
|
|
||||||
|
def test_a_non_json_success_response_returns_none(self):
|
||||||
|
response = Mock(ok=True)
|
||||||
|
response.json.side_effect = ValueError("no json")
|
||||||
|
session = Mock(post=Mock(return_value=response))
|
||||||
|
|
||||||
|
self.assertIsNone(i18n_sync.translate(session, "http://lt", "", "Hi", "de"))
|
||||||
|
|
||||||
|
def test_a_transport_failure_returns_none(self):
|
||||||
|
session = Mock(
|
||||||
|
post=Mock(side_effect=i18n_sync.requests.ConnectionError("reset"))
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNone(i18n_sync.translate(session, "http://lt", "", "Hi", "de"))
|
||||||
|
|
||||||
|
def test_a_non_string_translation_is_refused(self):
|
||||||
|
session = self._session(True, {"translatedText": ["Hallo", "Welt"]})
|
||||||
|
|
||||||
|
self.assertIsNone(i18n_sync.translate(session, "http://lt", "", "Hi", "de"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestSupportedLanguages(unittest.TestCase):
|
||||||
|
def _session(self, payload=None, side_effect=None):
|
||||||
|
response = Mock(raise_for_status=Mock())
|
||||||
|
if side_effect is not None:
|
||||||
|
response.json.side_effect = side_effect
|
||||||
|
else:
|
||||||
|
response.json.return_value = payload
|
||||||
|
return Mock(get=Mock(return_value=response))
|
||||||
|
|
||||||
|
def test_the_offered_codes_are_returned(self):
|
||||||
|
session = self._session([{"code": "de"}, {"code": "fr"}])
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
i18n_sync.supported_languages("http://lt", session), {"de", "fr"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_an_unreachable_instance_is_reported(self):
|
||||||
|
session = Mock(get=Mock(side_effect=i18n_sync.requests.ConnectTimeout("slow")))
|
||||||
|
|
||||||
|
with self.assertRaises(i18n_sync.BackendError):
|
||||||
|
i18n_sync.supported_languages("http://lt", session)
|
||||||
|
|
||||||
|
def test_an_error_status_is_reported(self):
|
||||||
|
response = Mock()
|
||||||
|
response.raise_for_status.side_effect = i18n_sync.requests.HTTPError("503")
|
||||||
|
response.json.return_value = [{"code": "de"}]
|
||||||
|
session = Mock(get=Mock(return_value=response))
|
||||||
|
|
||||||
|
with self.assertRaises(i18n_sync.BackendError):
|
||||||
|
i18n_sync.supported_languages("http://lt", session)
|
||||||
|
|
||||||
|
def test_a_non_json_listing_is_reported(self):
|
||||||
|
session = self._session(side_effect=ValueError("no json"))
|
||||||
|
|
||||||
|
with self.assertRaises(i18n_sync.BackendError):
|
||||||
|
i18n_sync.supported_languages("http://lt", session)
|
||||||
|
|
||||||
|
def test_json_of_the_wrong_shape_is_reported(self):
|
||||||
|
for payload in ({"error": "Slow down"}, ["de", "fr"], None, [{"name": "de"}]):
|
||||||
|
with self.subTest(payload=payload):
|
||||||
|
with self.assertRaises(i18n_sync.BackendError):
|
||||||
|
i18n_sync.supported_languages("http://lt", self._session(payload))
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadKeep(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.directory = Path(tempfile.mkdtemp())
|
||||||
|
self.addCleanup(shutil.rmtree, self.directory, True)
|
||||||
|
self.path = self.directory / "keep.txt"
|
||||||
|
|
||||||
|
def test_a_missing_file_keeps_nothing(self):
|
||||||
|
self.assertEqual(i18n_sync.read_keep(self.path), [])
|
||||||
|
|
||||||
|
def test_comments_and_blank_lines_are_ignored(self):
|
||||||
|
self.path.write_text(
|
||||||
|
"# brands\n\nMastodon\n Bluesky \n\n# more\nNextcloud\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
i18n_sync.read_keep(self.path), ["Mastodon", "Bluesky", "Nextcloud"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_shipped_list_names_the_brands_of_the_sample_configuration(self):
|
||||||
|
shipped = i18n_sync.read_keep(i18n_sync.DEFAULT_KEEP_PATH)
|
||||||
|
|
||||||
|
self.assertGreaterEqual(set(shipped), {"Mastodon", "Nextcloud", "Matrix"})
|
||||||
|
self.assertNotIn("Pictures", shipped)
|
||||||
|
self.assertNotIn("Imprint", shipped)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandLine(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.directory = Path(tempfile.mkdtemp())
|
||||||
|
self.addCleanup(shutil.rmtree, self.directory, True)
|
||||||
|
self.keep_file = self.directory / "keep.txt"
|
||||||
|
self.config = self.directory / "config.yaml"
|
||||||
|
self.config.write_text("cards: []\n", encoding="utf-8")
|
||||||
|
|
||||||
|
def _main(self, *extra):
|
||||||
|
with patch.object(i18n_sync, "sync") as sync:
|
||||||
|
i18n_sync.main(
|
||||||
|
[
|
||||||
|
"--url",
|
||||||
|
"http://lt/",
|
||||||
|
"--config",
|
||||||
|
str(self.config),
|
||||||
|
"--keep-file",
|
||||||
|
str(self.keep_file),
|
||||||
|
*extra,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return sync.call_args
|
||||||
|
|
||||||
|
def test_the_keep_file_reaches_the_sync(self):
|
||||||
|
self.keep_file.write_text("# brands\nMastodon\nNextcloud\n", encoding="utf-8")
|
||||||
|
|
||||||
|
keep = self._main().args[5]
|
||||||
|
|
||||||
|
self.assertEqual(list(keep), ["Mastodon", "Nextcloud"])
|
||||||
|
|
||||||
|
def test_the_command_line_adds_to_the_keep_file(self):
|
||||||
|
self.keep_file.write_text("Mastodon\n", encoding="utf-8")
|
||||||
|
|
||||||
|
keep = self._main("--keep", "Taiga").args[5]
|
||||||
|
|
||||||
|
self.assertEqual(list(keep), ["Mastodon", "Taiga"])
|
||||||
|
|
||||||
|
def test_a_trailing_slash_is_stripped_from_the_url(self):
|
||||||
|
self.assertEqual(self._main().args[0], "http://lt")
|
||||||
|
|
||||||
|
def test_a_backend_failure_is_reported_as_an_exit_code(self):
|
||||||
|
with patch.object(i18n_sync, "sync", side_effect=i18n_sync.BackendError("no")):
|
||||||
|
self.assertEqual(
|
||||||
|
i18n_sync.main(["--url", "http://lt", "--config", str(self.config)]), 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadExisting(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.directory = Path(tempfile.mkdtemp())
|
||||||
|
self.addCleanup(shutil.rmtree, self.directory, True)
|
||||||
|
self.path = self.directory / "de.yaml"
|
||||||
|
|
||||||
|
def test_a_missing_file_is_an_empty_catalog(self):
|
||||||
|
self.assertEqual(i18n_sync.load_existing(self.path), {})
|
||||||
|
|
||||||
|
def test_a_readable_catalog_is_returned(self):
|
||||||
|
self.path.write_text("Hi: Hallo\n", encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertEqual(i18n_sync.load_existing(self.path), {"Hi": "Hallo"})
|
||||||
|
|
||||||
|
def test_an_unparsable_catalog_is_refused(self):
|
||||||
|
self.path.write_text('Hi: "unterminated\n', encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIsNone(i18n_sync.load_existing(self.path))
|
||||||
|
|
||||||
|
def test_a_non_mapping_catalog_is_refused(self):
|
||||||
|
self.path.write_text("- a\n- b\n", encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIsNone(i18n_sync.load_existing(self.path))
|
||||||
|
|
||||||
|
def test_a_non_utf8_catalog_is_refused(self):
|
||||||
|
self.path.write_bytes(b"\xffHi: Hallo\n")
|
||||||
|
|
||||||
|
self.assertIsNone(i18n_sync.load_existing(self.path))
|
||||||
|
|
||||||
|
def test_a_directory_at_the_catalog_path_is_refused(self):
|
||||||
|
(self.directory / "sub.yaml").mkdir()
|
||||||
|
|
||||||
|
self.assertIsNone(i18n_sync.load_existing(self.directory / "sub.yaml"))
|
||||||
|
|
||||||
|
def test_an_empty_or_comment_only_catalog_is_an_empty_catalog(self):
|
||||||
|
for text in ("", "\n\n", "# only a comment\n"):
|
||||||
|
with self.subTest(text=text):
|
||||||
|
self.path.write_text(text, encoding="utf-8")
|
||||||
|
self.assertEqual(i18n_sync.load_existing(self.path), {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestWriteCatalog(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.directory = Path(tempfile.mkdtemp())
|
||||||
|
self.addCleanup(shutil.rmtree, self.directory, True)
|
||||||
|
self.path = self.directory / "de.yaml"
|
||||||
|
|
||||||
|
def test_the_catalog_is_written_and_no_scratch_file_is_left(self):
|
||||||
|
i18n_sync.write_catalog(self.path, {"Hi": "Hallo", "Umlaut": "Grüße"})
|
||||||
|
|
||||||
|
text = self.path.read_text(encoding="utf-8")
|
||||||
|
self.assertEqual(yaml.safe_load(text), {"Hi": "Hallo", "Umlaut": "Grüße"})
|
||||||
|
self.assertIn("Grüße", text)
|
||||||
|
self.assertEqual([p.name for p in self.directory.iterdir()], ["de.yaml"])
|
||||||
|
|
||||||
|
def test_a_write_that_dies_halfway_leaves_the_previous_catalog_intact(self):
|
||||||
|
self.path.write_text("Hi: HAND-EDITED\n", encoding="utf-8")
|
||||||
|
complete = Path.write_text
|
||||||
|
|
||||||
|
def dies_halfway(target, data, *args, **kwargs):
|
||||||
|
complete(target, data[: len(data) // 2], *args, **kwargs)
|
||||||
|
raise OSError("no space left on device")
|
||||||
|
|
||||||
|
with patch.object(Path, "write_text", dies_halfway):
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
i18n_sync.write_catalog(self.path, {"Hi": "machine", "Zebra": "Zebra"})
|
||||||
|
|
||||||
|
self.assertEqual(self.path.read_text(encoding="utf-8"), "Hi: HAND-EDITED\n")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSync(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.directory = Path(tempfile.mkdtemp())
|
||||||
|
self.addCleanup(shutil.rmtree, self.directory, True)
|
||||||
|
self.addCleanup(setattr, i18n, "CONTENT_DIR", i18n.CONTENT_DIR)
|
||||||
|
i18n.CONTENT_DIR = self.directory
|
||||||
|
self.path = self.directory / "de.yaml"
|
||||||
|
|
||||||
|
def _run(self, sources, offered=("de",), translated="UEBERSETZT", keep=()):
|
||||||
|
listing = Mock(raise_for_status=Mock())
|
||||||
|
listing.json.return_value = [{"code": code} for code in offered]
|
||||||
|
answer = Mock(ok=True)
|
||||||
|
answer.json.return_value = {"translatedText": translated}
|
||||||
|
session = Mock(get=Mock(return_value=listing), post=Mock(return_value=answer))
|
||||||
|
|
||||||
|
with patch.object(i18n_sync.requests, "Session", return_value=session):
|
||||||
|
i18n_sync.sync("http://lt", "", set(sources), ["de"], self.directory, keep)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def test_missing_entries_are_written(self):
|
||||||
|
self._run(["Hello"])
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
yaml.safe_load(self.path.read_text(encoding="utf-8")),
|
||||||
|
{"Hello": "UEBERSETZT"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_existing_entries_are_never_overwritten(self):
|
||||||
|
self.path.write_text("Hello: HAND-EDITED\n", encoding="utf-8")
|
||||||
|
|
||||||
|
session = self._run(["Hello", "World"])
|
||||||
|
|
||||||
|
catalog = yaml.safe_load(self.path.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(catalog["Hello"], "HAND-EDITED")
|
||||||
|
self.assertEqual(session.post.call_count, 1)
|
||||||
|
|
||||||
|
def test_an_unparsable_catalog_is_left_alone(self):
|
||||||
|
broken = 'Hello: "unterminated\n'
|
||||||
|
self.path.write_text(broken, encoding="utf-8")
|
||||||
|
|
||||||
|
session = self._run(["Hello"])
|
||||||
|
|
||||||
|
self.assertEqual(self.path.read_text(encoding="utf-8"), broken)
|
||||||
|
session.post.assert_not_called()
|
||||||
|
|
||||||
|
def test_a_language_the_instance_does_not_offer_is_skipped(self):
|
||||||
|
session = self._run(["Hello"], offered=("fr",))
|
||||||
|
|
||||||
|
self.assertFalse(self.path.exists())
|
||||||
|
session.post.assert_not_called()
|
||||||
|
|
||||||
|
def test_a_protected_string_is_stored_as_itself(self):
|
||||||
|
session = self._run(["Mastodon", "Hello"], keep=["Mastodon"])
|
||||||
|
|
||||||
|
catalog = yaml.safe_load(self.path.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(catalog["Mastodon"], "Mastodon")
|
||||||
|
self.assertEqual(session.post.call_count, 1)
|
||||||
|
|
||||||
|
def test_a_protected_string_never_overwrites_an_existing_entry(self):
|
||||||
|
self.path.write_text("Mastodon: HAND-EDITED\n", encoding="utf-8")
|
||||||
|
|
||||||
|
self._run(["Mastodon", "Hello"], keep=["Mastodon"])
|
||||||
|
|
||||||
|
catalog = yaml.safe_load(self.path.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(catalog["Mastodon"], "HAND-EDITED")
|
||||||
|
self.assertEqual(catalog["Hello"], "UEBERSETZT")
|
||||||
|
|
||||||
|
def test_an_empty_translation_is_not_stored(self):
|
||||||
|
self._run(["Hello"], translated="")
|
||||||
|
|
||||||
|
self.assertFalse(self.path.exists())
|
||||||
|
|
||||||
|
def test_a_run_that_translated_nothing_leaves_the_file_untouched(self):
|
||||||
|
original = "# Reviewed by a native speaker, keep the order.\nZebra: Zebra\n"
|
||||||
|
self.path.write_text(original, encoding="utf-8")
|
||||||
|
|
||||||
|
self._run(["Hello"], translated=None)
|
||||||
|
|
||||||
|
self.assertEqual(self.path.read_text(encoding="utf-8"), original)
|
||||||
|
|
||||||
|
def test_a_string_the_shipped_catalog_covers_is_not_requested(self):
|
||||||
|
ui = Path(tempfile.mkdtemp())
|
||||||
|
self.addCleanup(shutil.rmtree, ui, True)
|
||||||
|
self.addCleanup(setattr, i18n, "UI_DIR", i18n.UI_DIR)
|
||||||
|
i18n.UI_DIR = ui
|
||||||
|
(ui / "de.yaml").write_text("Close: Schliessen\n", encoding="utf-8")
|
||||||
|
|
||||||
|
session = self._run(["Close", "Hello"])
|
||||||
|
|
||||||
|
self.assertEqual(session.post.call_count, 1)
|
||||||
|
self.assertNotIn("Close", yaml.safe_load(self.path.read_text(encoding="utf-8")))
|
||||||
|
|
||||||
|
def test_a_language_that_cannot_be_written_does_not_stop_the_others(self):
|
||||||
|
listing = Mock(raise_for_status=Mock())
|
||||||
|
listing.json.return_value = [{"code": "de"}, {"code": "fr"}]
|
||||||
|
answer = Mock(ok=True)
|
||||||
|
answer.json.return_value = {"translatedText": "UEBERSETZT"}
|
||||||
|
session = Mock(get=Mock(return_value=listing), post=Mock(return_value=answer))
|
||||||
|
original = i18n_sync.write_catalog
|
||||||
|
|
||||||
|
def fails_for_german(path, catalog):
|
||||||
|
if path.name == "de.yaml":
|
||||||
|
raise OSError("read-only")
|
||||||
|
original(path, catalog)
|
||||||
|
|
||||||
|
with patch.object(i18n_sync, "write_catalog", fails_for_german):
|
||||||
|
with patch.object(i18n_sync.requests, "Session", return_value=session):
|
||||||
|
i18n_sync.sync("http://lt", "", {"Hello"}, ["de", "fr"], self.directory)
|
||||||
|
|
||||||
|
self.assertTrue((self.directory / "fr.yaml").is_file())
|
||||||
|
|
||||||
|
def test_the_catalog_directory_is_created(self):
|
||||||
|
nested = self.directory / "content"
|
||||||
|
self.directory = nested
|
||||||
|
self.path = nested / "de.yaml"
|
||||||
|
|
||||||
|
self._run(["Hello"])
|
||||||
|
|
||||||
|
self.assertTrue(self.path.is_file())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -2,7 +2,7 @@ import unittest
|
|||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
|
||||||
class AnchorCollector(HTMLParser):
|
class AnchorCollector(HTMLParser):
|
||||||
@@ -20,14 +20,27 @@ class TestNavigationTemplate(unittest.TestCase):
|
|||||||
template_dir = Path(__file__).resolve().parents[2] / "app" / "templates"
|
template_dir = Path(__file__).resolve().parents[2] / "app" / "templates"
|
||||||
environment = Environment(
|
environment = Environment(
|
||||||
loader=FileSystemLoader(template_dir),
|
loader=FileSystemLoader(template_dir),
|
||||||
autoescape=select_autoescape(),
|
autoescape=True,
|
||||||
)
|
)
|
||||||
environment.globals["url_for"] = lambda _endpoint, filename: (
|
environment.globals["url_for"] = lambda endpoint, **kwargs: (
|
||||||
f"/static/{filename}"
|
f"/static/{kwargs['filename']}"
|
||||||
|
if endpoint == "static"
|
||||||
|
else f"/{kwargs['lang']}/"
|
||||||
|
)
|
||||||
|
environment.globals["asset_src"] = lambda asset: (
|
||||||
|
(asset or {}).get("external_url")
|
||||||
|
or (
|
||||||
|
f"/static/{(asset or {}).get('cache')}"
|
||||||
|
if (asset or {}).get("cache")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
rendered = environment.get_template("moduls/navigation.html.j2").render(
|
rendered = environment.get_template("moduls/navigation.html.j2").render(
|
||||||
menu_type="header",
|
menu_type="header",
|
||||||
|
lang="en",
|
||||||
|
languages={"en": "English", "de": "Deutsch"},
|
||||||
|
t=lambda source: source,
|
||||||
platform={
|
platform={
|
||||||
"titel": "Portfolio",
|
"titel": "Portfolio",
|
||||||
"logo": {"cache": "logo.png"},
|
"logo": {"cache": "logo.png"},
|
||||||
@@ -62,8 +75,15 @@ class TestNavigationTemplate(unittest.TestCase):
|
|||||||
and "dropdown-toggle" in anchor.get("class", "")
|
and "dropdown-toggle" in anchor.get("class", "")
|
||||||
]
|
]
|
||||||
|
|
||||||
self.assertEqual(len(dropdown_toggles), 1)
|
self.assertEqual(len(dropdown_toggles), 2)
|
||||||
self.assertEqual(dropdown_toggles[0].get("data-bs-toggle"), "dropdown")
|
for toggle in dropdown_toggles:
|
||||||
|
self.assertEqual(toggle.get("data-bs-toggle"), "dropdown")
|
||||||
|
|
||||||
|
language_links = [
|
||||||
|
anchor for anchor in parser.anchors if anchor.get("hreflang") == "de"
|
||||||
|
]
|
||||||
|
self.assertEqual(len(language_links), 1)
|
||||||
|
self.assertEqual(language_links[0]["href"], "/de/")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
83
utils/generate_languages.py
Normal file
83
utils/generate_languages.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regenerate app/utils/languages.py from ISO 639-1 and CLDR.
|
||||||
|
|
||||||
|
Every ISO 639-1 alpha-2 code becomes an entry. The display name is the CLDR
|
||||||
|
endonym where CLDR knows the language, and the English ISO name for the
|
||||||
|
remainder. Writing direction comes from CLDR's character order.
|
||||||
|
|
||||||
|
Needs the dev extra: pip install -e ".[dev]"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pycountry
|
||||||
|
from babel import Locale
|
||||||
|
from babel.localedata import locale_identifiers
|
||||||
|
|
||||||
|
TARGET = Path(__file__).resolve().parents[1] / "app" / "utils" / "languages.py"
|
||||||
|
|
||||||
|
HEADER = '''"""ISO 639-1 languages, their display names and writing direction.
|
||||||
|
|
||||||
|
Generated by utils/generate_languages.py — edit that script, not this file.
|
||||||
|
|
||||||
|
Display names are CLDR endonyms where CLDR covers the language and the English
|
||||||
|
ISO 639-1 name for the {fallback} codes it does not.
|
||||||
|
"""
|
||||||
|
|
||||||
|
LANGUAGES = {{
|
||||||
|
'''
|
||||||
|
|
||||||
|
FOOTER = """}}
|
||||||
|
|
||||||
|
RTL_LANGUAGES = frozenset({rtl})
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def collect():
|
||||||
|
"""Return (code -> display name) and the set of right-to-left codes."""
|
||||||
|
cldr = {code for code in locale_identifiers() if len(code) == 2 and code.isalpha()}
|
||||||
|
names, rtl, fallbacks = {}, set(), 0
|
||||||
|
|
||||||
|
for language in sorted(
|
||||||
|
pycountry.languages, key=lambda item: getattr(item, "alpha_2", "")
|
||||||
|
):
|
||||||
|
code = getattr(language, "alpha_2", None)
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
if code in cldr:
|
||||||
|
locale = Locale.parse(code)
|
||||||
|
names[code] = locale.get_display_name(code)
|
||||||
|
if locale.character_order == "right-to-left":
|
||||||
|
rtl.add(code)
|
||||||
|
else:
|
||||||
|
names[code] = language.name
|
||||||
|
fallbacks += 1
|
||||||
|
|
||||||
|
return names, rtl, fallbacks
|
||||||
|
|
||||||
|
|
||||||
|
def render(names, rtl, fallbacks):
|
||||||
|
"""Return the module source, English first and the rest by code."""
|
||||||
|
ordered = ["en"] + [code for code in sorted(names) if code != "en"]
|
||||||
|
body = "".join(f' "{code}": "{names[code]}",\n' for code in ordered)
|
||||||
|
listed = ", ".join(f'"{code}"' for code in sorted(rtl))
|
||||||
|
return (
|
||||||
|
HEADER.format(fallback=fallbacks) + body + FOOTER.format(rtl="{" + listed + "}")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
names, rtl, fallbacks = collect()
|
||||||
|
TARGET.write_text(render(names, rtl, fallbacks), encoding="utf-8")
|
||||||
|
subprocess.run([sys.executable, "-m", "ruff", "format", str(TARGET)], check=False)
|
||||||
|
print(
|
||||||
|
f"{len(names)} languages, {len(rtl)} right-to-left, "
|
||||||
|
f"{fallbacks} without a CLDR endonym"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
306
utils/i18n_sync.py
Normal file
306
utils/i18n_sync.py
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fill missing content translations from a LibreTranslate instance.
|
||||||
|
|
||||||
|
Reads the prose strings out of the live ``app/config.yaml`` and writes one
|
||||||
|
catalogue per language to ``app/i18n/content/``. Entries that already exist are
|
||||||
|
never overwritten, so a hand-corrected translation survives every later run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
from app.utils import i18n # noqa: E402
|
||||||
|
|
||||||
|
DEFAULT_CONFIG_PATH = REPO_ROOT / "app" / "config.yaml"
|
||||||
|
DEFAULT_KEEP_PATH = i18n.I18N_DIR / "keep.txt"
|
||||||
|
REQUEST_TIMEOUT = 30
|
||||||
|
|
||||||
|
|
||||||
|
def collect_sources(node, key=None, found=None):
|
||||||
|
"""Return every prose string in ``node`` that a backend may translate.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
node: the raw configuration tree, or any subtree of it.
|
||||||
|
key: the mapping key ``node`` was reached through.
|
||||||
|
found: accumulator, for recursion.
|
||||||
|
"""
|
||||||
|
found = set() if found is None else found
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for name, value in node.items():
|
||||||
|
collect_sources(value, name, found)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for item in node:
|
||||||
|
collect_sources(item, key, found)
|
||||||
|
elif isinstance(node, str) and key in i18n.TRANSLATABLE_KEYS and node.strip():
|
||||||
|
found.add(node)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def read_keep(path):
|
||||||
|
"""Return the strings listed in ``path``, ignoring blanks and comments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: a text file with one string per line, or a path that does not exist.
|
||||||
|
"""
|
||||||
|
if not path.is_file():
|
||||||
|
return []
|
||||||
|
lines = path.read_text(encoding="utf-8").splitlines()
|
||||||
|
return [
|
||||||
|
stripped
|
||||||
|
for stripped in (line.strip() for line in lines)
|
||||||
|
if stripped and not stripped.startswith("#")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BackendError(Exception):
|
||||||
|
"""The translation backend answered in a way the run cannot continue from."""
|
||||||
|
|
||||||
|
|
||||||
|
def supported_languages(url, session):
|
||||||
|
"""Return the language codes the LibreTranslate instance at ``url`` offers."""
|
||||||
|
try:
|
||||||
|
response = session.get(f"{url}/languages", timeout=REQUEST_TIMEOUT)
|
||||||
|
response.raise_for_status()
|
||||||
|
offered = response.json()
|
||||||
|
except requests.RequestException as error:
|
||||||
|
raise BackendError(f"{url} is not reachable: {error}")
|
||||||
|
except ValueError:
|
||||||
|
raise BackendError(f"{url}/languages did not answer JSON")
|
||||||
|
|
||||||
|
if not isinstance(offered, list):
|
||||||
|
raise BackendError(
|
||||||
|
f"{url}/languages answered {type(offered).__name__}, not a list"
|
||||||
|
)
|
||||||
|
codes = {
|
||||||
|
entry["code"]
|
||||||
|
for entry in offered
|
||||||
|
if isinstance(entry, dict) and isinstance(entry.get("code"), str)
|
||||||
|
}
|
||||||
|
if not codes:
|
||||||
|
raise BackendError(f"{url}/languages listed no usable language codes")
|
||||||
|
return codes
|
||||||
|
|
||||||
|
|
||||||
|
def load_existing(path):
|
||||||
|
"""Return the catalogue at ``path``, or None when it must not be rewritten.
|
||||||
|
|
||||||
|
Distinct from ``i18n.read_catalog``, which degrades an unreadable catalogue
|
||||||
|
to English at render time. Here the same file means the hand-written entries
|
||||||
|
are unknown, and writing would replace them with a fresh machine pass.
|
||||||
|
"""
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeDecodeError, yaml.YAMLError) as error:
|
||||||
|
print(f" ! {path.name}: {type(error).__name__}, refusing to overwrite it")
|
||||||
|
return None
|
||||||
|
if loaded is None:
|
||||||
|
return {}
|
||||||
|
if not isinstance(loaded, dict):
|
||||||
|
print(f" ! {path.name}: not a mapping, refusing to overwrite it")
|
||||||
|
return None
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
def write_catalog(path, catalog):
|
||||||
|
"""Replace ``path`` with ``catalog`` in one step.
|
||||||
|
|
||||||
|
Writing in place would leave a half-written catalogue behind if the process
|
||||||
|
is interrupted or the disk fills, and the truncated remainder can still be
|
||||||
|
valid YAML — the next run would then machine-fill the destroyed entries.
|
||||||
|
"""
|
||||||
|
temporary = path.with_name(f"{path.name}.tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
yaml.safe_dump(catalog, allow_unicode=True, sort_keys=True, width=1000),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(temporary, path)
|
||||||
|
|
||||||
|
|
||||||
|
def translate(session, url, api_key, text, target):
|
||||||
|
"""Translate one string into ``target``, or return None on failure."""
|
||||||
|
payload = {
|
||||||
|
"q": text,
|
||||||
|
"source": i18n.SOURCE_LANGUAGE,
|
||||||
|
"target": target,
|
||||||
|
"format": "text",
|
||||||
|
}
|
||||||
|
if api_key:
|
||||||
|
payload["api_key"] = api_key
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = session.post(
|
||||||
|
f"{url}/translate", data=payload, timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
except requests.RequestException as error:
|
||||||
|
print(f" ! {target}: {type(error).__name__}, {error}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
print(f" ! {target}: {response.status_code} {response.text[:120]}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
translated = response.json().get("translatedText")
|
||||||
|
except ValueError:
|
||||||
|
print(f" ! {target}: answered 200 but not JSON")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(translated, str) or not translated.strip():
|
||||||
|
print(f" ! {target}: unusable translatedText ({translated!r})")
|
||||||
|
return None
|
||||||
|
return translated
|
||||||
|
|
||||||
|
|
||||||
|
def sync(url, api_key, sources, targets, directory, keep=()):
|
||||||
|
"""Fill and write the catalogue of every language in ``targets``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: base URL of the LibreTranslate instance.
|
||||||
|
api_key: API key, or an empty string.
|
||||||
|
sources: English strings to translate.
|
||||||
|
targets: language codes to fill.
|
||||||
|
directory: catalogue directory to write into.
|
||||||
|
keep: strings to store as themselves instead of translating.
|
||||||
|
"""
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
session = requests.Session()
|
||||||
|
|
||||||
|
available = supported_languages(url, session)
|
||||||
|
for target in targets:
|
||||||
|
if target not in available:
|
||||||
|
print(f"- {target}: not offered by {url}, skipped")
|
||||||
|
continue
|
||||||
|
|
||||||
|
path = directory / f"{target}.yaml"
|
||||||
|
catalog = load_existing(path)
|
||||||
|
if catalog is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
protected = {word: word for word in keep if word not in catalog}
|
||||||
|
catalog.update(protected)
|
||||||
|
|
||||||
|
shipped = (
|
||||||
|
{}
|
||||||
|
if directory == i18n.UI_DIR
|
||||||
|
else i18n.read_catalog(i18n.UI_DIR / f"{target}.yaml")
|
||||||
|
)
|
||||||
|
missing = sorted(
|
||||||
|
source
|
||||||
|
for source in sources
|
||||||
|
if source not in catalog and source not in shipped
|
||||||
|
)
|
||||||
|
if not missing:
|
||||||
|
print(f"- {target}: complete")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"- {target}: translating {len(missing)} string(s)")
|
||||||
|
added = 0
|
||||||
|
for source in missing:
|
||||||
|
translated = translate(session, url, api_key, source, target)
|
||||||
|
if translated:
|
||||||
|
catalog[source] = translated
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
if not added and not protected:
|
||||||
|
print(f" ! {target}: nothing translated, leaving the file untouched")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
write_catalog(path, catalog)
|
||||||
|
except OSError as error:
|
||||||
|
print(f" ! {target}: could not write {path.name}: {error}")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--url",
|
||||||
|
required=True,
|
||||||
|
help="Base URL of a LibreTranslate instance, e.g. http://localhost:5002",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--api-key",
|
||||||
|
default="",
|
||||||
|
help="API key, if the instance requires one.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--config",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_CONFIG_PATH,
|
||||||
|
help=f"Configuration to read the prose from (default: {DEFAULT_CONFIG_PATH}).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--catalog",
|
||||||
|
choices=("content", "ui"),
|
||||||
|
default="content",
|
||||||
|
help=(
|
||||||
|
"content: your configuration's prose, generated per deployment. "
|
||||||
|
"ui: the interface strings that ship with the project."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--keep",
|
||||||
|
nargs="+",
|
||||||
|
default=[],
|
||||||
|
metavar="STRING",
|
||||||
|
help=(
|
||||||
|
"Store these as themselves instead of translating, "
|
||||||
|
"in addition to --keep-file."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--keep-file",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_KEEP_PATH,
|
||||||
|
help=(
|
||||||
|
f"One string per line, stored untranslated (default: {DEFAULT_KEEP_PATH})."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--languages",
|
||||||
|
nargs="+",
|
||||||
|
default=[code for code in i18n.LANGUAGES if code != i18n.SOURCE_LANGUAGE],
|
||||||
|
help="Language codes to fill (default: every shipped language).",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if args.catalog == "ui":
|
||||||
|
directory = i18n.UI_DIR
|
||||||
|
sources = set(i18n.UI_STRINGS)
|
||||||
|
print(f"{len(sources)} interface string(s)")
|
||||||
|
else:
|
||||||
|
directory = i18n.CONTENT_DIR
|
||||||
|
config = yaml.safe_load(args.config.read_text(encoding="utf-8"))
|
||||||
|
sources = collect_sources(config) | set(i18n.UI_STRINGS)
|
||||||
|
print(f"{len(sources)} string(s) in {args.config} and the interface")
|
||||||
|
|
||||||
|
keep = read_keep(args.keep_file) + args.keep
|
||||||
|
print(f"{len(keep)} string(s) kept untranslated")
|
||||||
|
|
||||||
|
try:
|
||||||
|
sync(
|
||||||
|
args.url.rstrip("/"),
|
||||||
|
args.api_key,
|
||||||
|
sources,
|
||||||
|
args.languages,
|
||||||
|
directory,
|
||||||
|
keep,
|
||||||
|
)
|
||||||
|
except BackendError as error:
|
||||||
|
print(f"ERROR: {error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user