mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-08-24 13:14:32 +00:00
Compare commits
5 Commits
03b7cef90e
...
ef1c8ff09a
| Author | SHA1 | Date | |
|---|---|---|---|
| ef1c8ff09a | |||
| 0c67f999f6 | |||
| efabbd3b2e | |||
| 2a35b2910a | |||
| a42930a699 |
55
.github/workflows/lint.yml
vendored
55
.github/workflows/lint.yml
vendored
@@ -19,6 +19,61 @@ jobs:
|
||||
- name: Run actionlint
|
||||
run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest
|
||||
|
||||
lint-yaml:
|
||||
name: Lint YAML
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install lint dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install ".[dev]"
|
||||
|
||||
- name: Run yamllint
|
||||
run: yamllint --strict .
|
||||
|
||||
lint-js:
|
||||
name: Lint JavaScript
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: app/package.json
|
||||
|
||||
- name: Install Node dependencies
|
||||
working-directory: app
|
||||
run: npm install
|
||||
|
||||
- name: Run eslint
|
||||
working-directory: app
|
||||
run: npx eslint .
|
||||
|
||||
lint-shell:
|
||||
name: Lint shell scripts
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Run shellcheck
|
||||
run: docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable scripts/*.sh
|
||||
|
||||
lint-python:
|
||||
name: Lint Python
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
app/config.yaml
|
||||
app/i18n/content/
|
||||
*__pycache__*
|
||||
app/static/cache/*
|
||||
.env
|
||||
@@ -10,3 +11,4 @@ hadolint-results.sarif
|
||||
build/
|
||||
*.egg-info/
|
||||
app/core.*
|
||||
.mcp.json
|
||||
|
||||
23
.yamllint
Normal file
23
.yamllint
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
# Correctness only. No `extends: default`, so nothing but the rules below runs:
|
||||
# the repository predates this linter and its cosmetic findings (indentation,
|
||||
# line length, trailing spaces) would be noise nobody acts on.
|
||||
#
|
||||
# key-duplicates is the rule that earns its keep: PyYAML keeps the last of two
|
||||
# identical keys without complaining, so a duplicated entry in a translation
|
||||
# catalogue silently drops a translation.
|
||||
ignore: |
|
||||
.git/
|
||||
.venv/
|
||||
venv/
|
||||
node_modules/
|
||||
app/node_modules/
|
||||
app/static/vendor/
|
||||
build/
|
||||
.ruff_cache/
|
||||
|
||||
rules:
|
||||
key-duplicates: enable
|
||||
octal-values:
|
||||
forbid-implicit-octal: true
|
||||
forbid-explicit-octal: true
|
||||
53
Makefile
53
Makefile
@@ -78,6 +78,8 @@ run-dev: env config config
|
||||
-p "$$PORT:$$PORT" \
|
||||
--name portfolio \
|
||||
-v "$(PWD)/app/:/app" \
|
||||
-e PORT="$$PORT" \
|
||||
-e TRUSTED_HOSTS="$$TRUSTED_HOSTS" \
|
||||
-e FLASK_APP=app.py \
|
||||
-e FLASK_ENV=development \
|
||||
"$$IMAGE_NAME"
|
||||
@@ -89,6 +91,8 @@ run-prod: env config config
|
||||
docker run -d \
|
||||
-p "$$PORT:$$PORT" \
|
||||
--name portfolio \
|
||||
-e PORT="$$PORT" \
|
||||
-e TRUSTED_HOSTS="$$TRUSTED_HOSTS" \
|
||||
"$$IMAGE_NAME"
|
||||
|
||||
.PHONY: logs
|
||||
@@ -132,6 +136,23 @@ install-dev:
|
||||
# Install runtime and developer dependencies from pyproject.toml.
|
||||
$(PYTHON) -m pip install -e ".[dev]"
|
||||
|
||||
.PHONY: i18n
|
||||
i18n: env config
|
||||
# Fill missing content translations in app/i18n/content/ via LibreTranslate.
|
||||
@$(call _require_env,LIBRETRANSLATE_URL); \
|
||||
$(PYTHON) utils/i18n_sync.py \
|
||||
--url "$$LIBRETRANSLATE_URL" \
|
||||
--api-key "$$LIBRETRANSLATE_API_KEY"
|
||||
|
||||
.PHONY: i18n-ui
|
||||
i18n-ui: env
|
||||
# Fill missing interface translations in app/i18n/ui/ via LibreTranslate.
|
||||
@$(call _require_env,LIBRETRANSLATE_URL); \
|
||||
$(PYTHON) utils/i18n_sync.py \
|
||||
--catalog ui \
|
||||
--url "$$LIBRETRANSLATE_URL" \
|
||||
--api-key "$$LIBRETRANSLATE_API_KEY"
|
||||
|
||||
.PHONY: lint-actions
|
||||
lint-actions:
|
||||
# Lint GitHub Actions workflows.
|
||||
@@ -148,6 +169,21 @@ lint-docker:
|
||||
# Lint the Dockerfile.
|
||||
docker run --rm -i hadolint/hadolint < Dockerfile
|
||||
|
||||
.PHONY: lint-yaml
|
||||
lint-yaml: install-dev
|
||||
# Lint YAML for duplicate keys and ambiguous scalars (see .yamllint).
|
||||
$(PYTHON) -m yamllint --strict .
|
||||
|
||||
.PHONY: lint-js
|
||||
lint-js: node-deps
|
||||
# Lint the browser and Cypress JavaScript.
|
||||
cd app && env -u ELECTRON_RUN_AS_NODE npx eslint .
|
||||
|
||||
.PHONY: lint-shell
|
||||
lint-shell:
|
||||
# Lint the shell scripts.
|
||||
docker run --rm -v "$$PWD:/mnt" -w /mnt koalaman/shellcheck:stable scripts/*.sh
|
||||
|
||||
.PHONY: test-lint
|
||||
test-lint:
|
||||
# Run lint guardrail tests.
|
||||
@@ -169,7 +205,7 @@ test-security: install
|
||||
$(PYTHON) -m unittest discover -s tests/security -t .
|
||||
|
||||
.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.
|
||||
|
||||
.PHONY: security
|
||||
@@ -179,9 +215,20 @@ security: install-dev test-security
|
||||
$(PYTHON) utils/export_runtime_requirements.py > /tmp/portfolio-runtime-requirements.txt
|
||||
$(PYTHON) -m pip_audit -r /tmp/portfolio-runtime-requirements.txt
|
||||
|
||||
.PHONY: node-deps
|
||||
node-deps:
|
||||
# Install the Cypress binary and the browser vendor assets into app/.
|
||||
cd app && npm install
|
||||
|
||||
.PHONY: test-e2e
|
||||
test-e2e:
|
||||
# Run Cypress end-to-end tests via act (stop portfolio container to free port first).
|
||||
test-e2e: env config node-deps
|
||||
# Run Cypress against a locally started Flask app — no act, no runner image.
|
||||
@$(call _require_env,PORT); \
|
||||
PORT="$$PORT" PYTHON="$(PYTHON)" scripts/run-e2e.sh
|
||||
|
||||
.PHONY: test-e2e-act
|
||||
test-e2e-act:
|
||||
# Run the CI end-to-end job through act (stop portfolio container to free port first).
|
||||
-docker stop portfolio 2>/dev/null || true
|
||||
$(ACT) workflow_dispatch -W .github/workflows/tests.yml -j e2e
|
||||
-docker start portfolio 2>/dev/null || true
|
||||
|
||||
50
README.md
50
README.md
@@ -22,6 +22,8 @@ A lightweight, Docker-powered portfolio/landing-page generator—fully customiza
|
||||
Auto-cache assets for lightning-fast loading.
|
||||
- **Responsive Design**
|
||||
Built on Bootstrap; looks great on desktop, tablet & mobile.
|
||||
- **184 Languages**
|
||||
Every ISO 639-1 code, browser-negotiated, RTL-aware, with machine translation for your own content.
|
||||
- **YAML-Driven**
|
||||
All content & structure defined in a simple `config.yaml`.
|
||||
- **CLI Control**
|
||||
@@ -139,12 +141,60 @@ 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. Only prose (`description`, `text`, `warning`, `info`, `subtitel`) is
|
||||
filled automatically; `name` and `title` are left to you, because a machine
|
||||
cannot tell the menu label "Pictures" from the brand "Mastodon". Write those
|
||||
into the content catalogue yourself when you want them translated.
|
||||
|
||||
---
|
||||
|
||||
## 🚢 Production Deployment
|
||||
|
||||
* Use a reverse proxy (NGINX/Apache).
|
||||
* Secure with SSL/TLS.
|
||||
* Swap to a production database if needed.
|
||||
|
||||
Because every page carries a canonical URL and 184 `hreflang` alternates, two
|
||||
details of the proxy setup now matter:
|
||||
|
||||
* **Set `TRUSTED_HOSTS`** in `.env` to your public hostname(s), comma-separated.
|
||||
Left empty, the app reflects whatever `Host` header arrives into its canonical,
|
||||
`hreflang` and redirect URLs — so a shared cache in front of it can be made to
|
||||
store a redirect pointing somewhere else.
|
||||
* **Have the proxy send `X-Forwarded-Proto`.** Without it the app cannot know TLS
|
||||
terminated upstream and every canonical URL claims `http://`. `X-Forwarded-Host`
|
||||
is deliberately *not* trusted; set `Host` to the public name instead.
|
||||
|
||||
---
|
||||
|
||||
## 📜 License
|
||||
|
||||
100
app/app.py
100
app/app.py
@@ -3,10 +3,12 @@ import os
|
||||
|
||||
import requests
|
||||
import yaml
|
||||
from flask import Flask, current_app, render_template, url_for
|
||||
from flask import Flask, current_app, make_response, render_template, request, url_for
|
||||
from markupsafe import Markup
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
try:
|
||||
from app.utils import i18n
|
||||
from app.utils.asset_resolver import asset_src, resolve_asset_cache
|
||||
from app.utils.cache_manager import CacheManager
|
||||
from app.utils.compute_card_classes import compute_card_classes
|
||||
@@ -17,6 +19,10 @@ except ImportError: # pragma: no cover - supports running from the app/ directo
|
||||
from utils.compute_card_classes import compute_card_classes
|
||||
from utils.configuration_resolver import ConfigurationResolver
|
||||
|
||||
from utils import i18n
|
||||
|
||||
TRANSLATED_SECTIONS = ("cards", "company", "navigation", "platform")
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
FLASK_ENV = os.getenv("FLASK_ENV", "production")
|
||||
@@ -42,6 +48,8 @@ def load_config(app):
|
||||
resolver = ConfigurationResolver(config)
|
||||
resolver.resolve_links()
|
||||
app.config.update(resolver.get_config())
|
||||
app.config["TRANSLATED_CONFIG"] = {}
|
||||
i18n.clear_catalogs()
|
||||
|
||||
|
||||
def cache_icons_and_logos(app):
|
||||
@@ -60,6 +68,23 @@ def cache_icons_and_logos(app):
|
||||
# Initialize Flask app
|
||||
app = Flask(__name__)
|
||||
|
||||
app.jinja_options = {**app.jinja_options, "autoescape": True}
|
||||
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, 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_config(app)
|
||||
cache_icons_and_logos(app)
|
||||
@@ -91,36 +116,79 @@ def reload_config_in_dev():
|
||||
cache_icons_and_logos(app)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
"""Render the main index page."""
|
||||
cards = app.config["cards"]
|
||||
lg_classes, md_classes = compute_card_classes(cards)
|
||||
apod_bg = None
|
||||
def translated_config(lang):
|
||||
"""Return the configuration sections translated into ``lang``, memoized.
|
||||
|
||||
The memo is dropped by ``load_config``, so a development reload picks up
|
||||
edited content on the next request.
|
||||
"""
|
||||
memo = app.config["TRANSLATED_CONFIG"]
|
||||
if lang not in memo:
|
||||
source = {section: app.config[section] for section in TRANSLATED_SECTIONS}
|
||||
memo[lang] = i18n.translate_tree(source, lang)
|
||||
return memo[lang]
|
||||
|
||||
|
||||
def apod_background():
|
||||
"""Return today's NASA APOD image URL, or None when unavailable."""
|
||||
api_key = app.config.get("NASA_API_KEY")
|
||||
if api_key:
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
"https://api.nasa.gov/planetary/apod",
|
||||
params={"api_key": api_key},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.ok:
|
||||
data = resp.json()
|
||||
if data.get("media_type") == "image":
|
||||
apod_bg = data.get("url")
|
||||
except requests.RequestException:
|
||||
logging.warning("APOD lookup failed", exc_info=True)
|
||||
return None
|
||||
|
||||
if not resp.ok:
|
||||
return None
|
||||
|
||||
data = resp.json()
|
||||
return data.get("url") if data.get("media_type") == "image" else None
|
||||
|
||||
|
||||
def render_index(lang):
|
||||
"""Render the index page in ``lang``."""
|
||||
config = translated_config(lang)
|
||||
cards = config["cards"]
|
||||
lg_classes, md_classes = compute_card_classes(cards)
|
||||
|
||||
return render_template(
|
||||
"pages/index.html.j2",
|
||||
cards=cards,
|
||||
company=app.config["company"],
|
||||
navigation=app.config["navigation"],
|
||||
platform=app.config["platform"],
|
||||
company=config["company"],
|
||||
navigation=config["navigation"],
|
||||
platform=config["platform"],
|
||||
lg_classes=lg_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__":
|
||||
app.run(
|
||||
debug=(FLASK_ENV == "development"),
|
||||
|
||||
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\/$/);
|
||||
});
|
||||
});
|
||||
197
app/cypress/e2e/injection.spec.js
Normal file
197
app/cypress/e2e/injection.spec.js
Normal file
@@ -0,0 +1,197 @@
|
||||
// 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 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 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 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cypress": "^14.5.1"
|
||||
"@eslint/js": "^10.0.1",
|
||||
"cypress": "^14.5.1",
|
||||
"eslint": "^10.9.0",
|
||||
"globals": "^17.11.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node scripts/copy-vendor.js",
|
||||
|
||||
@@ -29,6 +29,10 @@ copyFile(
|
||||
path.join(NM, 'bootstrap', 'dist', 'css', 'bootstrap.min.css'),
|
||||
path.join(VENDOR, 'bootstrap', 'css', 'bootstrap.min.css')
|
||||
);
|
||||
copyFile(
|
||||
path.join(NM, 'bootstrap', 'dist', 'css', 'bootstrap.rtl.min.css'),
|
||||
path.join(VENDOR, 'bootstrap', 'css', 'bootstrap.rtl.min.css')
|
||||
);
|
||||
copyFile(
|
||||
path.join(NM, 'bootstrap', 'dist', 'js', 'bootstrap.bundle.min.js'),
|
||||
path.join(VENDOR, 'bootstrap', 'js', 'bootstrap.bundle.min.js')
|
||||
|
||||
@@ -120,6 +120,12 @@ div#navbarNavfooter .navbar-nav {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.dropdown-menu.language-menu {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
main, footer, header, nav {
|
||||
position: relative;
|
||||
box-shadow:
|
||||
|
||||
@@ -34,6 +34,10 @@ function syncIframeHeight() {
|
||||
|
||||
// Function to open a URL in an iframe (jQuery version mit 1500 ms Fade)
|
||||
function openIframe(url) {
|
||||
if (!isSafeUrl(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var $container = scrollbarContainer ? $(scrollbarContainer) : null;
|
||||
var $customScroll = customScrollbar ? $(customScrollbar) : null;
|
||||
var $main = $(mainElement);
|
||||
|
||||
@@ -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) {
|
||||
closeAllModals();
|
||||
const modalTitle = document.getElementById('dynamicModalLabel');
|
||||
if (subitem.icon && subitem.icon.class) {
|
||||
modalTitle.innerHTML = `<i class="${subitem.icon.class}"></i> ${subitem.name}`;
|
||||
} else {
|
||||
modalTitle.innerText = subitem.name;
|
||||
}
|
||||
modalTitle.replaceChildren(...iconAndName(subitem));
|
||||
|
||||
const identifierBox = document.getElementById('dynamicIdentifierBox');
|
||||
const modalContent = document.getElementById('dynamicModalContent');
|
||||
@@ -21,7 +58,7 @@ function openDynamicPopup(subitem) {
|
||||
const box = document.getElementById(boxId);
|
||||
if (content) {
|
||||
box.classList.remove('d-none');
|
||||
document.getElementById(textId).innerHTML = marked.parse(content);
|
||||
document.getElementById(textId).innerHTML = renderMarkdown(content);
|
||||
} else {
|
||||
box.classList.add('d-none');
|
||||
}
|
||||
@@ -44,16 +81,19 @@ function openDynamicPopup(subitem) {
|
||||
if (subitem.url) {
|
||||
linkBox.classList.remove('d-none');
|
||||
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) {
|
||||
linkHref.classList.add('iframe');
|
||||
// Attach an event listener that prevents the default behavior and
|
||||
// opens the URL in an iframe when clicked.
|
||||
linkHref.addEventListener('click', function(event) {
|
||||
linkHref.onclick = function(event) {
|
||||
event.preventDefault();
|
||||
openIframe(subitem.url);
|
||||
closeAllModals()
|
||||
});
|
||||
closeAllModals();
|
||||
};
|
||||
}
|
||||
} else {
|
||||
linkBox.classList.add('d-none');
|
||||
@@ -69,13 +109,13 @@ function openDynamicPopup(subitem) {
|
||||
items.forEach(item => {
|
||||
const listItem = document.createElement('li');
|
||||
listItem.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'align-items-center');
|
||||
listItem.innerHTML = `
|
||||
<span>
|
||||
<i class="${item.icon.class}"></i> ${item.name}
|
||||
</span>
|
||||
<button class="btn btn-outline-secondary btn-sm">Open</button>
|
||||
`;
|
||||
listItem.querySelector('button').addEventListener('click', () => onClickHandler(item));
|
||||
const label = document.createElement('span');
|
||||
label.replaceChildren(...iconAndName(item));
|
||||
const button = document.createElement('button');
|
||||
button.className = 'btn btn-outline-secondary btn-sm';
|
||||
button.textContent = t('Open');
|
||||
listItem.replaceChildren(label, button);
|
||||
button.addEventListener('click', () => onClickHandler(item));
|
||||
list.appendChild(listItem);
|
||||
});
|
||||
} else {
|
||||
@@ -90,7 +130,7 @@ function openDynamicPopup(subitem) {
|
||||
copyButton.onclick = () => {
|
||||
modalContent.select();
|
||||
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) {
|
||||
const rect = submenu.getBoundingClientRect();
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
|
||||
const spaceAbove = parentRect.top;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{{ lang }}" dir="{{ lang_dir }}">
|
||||
<head>
|
||||
<title>{{platform.titel}}</title>
|
||||
<meta charset="utf-8" >
|
||||
@@ -8,8 +8,13 @@
|
||||
type="image/x-icon"
|
||||
href="{{ asset_src(platform.favicon) }}"
|
||||
>
|
||||
<link rel="canonical" href="{{ url_for('localized_index', lang=lang, _external=True) }}">
|
||||
{% for code in languages %}
|
||||
<link rel="alternate" hreflang="{{ code }}" href="{{ url_for('localized_index', lang=code, _external=True) }}">
|
||||
{% endfor %}
|
||||
<link rel="alternate" hreflang="x-default" href="{{ url_for('index', _external=True) }}">
|
||||
<!-- Bootstrap CSS only -->
|
||||
<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 -->
|
||||
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
|
||||
<!-- Bootstrap Icons -->
|
||||
@@ -60,12 +65,13 @@
|
||||
<p itemprop="name">{{ company.titel }} <br />
|
||||
{{ company.subtitel }}</p>
|
||||
<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>
|
||||
</footer>
|
||||
</div>
|
||||
<!-- Include modal -->
|
||||
{% include "moduls/modal.html.j2" %}
|
||||
<script>window.I18N = {{ ui_strings | tojson }};</script>
|
||||
{% for name in [
|
||||
'modal',
|
||||
'navigation',
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<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 class="modal-body">
|
||||
<!-- 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 -->
|
||||
{{ 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 -->
|
||||
<div id="dynamicDescriptionText" class="mt-2 d-none"></div>
|
||||
@@ -31,7 +31,7 @@
|
||||
<!-- Input box for Identifier -->
|
||||
<div id="dynamicIdentifierBox" class="input-group mt-2 d-none">
|
||||
<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>
|
||||
|
||||
<!-- Link -->
|
||||
@@ -40,13 +40,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
{{ list_section('dynamicChildrenSection', 'Options', 'dynamicChildrenList') }}
|
||||
{{ list_section('dynamicChildrenSection', t('Options'), 'dynamicChildrenList') }}
|
||||
|
||||
<!-- Alternatives -->
|
||||
{{ list_section('dynamicAlternativesSection', 'Alternatives', 'dynamicAlternativesList') }}
|
||||
{{ list_section('dynamicAlternativesSection', t('Alternatives'), 'dynamicAlternativesList') }}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -98,6 +98,23 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
{% 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>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
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"
|
||||
|
||||
|
||||
AUTOFILL_KEYS = frozenset({"description", "text", "warning", "info", "subtitel"})
|
||||
|
||||
TRANSLATABLE_KEYS = AUTOFILL_KEYS | frozenset({"name", "title"})
|
||||
|
||||
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"})
|
||||
@@ -3,4 +3,11 @@ 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
|
||||
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=
|
||||
@@ -9,16 +9,21 @@ description = "A lightweight YAML-driven portfolio and landing-page generator."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
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",
|
||||
"requests",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"babel",
|
||||
"bandit",
|
||||
"pip-audit",
|
||||
"pycountry",
|
||||
"ruff",
|
||||
"yamllint",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
@@ -30,6 +35,8 @@ include = ["app", "app.*"]
|
||||
[tool.setuptools.package-data]
|
||||
app = [
|
||||
"config.sample.yaml",
|
||||
"i18n/ui/*.yaml",
|
||||
"i18n/content/*.yaml",
|
||||
"templates/**/*.j2",
|
||||
"static/css/*.css",
|
||||
"static/js/*.js",
|
||||
|
||||
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"
|
||||
195
tests/integration/test_app_routes.py
Normal file
195
tests/integration/test_app_routes.py
Normal file
@@ -0,0 +1,195 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
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"] = {"Imprint": "<script>alert(1)</script>"}
|
||||
|
||||
body = self.client.get("/de/").get_data(as_text=True)
|
||||
|
||||
self.assertNotIn("<script>alert(1)</script>", body)
|
||||
self.assertIn("<script>alert(1)", body)
|
||||
|
||||
|
||||
class TestExternalUrls(AppRouteMixin, unittest.TestCase):
|
||||
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()
|
||||
@@ -1,8 +1,17 @@
|
||||
import re
|
||||
import tomllib
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def distributions(requirements):
|
||||
"""Return the distribution names of PEP 508 requirement strings."""
|
||||
return {
|
||||
re.split(r"[<>=!~\[; ]", requirement, maxsplit=1)[0]
|
||||
for requirement in requirements
|
||||
}
|
||||
|
||||
|
||||
class TestPythonPackaging(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.repo_root = Path(__file__).resolve().parents[2]
|
||||
@@ -18,11 +27,20 @@ class TestPythonPackaging(unittest.TestCase):
|
||||
self.assertEqual(build_system["build-backend"], "setuptools.build_meta")
|
||||
self.assertIn("setuptools>=69", build_system["requires"])
|
||||
self.assertGreaterEqual(
|
||||
set(project["dependencies"]),
|
||||
distributions(project["dependencies"]),
|
||||
{"flask", "pyyaml", "requests"},
|
||||
)
|
||||
self.assertEqual(project["requires-python"], ">=3.12")
|
||||
|
||||
def test_flask_is_pinned_to_a_version_that_honours_trusted_hosts(self):
|
||||
requirement = next(
|
||||
item
|
||||
for item in self.pyproject["project"]["dependencies"]
|
||||
if item.startswith("flask")
|
||||
)
|
||||
|
||||
self.assertIn(">=3.1", requirement)
|
||||
|
||||
def test_pyproject_defines_dev_dependencies_and_package_contents(self):
|
||||
project = self.pyproject["project"]
|
||||
setuptools_config = self.pyproject["tool"]["setuptools"]
|
||||
@@ -30,8 +48,8 @@ class TestPythonPackaging(unittest.TestCase):
|
||||
package_data = setuptools_config["package-data"]["app"]
|
||||
|
||||
self.assertGreaterEqual(
|
||||
set(project["optional-dependencies"]["dev"]),
|
||||
{"bandit", "pip-audit", "ruff"},
|
||||
distributions(project["optional-dependencies"]["dev"]),
|
||||
{"bandit", "pip-audit", "ruff", "yamllint"},
|
||||
)
|
||||
self.assertEqual(setuptools_config["py-modules"], ["main"])
|
||||
self.assertEqual(package_find["include"], ["app", "app.*"])
|
||||
|
||||
78
tests/lint/test_tooling_configuration.py
Normal file
78
tests/lint/test_tooling_configuration.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""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 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 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()
|
||||
217
tests/unit/test_i18n.py
Normal file
217
tests/unit/test_i18n.py
Normal file
@@ -0,0 +1,217 @@
|
||||
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_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_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\nImprint: Impressum\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
self.assertEqual(i18n.read_catalog(self.path), {"Imprint": "Impressum"})
|
||||
|
||||
|
||||
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()
|
||||
293
tests/unit/test_i18n_sync.py
Normal file
293
tests/unit/test_i18n_sync.py
Normal file
@@ -0,0 +1,293 @@
|
||||
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),
|
||||
{
|
||||
"I lead transformations.",
|
||||
"Another card.",
|
||||
"Application menu",
|
||||
"A tagline",
|
||||
},
|
||||
)
|
||||
|
||||
def test_label_and_structural_keys_are_left_alone(self):
|
||||
config = {
|
||||
"name": "Mastodon",
|
||||
"title": "Cybermaster",
|
||||
"url": "https://example.test",
|
||||
"link_text": "www.example.test",
|
||||
}
|
||||
|
||||
self.assertEqual(i18n_sync.collect_sources(config), set())
|
||||
|
||||
def test_blank_values_are_ignored(self):
|
||||
self.assertEqual(i18n_sync.collect_sources({"text": " "}), set())
|
||||
|
||||
|
||||
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, status=403, text="denied")
|
||||
|
||||
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_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 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"})
|
||||
|
||||
self.assertEqual(
|
||||
yaml.safe_load(self.path.read_text(encoding="utf-8")), {"Hi": "Hallo"}
|
||||
)
|
||||
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"):
|
||||
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)
|
||||
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_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_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 pathlib import Path
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
|
||||
class AnchorCollector(HTMLParser):
|
||||
@@ -20,10 +20,12 @@ class TestNavigationTemplate(unittest.TestCase):
|
||||
template_dir = Path(__file__).resolve().parents[2] / "app" / "templates"
|
||||
environment = Environment(
|
||||
loader=FileSystemLoader(template_dir),
|
||||
autoescape=select_autoescape(),
|
||||
autoescape=True,
|
||||
)
|
||||
environment.globals["url_for"] = lambda _endpoint, filename: (
|
||||
f"/static/{filename}"
|
||||
environment.globals["url_for"] = lambda endpoint, **kwargs: (
|
||||
f"/static/{kwargs['filename']}"
|
||||
if endpoint == "static"
|
||||
else f"/{kwargs['lang']}/"
|
||||
)
|
||||
environment.globals["asset_src"] = lambda asset: (
|
||||
(asset or {}).get("external_url")
|
||||
@@ -36,6 +38,9 @@ class TestNavigationTemplate(unittest.TestCase):
|
||||
|
||||
rendered = environment.get_template("moduls/navigation.html.j2").render(
|
||||
menu_type="header",
|
||||
lang="en",
|
||||
languages={"en": "English", "de": "Deutsch"},
|
||||
t=lambda source: source,
|
||||
platform={
|
||||
"titel": "Portfolio",
|
||||
"logo": {"cache": "logo.png"},
|
||||
@@ -70,8 +75,15 @@ class TestNavigationTemplate(unittest.TestCase):
|
||||
and "dropdown-toggle" in anchor.get("class", "")
|
||||
]
|
||||
|
||||
self.assertEqual(len(dropdown_toggles), 1)
|
||||
self.assertEqual(dropdown_toggles[0].get("data-bs-toggle"), "dropdown")
|
||||
self.assertEqual(len(dropdown_toggles), 2)
|
||||
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__":
|
||||
|
||||
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())
|
||||
257
utils/i18n_sync.py
Normal file
257
utils/i18n_sync.py
Normal file
@@ -0,0 +1,257 @@
|
||||
#!/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"
|
||||
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.AUTOFILL_KEYS and node.strip():
|
||||
found.add(node)
|
||||
return found
|
||||
|
||||
|
||||
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):
|
||||
print(f" ! {target}: translatedText was {type(translated).__name__}")
|
||||
return None
|
||||
return translated
|
||||
|
||||
|
||||
def sync(url, api_key, sources, targets, directory):
|
||||
"""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.
|
||||
"""
|
||||
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
|
||||
|
||||
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:
|
||||
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(
|
||||
"--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")
|
||||
|
||||
try:
|
||||
sync(args.url.rstrip("/"), args.api_key, sources, args.languages, directory)
|
||||
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