80 Commits

Author SHA1 Message Date
d115fc99b2 Release version 2.1.0 2026-08-22 17:26:20 +02:00
e96e8684e6 feat(i18n): translate labels, and keep brand names out of it
name and title were translated at render time but never machine-filled,
on the grounds that no backend tells the menu label "Pictures" from the
brand "Mastodon". That left the visible half of a card in English. They
are filled now, and the two key sets collapse into one.

The brands need somewhere to be named instead. app/i18n/keep.txt lists
them, one per line, and every entry is stored as itself in every target
language: no request, and never over an entry written by hand. --keep adds
one-off strings, --keep-file points elsewhere.

The shipped list holds the 43 product names that appear as name: or title:
in config.sample.yaml. Generic labels — Pictures, Imprint, Settings,
Certificates — are deliberately absent, and so are Cybermaster, Polymath
and Yachtmaster, which read as brand or as job title depending on who is
asking.

Two of these behaviours first shipped unguarded. A test that protected a
string and asserted the hand-written value survived passed either way,
because a run where nothing is missing reports "complete" and never
writes; and nothing exercised main(), so the keep file could stop being
read without a failure. Both are covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 17:19:16 +02:00
747ce379cc fix(app): stop trusting X-Forwarded-For, and pin what the audit found
ProxyFix defaults x_for to 1, so ProxyFix(app.wsgi_app, x_proto=1) never
disabled it: request.remote_addr and the access log were forgeable by any
client that reached the app directly. It is x_for=0 now, asserted rather
than assumed.

A mutation audit over the change set reverted 196 deliberate behaviours
and found 47 that no test noticed. This closes the ones that carry damage:

- apod_background lost its key check, its transport guard, its status
  guard and its media-type check without a single test failing. Each one
  turns a slow or unhappy NASA into a 500 on every page.
- Untrusted values reached innerHTML through window.I18N, which the
  translation backend writes, and the modal's click handlers stacked so a
  later click opened an earlier popup's URL.
- The sync tool could ask for HTML instead of text, translate from "auto"
  instead of English, run without a timeout, store an empty translation
  that marks the string done for good, abandon 28 languages because one
  could not be written, and report success after reaching nothing.
- Neither the lint target, the CI jobs, the vendored RTL stylesheet, the
  documented environment keys, nor any of the four hardenings in
  scripts/run-e2e.sh was observed by anything.

Three of the new tests passed for the wrong reason on their first cut —
a mock that answered None whether or not the guard existed, a
raise_for_status that was never called, a string that stayed in the file
after the mutation. The audit found those too; all 24 reverts now fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 17:19:15 +02:00
ef1c8ff09a feat(i18n): offer every ISO 639-1 language
The table was thirty languages typed by hand. It is now generated:
utils/generate_languages.py takes the 184 alpha-2 codes from pycountry,
the display names from CLDR through babel, and the writing direction from
CLDR character order. 159 languages carry their endonym; the remaining 25
have no CLDR entry and carry their English ISO name.

That corrects the right-to-left set, which had four entries and needs ten
— dv, ks, ps, sd, ug and yi were simply missed.

Only 29 languages ship an interface catalogue, so the other 155 render in
English until one is filled. make i18n-ui fills app/i18n/ui/ for them, and
make i18n now covers the interface strings as well; neither asks for a
string a shipped catalogue already answers, so hand-written entries stay.

184 entries do not fit on a screen, so the language menu scrolls inside
itself. overscroll-behavior keeps the page behind it from moving once the
list reaches its end.

babel and pycountry are dev dependencies: the generator needs them, the
application does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:18:30 +02:00
0c67f999f6 chore: ignore the local MCP configuration
.mcp.json is written per machine and carries no repository state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:18:30 +02:00
efabbd3b2e fix(modal): stop untrusted content reaching innerHTML and the iframe
Every one of these paths checked a string that the browser reinterprets
afterwards. isSafeUrl now hangs the value on an <a> and reads back
probe.protocol, so the check sees what the browser will see: a pre-parse
test reads "&#106;avascript:" as a relative path and passes it, and the
HTML parser then decodes it to "javascript:".

marked passes raw HTML through and emits hrefs unescaped. renderMarkdown
escapes the angle brackets before parsing, parses into an inert DOMParser
document where no script runs and no image loads, and drops anchors and
images whose scheme is not http, https or mailto. Blockquotes and
<autolinks> stop working as a result; neither appears in the configuration.

modalTitle and the alternatives list interpolated subitem.name and
icon.class into innerHTML. Both are built as nodes now. name is a
translatable key, so it arrives from the machine-written catalogues.

The link kept its click handler and its class across popups, because one
anchor serves all of them: a later, unrelated click opened whatever an
earlier popup pointed at, and addEventListener stacked one handler per
open. Both are reset per popup and the handler is assigned, not added.

openIframe guards its own argument. Removing the href alone left the
handler passing the raw URL on, and ?iframe= in the query string reaches
the same sink with no configuration involved at all.

Verified in headless Chromium: decimal and hex character references,
&Tab;- and &NewLine;-split schemes, reference-style links, raw HTML as a
link's text, and the two name sinks all executed before these changes.
injection.spec.js keeps all fifteen payloads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:18:29 +02:00
2a35b2910a feat(i18n): serve every page in 30 languages
The interface ships translated; page content stays English until a
LibreTranslate instance fills app/i18n/content/ through make i18n. A string
without a catalogue entry falls back to its English source, so a half-filled
catalogue degrades instead of breaking.

Translation runs after ConfigurationResolver.resolve_links(), on a copy.
resolve_links matches by the `name` field, so translating it beforehand
would break every `link:` reference in the configuration.

negotiate() normalises to the primary subtag itself. Werkzeug's best_match
returns an exact match before it considers a primary-tag fallback, so the
Chrome default `de-DE,en;q=0.8` resolves to English there. "/" carries
Vary: Accept-Language, without which a shared cache pins the first
visitor's language for everyone.

The route rule lists the known codes as a converter argument. A bare
"/<lang>/" answers /robots.txt and /favicon.ico with a permanently
cacheable 308 to their trailing-slash form.

Templates gain lang, dir, the RTL stylesheet, a canonical URL and 30
hreflang alternates. Those are the first external URLs in this app:
ProxyFix takes the scheme from X-Forwarded-Proto so they do not claim
http:// behind a TLS-terminating proxy, X-Forwarded-Host stays untrusted
because nginx passes a client-supplied one through, and TRUSTED_HOSTS lets
Flask reject a forged Host outright.

Flask only autoescapes .html/.htm/.xml/.xhtml/.svg, so every *.html.j2
template interpolated configuration raw. Enabling it changes two lines of
the shipped page, both an apostrophe.

read_catalog degrades an unreadable catalogue to English rather than
serving a 500, and drops non-string entries that would otherwise render as
"42". i18n_sync writes atomically, never overwrites an existing entry,
refuses to touch a catalogue it could not parse, and leaves the file alone
when a run translated nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:18:29 +02:00
a42930a699 build(make): run e2e without act, lint yaml, js and shell
test-e2e drove Cypress through act, which fails to start where it cannot
resolve a host address for its artifact server. It now starts Flask and
Cypress in one shell via scripts/run-e2e.sh, so both share a network
namespace; the act path stays available as test-e2e-act.

The script guards the cases that made the old target lie: it aborts when
something already serves the port instead of testing that server, checks
that its own Flask is alive before trusting a response, pins Cypress to
the same origin Flask binds, and drops ELECTRON_RUN_AS_NODE, which VS Code
exports and which makes Cypress' bundled Electron reject its own flags.

30 YAML and 18 JavaScript files had no linter. yamllint runs correctness
rules only, because the repository predates it and its cosmetic findings
would be noise; key-duplicates is the one that earns its keep, since
PyYAML keeps the last of two identical keys without complaining. eslint
runs the recommended set and already found a dead getBoundingClientRect()
call in navigation.js. Both get a CI job so make lint and the workflows
stop diverging.

flask>=3.1 because app.config["TRUSTED_HOSTS"] arrived in 3.1 and an older
Flask accepts the key and ignores it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:18:27 +02:00
03b7cef90e Release version 2.0.0 2026-05-18 12:26:26 +02:00
4b29448b33 lint 2026-05-18 12:21:05 +02:00
3f30621630 feat(assets): probe-first resolver + SPOT for IMAGE_NAME/PORT + README screenshot
Probe-first asset resolution (regression fix)
---------------------------------------------

cache_manager.cache_file() returned either a relative cache path
(success) or None (failure). The previous app.py fallback
asset['cache'] = cached or asset['source'] mixed both types into
one field, which the template wrapped in url_for('static', ...)
regardless — producing broken
/static/https://file.infinito.nexus/.../logo.png URLs whenever the
source couldn't be downloaded.

- New app/utils/asset_resolver.py: HEAD-probes the URL (3 s
  timeout, image/* content type). On hit, embed directly via a
  new external_url field — no download required. On miss, fall
  back to cache_manager.cache_file. If that also fails, expose
  the source URL via external_url so the browser shows the alt
  text instead of an empty src.
- app.py exposes an asset_src(asset) context processor that
  picks external_url first, then url_for('static', cache),
  so the template never wraps an absolute URL in a static prefix.
- Templates (base, navigation, card) switch to asset_src(...) and
  gate the card image branch on cache or external_url.
- 16 unit tests cover every probe/cache/fallback branch; one live
  integration test exercises the canonical
  https://file.infinito.nexus/assets/img/logo.png to prove the
  probe-first path works end-to-end (cache dir stays empty).
- config.sample.yaml: new Infinito.Nexus card driven by the same
  canonical asset URL.

Single source of truth for IMAGE_NAME and PORT
----------------------------------------------

- env.example is now the only place the literal values live.
- Makefile and docker-compose.yml reference \$(IMAGE_NAME) /
  \${IMAGE_NAME:?…} (same for PORT); no defaults, no silent
  fallbacks.
- New make env / make config bootstrap .env / app/config.yaml
  from their checked-in templates. Idempotent.
- All container-using targets depend on the two bootstrap targets
  so a fresh checkout runs in a single invocation.
- Recipes source .env at recipe-execution time so they pick up a
  freshly bootstrapped .env in the same make invocation.

README
------

- Screenshot added under the title.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 12:19:15 +02:00
39a41e561c Release version 1.2.0 2026-05-11 02:51:17 +02:00
4424db22cb fix(cypress): allow scrollTo on non-scrollable pages in footer dropup test
cy.scrollTo('bottom') threw on CI ("element is not scrollable") whenever
the rendered page fit inside the viewport. Pass ensureScrollable:false
so the call is a no-op on short pages — the footer is already in view
and the subsequent rect-position pre-check enforces the actual
precondition that chooseDirection() needs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:38:06 +02:00
28a796e24f chore(claude): enable sandbox and consolidate bash allowlist
Activate the harness sandbox (enabled + autoAllowBashIfSandboxed +
filesystem write/deny rules) and replace the ~30 specific Bash(...)
permission entries with a single Bash(*) wildcard. The existing deny
list (git push --force, git reset --hard, rm -rf, sudo) and ask list
(git push, docker run, curl) keep their precedence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:36:28 +02:00
f3c15e3e1c fix(navigation): unclip dropdowns and flip toward the side with more space
- Move <header> overflow:hidden into body.fullscreen scope and drop the
  implicit-vertical-clip overflow-x:auto from .navbar-nav so dropdown
  menus can escape the navbar.
- Drive top-level dropdowns through bootstrap.Dropdown (popperConfig
  strategy:'fixed'), and add a chooseDirection() helper that toggles
  .dropup/.dropdown on the .nav-item based on space above vs below
  before each show. Split the navigation.css rules to position the menu
  with top:100% or bottom:100% accordingly.
- Mark the dropdown toggle with data-bs-toggle="dropdown" in the
  template; cover that with a Jinja-rendered unit test and add Cypress
  specs for the header (opens downward) and footer (flips to .dropup)
  cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:26:02 +02:00
3301f8d95f chore(claude): allow jobs bash command in harness
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:22:06 +02:00
a575fddaa2 build(compose): run npm install in container, persist deps in named volumes
Replace the host-side `make npm-install` step with a compose-level
`command:` override that runs `npm install` inside the container on
every start. Back node_modules and static/vendor with named volumes so
the bind-mounted source tree no longer shadows the install while state
still survives container restarts. Drop the now-redundant npm-install
target and its references in up/dev/prod/test-e2e.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:17:53 +02:00
c9fe7d8583 chore(claude): expand harness allowlist and ignore local state
Add permissions for read-only test/inspection commands (make test-e2e,
docker exec/restart, /tmp reads) and gitignore everything under .claude
except the shared settings/gitignore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:15:04 +02:00
03f17a6e05 build(make): run npm-install before docker-compose targets
Add npm-install dependency to up/dev/prod targets so app/static/vendor
and app/node_modules exist on the host before the ./app:/app bind mount
masks the build-time artifacts inside the container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:40:19 +02:00
3132aab2a5 Release version 1.1.0 2026-03-30 10:47:32 +02:00
3d1db1f8ba Updated mirrors 2026-03-30 10:46:39 +02:00
58872ced81 fix(ci): grant security-events: write to lint job
The lint-docker job in lint.yml requires security-events: write
for SARIF upload; must be explicitly granted to the caller job.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 10:18:14 +02:00
13b3af3330 Entered and removed whitespaces in README.md 2026-03-30 10:16:53 +02:00
eca7084f4e fix(ci): grant security-events and packages permissions to security job
Reusable workflow calls inherit only explicitly granted permissions.
The nested security job requires packages: read and security-events: write
for CodeQL analysis.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 10:16:30 +02:00
6861b2c0eb fix(nav): prevent navbar items from wrapping to second line
- Set flex-wrap: nowrap on navbar-nav to keep all items in one row
- Add hidden overflow-x scroll (no visible scrollbar) as fallback
- Fix #navbar_logo taking up space when invisible via max-width transition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 10:10:55 +02:00
66b1f0d029 feat(agents): add AGENTS.md and CLAUDE.md with pre-commit rules
- Add AGENTS.md: require make test before every non-doc commit and
  document the npm vendor asset workflow
- Add CLAUDE.md: instruct agents to read AGENTS.md at conversation start
- Add npm-install dependency to test-e2e Makefile target

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:59:09 +02:00
a29a0b1862 feat(vendor): replace CDN dependencies with local npm packages
Introduces a vendor build pipeline so all third-party browser assets
(Bootstrap, Bootstrap Icons, Font Awesome, marked, jQuery) are served
from local static files instead of external CDNs.

- Add app/package.json with vendor deps and postinstall/build scripts
- Add app/scripts/copy-vendor.js to copy assets to static/vendor/
- Update base.html.j2 to use url_for('static', ...) for all vendor assets
- Update Dockerfile to install Node.js/npm and run npm install
- Update .gitignore to exclude app/node_modules/ and app/static/vendor/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:29:28 +02:00
252b50d2a7 feat: migrate to pyproject.toml, add test suites, split CI workflows
- Replace requirements.txt with pyproject.toml for modern Python packaging
- Add unit, integration, lint and security test suites under tests/
- Add utils/export_runtime_requirements.py and utils/check_hadolint_sarif.py
- Split monolithic CI into reusable lint.yml, security.yml and tests.yml
- Refactor ci.yml to orchestrate reusable workflows; publish on semver tag only
- Modernize Dockerfile: pin python:3.12-slim, install via pyproject.toml
- Expand Makefile with lint, security, test and CI targets
- Add test-e2e via act with portfolio container stop/start around run
- Fix navbar_logo_visibility.spec.js: win.fullscreen() → win.enterFullscreen()
- Set use_reloader=False in app.run() to prevent double-start in CI
- Add app/core.* and build artifacts to .gitignore
- Fix apt-get → sudo apt-get in tests.yml e2e job
- Fix pip install --ignore-installed to handle stale act cache

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 23:03:09 +02:00
2c61da9fc3 chore: remove comments from settings.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 23:37:10 +01:00
2d8185b747 chore: add Claude Code project permissions settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 23:16:29 +01:00
a47a5babce chore: remove unused imports 2026-03-28 19:08:57 +01:00
3f6c90cc3c Release version 1.0.0 2026-02-19 11:18:50 +01:00
69c4f15ce7 Prepare config.yaml from sample in CI before Cypress 2026-02-19 11:14:52 +01:00
56c1b0d0cd Fix Cypress action for repositories without lockfile 2026-02-19 11:13:12 +01:00
91e9caea48 Fix CI node cache path and npm install strategy 2026-02-19 11:11:11 +01:00
feb6af28ef Add CI workflow for tests and conditional image publish 2026-02-19 11:05:26 +01:00
f8c2b4236b Added d-flex to place logo next to brand 2025-07-21 14:24:28 +02:00
dc2626e020 Added test for log 2025-07-21 12:18:07 +02:00
46b0b744ca Added logo to navbar when in fullscreen 2025-07-21 11:39:59 +02:00
5f2e7ef696 Changed pkgmgr commands 2025-07-12 18:54:42 +02:00
152a85bfb8 Merge branch 'main' of github.com:kevinveenbirkenbach/portfolio 2025-07-12 18:53:03 +02:00
fdfe301868 Renamed PortWebUI to PortUI 2025-07-12 18:52:51 +02:00
cbfe1ed8ae Update README.md
Solved another layout bug
2025-07-12 18:27:47 +02:00
9470162236 Solved formatation bug 2025-07-12 18:26:51 +02:00
6a57fa1e00 Optimized README.md 2025-07-12 18:24:49 +02:00
ab67fc0b29 Renamed portfolio to PortWebUI 2025-07-12 18:22:19 +02:00
e18566d801 Solved some bugs 2025-07-09 22:20:58 +02:00
7bc0f32145 Added cypress tests 2025-07-08 17:16:57 +02:00
6ed3e60dd0 Solved 2tap fullscreen hight bug 2025-07-08 14:39:13 +02:00
ab8ea0dbd6 Added iframe observer 2025-07-07 23:40:35 +02:00
b0446dcd29 Added include svgs 2025-07-07 19:14:29 +02:00
55d309b2d7 Changed fade between html iframe animation 2025-07-07 15:37:24 +02:00
d99a8c8452 Added restore functionality to small logo 2025-07-07 15:06:36 +02:00
3f6a195ecb Optimizid hover 2025-07-07 13:37:02 +02:00
430ea4a120 Solved loading bug 2025-07-07 13:19:49 +02:00
cc0fc9b77f Replaced object by svg 2025-07-07 12:46:56 +02:00
9ada9acb3a Implemented SVG handling 2025-07-07 12:40:25 +02:00
246ef1b059 Added backup logik for missing images 2025-07-07 08:57:30 +02:00
6572a39d48 Added hover effects to cards 2025-07-06 22:25:22 +02:00
a80262c0d4 Solved container height bug 2025-07-06 22:18:28 +02:00
531c2295bd Added header/footer animation 2025-07-06 18:14:42 +02:00
0640ec6439 Added animation for fullscreen log 2025-07-06 17:46:51 +02:00
a7eb14046f Added fullwidth animation 2025-07-06 17:35:40 +02:00
539580ad09 Added missing enterfullscreen function 2025-07-06 17:13:15 +02:00
faf5bd1e8c Solved iframe margin bug 2025-07-06 10:55:12 +02:00
97378422bd Solved more CSS bugs 2025-07-05 21:07:31 +02:00
2632c21de3 Removed unnecessary log messages 2025-07-05 20:33:08 +02:00
64db9a4e6a Implemented Nasa Picture of the day 2025-07-05 20:08:00 +02:00
d0f8d7d172 Added logo for small screen 2025-07-05 18:54:18 +02:00
20b6c731b8 Added onclick functionality for menu items 2025-07-05 18:32:26 +02:00
2f63009c31 Implemented full width function 2025-07-05 18:00:23 +02:00
f0d4206731 Finished resize implementation for iframe 2025-07-05 16:53:25 +02:00
b8aad8b695 Removed non functional resize code 2025-07-05 14:39:18 +02:00
697696347f finished fullscreen base implementation 2025-07-05 14:20:52 +02:00
d6389157ec Added fullscreen mode 2025-07-05 13:30:25 +02:00
25dbc3f331 Added correct iframe size loading 2025-07-05 13:17:38 +02:00
bb8799eb8a Added functionality for iframe url 2025-07-05 11:54:20 +02:00
86fd72b623 Solved wrong environment bug 2025-07-05 11:41:18 +02:00
9c24a8658f Solved other port mapping issues 2025-07-05 11:06:42 +02:00
5fc19f6ccb Solved other port bugs 2025-07-05 10:55:32 +02:00
119 changed files with 7193 additions and 743 deletions

3
.claude/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*
!.gitignore
!.settings.json

52
.claude/settings.json Normal file
View File

@@ -0,0 +1,52 @@
{
"permissions": {
"allow": [
"Read",
"Edit",
"Write",
"Bash(*)",
"Read(//tmp/**)",
"WebSearch",
"WebFetch(domain:github.com)",
"WebFetch(domain:raw.githubusercontent.com)",
"WebFetch(domain:api.github.com)",
"WebFetch(domain:docs.docker.com)",
"WebFetch(domain:pypi.org)",
"WebFetch(domain:docs.cypress.io)",
"WebFetch(domain:flask.palletsprojects.com)",
"Skill(update-config)",
"Skill(update-config:*)"
],
"deny": [
"Bash(git push --force*)",
"Bash(git reset --hard*)",
"Bash(rm -rf*)",
"Bash(sudo*)"
],
"ask": [
"Bash(git push*)",
"Bash(docker run*)",
"Bash(curl*)"
],
"additionalDirectories": [
"/tmp"
]
},
"sandbox": {
"enabled": true,
"autoAllowBashIfSandboxed": true,
"filesystem": {
"allowWrite": [
".",
"/tmp"
],
"denyRead": [
"~/.ssh",
"~/.gnupg",
"~/.kube",
"~/.aws",
"~/.config/gcloud"
]
}
}
}

0
.codex Normal file
View File

90
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,90 @@
name: CI
on:
pull_request:
push:
branches:
- "**"
tags-ignore:
- "**"
permissions:
contents: read
jobs:
security:
name: Run security workflow
uses: ./.github/workflows/security.yml
permissions:
contents: read
packages: read
security-events: write
tests:
name: Run test workflow
uses: ./.github/workflows/tests.yml
lint:
name: Run lint workflow
uses: ./.github/workflows/lint.yml
permissions:
contents: read
security-events: write
publish:
name: Publish image
runs-on: ubuntu-latest
needs:
- security
- tests
- lint
if: github.event_name == 'push'
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Detect semver tag on current commit
id: semver
run: |
SEMVER_TAG="$(git tag --points-at "$GITHUB_SHA" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || true)"
if [ -n "$SEMVER_TAG" ]; then
{
echo "found=true"
echo "raw_tag=$SEMVER_TAG"
echo "version=${SEMVER_TAG#v}"
} >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
fi
- name: Compute image name
if: steps.semver.outputs.found == 'true'
id: image
run: echo "name=ghcr.io/$(echo "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
if: steps.semver.outputs.found == 'true'
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
if: steps.semver.outputs.found == 'true'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and publish image
if: steps.semver.outputs.found == 'true'
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.image.outputs.name }}:${{ steps.semver.outputs.version }}

132
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,132 @@
name: Lint
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
jobs:
lint-actions:
name: Lint GitHub Actions
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run actionlint
run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest
lint-yaml:
name: Lint YAML
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install lint dependencies
run: |
python -m pip install --upgrade pip
pip install ".[dev]"
- name: Run yamllint
run: yamllint --strict .
lint-js:
name: Lint JavaScript
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: app/package.json
- name: Install Node dependencies
working-directory: app
run: npm install
- name: Run eslint
working-directory: app
run: npx eslint .
lint-shell:
name: Lint shell scripts
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run shellcheck
run: docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable scripts/*.sh
lint-python:
name: Lint Python
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install lint dependencies
run: |
python -m pip install --upgrade pip
pip install ".[dev]"
- name: Ruff lint
run: ruff check .
- name: Ruff format check
run: ruff format --check .
lint-docker:
name: Lint Dockerfile
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run hadolint
id: hadolint
continue-on-error: true
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5
with:
dockerfile: ./Dockerfile
format: sarif
output-file: hadolint-results.sarif
failure-threshold: warning
- name: Upload hadolint SARIF
if: always() && github.event_name == 'push'
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: hadolint-results.sarif
wait-for-processing: true
category: hadolint
- name: Fail on hadolint warnings
if: always()
run: python3 utils/check_hadolint_sarif.py hadolint-results.sarif

48
.github/workflows/security.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: Security
on:
workflow_call:
permissions:
contents: read
jobs:
analyze:
name: Run security scan
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
contents: read
packages: read
security-events: write
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: javascript-typescript
build-mode: none
- language: python
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
queries: security-extended,security-and-quality
- name: Run manual build steps
if: matrix.build-mode == 'manual'
run: |
echo "No manual build is configured for this repository."
exit 1
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v4
with:
category: /language:${{ matrix.language }}

194
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,194 @@
name: Tests
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
jobs:
test-lint:
name: Run lint tests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Run lint test suite
run: python -m unittest discover -s tests/lint -t .
test-integration:
name: Run integration tests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install integration test dependencies
run: |
python -m pip install --upgrade pip
pip install --ignore-installed .
- name: Run integration test suite
run: python -m unittest discover -s tests/integration -t .
test-unit:
name: Run unit tests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install unit test dependencies
run: |
python -m pip install --upgrade pip
pip install --ignore-installed .
- name: Run unit test suite
run: python -m unittest discover -s tests/unit -t .
security-python:
name: Run Python security checks
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install security dependencies
run: |
python -m pip install --upgrade pip
pip install --ignore-installed ".[dev]"
- name: Run Bandit
run: python -m bandit -q -ll -ii -r app main.py
- name: Export runtime requirements
run: python utils/export_runtime_requirements.py > runtime-requirements.txt
- name: Audit Python runtime dependencies
run: python -m pip_audit -r runtime-requirements.txt
test-security:
name: Run security guardrail tests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install security test dependencies
run: |
python -m pip install --upgrade pip
pip install --ignore-installed .
- name: Run security test suite
run: python -m unittest discover -s tests/security -t .
e2e:
name: Run end-to-end tests
runs-on: ubuntu-latest
needs:
- test-lint
- test-unit
- test-integration
- security-python
- test-security
env:
FLASK_HOST: "127.0.0.1"
FLASK_PORT: "5001"
PORT: "5001"
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install --ignore-installed .
- name: Prepare app config for CI
run: cp app/config.sample.yaml app/config.yaml
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: app/package.json
- name: Install Node dependencies
working-directory: app
run: npm install
- name: Install Cypress system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libasound2t64 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libatspi2.0-0t64 \
libcups2t64 \
libdrm2 \
libgbm1 \
libglib2.0-0t64 \
libgtk-3-0t64 \
libnotify4 \
libnspr4 \
libnss3 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
libxss1 \
libxtst6 \
xauth \
xvfb
- name: Run Cypress tests
uses: cypress-io/github-action@v6
with:
working-directory: app
install: false
start: python app.py
wait-on: http://127.0.0.1:5001
wait-on-timeout: 120

12
.gitignore vendored
View File

@@ -1,4 +1,14 @@
app/config.yaml
app/i18n/content/
*__pycache__*
app/static/cache/*
.env
.env
app/cypress/screenshots/*
.ruff_cache/
app/node_modules/
app/static/vendor/
hadolint-results.sarif
build/
*.egg-info/
app/core.*
.mcp.json

23
.yamllint Normal file
View File

@@ -0,0 +1,23 @@
---
# Correctness only. No `extends: default`, so nothing but the rules below runs:
# the repository predates this linter and its cosmetic findings (indentation,
# line length, trailing spaces) would be noise nobody acts on.
#
# key-duplicates is the rule that earns its keep: PyYAML keeps the last of two
# identical keys without complaining, so a duplicated entry in a translation
# catalogue silently drops a translation.
ignore: |
.git/
.venv/
venv/
node_modules/
app/node_modules/
app/static/vendor/
build/
.ruff_cache/
rules:
key-duplicates: enable
octal-values:
forbid-implicit-octal: true
forbid-explicit-octal: true

13
AGENTS.md Normal file
View File

@@ -0,0 +1,13 @@
# Agent Instructions
## Pre-Commit Validation
- You MUST run `make test` before every commit whenever the staged change includes at least one file that is not `.md` or `.rst`, unless explicitly instructed otherwise.
- You MUST commit only after all tests pass.
- You MUST NOT commit automatically without explicit confirmation from the user.
## Vendor Assets
- Browser vendor assets (Bootstrap, Font Awesome, etc.) are managed via npm.
- Run `npm install` inside `app/` to populate `app/static/vendor/` before starting the dev server or running e2e tests.
- Never commit `app/node_modules/` or `app/static/vendor/` — both are gitignored and generated at build time.

49
CHANGELOG.md Normal file
View File

@@ -0,0 +1,49 @@
# Changelog
## [2.1.0] - 2026-08-22
* Multilingual site: every ISO 639-1 language has its own URL, */* follows the visitor's browser language, and a switcher in the navbar lists all 184 in their own script
* Right-to-left layouts: Arabic, Hebrew, Persian, Urdu and six more mirror the page automatically
* Machine translation: *make i18n* fills your card and menu texts from a LibreTranslate instance, *make i18n-ui* the interface strings; hand-corrected entries are never overwritten
* Brand protection: list your product names in *app/i18n/keep.txt* and they stay untranslated in every language
* Search engines: each page declares a canonical URL and an *hreflang* alternate per language
* Security: configuration and translations are HTML-escaped, script URLs are stripped from links, images and the iframe view — set *TRUSTED_HOSTS* in *.env* to pin your public hostname
* Reliability: a slow NASA APOD lookup no longer takes the page down
* Developer tooling: *make test-e2e* runs Cypress directly instead of through act, and YAML, JavaScript and shell now have linters in CI
## [2.0.0] - 2026-05-18
* * Asset resolution: new probe-first resolver tries a HEAD request and embeds reachable image URLs directly via a new external_url field, falling back to the cache-download path only when the probe fails; broken /static/https://... URLs no longer appear when the source cannot be downloaded
* Template integration: an asset_src context processor in app.py picks external_url first and url_for(static, cache) second, so base.html.j2, navigation.html.j2, and card.html.j2 never wrap an absolute URL in the static prefix
* Test coverage: 16 new unit tests cover every probe, cache, and fallback branch; a live integration test exercises https://file.infinito.nexus/assets/img/logo.png to prove the probe-first path works end-to-end without writing to the cache directory
* Sample configuration: new Infinito.Nexus card in app/config.sample.yaml driven by the canonical file.infinito.nexus asset URL
* SPOT for build variables: env.example is the single source of truth for IMAGE_NAME and PORT; the Makefile and docker-compose.yml reference both with no defaults and fail loudly when either variable is missing
* Bootstrap targets: make env and make config materialise .env and app/config.yaml from their checked-in templates without overwriting existing files; build, build-no-cache, up, run-dev, run-prod, dev, prod, and browse depend on both so a fresh checkout runs in a single make invocation
* Recipe sourcing: Makefile recipes load .env at recipe-execution time via a shared _require_env helper, so a freshly bootstrapped .env is picked up in the same make invocation that created it
* README: PortUI screenshot added under the title
* Lint: removed an unused sys import in the live integration test
## [1.2.0] - 2026-05-11
* * Navigation behavior: Top-level dropdowns now open reliably on hover and click via Bootstrap, escape the header and navbar overflow clips, and flip between downward and upward based on whether more space is above or below the toggle
* Compose-driven dependencies: docker-compose runs npm install inside the container on every up and persists node_modules plus static/vendor in named volumes, removing the host-side npm-install step from up, dev, prod, and test-e2e
* Test coverage: New Cypress specs cover both header and footer dropdown directions, with a Jinja unit test guarding the data-bs-toggle attribute on top-level dropdown toggles
* Harness configuration: Enabled the Claude Code sandbox with scoped filesystem rules, consolidated the bash allowlist behind a single wildcard, and gitignored local-only state under .claude
## [1.1.0] - 2026-03-30
* *CI stabilization and modularization*: Split into reusable workflows (lint, security, tests) with correct permissions for CodeQL and SARIF uploads
* *Modern Python packaging*: Migration to pyproject.toml and updated Dockerfile using Python 3.12
* *Improved test coverage*: Added unit, integration, lint, security, and E2E tests using act
* *Local vendor assets*: Replaced external CDNs with npm-based local asset pipeline
* *Enhanced build workflow*: Extended Makefile with targets for test, lint, security, and CI plus vendor build process
* *Frontend fix*: Prevented navbar wrapping and improved layout behavior
* *Developer guidelines*: Introduced AGENTS.md and CLAUDE.md with enforced pre-commit rules
## [1.0.0] - 2026-02-19
* Official Release🥳

5
CLAUDE.md Normal file
View File

@@ -0,0 +1,5 @@
# CLAUDE.md
## Startup
You MUST read `AGENTS.md` and follow all instructions in it at the start of every conversation before doing anything else.

View File

@@ -1,21 +1,20 @@
# Base image for Python
FROM python:slim
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
FLASK_HOST=0.0.0.0
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm && rm -rf /var/lib/apt/lists/*
WORKDIR /tmp/build
COPY pyproject.toml README.md main.py ./
COPY app ./app
RUN python -m pip install --no-cache-dir .
# Set the working directory
WORKDIR /app
# Copy and install dependencies
COPY app/requirements.txt requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY app/ .
RUN npm install --prefix /app
# Set default port environment variable
ENV PORT=5000
# Expose port (optional for documentation)
EXPOSE ${PORT}
# Start command using shell to allow env substitution
CMD ["sh", "-c", "exec python app.py --port=${PORT}"]
CMD ["python", "app.py"]

4
MIRRORS Normal file
View File

@@ -0,0 +1,4 @@
git@github.com:kevinveenbirkenbach/port-ui.git
ssh://git@code.infinito.nexus:2201/kevinveenbirkenbach/port-ui.git
ssh://git@git.veen.world:2201/kevinveenbirkenbach/port-ui.git

256
Makefile Normal file
View File

@@ -0,0 +1,256 @@
# Load environment variables from .env
ifneq (,$(wildcard .env))
include .env
# Export variables defined in .env
export $(shell sed 's/=.*//' .env)
endif
PYTHON ?= python3
ACT ?= act
# Bootstrap the local .env from the checked-in env.example template.
# Idempotent: leaves an existing .env untouched.
.PHONY: env
env:
@if [ -f .env ]; then \
echo ".env already exists — leaving it alone."; \
else \
cp env.example .env; \
echo "Created .env from env.example — review and adjust."; \
fi
# Bootstrap app/config.yaml from the checked-in app/config.sample.yaml
# template. Idempotent: leaves an existing config.yaml untouched. The
# Dockerfile COPYs the whole app/ directory at build time, so this file
# must exist before `make build` / `make up`.
.PHONY: config
config:
@if [ -f app/config.yaml ]; then \
echo "app/config.yaml already exists — leaving it alone."; \
else \
cp app/config.sample.yaml app/config.yaml; \
echo "Created app/config.yaml from app/config.sample.yaml — review and adjust."; \
fi
# Build/run recipes source .env at recipe-execution time (not at make
# parse time) so they work in the same invocation that bootstrapped
# .env via the `env` prereq. Without this, the inner $$IMAGE_NAME /
# $$PORT would be empty on the very first `make build` after a fresh
# checkout — the parse-time `include .env` happens before `env` runs.
define _require_env
if [ ! -f .env ]; then echo "ERROR: .env missing"; exit 1; fi; \
. ./.env; \
for v in $(1); do \
eval "val=\$$$$v"; \
[ -n "$$val" ] || { echo "ERROR: $$v is empty in .env (see env.example)"; exit 1; }; \
done
endef
.PHONY: build
build: env config
# Build the Docker image.
@$(call _require_env,IMAGE_NAME); \
docker build -t "$$IMAGE_NAME" .
.PHONY: build-no-cache
build-no-cache: env config
# Build the Docker image without cache.
@$(call _require_env,IMAGE_NAME); \
docker build --no-cache -t "$$IMAGE_NAME" .
.PHONY: up
up: env config
# Start the application using docker-compose with build.
docker-compose up -d --build --force-recreate
.PHONY: down
down:
# Stop and remove the 'portfolio' container, ignore errors, and bring down compose.
- docker stop portfolio || true
- docker rm portfolio || true
- docker-compose down
.PHONY: run-dev
run-dev: env config config
# Run the container in development mode (hot-reload).
@$(call _require_env,IMAGE_NAME PORT); \
docker run -d \
-p "$$PORT:$$PORT" \
--name portfolio \
-v "$(PWD)/app/:/app" \
-e PORT="$$PORT" \
-e TRUSTED_HOSTS="$$TRUSTED_HOSTS" \
-e FLASK_APP=app.py \
-e FLASK_ENV=development \
"$$IMAGE_NAME"
.PHONY: run-prod
run-prod: env config config
# Run the container in production mode.
@$(call _require_env,IMAGE_NAME PORT); \
docker run -d \
-p "$$PORT:$$PORT" \
--name portfolio \
-e PORT="$$PORT" \
-e TRUSTED_HOSTS="$$TRUSTED_HOSTS" \
"$$IMAGE_NAME"
.PHONY: logs
logs:
# Display the logs of the 'portfolio' container.
docker logs -f portfolio
.PHONY: dev
dev: env config
# Start the application in development mode using docker-compose.
FLASK_ENV=development docker-compose up -d
.PHONY: prod
prod: env config
# Start the application in production mode using docker-compose (with build).
docker-compose up -d --build
.PHONY: cleanup
cleanup:
# Remove all stopped Docker containers to reclaim space.
docker container prune -f
.PHONY: delete
delete:
# Force remove the 'portfolio' container if it exists.
- docker rm -f portfolio
.PHONY: browse
browse: env
# Open the application in the browser at http://localhost:$$PORT
@$(call _require_env,PORT); \
chromium "http://localhost:$$PORT"
.PHONY: install
install:
# Install runtime Python dependencies from pyproject.toml.
$(PYTHON) -m pip install -e .
.PHONY: install-dev
install-dev:
# Install runtime and developer dependencies from pyproject.toml.
$(PYTHON) -m pip install -e ".[dev]"
.PHONY: i18n
i18n: env config
# Fill missing content translations in app/i18n/content/ via LibreTranslate.
@$(call _require_env,LIBRETRANSLATE_URL); \
$(PYTHON) utils/i18n_sync.py \
--url "$$LIBRETRANSLATE_URL" \
--api-key "$$LIBRETRANSLATE_API_KEY"
.PHONY: i18n-ui
i18n-ui: env
# Fill missing interface translations in app/i18n/ui/ via LibreTranslate.
@$(call _require_env,LIBRETRANSLATE_URL); \
$(PYTHON) utils/i18n_sync.py \
--catalog ui \
--url "$$LIBRETRANSLATE_URL" \
--api-key "$$LIBRETRANSLATE_API_KEY"
.PHONY: lint-actions
lint-actions:
# Lint GitHub Actions workflows.
docker run --rm -v "$$PWD:/repo" -w /repo rhysd/actionlint:latest
.PHONY: lint-python
lint-python: install-dev
# Run Python lint and format checks.
$(PYTHON) -m ruff check .
$(PYTHON) -m ruff format --check .
.PHONY: lint-docker
lint-docker:
# Lint the Dockerfile.
docker run --rm -i hadolint/hadolint < Dockerfile
.PHONY: lint-yaml
lint-yaml: install-dev
# Lint YAML for duplicate keys and ambiguous scalars (see .yamllint).
$(PYTHON) -m yamllint --strict .
.PHONY: lint-js
lint-js: node-deps
# Lint the browser and Cypress JavaScript.
cd app && env -u ELECTRON_RUN_AS_NODE npx eslint .
.PHONY: lint-shell
lint-shell:
# Lint the shell scripts.
docker run --rm -v "$$PWD:/mnt" -w /mnt koalaman/shellcheck:stable scripts/*.sh
.PHONY: test-lint
test-lint:
# Run lint guardrail tests.
$(PYTHON) -m unittest discover -s tests/lint -t .
.PHONY: test-integration
test-integration: install
# Run repository integration tests.
$(PYTHON) -m unittest discover -s tests/integration -t .
.PHONY: test-unit
test-unit: install
# Run repository unit tests.
$(PYTHON) -m unittest discover -s tests/unit -t .
.PHONY: test-security
test-security: install
# Run repository security guardrail tests.
$(PYTHON) -m unittest discover -s tests/security -t .
.PHONY: lint
lint: lint-actions lint-python lint-yaml lint-js lint-docker lint-shell test-lint
# Run the full lint suite.
.PHONY: security
security: install-dev test-security
# Run security checks.
$(PYTHON) -m bandit -q -ll -ii -r app main.py
$(PYTHON) utils/export_runtime_requirements.py > /tmp/portfolio-runtime-requirements.txt
$(PYTHON) -m pip_audit -r /tmp/portfolio-runtime-requirements.txt
.PHONY: node-deps
node-deps:
# Install the Cypress binary and the browser vendor assets into app/.
cd app && npm install
.PHONY: test-e2e
test-e2e: env config node-deps
# Run Cypress against a locally started Flask app — no act, no runner image.
@$(call _require_env,PORT); \
PORT="$$PORT" PYTHON="$(PYTHON)" scripts/run-e2e.sh
.PHONY: test-e2e-act
test-e2e-act:
# Run the CI end-to-end job through act (stop portfolio container to free port first).
-docker stop portfolio 2>/dev/null || true
$(ACT) workflow_dispatch -W .github/workflows/tests.yml -j e2e
-docker start portfolio 2>/dev/null || true
.PHONY: test-workflow
test-workflow:
# Run the GitHub test workflow locally via act.
$(ACT) workflow_dispatch -W .github/workflows/tests.yml
.PHONY: lint-workflow
lint-workflow:
# Run the GitHub lint workflow locally via act.
$(ACT) workflow_dispatch -W .github/workflows/lint.yml
.PHONY: quality
quality: lint-workflow test-workflow
# Run the GitHub lint and test workflows locally via act.
.PHONY: ci
ci: lint security test-unit test-integration test-e2e
# Run the local CI suite.
.PHONY: test
test: ci
# Run the full validation suite.

217
README.md
View File

@@ -1,86 +1,99 @@
# Portfolio CMS: Flask-based Portfolio Management 🚀
# PortUI 🖥️✨
[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach) [![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate)
This software allows individuals and institutions to set up an easy portfolio/landingpage/homepage to showcase their projects and online presence. It is highly customizable via a YAML configuration file.
A lightweight, Docker-powered portfolio/landing-page generator—fully customizable via YAML! Showcase your projects, skills, and online presence in minutes.
## Features ✨
![PortUI screenshot](assets/img/screenshot.png)
- **Dynamic Navigation:** Easily create dropdown menus and nested links.
- **Customizable Cards:** Showcase your skills, projects, or services.
- **Cache Management:** Optimize your assets with automatic caching.
- **Responsive Design:** Beautiful on any device with Bootstrap.
- **Easy Configuration:** Update content using a YAML file.
- **Command Line Interface:** Manage Docker containers with the `portfolio` CLI.
> 🚀 You can also pair PortUI with JavaScript for sleek, web-based desktop-style interfaces.
> 💻 Example in action: [CyMaIS.Cloud](https://cymais.cloud/) (demo)
> 🌐 Another live example: [veen.world](https://www.veen.world/) (Kevins personal site)
## Access 🌐
---
### Local Access
Access the application locally at [http://127.0.0.1:5000](http://127.0.0.1:5000).
## ✨ Key Features
## Getting Started 🏁
- **Dynamic Navigation**
Create dropdowns & nested menus with ease.
- **Customizable Cards**
Highlight skills, projects, or services—with icons, titles, and links.
- **Smart Cache Management**
Auto-cache assets for lightning-fast loading.
- **Responsive Design**
Built on Bootstrap; looks great on desktop, tablet & mobile.
- **184 Languages**
Every ISO 639-1 code, browser-negotiated, RTL-aware, with machine translation for your own content.
- **YAML-Driven**
All content & structure defined in a simple `config.yaml`.
- **CLI Control**
Manage Docker containers via the `portfolio` command.
### Prerequisites 📋
---
- Docker and Docker Compose installed on your system.
- Basic knowledge of Python and YAML for configuration.
## 🌐 Quick Access
### Installation 🛠️
- **Local Preview:**
[http://127.0.0.1:5000](http://127.0.0.1:5000)
#### Installation via git clone
---
1. **Clone the repository:**
## 🏁 Getting Started
### 🔧 Prerequisites
- Docker & Docker Compose
- Basic Python & YAML knowledge
### 🛠️ Installation via Git
1. **Clone & enter repo**
```bash
git clone <repository_url>
cd <repository_directory>
```
2. **Update the configuration:**
Create a `config.yaml` file. You can use `config.sample.yaml` as an example (see below for details on the configuration).
2. **Configure**
Copy `config.sample.yaml` → `config.yaml` & customize.
3. **Build & run**
3. **Build and run the Docker container:**
```bash
docker-compose up --build
```
4. **Browse**
Open [http://localhost:5000](http://localhost:5000)
4. **Access your portfolio:**
Open your browser and navigate to [http://localhost:5000](http://localhost:5000).
### Installation via Kevin's Package Manager
You can install the `portfolio` CLI using [Kevin's package manager](https://github.com/kevinveenbirkenbach/package-manager). Simply run:
### 📦 Installation via Kevins Package Manager
```bash
pkgmgr install portfolio
pkgmgr install portui
```
This will install the CLI tool, making it available system-wide.
Once installed, the `portui` CLI is available system-wide.
### Available Commands
---
After installation, you can access the help information for the CLI by running:
## 🖥️ CLI Commands
```bash
portfolio --help
portui --help
```
This command displays detailed instructions on how to use the following commands:
* `build`Build the Docker image
* `up`Start containers (with build)
* `down`Stop & remove containers
* `run-dev`Dev mode (hot-reload)
* `run-prod`Production mode
* `logs`View container logs
* `dev`Docker-Compose dev environment
* `prod`Docker-Compose prod environment
* `cleanup`Prune stopped containers
- **build:** Build the Docker image for the portfolio application.
- **up:** Start the application using docker-compose (with build).
- **down:** Stop and remove the Docker container.
- **run-dev:** Run the container in development mode with hot-reloading.
- **run-prod:** Run the container in production mode.
- **logs:** Display the logs of the running container.
- **dev:** Start the application in development mode using docker-compose.
- **prod:** Start the application in production mode using docker-compose.
- **cleanup:** Remove all stopped Docker containers to clean up your Docker environment.
---
## YAML Configuration Guide 🔧
## 🔧 YAML Configuration Guide
The portfolio is powered by a YAML configuration file (`config.yaml`). This file allows you to define the structure and content of your site, including cards, navigation, and company details.
### YAML Configuration Example 📄
Define your sites structure in `config.yaml`:
```yaml
accounts:
@@ -94,17 +107,12 @@ accounts:
icon:
class: fas fa-newspaper
children:
- name: Microblogs
description: Stay updated with my microblog posts.
- name: Mastodon
description: Follow me on Mastodon.
icon:
class: fa-solid fa-pen-nib
children:
- name: Mastodon
description: Follow my updates on Mastodon.
icon:
class: fa-brands fa-mastodon
url: https://microblog.veen.world/@kevinveenbirkenbach
identifier: "@kevinveenbirkenbach@microblog.veen.world"
class: fa-brands fa-mastodon
url: https://microblog.veen.world/@kevinveenbirkenbach
identifier: "@kevinveenbirkenbach@microblog.veen.world"
cards:
- icon:
source: https://cloud.veen.world/s/logo_agile_coach_512x512/download
@@ -112,9 +120,10 @@ accounts:
text: I lead agile transformations and improve team dynamics through Scrum and Agile Coaching.
url: https://www.agile-coach.world
link_text: www.agile-coach.world
company:
titel: Kevin Veen-Birkenbach
subtitel: Consulting and Coaching Solutions
title: Kevin Veen-Birkenbach
subtitle: Consulting & Coaching Solutions
logo:
source: https://cloud.veen.world/s/logo_face_512x512/download
favicon:
@@ -127,26 +136,90 @@ company:
imprint_url: https://s.veen.world/imprint
```
### Understanding the `children` Key 🔍
* **`children`** enables multi-level menus.
* **`link`** references other YAML paths to avoid duplication.
The `children` key allows hierarchical nesting of elements. Each child can itself have children, enabling the creation of multi-level navigation menus or grouped content.
---
### Understanding the `link` Key 🔗
## 🌍 Languages
The `link` key allows you to reference another part of the YAML configuration by its path, which helps avoid duplication and maintain consistency.
Every ISO 639-1 language — all 184 two-letter codes — has a URL, a display
name in its own script and a writing direction. The interface ships translated
for 29 of them; the rest fall back to English string by string until a
catalogue is filled. `/` serves the best match for the visitor's
`Accept-Language` header, `/<code>/` forces one, and a switcher in the navbar
lists them all. The ten right-to-left languages get `dir="rtl"` and Bootstrap's RTL
stylesheet automatically.
## Deployment 🚢
Translations live in two catalogues, both keyed by the English source string:
For production deployment, ensure to:
| Path | Tracked | Holds |
| --- | --- | --- |
| `app/i18n/ui/<code>.yaml` | yes | Interface strings. Shipped for 29 languages; English is the source and has no file. |
| `app/i18n/content/<code>.yaml` | no | Your `config.yaml` prose, generated per deployment. |
- Use a reverse proxy like NGINX or Apache.
- Secure your site with SSL/TLS.
- Use a production-ready database if required.
A string with no catalogue entry falls back to English, so a half-filled
catalogue degrades instead of breaking.
## License 📜
Fill the content catalogues from a [LibreTranslate](https://libretranslate.com/)
instance — set `LIBRETRANSLATE_URL` in `.env`, then:
This project is licensed under the GNU Affero General Public License Version 3. See the [LICENSE](./LICENSE) file for details.
```bash
make i18n
```
## Author ✍️
This fills the interface strings of the languages that ship no catalogue as
well. Existing entries are never overwritten, and a string the shipped
catalogue already covers is never requested, so corrections you make by hand
survive later runs.
This software was created by [Kevin Veen-Birkenbach](https://www.veen.world/).
`name`, `title`, `description`, `text`, `warning`, `info` and `subtitel` are
translated; `url`, `link_text`, `identifier` and icon classes never are.
A machine cannot tell the menu label "Pictures" from the brand "Mastodon", so
list the brands in `app/i18n/keep.txt`, one per line — they are then stored as
themselves in every language and cost no request:
```
# Strings utils/i18n_sync.py stores as themselves instead of translating.
Mastodon
Nextcloud
freelancermap.de
```
Add one-off entries with `--keep Foo Bar`, or point somewhere else with
`--keep-file`. A protected string never replaces an entry you already wrote by
hand.
---
## 🚢 Production Deployment
* Use a reverse proxy (NGINX/Apache).
* Secure with SSL/TLS.
* Swap to a production database if needed.
Because every page carries a canonical URL and 184 `hreflang` alternates, two
details of the proxy setup now matter:
* **Set `TRUSTED_HOSTS`** in `.env` to your public hostname(s), comma-separated.
Left empty, the app reflects whatever `Host` header arrives into its canonical,
`hreflang` and redirect URLs — so a shared cache in front of it can be made to
store a redirect pointing somewhere else.
* **Have the proxy send `X-Forwarded-Proto`.** Without it the app cannot know TLS
terminated upstream and every canonical URL claims `http://`. `X-Forwarded-Host`
is deliberately *not* trusted; set `Host` to the public name instead.
---
## 📜 License
Licensed under **GNU AGPLv3**. See [LICENSE](./LICENSE) for details.
---
## ✍️ Author
Created by [Kevin Veen-Birkenbach](https://www.veen.world/)
Enjoy building your portfolio! 🌟

2
app/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
package-lock.json

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Portfolio UI web application package."""

View File

@@ -1,9 +1,34 @@
import logging
import os
from flask import Flask, render_template
import requests
import yaml
from utils.configuration_resolver import ConfigurationResolver
from utils.cache_manager import CacheManager
from utils.compute_card_classes import compute_card_classes
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
from app.utils.configuration_resolver import ConfigurationResolver
except ImportError: # pragma: no cover - supports running from the app/ directory.
from utils.asset_resolver import asset_src, resolve_asset_cache
from utils.cache_manager import CacheManager
from utils.compute_card_classes import compute_card_classes
from utils.configuration_resolver import ConfigurationResolver
from utils import i18n
TRANSLATED_SECTIONS = ("cards", "company", "navigation", "platform")
logging.basicConfig(level=logging.DEBUG)
FLASK_ENV = os.getenv("FLASK_ENV", "production")
FLASK_HOST = os.getenv("FLASK_HOST", "127.0.0.1")
FLASK_PORT = int(os.getenv("FLASK_PORT", os.getenv("PORT", 5000)))
print(f"Starting app on {FLASK_HOST}:{FLASK_PORT}, FLASK_ENV={FLASK_ENV}")
# Initialize the CacheManager
cache_manager = CacheManager()
@@ -11,36 +36,78 @@ cache_manager = CacheManager()
# Clear cache on startup
cache_manager.clear_cache()
def load_config(app):
"""Load and resolve the configuration from config.yaml."""
with open("config.yaml", "r") as f:
config = yaml.safe_load(f)
with open("config.yaml", "r", encoding="utf-8") as handle:
config = yaml.safe_load(handle)
if config.get("nasa_api_key"):
app.config["NASA_API_KEY"] = config["nasa_api_key"]
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):
"""Cache all icons and logos to local files."""
"""Resolve every icon/logo/favicon to either a local cache path or
an external URL (see ``resolve_asset_cache``)."""
for card in app.config["cards"]:
icon = card.get("icon", {})
if icon.get("source"):
icon["cache"] = cache_manager.cache_file(icon["source"])
icon = card.get("icon")
if icon:
resolve_asset_cache(icon, cache_manager)
app.config["company"]["logo"]["cache"] = cache_manager.cache_file(app.config["company"]["logo"]["source"])
app.config["platform"]["favicon"]["cache"] = cache_manager.cache_file(app.config["platform"]["favicon"]["source"])
app.config["platform"]["logo"]["cache"] = cache_manager.cache_file(app.config["platform"]["logo"]["source"])
resolve_asset_cache(app.config["company"]["logo"], cache_manager)
resolve_asset_cache(app.config["platform"]["favicon"], cache_manager)
resolve_asset_cache(app.config["platform"]["logo"], cache_manager)
# Get the environment variable FLASK_ENV or set a default value
FLASK_ENV = os.getenv("FLASK_ENV", "production")
# Initialize Flask app
app = Flask(__name__)
app.jinja_options = {**app.jinja_options, "autoescape": True}
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=0, x_proto=1)
def trusted_hosts(raw):
"""Parse a comma-separated host list, or None when nothing is configured.
Args:
raw: the ``TRUSTED_HOSTS`` value, possibly empty.
"""
hosts = [host.strip() for host in raw.split(",") if host.strip()]
return hosts or None
app.config["TRUSTED_HOSTS"] = trusted_hosts(os.getenv("TRUSTED_HOSTS", ""))
# Load configuration and cache assets on startup
load_config(app)
cache_icons_and_logos(app)
@app.context_processor
def utility_processor():
def include_svg(path):
full_path = os.path.join(current_app.root_path, "static", path)
try:
with open(full_path, "r", encoding="utf-8") as handle:
svg = handle.read()
# Trusted local SVG asset shipped with the application package.
return Markup(svg) # nosec B704
except OSError:
return ""
def template_asset_src(asset):
return asset_src(asset, lambda filename: url_for("static", filename=filename))
return dict(include_svg=include_svg, asset_src=template_asset_src)
@app.before_request
def reload_config_in_dev():
"""Reload config and recache icons before each request in development mode."""
@@ -48,21 +115,84 @@ def reload_config_in_dev():
load_config(app)
cache_icons_and_logos(app)
@app.route('/')
def index():
"""Render the main index page."""
cards = app.config["cards"]
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 not api_key:
return None
try:
resp = requests.get(
"https://api.nasa.gov/planetary/apod",
params={"api_key": api_key},
timeout=10,
)
except requests.RequestException:
logging.warning("APOD lookup failed", exc_info=True)
return None
if not resp.ok:
return None
data = resp.json()
return data.get("url") if data.get("media_type") == "image" else None
def render_index(lang):
"""Render the index page in ``lang``."""
config = translated_config(lang)
cards = config["cards"]
lg_classes, md_classes = compute_card_classes(cards)
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
md_classes=md_classes,
apod_bg=apod_background(),
lang=lang,
lang_dir=i18n.direction(lang),
languages=i18n.LANGUAGES,
ui_strings=i18n.ui_strings(lang),
t=lambda source: i18n.catalog(lang).get(source, source),
)
@app.route("/")
def index():
"""Render the index page in the language the browser asks for."""
response = make_response(render_index(i18n.negotiate(request.accept_languages)))
response.headers["Vary"] = "Accept-Language"
return response
@app.route(f"/<any({','.join(i18n.LANGUAGES)}):lang>/")
def localized_index(lang):
"""Render the index page in an explicitly requested language."""
return render_index(lang)
if __name__ == "__main__":
port = int(os.getenv("PORT", 5000))
app.run(debug=(FLASK_ENV == "development"), host="0.0.0.0", port=port)
app.run(
debug=(FLASK_ENV == "development"),
host=FLASK_HOST,
port=FLASK_PORT,
use_reloader=False,
)

View File

@@ -1,5 +1,6 @@
---
accounts:
nasa_api_key: YOUR_REAL_KEY_HERE
name: Online Presence
description: Discover my online presence.
icon:
@@ -197,7 +198,16 @@ accounts:
url: https://s.veen.world/cloud
cards:
- icon:
- icon:
source: https://file.infinito.nexus/assets/img/logo.png
title: Infinito.Nexus
text: Open-source self-hosting stack — one-click deployable FOSS web apps with
shared identity, asset, and observability services. The platform that hosts
this site, including the dashboard you are looking at.
url: https://infinito.nexus
link_text: infinito.nexus
iframe: true
- icon:
source: https://cloud.veen.world/s/logo_agile_coach_512x512/download
title: Agile Coach
text: I lead agile transformations and improve team dynamics through Scrum, DevOps,
@@ -647,4 +657,36 @@ navigation:
class: fa-solid fa-scale-balanced
url: https://s.veen.world/imprint
iframe: true
- name: Settings
description: Application settings
icon:
class: fa-solid fa-cog
children:
- name: Toggle Fullscreen
description: Enter or exit fullscreen mode
icon:
class: fa-solid fa-expand-arrows-alt
onclick: "toggleFullscreen()"
- name: Toggle Full Width
description: Switch between normal and full-width layout
icon:
class: fa-solid fa-arrows-left-right
onclick: "setFullWidth(!initFullWidthFromUrl())"
- name: Open in new tab
description: Open the currently embedded iframe URL in a fresh browser tab
icon:
class: fa-solid fa-up-right-from-square
onclick: openIframeInNewTab()
- name: Print
description: Print the current view
icon:
class: fa-solid fa-print
onclick: window.print()
- name: Zoom +
icon:
class: fa-solid fa-search-plus
onclick: zoomPage(1.1)
- name: Zoom
icon:
class: fa-solid fa-search-minus
onclick: zoomPage(0.9)

19
app/cypress.config.js Normal file
View File

@@ -0,0 +1,19 @@
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
// your app under test must already be running on this port
baseUrl: `http://localhost:${process.env.PORT || 5001}`,
defaultCommandTimeout: 60000,
pageLoadTimeout: 60000,
requestTimeout: 1500,
responseTimeout: 15000,
specPattern: 'cypress/e2e/**/*.spec.js',
supportFile: false,
setupNodeEvents(on, config) {
// here you could hook into events, but we dont need anything special
return config;
}
},
});

View File

@@ -0,0 +1,90 @@
// cypress/e2e/container.spec.js
describe('Custom Scroll & Container Resizing', () => {
beforeEach(() => {
// Assumes your app is running at baseUrl, and container.js is loaded on “/”
cy.visit('/');
});
it('on load, the scroll-container gets a positive height and proper overflow', () => {
// wait for our JS to run
cy.window().should('have.property', 'adjustScrollContainerHeight');
// Grab the inline style of .scroll-container
cy.get('.scroll-container')
.should('have.attr', 'style')
.then(style => {
// height:<number>px must be present
const m = style.match(/height:\s*(\d+(?:\.\d+)?)px/);
expect(m, 'height set').to.not.be.null;
expect(parseFloat(m[1]), 'height > 0').to.be.greaterThan(0);
// overflow shorthand should include both hidden & auto (order-insensitive)
expect(style).to.include('overflow:');
expect(style).to.match(/overflow:\s*(hidden\s+auto|auto\s+hidden)/);
});
});
it('on window resize, scroll-container height updates', () => {
// record original height
cy.get('.scroll-container')
.invoke('css', 'height')
.then(orig => {
// resize to a smaller viewport
cy.viewport(320, 480);
cy.wait(100); // allow resize handler to fire
cy.get('.scroll-container')
.invoke('css', 'height')
.then(newH => {
expect(parseFloat(newH), 'height changed on resize').to.not.equal(parseFloat(orig));
});
});
});
context('custom scrollbar thumb', () => {
beforeEach(() => {
// inject tall content to force scrolling
cy.get('.scroll-container').then($sc => {
$sc[0].innerHTML = '<div style="height:2000px">long</div>';
});
// re-run scrollbar setup
cy.window().invoke('updateCustomScrollbar');
});
it('shows a thumb with reasonable size & position', () => {
cy.get('#custom-scrollbar').should('have.css', 'opacity', '1');
cy.get('#scroll-thumb')
.should('have.css', 'height')
.then(h => {
const hh = parseFloat(h);
expect(hh).to.be.at.least(20);
// ensure thumb is smaller than container
cy.get('#custom-scrollbar')
.invoke('css', 'height')
.then(ch => {
expect(hh).to.be.lessThan(parseFloat(ch));
});
});
// scroll a bit and verify thumb.top changes
cy.get('.scroll-container').scrollTo(0, 200);
cy.wait(50);
cy.get('#scroll-thumb')
.invoke('css', 'top')
.then(t => {
expect(parseFloat(t)).to.be.greaterThan(0);
});
});
it('hides scrollbar when content fits', () => {
// remove overflow
cy.get('.scroll-container').then($sc => {
$sc[0].innerHTML = '<div style="height:10px">tiny</div>';
});
cy.window().invoke('updateCustomScrollbar');
cy.get('#custom-scrollbar').should('have.css', 'opacity', '0');
});
});
});

View File

@@ -0,0 +1,85 @@
// cypress/e2e/fullscreen.spec.js
describe('Fullscreen Toggle', () => {
const ROOT = '/';
beforeEach(() => {
cy.visit(ROOT);
});
it('defaults to normal mode when no fullscreen param is present', () => {
// Body should not have fullscreen class
cy.get('body').should('not.have.class', 'fullscreen');
// URL should not include `fullscreen`
cy.url().should('not.include', 'fullscreen=');
// Header and footer should be visible (max-height > 0)
cy.get('header').should('have.css', 'max-height').and(value => {
expect(parseFloat(value)).to.be.greaterThan(0);
});
cy.get('footer').should('have.css', 'max-height').and(value => {
expect(parseFloat(value)).to.be.greaterThan(0);
});
});
it('initFullscreenFromUrl() picks up ?fullscreen=1 on load', () => {
cy.visit(`${ROOT}?fullscreen=1`);
cy.get('body').should('have.class', 'fullscreen');
cy.url().should('include', 'fullscreen=1');
// Header and footer should be collapsed (max-height == 0)
cy.get('header').should('have.css', 'max-height', '0px');
cy.get('footer').should('have.css', 'max-height', '0px');
});
it('enterFullscreen() adds fullscreen class, sets full width, and updates URL', () => {
cy.window().then(win => {
win.exitFullscreen(); // ensure starting state
win.enterFullscreen();
});
cy.get('body').should('have.class', 'fullscreen');
cy.url().should('include', 'fullscreen=1');
cy.get('.container, .container-fluid')
.should('have.class', 'container-fluid');
cy.get('header').should('have.css', 'max-height', '0px');
cy.get('footer').should('have.css', 'max-height', '0px');
});
it('exitFullscreen() removes fullscreen class, resets width, and URL param', () => {
// start in fullscreen
cy.window().invoke('enterFullscreen');
// then exit
cy.window().invoke('exitFullscreen');
cy.get('body').should('not.have.class', 'fullscreen');
cy.url().should('not.include', 'fullscreen=');
cy.get('.container, .container-fluid')
.should('have.class', 'container')
.and('not.have.class', 'container-fluid');
// Header and footer should be expanded again
cy.get('header').should('have.css', 'max-height').and(value => {
expect(parseFloat(value)).to.be.greaterThan(0);
});
cy.get('footer').should('have.css', 'max-height').and(value => {
expect(parseFloat(value)).to.be.greaterThan(0);
});
});
it('toggleFullscreen() toggles into and out of fullscreen', () => {
// Toggle into fullscreen
cy.window().invoke('toggleFullscreen');
cy.get('body').should('have.class', 'fullscreen');
cy.url().should('include', 'fullscreen=1');
// Toggle back
cy.window().invoke('toggleFullscreen');
cy.get('body').should('not.have.class', 'fullscreen');
cy.url().should('not.include', 'fullscreen=');
});
});

View File

@@ -0,0 +1,61 @@
// cypress/e2e/fullwidth.spec.js
describe('Full-width Toggle', () => {
// test page must include your <div class="container"> wrapper
const ROOT = '/';
it('defaults to .container when no param is present', () => {
cy.visit(ROOT);
cy.get('.container, .container-fluid')
.should('have.class', 'container')
.and('not.have.class', 'container-fluid');
// URL should not include `fullwidth`
cy.url().should('not.include', 'fullwidth=');
});
it('initFullWidthFromUrl() picks up ?fullwidth=1 on load', () => {
cy.visit(`${ROOT}?fullwidth=1`);
cy.get('.container, .container-fluid')
.should('have.class', 'container-fluid')
.and('not.have.class', 'container');
cy.url().should('include', 'fullwidth=1');
});
it('setFullWidth(true) switches to container-fluid and updates URL', () => {
cy.visit(ROOT);
// call your global function
cy.window().invoke('setFullWidth', true);
cy.get('.container, .container-fluid')
.should('have.class', 'container-fluid')
.and('not.have.class', 'container');
cy.url().should('include', 'fullwidth=1');
});
it('setFullWidth(false) reverts to container and removes URL param', () => {
cy.visit(`${ROOT}?fullwidth=1`);
// now reset
cy.window().invoke('setFullWidth', false);
cy.get('.container, .container-fluid')
.should('have.class', 'container')
.and('not.have.class', 'container-fluid');
cy.url().should('not.include', 'fullwidth=1');
});
it('updateUrlFullWidth() toggles the query param without changing layout', () => {
cy.visit(ROOT);
// manually toggle URL only
cy.window().invoke('updateUrlFullWidth', true);
cy.url().should('include', 'fullwidth=1');
cy.window().invoke('updateUrlFullWidth', false);
cy.url().should('not.include', 'fullwidth=');
});
});

View 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\/$/);
});
});

View File

@@ -0,0 +1,46 @@
// cypress/e2e/iframe.spec.js
describe('Iframe integration', () => {
beforeEach(() => {
// Visit the apps base URL (configured in cypress.config.js)
cy.visit('/');
});
it('opens the iframe when an .iframe-link is clicked', () => {
// Find the first iframe-link on the page
cy.get('.iframe-link').first().then($link => {
const href = $link.prop('href');
// Click it
cy.wrap($link).click();
// The URL should now include ?iframe=<encoded href>
cy.url().should('include', 'iframe=' + encodeURIComponent(href));
// The <body> should have the "fullscreen" class
cy.get('body').should('have.class', 'fullscreen');
// And the <main> should contain a visible <iframe src="<href>">
cy.get('main iframe')
.should('have.attr', 'src', href)
.and('be.visible');
});
});
it('restores the original content when a .js-restore element is clicked', () => {
// First open the iframe
cy.get('.iframe-link').first().click();
// Then click the first .js-restore element (e.g. header or logo)
cy.get('.js-restore').first().click();
// The URL must no longer include the iframe parameter
cy.url().should('not.include', 'iframe=');
// The <body> should no longer have the "fullscreen" class
cy.get('body').should('not.have.class', 'fullscreen');
// And no <iframe> should remain inside <main>
cy.get('main iframe').should('not.exist');
});
});

View File

@@ -0,0 +1,263 @@
// cypress/e2e/injection.spec.js
describe('Untrusted content in the modal', () => {
const base = {
name: 'Test Item',
identifier: 'ABC123',
icon: { class: 'fa fa-test' },
};
beforeEach(() => {
cy.visit('/');
cy.window().then(win => {
cy.stub(win.navigator.clipboard, 'writeText').resolves();
cy.stub(win, 'alert');
});
});
function open(item = {}) {
cy.window().invoke('openDynamicPopup', { ...base, ...item });
}
describe('markdown rendered into innerHTML', () => {
it('strips a plain script URL', () => {
open({
warning: '[click me](javascript:window.__xss = true)',
info: '![x](data:text/html;base64,PHNjcmlwdD4=)',
});
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](&#106;avascript:window.__xss=1) [b](&#x6A;avascript:window.__xss=1)',
info: '[c](java&Tab;script:window.__xss=1) [d](java&NewLine;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]: &#106;avascript:window.__xss=1',
});
cy.get('#dynamicModalWarningText').find('a').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('neutralises raw HTML', () => {
open({
warning: '<img src=x onerror="window.__xss = true">',
info: '<a href="javascript:window.__xss = true">x</a>',
});
cy.get('#dynamicModalWarningText').find('img').should('not.exist');
cy.get('#dynamicModalWarningText').should('contain.text', 'onerror');
cy.get('#dynamicModalInfoText').find('a').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('neutralises raw HTML used as the text of a stripped link', () => {
open({ warning: '[<img src=x onerror="window.__xss = true">](javascript:bad)' });
cy.get('#dynamicModalWarningText').find('img').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('keeps a relative link', () => {
open({ warning: 'See [the notes](/#anchor)' });
cy.get('#dynamicModalWarningText')
.find('a')
.should('have.attr', 'href', '/#anchor');
});
it('keeps the text of a link it strips', () => {
open({ warning: '[read this](javascript:window.__xss=1)' });
cy.get('#dynamicModalWarningText').should('contain.text', 'read this');
cy.window().should('not.have.property', '__xss');
});
it('keeps ordinary markdown', () => {
open({ warning: 'See [Matrix](https://matrix.org/) and **mind** this' });
cy.get('#dynamicModalWarningText')
.find('a')
.should('have.attr', 'href', 'https://matrix.org/');
cy.get('#dynamicModalWarningText').find('strong').should('have.text', 'mind');
});
});
describe('values interpolated outside markdown', () => {
it('does not treat an interface string as markup', () => {
cy.window().then(win => {
win.I18N.Open = '<img src=x onerror="window.__xss = true">';
});
open({
alternatives: [
{ name: 'Alt', identifier: 'A', icon: { class: 'fa-alt' } },
],
});
cy.get('#dynamicAlternativesList').find('img').should('not.exist');
cy.get('#dynamicAlternativesList').should('contain.text', 'onerror');
cy.window().should('not.have.property', '__xss');
});
it('falls back to the English source when a string is missing', () => {
cy.window().then(win => {
delete win.I18N;
});
open({
alternatives: [
{ name: 'Alt', identifier: 'A', icon: { class: 'fa-alt' } },
],
});
cy.get('#dynamicAlternativesList button').should('have.text', 'Open');
});
it('renders no placeholder for a missing name', () => {
open({ name: undefined });
cy.get('#dynamicModalLabel').should('not.contain.text', 'undefined');
});
it('does not treat the name or the icon class as markup', () => {
open({
name: '<img src=x onerror="window.__xss = true">',
icon: { class: 'fa" onmouseover="window.__xss = true' },
alternatives: [
{
name: '<img src=y onerror="window.__xss = true">',
identifier: 'ALT1',
icon: { class: 'fa-alt' },
},
],
});
cy.get('#dynamicModalLabel').find('img').should('not.exist');
cy.get('#dynamicModalLabel').should('contain.text', 'onerror');
cy.get('#dynamicAlternativesList').find('img').should('not.exist');
cy.get('#dynamicAlternativesList').should('contain.text', 'onerror');
cy.window().should('not.have.property', '__xss');
});
});
describe('the link the modal offers', () => {
it('drops a URL that uses an unsafe scheme', () => {
open({ url: 'javascript:window.__xss = true', description: 'Bad' });
cy.get('#dynamicModalLinkHref').should('not.have.attr', 'href');
cy.get('#dynamicModalLinkHref').should('have.text', 'Bad');
cy.window().should('not.have.property', '__xss');
});
it('keeps an ordinary URL', () => {
open({ url: 'https://example.com', description: 'Good' });
cy.get('#dynamicModalLinkHref').should(
'have.attr',
'href',
'https://example.com',
);
});
it('keeps a URL that carries surrounding whitespace', () => {
open({ url: ' https://example.com ', description: 'Good' });
cy.get('#dynamicModalLinkHref').should('have.attr', 'href');
});
it('keeps a mailto URL', () => {
open({ url: 'mailto:kevin@veen.world', description: 'Write' });
cy.get('#dynamicModalLinkHref').should(
'have.attr',
'href',
'mailto:kevin@veen.world',
);
});
it('restores the link after a popup whose URL was dropped', () => {
open({ url: 'javascript:window.__xss = true', description: 'Bad' });
cy.get('#dynamicModalLinkHref').should('not.have.attr', 'href');
open({ url: 'https://example.com', description: 'Good' });
cy.get('#dynamicModalLinkHref').should(
'have.attr',
'href',
'https://example.com',
);
});
it('does not let one popup iframe handler outlive it', () => {
open({ url: 'https://a.test/', description: 'A', iframe: true });
cy.get('#dynamicModalLinkHref').should('have.class', 'iframe');
open({ url: 'https://b.test/', description: 'B' });
cy.get('#dynamicModalLinkHref').should('not.have.class', 'iframe');
cy.get('#dynamicModalLinkHref').should($anchor => {
expect($anchor[0].onclick, 'stale click handler').to.equal(null);
});
});
it('opens the current popup URL, not an earlier one', () => {
open({ url: 'https://a.test/', description: 'A', iframe: true });
open({ url: 'https://b.test/', description: 'B', iframe: true });
cy.get('#dynamicModalLinkHref').click();
cy.get('#main')
.find('iframe', { timeout: 4000 })
.should('have.attr', 'src', 'https://b.test/');
});
});
});
describe('Untrusted content reaching the iframe', () => {
const AFTER_THE_FADE = 3000;
it('refuses to open a script URL handed over by the modal', () => {
cy.visit('/');
cy.window().invoke('openDynamicPopup', {
name: 'Bad',
icon: { class: 'fa fa-test' },
url: 'javascript:window.__xss = true',
description: 'Watch',
iframe: true,
});
cy.get('#dynamicModalLinkHref').click({ force: true });
cy.wait(AFTER_THE_FADE);
cy.get('#main').find('iframe').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('refuses a script URL supplied through the query string', () => {
cy.visit('/?iframe=javascript:window.__xss%20%3D%20true');
cy.wait(AFTER_THE_FADE);
cy.get('#main').find('iframe').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('still opens an ordinary URL from the query string', () => {
cy.visit('/?iframe=https://example.com/');
cy.get('#main')
.find('iframe', { timeout: AFTER_THE_FADE })
.should('have.attr', 'src', 'https://example.com/');
});
});

View File

@@ -0,0 +1,196 @@
// cypress/e2e/menu.spec.js
describe('Navigation dropdowns', () => {
beforeEach(() => {
cy.viewport(1280, 720);
cy.visit('/');
});
it('opens top-level dropdowns with explicit Bootstrap instances', () => {
cy.get('#navbarNavheader .nav-item.dropdown > .nav-link.dropdown-toggle')
.first()
.as('toggle')
.should('have.attr', 'data-bs-toggle', 'dropdown')
.and('have.attr', 'aria-expanded', 'false');
cy.get('@toggle').then($toggle => {
cy.window().then(win => {
expect(win.bootstrap.Dropdown.getInstance($toggle[0])).to.exist;
});
});
cy.get('@toggle')
.parent('.nav-item')
.find('> .dropdown-menu')
.as('menu')
.should('not.have.class', 'show')
.should('not.be.visible');
cy.get('@toggle').click();
cy.get('@toggle').should('have.attr', 'aria-expanded', 'true');
cy.get('@toggle').parent('.nav-item').should('have.class', 'dropdown');
cy.get('@menu')
.should('have.class', 'show')
.and('be.visible');
});
it('flips footer dropdowns upward where there is more space above', () => {
cy.get('#navbarNavfooter .nav-item .nav-link.dropdown-toggle')
.first()
.as('toggle');
cy.get('@toggle')
.parent('.nav-item')
.as('item')
.find('> .dropdown-menu')
.as('menu')
.should('not.have.class', 'show');
// Make sure the footer sits at the bottom of the viewport before clicking
// — otherwise the toggle could land near the top and chooseDirection would
// keep .dropdown (more space below than above).
// ensureScrollable:false because on short pages the body isn't scrollable
// and the footer is already in view (which is fine for this test).
cy.scrollTo('bottom', { ensureScrollable: false });
cy.get('@toggle').then($toggle => {
const rect = $toggle[0].getBoundingClientRect();
expect(rect.top, 'toggle is in the lower half of the viewport')
.to.be.greaterThan(Cypress.config('viewportHeight') / 2);
});
cy.get('@toggle').click({ scrollBehavior: false });
cy.get('@item').should('have.class', 'dropup');
cy.get('@item').should('not.have.class', 'dropdown');
cy.get('@menu')
.should('have.class', 'show')
.and('be.visible');
});
});
describe('Dynamic Popup', () => {
const base = {
name: 'Test Item',
identifier: 'ABC123',
description: 'A simple description',
warning: '**Be careful!**',
info: '_Some info_',
url: null,
iframe: false,
icon: { class: 'fa fa-test' },
alternatives: [
{ name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } }
],
children: [
{ name: 'Child One', identifier: 'CH1', icon: { class: 'fa fa-child1' } }
]
};
beforeEach(() => {
cy.visit('/');
cy.window().then(win => {
cy.stub(win.navigator.clipboard, 'writeText').resolves();
cy.stub(win, 'alert');
});
});
function open(item = {}) {
cy.window().invoke('openDynamicPopup', { ...base, ...item });
}
it('renders title with icon and text', () => {
open();
cy.get('#dynamicModalLabel')
.find('i.fa.fa-test')
.should('exist');
cy.get('#dynamicModalLabel')
.should('contain.text', 'Test Item');
});
it('falls back to plain text when no icon', () => {
open({ icon: null });
cy.get('#dynamicModalLabel')
.find('i')
.should('not.exist');
cy.get('#dynamicModalLabel')
.should('have.text', 'Test Item');
});
it('shows identifier when provided and populates input', () => {
open();
cy.get('#dynamicIdentifierBox').should('not.have.class', 'd-none');
cy.get('#dynamicModalContent').should('have.value', 'ABC123');
});
it('hides identifier box when none', () => {
open({ identifier: null });
cy.get('#dynamicIdentifierBox').should('have.class', 'd-none');
cy.get('#dynamicModalContent').should('have.value', '');
});
it('renders warning and info via marked', () => {
open();
cy.get('#dynamicModalWarning')
.should('not.have.class', 'd-none')
.find('#dynamicModalWarningText')
.should('contain.html', '<strong>Be careful!</strong>');
cy.get('#dynamicModalInfo')
.should('not.have.class', 'd-none')
.find('#dynamicModalInfoText')
.should('contain.html', '<em>Some info</em>');
});
it('hides warning/info when none provided', () => {
open({ warning: null, info: null });
cy.get('#dynamicModalWarning').should('have.class', 'd-none');
cy.get('#dynamicModalInfo').should('have.class', 'd-none');
});
it('shows description when no URL', () => {
open({ url: null, description: 'Only desc' });
cy.get('#dynamicDescriptionText')
.should('not.have.class', 'd-none')
.and('have.text', 'Only desc');
cy.get('#dynamicModalLink').should('have.class', 'd-none');
});
it('shows link when URL is provided', () => {
open({ url: 'https://example.com', description: 'Click me' });
cy.get('#dynamicModalLink').should('not.have.class', 'd-none');
cy.get('#dynamicModalLinkHref')
.should('have.attr', 'href', 'https://example.com')
.and('have.text', 'Click me');
});
it('populates alternatives and children lists', () => {
open();
cy.get('#dynamicAlternativesSection').should('not.have.class', 'd-none');
cy.get('#dynamicAlternativesList li')
.should('have.length', 1)
.first().contains('Alt One');
cy.get('#dynamicChildrenSection').should('not.have.class', 'd-none');
cy.get('#dynamicChildrenList li')
.should('have.length', 1)
.first().contains('Child One');
});
it('hides sections when no items', () => {
open({ alternatives: [], children: [] });
cy.get('#dynamicAlternativesSection').should('have.class', 'd-none');
cy.get('#dynamicChildrenSection').should('have.class', 'd-none');
});
it('clicking an “Open” in list re-opens popup with that item', () => {
open();
cy.get('#dynamicAlternativesList button').click();
cy.get('#dynamicModalLabel')
.should('contain.text', 'Alt One');
});
it('copy button selects & copies identifier', () => {
open();
cy.get('#dynamicCopyButton').click();
cy.window().its('navigator.clipboard.writeText')
.should('have.been.calledWith', 'ABC123');
cy.window().its('alert')
.should('have.been.calledWith', 'Identifier copied to clipboard!');
});
});

View File

@@ -0,0 +1,130 @@
// cypress/e2e/dynamic_popup.spec.js
describe('Dynamic Popup', () => {
const base = {
name: 'Test Item',
identifier: 'ABC123',
description: 'A simple description',
warning: '**Be careful!**',
info: '_Some info_',
url: null,
iframe: false,
icon: { class: 'fa fa-test' },
alternatives: [
{ name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } }
],
children: [
{ name: 'Child One', identifier: 'CH1', icon: { class: 'fa fa-child1' } }
]
};
beforeEach(() => {
cy.visit('/');
cy.window().then(win => {
cy.stub(win.navigator.clipboard, 'writeText').resolves();
cy.stub(win, 'alert');
});
});
function open(item = {}) {
cy.window().invoke('openDynamicPopup', { ...base, ...item });
}
it('renders title with icon and text', () => {
open();
cy.get('#dynamicModalLabel')
.find('i.fa.fa-test')
.should('exist');
cy.get('#dynamicModalLabel')
.should('contain.text', 'Test Item');
});
it('falls back to plain text when no icon', () => {
open({ icon: null });
cy.get('#dynamicModalLabel')
.find('i')
.should('not.exist');
cy.get('#dynamicModalLabel')
.should('have.text', 'Test Item');
});
it('shows identifier when provided and populates input', () => {
open();
cy.get('#dynamicIdentifierBox').should('not.have.class', 'd-none');
cy.get('#dynamicModalContent').should('have.value', 'ABC123');
});
it('hides identifier box when none', () => {
open({ identifier: null });
cy.get('#dynamicIdentifierBox').should('have.class', 'd-none');
cy.get('#dynamicModalContent').should('have.value', '');
});
it('renders warning and info via marked', () => {
open();
cy.get('#dynamicModalWarning')
.should('not.have.class', 'd-none')
.find('#dynamicModalWarningText')
.should('contain.html', '<strong>Be careful!</strong>');
cy.get('#dynamicModalInfo')
.should('not.have.class', 'd-none')
.find('#dynamicModalInfoText')
.should('contain.html', '<em>Some info</em>');
});
it('hides warning/info when none provided', () => {
open({ warning: null, info: null });
cy.get('#dynamicModalWarning').should('have.class', 'd-none');
cy.get('#dynamicModalInfo').should('have.class', 'd-none');
});
it('shows description when no URL', () => {
open({ url: null, description: 'Only desc' });
cy.get('#dynamicDescriptionText')
.should('not.have.class', 'd-none')
.and('have.text', 'Only desc');
cy.get('#dynamicModalLink').should('have.class', 'd-none');
});
it('shows link when URL is provided', () => {
open({ url: 'https://example.com', description: 'Click me' });
cy.get('#dynamicModalLink').should('not.have.class', 'd-none');
cy.get('#dynamicModalLinkHref')
.should('have.attr', 'href', 'https://example.com')
.and('have.text', 'Click me');
});
it('populates alternatives and children lists', () => {
open();
cy.get('#dynamicAlternativesSection').should('not.have.class', 'd-none');
cy.get('#dynamicAlternativesList li')
.should('have.length', 1)
.first().contains('Alt One');
cy.get('#dynamicChildrenSection').should('not.have.class', 'd-none');
cy.get('#dynamicChildrenList li')
.should('have.length', 1)
.first().contains('Child One');
});
it('hides sections when no items', () => {
open({ alternatives: [], children: [] });
cy.get('#dynamicAlternativesSection').should('have.class', 'd-none');
cy.get('#dynamicChildrenSection').should('have.class', 'd-none');
});
it('clicking an “Open” in list re-opens popup with that item', () => {
open();
cy.get('#dynamicAlternativesList button').click();
cy.get('#dynamicModalLabel')
.should('contain.text', 'Alt One');
});
it('copy button selects & copies identifier', () => {
open();
cy.get('#dynamicCopyButton').click();
cy.window().its('navigator.clipboard.writeText')
.should('have.been.calledWith', 'ABC123');
cy.window().its('alert')
.should('have.been.calledWith', 'Identifier copied to clipboard!');
});
});

View File

@@ -0,0 +1,32 @@
describe('Navbar Logo Visibility', () => {
beforeEach(() => {
cy.visit('/');
});
it('should have #navbar_logo present in the DOM', () => {
cy.get('#navbar_logo').should('exist');
});
it('should be invisible (opacity 0) by default', () => {
cy.get('#navbar_logo')
.should('exist')
.and('have.css', 'opacity', '0');
});
it('should become visible (opacity 1) after entering fullscreen', () => {
cy.window().then(win => {
win.enterFullscreen();
});
cy.get('#navbar_logo', { timeout: 4000 })
.should('have.css', 'opacity', '1');
});
it('should become invisible again (opacity 0) after exiting fullscreen', () => {
cy.window().then(win => {
win.enterFullscreen();
win.exitFullscreen();
});
cy.get('#navbar_logo', { timeout: 4000 })
.should('have.css', 'opacity', '0');
});
});

View File

@@ -0,0 +1,130 @@
// cypress/e2e/dynamic_popup.spec.js
describe('Dynamic Popup', () => {
const base = {
name: 'Test Item',
identifier: 'ABC123',
description: 'A simple description',
warning: '**Be careful!**',
info: '_Some info_',
url: null,
iframe: false,
icon: { class: 'fa fa-test' },
alternatives: [
{ name: 'Alt One', identifier: 'ALT1', icon: { class: 'fa fa-alt1' } }
],
children: [
{ name: 'Child One', identifier: 'CH1', icon: { class: 'fa fa-child1' } }
]
};
beforeEach(() => {
cy.visit('/');
cy.window().then(win => {
cy.stub(win.navigator.clipboard, 'writeText').resolves();
cy.stub(win, 'alert');
});
});
function open(item = {}) {
cy.window().invoke('openDynamicPopup', { ...base, ...item });
}
it('renders title with icon and text', () => {
open();
cy.get('#dynamicModalLabel')
.find('i.fa.fa-test')
.should('exist');
cy.get('#dynamicModalLabel')
.should('contain.text', 'Test Item');
});
it('falls back to plain text when no icon', () => {
open({ icon: null });
cy.get('#dynamicModalLabel')
.find('i')
.should('not.exist');
cy.get('#dynamicModalLabel')
.should('have.text', 'Test Item');
});
it('shows identifier when provided and populates input', () => {
open();
cy.get('#dynamicIdentifierBox').should('not.have.class', 'd-none');
cy.get('#dynamicModalContent').should('have.value', 'ABC123');
});
it('hides identifier box when none', () => {
open({ identifier: null });
cy.get('#dynamicIdentifierBox').should('have.class', 'd-none');
cy.get('#dynamicModalContent').should('have.value', '');
});
it('renders warning and info via marked', () => {
open();
cy.get('#dynamicModalWarning')
.should('not.have.class', 'd-none')
.find('#dynamicModalWarningText')
.should('contain.html', '<strong>Be careful!</strong>');
cy.get('#dynamicModalInfo')
.should('not.have.class', 'd-none')
.find('#dynamicModalInfoText')
.should('contain.html', '<em>Some info</em>');
});
it('hides warning/info when none provided', () => {
open({ warning: null, info: null });
cy.get('#dynamicModalWarning').should('have.class', 'd-none');
cy.get('#dynamicModalInfo').should('have.class', 'd-none');
});
it('shows description when no URL', () => {
open({ url: null, description: 'Only desc' });
cy.get('#dynamicDescriptionText')
.should('not.have.class', 'd-none')
.and('have.text', 'Only desc');
cy.get('#dynamicModalLink').should('have.class', 'd-none');
});
it('shows link when URL is provided', () => {
open({ url: 'https://example.com', description: 'Click me' });
cy.get('#dynamicModalLink').should('not.have.class', 'd-none');
cy.get('#dynamicModalLinkHref')
.should('have.attr', 'href', 'https://example.com')
.and('have.text', 'Click me');
});
it('populates alternatives and children lists', () => {
open();
cy.get('#dynamicAlternativesSection').should('not.have.class', 'd-none');
cy.get('#dynamicAlternativesList li')
.should('have.length', 1)
.first().contains('Alt One');
cy.get('#dynamicChildrenSection').should('not.have.class', 'd-none');
cy.get('#dynamicChildrenList li')
.should('have.length', 1)
.first().contains('Child One');
});
it('hides sections when no items', () => {
open({ alternatives: [], children: [] });
cy.get('#dynamicAlternativesSection').should('have.class', 'd-none');
cy.get('#dynamicChildrenSection').should('have.class', 'd-none');
});
it('clicking an “Open” in list re-opens popup with that item', () => {
open();
cy.get('#dynamicAlternativesList button').click();
cy.get('#dynamicModalLabel')
.should('contain.text', 'Alt One');
});
it('copy button selects & copies identifier', () => {
open();
cy.get('#dynamicCopyButton').click();
cy.window().its('navigator.clipboard.writeText')
.should('have.been.calledWith', 'ABC123');
cy.window().its('alert')
.should('have.been.calledWith', 'Identifier copied to clipboard!');
});
});

78
app/eslint.config.js Normal file
View File

@@ -0,0 +1,78 @@
'use strict';
/**
* Correctness only — eslint's recommended set, no stylistic rules.
*
* The browser scripts are plain <script> tags sharing one global scope: each
* file declares some functions and calls others declared elsewhere. That is why
* they are listed as globals and why no-redeclare is off — the declaring file
* would otherwise be reported for defining its own function.
*/
const js = require('@eslint/js');
const globals = require('globals');
// Vendored libraries plus the functions static/js files call across each other.
// Keep this to names that really cross a file boundary. Every superfluous
// entry is a permanent no-undef blind spot for a typo of that name.
const SHARED = {
bootstrap: 'readonly',
marked: 'readonly',
$: 'readonly',
jQuery: 'readonly',
openDynamicPopup: 'readonly',
closeAllModals: 'readonly',
isSafeUrl: 'readonly',
openIframe: 'readonly',
enterFullscreen: 'readonly',
exitFullscreen: 'readonly',
setFullWidth: 'readonly',
initFullWidthFromUrl: 'readonly',
adjustScrollContainerHeight: 'readonly',
updateCustomScrollbar: 'readonly',
};
module.exports = [
{ ignores: ['node_modules/**', 'static/vendor/**', 'cypress/screenshots/**'] },
{
files: ['static/js/**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'script',
globals: { ...globals.browser, ...SHARED },
},
rules: {
...js.configs.recommended.rules,
'no-redeclare': 'off',
// vars: 'local' — a top-level function here is the API other files and
// the templates call, so only unused locals are a defect.
'no-unused-vars': [
'error',
{ vars: 'local', args: 'none', caughtErrors: 'none' },
],
},
},
{
files: ['cypress/**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'script',
globals: {
...globals.browser,
...globals.mocha,
cy: 'readonly',
Cypress: 'readonly',
expect: 'readonly',
assert: 'readonly',
},
},
rules: js.configs.recommended.rules,
},
{
files: ['scripts/**/*.js', 'cypress.config.js', 'eslint.config.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
globals: globals.node,
},
rules: js.configs.recommended.rules,
},
];

46
app/i18n/keep.txt Normal file
View File

@@ -0,0 +1,46 @@
# Strings utils/i18n_sync.py stores as themselves instead of translating.
# One per line; blank lines and lines starting with # are ignored.
Baserow
Big Blue Button
Bluesky
Buy me a Coffee
Discourse
Duolingo
Eversports
Facebook
Friendica
Garmin
Gitea
GitHub
GitHub Sponsors
Infinito.Nexus
Instagram
Keycloak
LDAP
LinkedIn
Mailu
Mastodon
Matomo
Matrix
Nextcloud
Open Project
Patreon
PayPal
Peertube
Pixelfed
Signal
Snipe IT
Spotify
Taiga
Telegram
Twitter
WhatsApp
XING
YouTube
Yourls
Zoom
freelancermap.de
malt
phpMyAdmin
upwork.com

11
app/i18n/ui/ar.yaml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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:
Open Link: Bağlantıyı
Options: Seçenekler
Warning: Uyarı

11
app/i18n/ui/uk.yaml Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,11 @@
Alternatives: 替代方案
Close: 关闭
Copy: 复制
Identifier copied to clipboard!: 标识符已复制到剪贴板!
Imprint: 法律声明
Information: 信息
Language: 语言
Open: 打开
Open Link: 打开链接
Options: 选项
Warning: 警告

19
app/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"bootstrap": "5.2.2",
"bootstrap-icons": "1.9.1",
"jquery": "3.6.0",
"marked": "^4.3.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"cypress": "^14.5.1",
"eslint": "^10.9.0",
"globals": "^17.11.0"
},
"scripts": {
"build": "node scripts/copy-vendor.js",
"postinstall": "node scripts/copy-vendor.js"
}
}

View File

@@ -1,3 +0,0 @@
flask
requests
pyyaml

View File

@@ -0,0 +1,75 @@
'use strict';
/**
* Copies third-party browser assets from node_modules into static/vendor/
* so Flask can serve them without any CDN dependency.
* Runs automatically via the "postinstall" npm hook.
*/
const fs = require('fs');
const path = require('path');
const NM = path.join(__dirname, '..', 'node_modules');
const VENDOR = path.join(__dirname, '..', 'static', 'vendor');
function copyFile(src, dest) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
}
function copyDir(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const s = path.join(src, entry.name);
const d = path.join(dest, entry.name);
entry.isDirectory() ? copyDir(s, d) : fs.copyFileSync(s, d);
}
}
// Bootstrap CSS + JS bundle
copyFile(
path.join(NM, 'bootstrap', 'dist', 'css', 'bootstrap.min.css'),
path.join(VENDOR, 'bootstrap', 'css', 'bootstrap.min.css')
);
copyFile(
path.join(NM, 'bootstrap', 'dist', 'css', 'bootstrap.rtl.min.css'),
path.join(VENDOR, 'bootstrap', 'css', 'bootstrap.rtl.min.css')
);
copyFile(
path.join(NM, 'bootstrap', 'dist', 'js', 'bootstrap.bundle.min.js'),
path.join(VENDOR, 'bootstrap', 'js', 'bootstrap.bundle.min.js')
);
// Bootstrap Icons CSS + embedded fonts
copyFile(
path.join(NM, 'bootstrap-icons', 'font', 'bootstrap-icons.css'),
path.join(VENDOR, 'bootstrap-icons', 'font', 'bootstrap-icons.css')
);
copyDir(
path.join(NM, 'bootstrap-icons', 'font', 'fonts'),
path.join(VENDOR, 'bootstrap-icons', 'font', 'fonts')
);
// Font Awesome Free CSS + webfonts
copyFile(
path.join(NM, '@fortawesome', 'fontawesome-free', 'css', 'all.min.css'),
path.join(VENDOR, 'fontawesome', 'css', 'all.min.css')
);
copyDir(
path.join(NM, '@fortawesome', 'fontawesome-free', 'webfonts'),
path.join(VENDOR, 'fontawesome', 'webfonts')
);
// marked browser UMD build (path varies by version)
const markedCandidates = [
path.join(NM, 'marked', 'marked.min.js'), // v4.x
path.join(NM, 'marked', 'lib', 'marked.umd.min.js'), // v5.x
path.join(NM, 'marked', 'dist', 'marked.min.js'), // v9+
];
const markedSrc = markedCandidates.find(p => fs.existsSync(p));
if (!markedSrc) throw new Error('marked: no browser UMD build found in node_modules');
copyFile(markedSrc, path.join(VENDOR, 'marked', 'marked.min.js'));
// jQuery
copyFile(
path.join(NM, 'jquery', 'dist', 'jquery.min.js'),
path.join(VENDOR, 'jquery', 'jquery.min.js')
);

View File

@@ -38,6 +38,18 @@ a {
background-color: #f9f9f9;
}
.card {
transition: background-color 1s ease, transform 1s ease;
transition: color 1s ease, transform 1s ease;
}
.card:hover {
/* invert everything inside the card */
filter: invert(0.8) hue-rotate(144deg);
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
}
.card-body {
display: flex;
flex-direction: column;
@@ -70,7 +82,6 @@ h3.card-title {
/* Footer styles */
.footer {
margin-top: 12px;
text-align: center;
font-size: 0.7em;
}
@@ -85,8 +96,11 @@ h3.footer-title {
font-size: 1.3em;
}
.card-img-top i {
.card-img-top i, .card-img-top svg{
font-size: 100px;
fill: currentColor;
width: 100px;
height: auto;
}
div#navbarNavheader li.nav-item {
@@ -97,7 +111,22 @@ div#navbarNavfooter li.nav-item {
margin-right: 6px;
}
main {
/* Prevent nav items from wrapping to a second line.
overflow is intentionally NOT set here — overflow-x:auto would
implicitly clip overflow-y too and hide dropdown menus that open
below the navbar. */
div#navbarNavheader .navbar-nav,
div#navbarNavfooter .navbar-nav {
flex-wrap: nowrap;
}
.dropdown-menu.language-menu {
max-height: 60vh;
overflow-y: auto;
overscroll-behavior: contain;
}
main, footer, header, nav {
position: relative;
box-shadow:
/* Inner shadow */
@@ -108,3 +137,82 @@ main {
-10px 0 10px -10px rgba(0, 0, 0, 0.3); /* Left outer shadow */
overflow: visible;
}
header{
padding: 12px;
}
header,
footer {
left: 0;
right: 0;
bottom: 0;
top: 0;
margin: 0;
z-index: 1030;
background-color: var(--bs-light);
}
/* at the end of default.css */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
}
iframe{
margin-bottom: -10px;
}
.container-fluid {
max-width: 100% !important;
}
:root {
--anim-duration: 3s; /* Basis-Dauer */
}
.container,
.container-fluid {
transition:
max-width var(--anim-duration) ease-in-out,
padding-left var(--anim-duration) ease-in-out,
padding-right var(--anim-duration) ease-in-out;
}
#navbar_logo {
opacity: 0;
max-width: 0;
overflow: hidden;
transition: opacity var(--anim-duration) ease-in-out,
max-width var(--anim-duration) ease-in-out;
}
#navbar_logo.visible {
opacity: 1 !important;
max-width: 300px;
}
/* 1. Make sure headers and footers can collapse */
header,
footer {
/* choose a max-height thats >= your tallest header/footer */
max-height: 200px;
padding: 1rem;
transition:
max-height var(--anim-duration) ease-in-out,
padding var(--anim-duration) ease-in-out;
}
/* 2. In fullscreen mode, collapse them. overflow: hidden is scoped here
so dropdown menus can escape the header in normal mode. */
body.fullscreen header,
body.fullscreen footer {
overflow: hidden;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
}

View File

@@ -1,9 +1,19 @@
/* Top-level dropdown menu */
.nav-item .dropdown-menu {
position: absolute; /* Important for positioning */
top: 100%; /* Default opening direction: downwards */
/* Top-level dropdown menu — direction toggled by JS via .dropdown / .dropup */
.nav-item.dropdown > .dropdown-menu,
.nav-item.dropup > .dropdown-menu {
position: absolute;
left: 0;
z-index: 1050; /* Ensures the menu appears above other elements */
z-index: 1050;
}
.nav-item.dropdown > .dropdown-menu {
top: 100%;
bottom: auto;
}
.nav-item.dropup > .dropdown-menu {
top: auto;
bottom: 100%;
}
/* Submenu position */
@@ -19,12 +29,6 @@
transition: all 0.3s ease-in-out;
}
nav.navbar.menu-header {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
nav.navbar.menu-footer {
border-top-left-radius: 0;
border-top-right-radius: 0;
nav.navbar {
border-radius: 0;
}

View File

@@ -17,7 +17,7 @@ function adjustScrollContainerHeight() {
// Calculate the available height for the scroll area
const availableHeight = window.innerHeight - siblingsHeight;
scrollContainer.style.maxHeight = availableHeight + 'px';
scrollContainer.style.height = availableHeight + 'px';
scrollContainer.style.overflowY = 'auto';
scrollContainer.style.overflowX = 'hidden';

110
app/static/js/fullscreen.js Normal file
View File

@@ -0,0 +1,110 @@
/**
* Add or remove the `fullscreen=1` URL parameter.
* @param {boolean} enabled
*/
function updateUrlFullscreen(enabled) {
var url = new URL(window.location);
if (enabled) url.searchParams.set('fullscreen', '1');
else url.searchParams.delete('fullscreen');
window.history.replaceState({}, '', url);
}
/**
* Starts a requestAnimationFrame loop that calls your recalc methods,
* and stops automatically when the headers max-height transition ends.
*/
function recalcWhileCollapsing() {
const header = document.querySelector('header');
if (!header) return;
// 1) Start the RAF loop
let rafId;
const step = () => {
adjustScrollContainerHeight();
updateCustomScrollbar();
rafId = requestAnimationFrame(step);
};
step();
// 2) Listen for the end of the max-height transition
function onEnd(e) {
if (e.propertyName === 'max-height') {
cancelAnimationFrame(rafId);
header.removeEventListener('transitionend', onEnd);
}
}
header.addEventListener('transitionend', onEnd);
}
function enterFullscreen() {
document.body.classList.add('fullscreen');
setFullWidth(true);
updateUrlFullscreen(true);
// Nur jetzt sichtbar machen
const logo = document.getElementById('navbar_logo');
if (logo) {
logo.classList.add('visible');
}
recalcWhileCollapsing();
}
function exitFullscreen() {
document.body.classList.remove('fullscreen');
setFullWidth(false);
updateUrlFullscreen(false);
// Jetzt wieder verstecken
const logo = document.getElementById('navbar_logo');
if (logo) {
logo.classList.remove('visible');
}
recalcWhileCollapsing();
}
/**
* Toggle between enter and exit fullscreen.
*/
function toggleFullscreen() {
const params = new URLSearchParams(window.location.search);
const isFull = params.get('fullscreen') === '1';
if (isFull) exitFullscreen();
else enterFullscreen();
}
/**
* Read `fullscreen` flag from URL on load.
* @returns {boolean}
*/
function initFullscreenFromUrl() {
return new URLSearchParams(window.location.search).get('fullscreen') === '1';
}
// On page load: apply fullwidth & fullscreen flags
window.addEventListener('DOMContentLoaded', function() {
// first fullwidth
var wasFullWidth = initFullWidthFromUrl();
setFullWidth(wasFullWidth);
// now fullscreen
if (initFullscreenFromUrl()) {
enterFullscreen();
}
});
// Mirror native F11/fullscreen API events
document.addEventListener('fullscreenchange', function() {
if (document.fullscreenElement) enterFullscreen();
else exitFullscreen();
});
window.addEventListener('resize', function() {
var isUiFs = Math.abs(window.innerHeight - screen.height) < 2;
if (isUiFs) enterFullscreen();
else exitFullscreen();
});
// Expose globally
window.fullscreen = enterFullscreen;
window.exitFullscreen = exitFullscreen;
window.toggleFullscreen = toggleFullscreen;

View File

@@ -0,0 +1,42 @@
/**
* Toggles the .container class between .container and .container-fluid.
* @param {boolean} enabled true = full width, false = normal.
*/
function setFullWidth(enabled) {
var el = document.querySelector('.container, .container-fluid');
if (!el) return;
if (enabled) {
el.classList.replace('container', 'container-fluid');
updateUrlFullWidth(true);
} else {
el.classList.replace('container-fluid', 'container');
updateUrlFullWidth(false);
}
}
/**
* Reads the URL parameter `fullwidth` and applies full width if it's set.
* @returns {boolean} current fullwidth state
*/
function initFullWidthFromUrl() {
var isFull = new URLSearchParams(window.location.search).get('fullwidth') === '1';
setFullWidth(isFull);
return isFull;
}
/**
* Adds or removes the `fullwidth=1` URL parameter.
* @param {boolean} enabled
*/
function updateUrlFullWidth(enabled) {
var url = new URL(window.location);
if (enabled) url.searchParams.set('fullwidth', '1');
else url.searchParams.delete('fullwidth');
window.history.replaceState({}, '', url);
}
// Expose globally
window.setFullWidth = setFullWidth;
window.initFullWidthFromUrl = initFullWidthFromUrl;
window.updateUrlFullWidth = updateUrlFullWidth;

View File

@@ -1,101 +1,193 @@
// Global variables to store elements and original state
let mainElement, originalContent, originalMainStyle, container, customScrollbar;
let mainElement, originalContent, originalMainStyle, container, customScrollbar, scrollbarContainer;
let currentIframeUrl = null;
// Function to open a URL in an iframe using global variables
function openIframe(url) {
// Set a fixed height for the main element if not already set
if (!mainElement.style.height) {
mainElement.style.height = `${mainElement.clientHeight}px`;
}
// === Auto-open iframe if URL parameter is present ===
window.addEventListener('DOMContentLoaded', () => {
const paramUrl = new URLSearchParams(window.location.search).get('iframe');
if (paramUrl) {
currentIframeUrl = paramUrl;
enterFullscreen();
openIframe(paramUrl);
}
});
// Replace the container class with container-fluid if not already applied
if (container && !container.classList.contains("container-fluid")) {
container.classList.replace("container", "container-fluid");
}
// Hide the custom scrollbar
if (customScrollbar) {
customScrollbar.style.display = "none";
}
// Check if an iframe already exists in the main element
let iframe = mainElement.querySelector("iframe");
if (!iframe) {
// Create a new iframe element
iframe = document.createElement("iframe");
iframe.width = "100%";
iframe.style.border = "none";
iframe.style.height = mainElement.style.height; // Apply fixed height
iframe.style.overflow = "auto"; // Enable internal scrollbar
iframe.scrolling = "auto"; // Ensure scrollability
// Clear the main content before appending the iframe
mainElement.innerHTML = "";
mainElement.appendChild(iframe);
}
// Set the URL of the iframe
iframe.src = url;
}
document.addEventListener("DOMContentLoaded", function () {
// Initialize global variables
mainElement = document.querySelector("main");
originalContent = mainElement.innerHTML;
originalMainStyle = mainElement.getAttribute("style"); // might be null if no inline style exists
container = document.querySelector(".container");
customScrollbar = document.getElementById("custom-scrollbar");
// Get all links that should open in an iframe
const links = document.querySelectorAll(".iframe-link");
// Add click event listener to each iframe link
links.forEach(link => {
link.addEventListener("click", function (event) {
event.preventDefault(); // Prevent default link behavior
const url = this.getAttribute("href");
openIframe(url);
});
});
// Add click event listener to header h1 to restore the original main content and style
const headerH1 = document.querySelector("header h1");
if (headerH1) {
headerH1.style.cursor = "pointer";
headerH1.addEventListener("click", function () {
// Restore the original content of the main element (removing the iframe)
mainElement.innerHTML = originalContent;
// Restore the original inline style of the main element
if (originalMainStyle !== null) {
mainElement.setAttribute("style", originalMainStyle);
// Synchronize the height of the iframe to match the scroll-container or main element
function syncIframeHeight() {
const iframe = mainElement.querySelector("iframe");
if (iframe) {
if (scrollbarContainer) {
// Prefer inline height, otherwise inline max-height
const inlineHeight = scrollbarContainer.style.height;
const inlineMax = scrollbarContainer.style.maxHeight;
const target = inlineHeight || inlineMax;
if (target) {
iframe.style.height = target;
} else {
mainElement.removeAttribute("style");
iframe.style.height = mainElement.style.height;
}
// Optionally revert the container class back to "container" if needed
if (container && container.classList.contains("container-fluid")) {
container.classList.replace("container-fluid", "container");
}
// Optionally show the custom scrollbar again
if (customScrollbar) {
customScrollbar.style.display = "";
}
// Adjust scroll container height if that function exists
if (typeof adjustScrollContainerHeight === "function") {
adjustScrollContainerHeight();
}
});
}
// Adjust iframe height on window resize (optional, to keep it responsive)
window.addEventListener("resize", function () {
const iframe = mainElement.querySelector("iframe");
if (iframe) {
} else {
iframe.style.height = mainElement.style.height;
}
}
}
// Function to open a URL in an iframe (jQuery version mit 1500 ms Fade)
function openIframe(url) {
if (!isSafeUrl(url)) {
return;
}
var $container = scrollbarContainer ? $(scrollbarContainer) : null;
var $customScroll = customScrollbar ? $(customScrollbar) : null;
var $main = $(mainElement);
// Original-Content ausblenden mit 1500 ms
var promises = [];
if ($container) promises.push($container.fadeOut(1500).promise());
if ($customScroll) promises.push($customScroll.fadeOut(1500).promise());
$.when.apply($, promises).done(function() {
// now that scroll areas are hidden, go fullscreen
enterFullscreen();
// create iframe if it doesnt exist yet
var $iframe = $main.find('iframe');
if ($iframe.length === 0) {
originalMainStyle = $main.attr('style') || null;
$iframe = $('<iframe>', {
width: '100%',
frameborder: 0,
scrolling: 'auto'
}).css({ overflow: 'auto', display: 'none' });
$main.append($iframe);
}
// Quelle setzen und mit 1500 ms einblenden
$iframe
.attr('src', url)
.fadeIn(1500, function() {
syncIframeHeight();
observeIframeNavigation();
});
// URL-State pushen
var newUrl = new URL(window.location);
newUrl.searchParams.set('iframe', url);
window.history.pushState({ iframe: url }, '', newUrl);
});
}
/**
* Restore the original <main> content and exit fullscreen.
*/
function restoreOriginal() {
// Exit fullscreen (collapse header/footer and run recalcs)
exitFullscreen();
// Replace <main> innerHTML with the snapshot we took on load
mainElement.innerHTML = originalContent;
// Reset any inline styles on mainElement
if (originalMainStyle !== null) {
mainElement.setAttribute('style', originalMainStyle);
} else {
mainElement.removeAttribute('style');
}
// Re-run height adjustments for scroll container & thumb
adjustScrollContainerHeight();
updateCustomScrollbar();
// Clear iframe state and URL param
currentIframeUrl = null;
history.replaceState(null, '', window.location.pathname);
}
// Initialize event listeners after DOM content is loaded
document.addEventListener("DOMContentLoaded", function() {
// Cache references to elements and original state
mainElement = document.querySelector("main");
originalContent = mainElement.innerHTML;
originalMainStyle = mainElement.getAttribute("style"); // may be null
container = document.querySelector(".container");
customScrollbar = document.getElementById("custom-scrollbar");
scrollbarContainer = container.querySelector(".scroll-container")
document.querySelectorAll(".js-restore").forEach(el => {
el.style.cursor = "pointer";
el.addEventListener("click", restoreOriginal);
});
// === Close iframe & exit fullscreen when any .js-restore is clicked ===
document.querySelectorAll('.js-restore').forEach(el => {
el.style.cursor = 'pointer';
el.addEventListener('click', () => {
// first collapse header/footer and recalc container
exitFullscreen();
// then fade out and remove the iframe, fade content back
restoreOriginal();
// clear stored URL and reset browser address
currentIframeUrl = null;
history.replaceState(null, '', window.location.pathname);
});
});
});
/**
* Opens the current iframe URL in a new browser tab.
*/
function openIframeInNewTab() {
const params = new URLSearchParams(window.location.search);
const iframeUrl = params.get('iframe');
if (iframeUrl) {
window.open(iframeUrl, '_blank');
} else {
alert('No iframe is currently open.');
}
}
// expose globally so your templates onclick can find it
window.openIframeInNewTab = openIframeInNewTab;
// Adjust iframe height on window resize
window.addEventListener('resize', syncIframeHeight);
/**
* Observe iframe location changes (Same-Origin only).
*/
function observeIframeNavigation() {
const iframe = mainElement.querySelector("iframe");
if (!iframe || !iframe.contentWindow) return;
let lastUrl = iframe.contentWindow.location.href;
setInterval(() => {
try {
const currentUrl = iframe.contentWindow.location.href;
if (currentUrl !== lastUrl) {
lastUrl = currentUrl;
const newUrl = new URL(window.location);
newUrl.searchParams.set('iframe', currentUrl);
window.history.replaceState({}, '', newUrl);
}
} catch (e) {
// Cross-origin ignore
}
}, 500);
}
// Remember, open iframe, enter fullscreen, AND set the URL param immediately
document.querySelectorAll(".iframe-link").forEach(link => {
link.addEventListener("click", function(event) {
event.preventDefault();
currentIframeUrl = this.href;
enterFullscreen();
openIframe(currentIframeUrl);
// Update the browser URL right away
const newUrl = new URL(window.location);
newUrl.searchParams.set('iframe', currentIframeUrl);
window.history.replaceState({ iframe: currentIframeUrl }, '', newUrl);
});
});

View File

@@ -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, '&lt;').replace(/>/g, '&gt;');
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!'));
});
};

View File

@@ -1,6 +1,58 @@
document.addEventListener('DOMContentLoaded', () => {
const menuItems = document.querySelectorAll('.nav-item.dropdown');
const subMenuItems = document.querySelectorAll('.dropdown-submenu');
function getDirectChildByClass(item, className) {
return Array.from(item.children).find(child => child.classList?.contains(className));
}
function getMenu(item) {
return getDirectChildByClass(item, 'dropdown-menu');
}
function getToggle(item) {
return getDirectChildByClass(item, 'dropdown-toggle');
}
function isTopLevelDropdown(item) {
return (
item.classList.contains('nav-item') &&
(item.classList.contains('dropdown') || item.classList.contains('dropup'))
);
}
function chooseDirection(item) {
const rect = item.getBoundingClientRect();
const spaceAbove = rect.top;
const spaceBelow = window.innerHeight - rect.bottom;
if (spaceAbove > spaceBelow) {
item.classList.add('dropup');
item.classList.remove('dropdown');
} else {
item.classList.add('dropdown');
item.classList.remove('dropup');
}
}
function ensureDropdownInstances(root = document) {
const scope = root && root.querySelectorAll ? root : document;
scope
.querySelectorAll('.nav-item.dropdown > .dropdown-toggle, .nav-item.dropup > .dropdown-toggle')
.forEach(toggle => {
toggle.setAttribute('data-bs-toggle', 'dropdown');
if (!toggle.hasAttribute('aria-expanded')) {
toggle.setAttribute('aria-expanded', 'false');
}
if (window.bootstrap?.Dropdown) {
// Use Popper strategy: 'fixed' so the menu is positioned relative
// to the viewport and escapes ancestors with overflow:hidden
// (e.g. <header> which clips for the fullscreen-collapse animation).
window.bootstrap.Dropdown.getInstance(toggle)?.dispose();
new window.bootstrap.Dropdown(toggle, {
popperConfig(defaultBsPopperConfig) {
return { ...defaultBsPopperConfig, strategy: 'fixed' };
},
});
}
});
}
function addMenuEventListeners(items, isTopLevel) {
items.forEach(item => {
@@ -8,7 +60,7 @@ document.addEventListener('DOMContentLoaded', () => {
function onMouseEnter() {
clearTimeout(timeout);
openMenu(item, isTopLevel);
openMenu(item, isTopLevel, 'hover');
}
function onMouseLeave() {
@@ -25,7 +77,29 @@ document.addEventListener('DOMContentLoaded', () => {
// Open and adjust position on click
item.addEventListener('click', (e) => {
const toggle = getToggle(item);
const clickedToggle = toggle && (e.target === toggle || toggle.contains(e.target));
if (isTopLevel && !clickedToggle) {
e.stopPropagation();
return;
}
e.stopPropagation(); // Prevents menus from closing when clicking inside
if (isTopLevel) {
e.preventDefault();
if (window.bootstrap?.Dropdown) {
if (item.dataset.openedBy === 'click') {
closeMenu(item);
} else if (getMenu(item)) {
item.dataset.openedBy = 'click';
item.classList.add('open');
chooseDirection(item);
window.bootstrap.Dropdown.getOrCreateInstance(toggle).show();
}
return;
}
}
if (item.classList.contains('open')) {
closeMenu(item);
} else {
@@ -35,39 +109,59 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
const TOP_LEVEL_SELECTOR = '.nav-item.dropdown, .nav-item.dropup';
function addAllMenuEventListeners() {
const updatedMenuItems = document.querySelectorAll('.nav-item.dropdown');
const updatedMenuItems = document.querySelectorAll(TOP_LEVEL_SELECTOR);
const updatedSubMenuItems = document.querySelectorAll('.dropdown-submenu');
addMenuEventListeners(updatedMenuItems, true);
addMenuEventListeners(updatedSubMenuItems, false);
}
ensureDropdownInstances();
addAllMenuEventListeners();
// Global click listener to close menus when clicking outside
document.addEventListener('click', () => {
const menuItems = document.querySelectorAll(TOP_LEVEL_SELECTOR);
const subMenuItems = document.querySelectorAll('.dropdown-submenu');
[...menuItems, ...subMenuItems].forEach(item => closeMenu(item));
});
function openMenu(item, isTopLevel) {
function openMenu(item, isTopLevel, openedBy = 'script') {
item.classList.add('open');
const submenu = item.querySelector('.dropdown-menu');
if (submenu) {
submenu.style.display = 'block';
submenu.style.opacity = '1';
submenu.style.visibility = 'visible';
adjustMenuPosition(submenu, item, isTopLevel);
const submenu = getMenu(item);
if (!submenu) return;
if (isTopLevel) {
item.dataset.openedBy = openedBy;
const toggle = getToggle(item);
if (toggle && window.bootstrap?.Dropdown) {
chooseDirection(item);
window.bootstrap.Dropdown.getOrCreateInstance(toggle).show();
return;
}
}
submenu.style.display = 'block';
submenu.style.opacity = '1';
submenu.style.visibility = 'visible';
adjustMenuPosition(submenu, item, isTopLevel);
}
function closeMenu(item) {
item.classList.remove('open');
const submenu = item.querySelector('.dropdown-menu');
if (submenu) {
submenu.style.display = 'none';
submenu.style.opacity = '0';
submenu.style.visibility = 'hidden';
delete item.dataset.openedBy;
const submenu = getMenu(item);
if (!submenu) return;
if (isTopLevelDropdown(item)) {
const toggle = getToggle(item);
if (toggle && window.bootstrap?.Dropdown) {
window.bootstrap.Dropdown.getOrCreateInstance(toggle).hide();
return;
}
}
submenu.style.display = 'none';
submenu.style.opacity = '0';
submenu.style.visibility = 'hidden';
}
function isSmallScreen() {
@@ -75,7 +169,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
function adjustMenuPosition(submenu, parent, isTopLevel) {
const rect = submenu.getBoundingClientRect();
const parentRect = parent.getBoundingClientRect();
const spaceAbove = parentRect.top;

View File

@@ -1,29 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<html lang="{{ lang }}" dir="{{ lang_dir }}">
<head>
<title>{{platform.titel}}</title>
<meta charset="utf-8" >
<link rel="icon" type="image/x-icon" href="{{platform.favicon.cache}}">
<link
rel="icon"
type="image/x-icon"
href="{{ asset_src(platform.favicon) }}"
>
<link rel="canonical" href="{{ url_for('localized_index', lang=lang, _external=True) }}">
{% for code in languages %}
<link rel="alternate" hreflang="{{ code }}" href="{{ url_for('localized_index', lang=code, _external=True) }}">
{% endfor %}
<link rel="alternate" hreflang="x-default" href="{{ url_for('index', _external=True) }}">
<!-- Bootstrap CSS only -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-Zenh87qX5JnK2Jl0vWa8Ck2rdkQ2Bzep5IDxbcnCeuOxjzrPF/et3URy9Bv1WTRi" crossorigin="anonymous">
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap' ~ ('.rtl' if lang_dir == 'rtl' else '') ~ '.min.css') }}" rel="stylesheet">
<!-- Bootstrap JavaScript Bundle with Popper -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-OERcA2EqjJCMA+/3y+gxIOqMEjwtxJY7qPCqsdltbNJuaOe923+mo//f6V8Qbsw3" crossorigin="anonymous"></script>
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.9.1/font/bootstrap-icons.css">
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/font/bootstrap-icons.css') }}">
<!-- Fontawesome -->
<script src="https://kit.fontawesome.com/56f96da298.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/fontawesome/css/all.min.css') }}">
<!-- Markdown -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="{{ url_for('static', filename='vendor/marked/marked.min.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/default.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom_scrollbar.css') }}">
<!-- JQuery -->
<script src="{{ url_for('static', filename='vendor/jquery/jquery.min.js') }}"></script>
</head>
<body>
<body
{% if apod_bg %}
style="
background-image: url('{{ apod_bg }}');
background-size: cover;
background-position: center;
background-attachment: fixed;
"
{% endif %}
>
<div class="container">
<header class="header">
<img src="{{platform.logo.cache}}" alt="logo"/>
<header class="header js-restore">
<img
src="{{ asset_src(platform.logo) }}"
alt="logo"
/>
<h1>{{platform.titel}}</h1>
<h2>{{platform.subtitel}}</h2>
<br />
</header>
{% set menu_type = "header" %}
{% include "moduls/navigation.html.j2"%}
@@ -43,16 +65,23 @@
<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 src="{{ url_for('static', filename='js/modal.js') }}"></script>
<script src="{{ url_for('static', filename='js/navigation.js') }}"></script>
<script src="{{ url_for('static', filename='js/tooltip.js') }}"></script>
<script src="{{ url_for('static', filename='js/custom_scrollbar.js') }}"></script>
<script src="{{ url_for('static', filename='js/iframe.js') }}"></script>
<script>window.I18N = {{ ui_strings | tojson }};</script>
{% for name in [
'modal',
'navigation',
'tooltip',
'container',
'fullwidth',
'fullscreen',
'iframe',
] %}
<script src="{{ url_for('static', filename='js/' ~ name ~ '.js') }}"></script>
{% endfor %}
</body>
</html>

View File

@@ -1,23 +1,34 @@
<div class="card-column {{ lg_class }} {{ md_class }} col-12">
<div class="card h-100 d-flex flex-column">
<div class="card-body d-flex flex-column">
<div class="card-img-top">
{% if card.icon.class %}
<i class="{{ card.icon.class }}"></i>
{% else %}
<img src="{{ card.icon.cache }}" alt="{{ card.title }}" style="width: 100px; height: auto;">
{% endif %}
</div>
<hr />
<h3 class="card-title">{{ card.title }}</h3>
<p class="card-text">{{ card.text }}</p>
{% if card.url %}
<a href="{{ card.url }}" class="mt-auto btn btn-light stretched-link {% if card.iframe %}iframe-link{% endif %}">
<i class="fa-solid fa-globe"></i> {{ card.link_text }}
</a>
{% else %}
<i class="fa-solid fa-hourglass"></i> {{ card.link_text }}
{% endif %}
</div>
<div class="card h-100 d-flex flex-column">
<div class="card-body d-flex flex-column">
<div class="card-img-top">
{% if card.icon.cache and card.icon.cache.endswith('.svg') %}
{{ include_svg(card.icon.cache) }}
{% elif card.icon.cache or card.icon.external_url %}
<img
src="{{ asset_src(card.icon) }}"
alt="{{ card.title }}"
style="width:100px; height:auto;"
onerror="this.style.display='none'; this.nextElementSibling?.style.display='inline-block';">
{% if card.icon.class %}
<i class="{{ card.icon.class }}" style="display:none;"></i>
{% endif %}
{% elif card.icon.class %}
<i class="{{ card.icon.class }}"></i>
{% endif %}
</div>
<hr />
<h3 class="card-title">{{ card.title }}</h3>
<p class="card-text">{{ card.text }}</p>
{% if card.url %}
<a
href="{{ card.url }}"
class="mt-auto btn btn-light stretched-link {% if card.iframe %}iframe-link{% endif %}">
<i class="fa-solid fa-globe"></i> {{ card.link_text }}
</a>
{% else %}
<i class="fa-solid fa-hourglass"></i> {{ card.link_text }}
{% endif %}
</div>
</div>
</div>

View File

@@ -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>

View File

@@ -8,65 +8,113 @@
{% endmacro %}
<!-- Template for children -->
{% macro render_children(children) %}
{% for children in children %}
{% if children.children %}
<li class="dropdown-submenu position-relative">
<a class="dropdown-item dropdown-toggle" title="{{ children.description }}">
{{ render_icon_and_name(children) }}
</a>
<ul class="dropdown-menu">
{{ render_children(children.children) }}
</ul>
</li>
{% elif children.identifier or children.warning or children.info %}
<li>
<a class="dropdown-item" onclick='openDynamicPopup({{ children|tojson|safe }})' data-bs-toggle="tooltip" title="{{ children.description }}">
{{ render_icon_and_name(children) }}
</a>
</li>
{% else %}
<li>
<a class="dropdown-item {% if children.iframe %}iframe-link{% endif %}" href="{{ children.url }}" target="{{ children.target|default('_blank') }}" data-bs-toggle="tooltip" title="{{ children.description }}">
{{ render_icon_and_name(children) }}
</a>
</li>
{% endif %}
{% endfor %}
{% for child in children %}
{% if child.children %}
<li class="dropdown-submenu position-relative">
<a class="dropdown-item dropdown-toggle" title="{{ child.description }}">
{{ render_icon_and_name(child) }}
</a>
<ul class="dropdown-menu">
{{ render_children(child.children) }}
</ul>
</li>
{% elif child.identifier or child.warning or child.info %}
<li>
<a class="dropdown-item"
onclick='openDynamicPopup({{ child|tojson|safe }})'
data-bs-toggle="tooltip"
title="{{ child.description }}">
{{ render_icon_and_name(child) }}
</a>
</li>
{% else %}
<li>
<a class="dropdown-item {% if child.iframe %}iframe-link{% endif %}"
{% if child.onclick %}
onclick="{{ child.onclick }}"
{% else %}
href="{{ child.url }}"
{% endif %}
target="{{ child.target|default('_blank') }}"
data-bs-toggle="tooltip"
title="{{ child.description }}">
{{ render_icon_and_name(child) }}
</a>
</li>
{% endif %}
{% endfor %}
{% endmacro %}
<!-- Navigation Bar -->
<nav class="navbar navbar-expand-lg navbar-light bg-light menu-{{menu_type}}">
<div class="container-fluid">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav{{menu_type}}" aria-controls="navbarNav{{menu_type}}" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav{{menu_type}}">
<ul class="navbar-nav {% if menu_type == 'header' %}ms-auto{% endif %} btn-group">
{% for item in navigation[menu_type].children %}
{% if item.url %}
<!-- Single Item -->
<li class="nav-item">
<a class="nav-link btn btn-light {% if item.iframe %}iframe-link{% endif %}" href="{{ item.url }}" target="{{ item.target|default('_blank') }}" data-bs-toggle="tooltip" title="{{ item.description }}">
<nav class="navbar navbar-expand-lg navbar-light bg-light menu-{{menu_type}} mb-0">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav{{menu_type}}" aria-controls="navbarNav{{menu_type}}" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav{{menu_type}}">
{% if menu_type == "header" %}
<a class="navbar-brand align-items-center d-flex js-restore" id="navbar_logo" href="#">
<img
src="{{ asset_src(platform.logo) }}"
alt="{{ platform.titel }}"
class="d-inline-block align-text-top"
style="height:2rem">
<div class="ms-2 d-flex flex-column">
<span class="fs-4 fw-bold mb-0">{{ platform.titel }}</span>
{# <small class="fs-7 text-muted">{{ platform.subtitel }}</small> #}
</div>
{% endif %}
</a>
<ul class="navbar-nav {% if menu_type == 'header' %}ms-auto{% endif %} btn-group">
{% for item in navigation[menu_type].children %}
{% if item.url or item.onclick %}
<li class="nav-item">
<a class="nav-link btn btn-light {% if item.iframe %}iframe-link{% endif %}"
{% if item.onclick %}
onclick="{{ item.onclick }}"
{% else %}
href="{{ item.url }}"
{% endif %}
target="{{ item.target|default('_blank') }}"
data-bs-toggle="tooltip"
title="{{ item.description }}">
{{ render_icon_and_name(item) }}
</a>
</li>
{% else %}
<!-- Dropdown Menu -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle btn btn-light" id="navbarDropdown{{ loop.index }}" role="button" data-bs-toggle="dropdown" data-bs-display="dynamic" aria-expanded="false">
{% if item.icon is defined and item.icon.class is defined %}
{{ render_icon_and_name(item) }}
</a>
</li>
{% else %}
<!-- Dropdown Menu -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle btn btn-light" id="navbarDropdown{{ loop.index }}" role="button" data-bs-display="dynamic" aria-expanded="false">
{% if item.icon is defined and item.icon.class is defined %}
{{ render_icon_and_name(item) }}
{% else %}
<p>Missing icon in item: {{ item }}</p>
{% endif %}
</a>
<ul class="dropdown-menu">
{{ render_children(item.children) }}
</ul>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
{% else %}
<p>Missing icon in item: {{ item }}</p>
{% endif %}
</a>
<ul class="dropdown-menu">
{{ render_children(item.children) }}
</ul>
</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>

1
app/utils/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Utilities used by the Portfolio UI web application."""

View File

@@ -0,0 +1,89 @@
"""Split asset resolution into distinct fields so the template can
render the right URL.
Strategy
--------
1. **Probe** the source URL with a HEAD request. If it responds with an
``image/*`` content type, embed it directly via ``external_url``
(no download necessary — the browser fetches it itself).
2. **Cache** as a fallback: when the probe fails (404, non-image,
network error, …) try ``cache_manager.cache_file()`` to download
and serve via ``/static/cache/<file>``.
3. **External fallback**: if the download fails too, still expose the
source URL via ``external_url`` so the browser shows at least the
alt-text or broken-image icon instead of an empty ``src``.
Type safety
-----------
``cache`` only ever holds a relative path the static handler can serve;
absolute URLs land in ``external_url`` and are rendered as-is. The
previous fallback ``asset['cache'] = cached or asset['source']`` mixed
both shapes — and the template's ``url_for('static', filename=...)``
wrapper produced broken ``/static/https://…`` URLs when the source
was an absolute URL the container couldn't fetch.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Optional
import requests
_PROBE_TIMEOUT_SECONDS = 3
def _probe_image_url(url: str) -> bool:
"""Return True if a HEAD request to ``url`` succeeds with an
``image/*`` content type, suggesting the URL is safe to embed
directly as ``<img src>``."""
try:
resp = requests.head(url, allow_redirects=True, timeout=_PROBE_TIMEOUT_SECONDS)
resp.raise_for_status()
except requests.RequestException:
return False
content_type = resp.headers.get("Content-Type", "").lower()
return content_type.split(";", 1)[0].strip().startswith("image/")
def resolve_asset_cache(
asset: dict,
cache_manager,
probe: Callable[[str], bool] = _probe_image_url,
) -> None:
"""Set ``asset['cache']`` (local, served via /static/) or
``asset['external_url']`` (rendered as-is). Never both.
Tries the cheap direct-embed path first (``probe``) before falling
back to downloading via ``cache_manager``. See module docstring."""
source = asset.get("source")
asset["cache"] = None
asset["external_url"] = None
if not source:
return
if probe(source):
asset["external_url"] = source
return
cached = cache_manager.cache_file(source)
if cached:
asset["cache"] = cached
else:
asset["external_url"] = source
def asset_src(asset: Optional[dict], url_for_static: Callable[[str], str]) -> str:
"""Return the final URL for an asset dict prepared by
:func:`resolve_asset_cache`. ``url_for_static`` is a callable that
maps a relative cache path to its served URL (typically a partial
of Flask's ``url_for('static', filename=...)``)."""
if not asset:
return ""
external = asset.get("external_url")
if external:
return external
cached = asset.get("cache")
if cached:
return url_for_static(cached)
return ""

View File

@@ -1,66 +1,47 @@
import os
import hashlib
import mimetypes
import os
import requests
class CacheManager:
"""
A class to manage caching of files, including creating temporary directories
and caching files locally with hashed filenames.
"""
def __init__(self, cache_dir="static/cache"):
"""
Initialize the CacheManager with a cache directory.
:param cache_dir: The directory where cached files will be stored.
"""
self.cache_dir = cache_dir
self._ensure_cache_dir_exists()
def _ensure_cache_dir_exists(self):
"""
Ensure the cache directory exists. If it doesn't, create it.
"""
if not os.path.exists(self.cache_dir):
os.makedirs(self.cache_dir)
os.makedirs(self.cache_dir, exist_ok=True)
def clear_cache(self):
"""
Clear all files in the cache directory.
"""
if os.path.exists(self.cache_dir):
for filename in os.listdir(self.cache_dir):
file_path = os.path.join(self.cache_dir, filename)
if os.path.isfile(file_path):
os.remove(file_path)
path = os.path.join(self.cache_dir, filename)
if os.path.isfile(path):
os.remove(path)
def cache_file(self, file_url):
"""
Download a file and store it locally in the cache directory with a hashed filename.
hash_suffix = hashlib.blake2s(
file_url.encode("utf-8"),
digest_size=8,
).hexdigest()
parts = file_url.rstrip("/").split("/")
base = parts[-2] if parts[-1] == "download" else parts[-1]
:param file_url: The URL of the file to cache.
:return: The local path of the cached file.
"""
# Generate a hashed filename based on the URL
hash_object = hashlib.blake2s(file_url.encode('utf-8'), digest_size=8)
hash_suffix = hash_object.hexdigest()
try:
resp = requests.get(file_url, stream=True, timeout=5)
resp.raise_for_status()
except requests.RequestException:
return None
# Determine the base name for the file
splitted_file_url = file_url.split("/")
base_name = splitted_file_url[-2] if splitted_file_url[-1] == "download" else splitted_file_url[-1]
# Construct the full path for the cached file
filename = f"{base_name}_{hash_suffix}.png"
content_type = resp.headers.get("Content-Type", "")
ext = mimetypes.guess_extension(content_type.split(";")[0].strip()) or ".png"
filename = f"{base}_{hash_suffix}{ext}"
full_path = os.path.join(self.cache_dir, filename)
# If the file already exists, return the cached path
if os.path.exists(full_path):
return full_path
if not os.path.exists(full_path):
with open(full_path, "wb") as f:
for chunk in resp.iter_content(1024):
f.write(chunk)
# Download the file and save it locally
response = requests.get(file_url, stream=True)
if response.status_code == 200:
with open(full_path, "wb") as file:
for chunk in response.iter_content(1024):
file.write(chunk)
return full_path
return f"cache/{filename}"

View File

@@ -32,7 +32,7 @@ def compute_card_classes(cards):
lg_classes.append("col-lg-6")
else:
lg_classes.append("col-lg-4")
# md classes: If the number of cards is even or if not the last card, otherwise "col-md-12"
# Use a full-width last card on medium screens only when the total count is odd.
md_classes = []
for i in range(num_cards):
if num_cards % 2 == 0 or i < num_cards - 1:

View File

@@ -1,4 +1,3 @@
from pprint import pprint
class ConfigurationResolver:
"""
A class to resolve `link` entries in a nested configuration structure.
@@ -14,22 +13,9 @@ class ConfigurationResolver:
"""
self._recursive_resolve(self.config, self.config)
def __load_children(self,path):
"""
Check if explicitly children should be loaded and not parent
"""
return path.split('.').pop() == "children"
def _replace_in_dict_by_dict(self, dict_origine, old_key, new_dict):
if old_key in dict_origine:
# Entferne den alten Key
old_value = dict_origine.pop(old_key)
# Füge die neuen Key-Value-Paare hinzu
dict_origine.update(new_dict)
def _replace_in_list_by_list(self, list_origine, old_element, new_elements):
index = list_origine.index(old_element)
list_origine[index:index+1] = new_elements
list_origine[index : index + 1] = new_elements
def _replace_element_in_list(self, list_origine, old_element, new_element):
index = list_origine.index(old_element)
@@ -43,27 +29,43 @@ class ConfigurationResolver:
for key, value in list(current_config.items()):
if key == "children":
if value is None or not isinstance(value, list):
raise ValueError(f"Expected 'children' to be a list, but got {type(value).__name__} instead.")
raise ValueError(
"Expected 'children' to be a list, but got "
f"{type(value).__name__} instead."
)
for item in value:
if "link" in item:
loaded_link = self._find_entry(root_config, self._mapped_key(item['link']), False)
loaded_link = self._find_entry(
root_config,
self._mapped_key(item["link"]),
False,
)
if isinstance(loaded_link, list):
self._replace_in_list_by_list(value,item,loaded_link)
self._replace_in_list_by_list(value, item, loaded_link)
else:
self._replace_element_in_list(value,item,loaded_link)
self._replace_element_in_list(value, item, loaded_link)
else:
self._recursive_resolve(value, root_config)
self._recursive_resolve(value, root_config)
elif key == "link":
try:
loaded = self._find_entry(root_config, self._mapped_key(value), False)
loaded = self._find_entry(
root_config, self._mapped_key(value), False
)
if isinstance(loaded, list) and len(loaded) > 2:
loaded = self._find_entry(root_config, self._mapped_key(value), False)
loaded = self._find_entry(
root_config, self._mapped_key(value), False
)
current_config.clear()
current_config.update(loaded)
except Exception as e:
except Exception as e:
raise ValueError(
f"Error resolving link '{value}': {str(e)}. "
f"Current part: {key}, Current config: {current_config}" + (f", Loaded: {loaded}" if 'loaded' in locals() or 'loaded' in globals() else "")
f"Current part: {key}, Current config: {current_config}"
+ (
f", Loaded: {loaded}"
if "loaded" in locals() or "loaded" in globals()
else ""
)
)
else:
self._recursive_resolve(value, root_config)
@@ -71,69 +73,74 @@ class ConfigurationResolver:
for item in current_config:
self._recursive_resolve(item, root_config)
def _get_children(self,current):
if isinstance(current, dict) and ("children" in current and current["children"]):
def _get_children(self, current):
if isinstance(current, dict) and (
"children" in current and current["children"]
):
current = current["children"]
return current
def _mapped_key(self,name):
def _mapped_key(self, name):
return name.replace(" ", "").lower()
def _find_by_name(self,current, part):
def _find_by_name(self, current, part):
return next(
(item for item in current if isinstance(item, dict) and self._mapped_key(item.get("name", "")) == part),
None
)
(
item
for item in current
if isinstance(item, dict)
and self._mapped_key(item.get("name", "")) == part
),
None,
)
def _find_entry(self, config, path, children):
"""
Finds an entry in the configuration by a dot-separated path.
Supports both dictionaries and lists with `children` navigation.
"""
parts = path.split('.')
parts = path.split(".")
current = config
for part in parts:
if isinstance(current, list):
# If children explicit declared just load children
if part != "children":
# Look for a matching name in the list
found = self._find_by_name(current,part)
found = self._find_by_name(current, part)
if found:
current = found
print(
f"Matching entry for '{part}' in list. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
f"Matching entry for '{part}' in list. Path so far: "
f"{' > '.join(parts[: parts.index(part) + 1])}. "
f"Current list: {current}"
)
else:
raise ValueError(
f"No matching entry for '{part}' in list. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
f"No matching entry for '{part}' in list. Path so far: "
f"{' > '.join(parts[: parts.index(part) + 1])}. "
f"Current list: {current}"
)
elif isinstance(current, dict):
# Case-insensitive dictionary lookup
key = next((k for k in current if self._mapped_key(k) == part), None)
# If no fitting key was found search in the children
if key is None:
if "children" not in current:
raise KeyError(
f"No 'children' found in current dictionary. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
f"Current dictionary: {current}"
)
# The following line seems buggy; Why is children loaded allways and not just when children is set?
current = self._find_by_name(current["children"],part)
if not current:
raise KeyError(
f"Key '{part}' not found in dictionary. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
"No 'children' found in current dictionary. Path so far: "
f"{' > '.join(parts[: parts.index(part) + 1])}. "
f"Current dictionary: {current}"
)
else:
current = self._find_by_name(current["children"], part)
if not current:
raise KeyError(
f"Key '{part}' not found in dictionary. Path so far: "
f"{' > '.join(parts[: parts.index(part) + 1])}. "
f"Current dictionary: {current}"
)
else:
current = current[key]
else:
raise ValueError(
f"Invalid path segment '{part}'. Current type: {type(current)}. "
f"Path so far: {' > '.join(parts[:parts.index(part)+1])}"
f"Path so far: {' > '.join(parts[: parts.index(part) + 1])}"
)
if children:
current = self._get_children(current)

134
app/utils/i18n.py Normal file
View File

@@ -0,0 +1,134 @@
"""Language negotiation and translation of the resolved configuration tree.
Translation is catalogue-driven: a string is replaced only when the target
language's catalogue holds an entry for the exact English source string.
Anything unknown falls through to English, so a partially filled catalogue
degrades instead of breaking.
"""
import logging
from pathlib import Path
import yaml
try:
from app.utils.languages import LANGUAGES, RTL_LANGUAGES
except ImportError: # pragma: no cover - supports running from the app/ directory.
from utils.languages import LANGUAGES, RTL_LANGUAGES
I18N_DIR = Path(__file__).resolve().parent.parent / "i18n"
UI_DIR = I18N_DIR / "ui"
CONTENT_DIR = I18N_DIR / "content"
SOURCE_LANGUAGE = "en"
TRANSLATABLE_KEYS = frozenset(
{"description", "info", "name", "subtitel", "text", "title", "warning"}
)
UI_STRINGS = (
"Alternatives",
"Close",
"Copy",
"Identifier copied to clipboard!",
"Imprint",
"Information",
"Language",
"Open",
"Open Link",
"Options",
"Warning",
)
_catalogs: dict[str, dict[str, str]] = {}
def direction(code):
"""Return the writing direction of ``code`` as an HTML ``dir`` value."""
return "rtl" if code in RTL_LANGUAGES else "ltr"
def read_catalog(path):
"""Return the catalogue at ``path``, or an empty one if it is unusable.
Catalogues are hand-edited and machine-written, so a stray character must
degrade that language to English rather than take every page down with a
parse error. Non-string entries are dropped for the same reason: they would
otherwise reach the templates and render as ``42`` or ``null``.
"""
if not path.exists():
return {}
try:
loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, yaml.YAMLError):
logging.warning("Ignoring unreadable translation catalogue: %s", path)
return {}
if not isinstance(loaded, dict):
logging.warning(
"Ignoring translation catalogue that is not a mapping: %s", path
)
return {}
return {
key: value
for key, value in loaded.items()
if isinstance(key, str) and isinstance(value, str)
}
def clear_catalogs():
"""Drop the memoized catalogues so edited files are picked up."""
_catalogs.clear()
def catalog(code):
"""Return the merged UI and content catalogue for ``code``."""
if code not in _catalogs:
_catalogs[code] = {
**read_catalog(UI_DIR / f"{code}.yaml"),
**read_catalog(CONTENT_DIR / f"{code}.yaml"),
}
return _catalogs[code]
def negotiate(accepted, default=SOURCE_LANGUAGE):
"""Pick the best supported language from ``Accept-Language`` pairs.
Args:
accepted: iterable of ``(tag, quality)`` as produced by
``flask.request.accept_languages``.
default: language returned when no tag is supported.
Werkzeug's own ``best_match`` returns an exact match before it considers
primary-tag fallbacks, so ``de-DE,en;q=0.8`` resolves to English. Matching
on the primary subtag up front avoids that.
"""
best, best_quality = default, 0.0
for tag, quality in accepted:
code = tag.replace("_", "-").split("-")[0].lower()
if code in LANGUAGES and quality > best_quality:
best, best_quality = code, quality
return best
def translate_tree(node, code, key=None):
"""Return a copy of ``node`` with translatable leaves swapped for ``code``.
Args:
node: the resolved configuration tree, or any subtree of it.
code: target language code.
key: the mapping key ``node`` was reached through.
"""
if isinstance(node, dict):
return {name: translate_tree(value, code, name) for name, value in node.items()}
if isinstance(node, list):
return [translate_tree(item, code, key) for item in node]
if isinstance(node, str) and key in TRANSLATABLE_KEYS:
return catalog(code).get(node, node)
return node
def ui_strings(code):
"""Return the interface strings for ``code``, keyed by their English source."""
entries = catalog(code)
return {source: entries.get(source, source) for source in UI_STRINGS}

196
app/utils/languages.py Normal file
View 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": "ozbek",
"ve": "Tshivenḓa",
"vi": "Tiếng Việt",
"vo": "Volapük",
"wa": "walon",
"wo": "Wolof",
"xh": "IsiXhosa",
"yi": "ייִדיש",
"yo": "Èdè Yorùbá",
"za": "Vahcuengh",
"zh": "中文",
"zu": "isiZulu",
}
RTL_LANGUAGES = frozenset({"ar", "dv", "fa", "he", "ks", "ps", "sd", "ug", "ur", "yi"})

BIN
assets/img/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1024 KiB

View File

@@ -1,16 +1,26 @@
version: '3.8'
services:
portfolio:
# SPOT: IMAGE_NAME comes from .env (see env.example). No default —
# an empty tag would have compose fall back to `portfolio-portfolio`
# silently, which `make run-dev` would then fail to find.
image: ${IMAGE_NAME:?IMAGE_NAME must be set in .env (see env.example)}
build:
context: .
dockerfile: Dockerfile
image: application-portfolio
container_name: portfolio
ports:
- "${PORT:-5000}:${PORT:-5000}"
- "${PORT:?PORT must be set in .env (see env.example)}:${PORT:?PORT must be set in .env (see env.example)}"
env_file:
- .env
volumes:
- ./app:/app
environment:
- PORT=${PORT:-5000}
- node_modules:/app/node_modules
- vendor:/app/static/vendor
# Run `npm install` on every container start so the named volumes
# reflect the current package.json (postinstall regenerates vendor/).
command: sh -c "npm install --prefix /app --no-audit --no-fund && python app.py"
restart: unless-stopped
volumes:
node_modules:
vendor:

View File

@@ -1,2 +1,13 @@
PORT=5000
FLASK_ENV=production
PORT=5001
FLASK_ENV=production
# Single source of truth for the Docker image tag — read by both the
# Makefile (build/run-dev/run-prod) and docker-compose.yml so every
# build path produces and consumes the same tag.
IMAGE_NAME=portfolio
# Comma-separated public hostnames. Leave empty and the app reflects whatever
# Host header arrives into its canonical, hreflang and redirect URLs.
TRUSTED_HOSTS=
# LibreTranslate instance used by `make i18n` to fill app/i18n/content/.
# Only needed when generating translations, never at runtime.
LIBRETRANSLATE_URL=http://localhost:5002
LIBRETRANSLATE_API_KEY=

308
main.py
View File

@@ -1,41 +1,43 @@
#!/usr/bin/env python3
"""
main.py - A CLI tool for managing the Portfolio CMS Docker application.
This script provides commands to build and run the Docker container for the
portfolio application. It mimics the functionality of a Makefile with additional
explanatory text using argparse.
Commands:
build - Build the Docker image.
up - Start the application using docker-compose (with build).
down - Stop and remove the running container.
run-dev - Run the container in development mode (with hot-reloading).
run-prod - Run the container in production mode.
logs - Display the logs of the running container.
dev - Start the application in development mode using docker-compose.
prod - Start the application in production mode using docker-compose.
cleanup - Remove all stopped containers.
main.py - Proxy to Makefile targets for managing the Portfolio CMS Docker application.
Automatically generates CLI commands based on the Makefile definitions.
"""
import argparse
import re
import subprocess
import sys
import os
from dotenv import load_dotenv
from pathlib import Path
# Always load .env from the script's directory
dotenv_path = Path(__file__).resolve().parent / ".env"
MAKEFILE_PATH = Path(__file__).resolve().parent / "Makefile"
def load_targets(makefile_path):
"""
Parse the Makefile to extract targets and their help comments.
Assumes each target is defined as 'name:' and the following line that starts
with '\t#' provides its help text.
"""
targets = []
pattern = re.compile(r"^([A-Za-z0-9_\-]+):")
with open(makefile_path, "r", encoding="utf-8") as handle:
lines = handle.readlines()
for idx, line in enumerate(lines):
m = pattern.match(line)
if m:
name = m.group(1)
help_text = ""
if idx + 1 < len(lines):
next_line = lines[idx + 1].lstrip()
if next_line.startswith("#"):
help_text = next_line.lstrip("# ").strip()
targets.append((name, help_text))
return targets
if dotenv_path.exists():
load_dotenv(dotenv_path)
else:
print(f"⚠️ Warning: No .env file found at {dotenv_path}")
PORT = int(os.getenv("PORT", 5000))
def run_command(command, dry_run=False):
"""Utility function to run a shell command."""
"""Utility to run shell commands."""
print(f"Executing: {' '.join(command)}")
if dry_run:
print("Dry run enabled: command not executed.")
@@ -46,262 +48,32 @@ def run_command(command, dry_run=False):
print(f"Error: Command failed with exit code {e.returncode}")
sys.exit(e.returncode)
def build(args):
"""
Build the Docker image for the portfolio application.
Command:
docker build -t application-portfolio .
This command creates a Docker image named 'application-portfolio'
from the Dockerfile in the current directory.
"""
command = ["docker", "build", "-t", "application-portfolio", "."]
run_command(command, args.dry_run)
def up(args):
"""
Start the application using docker-compose with build.
Command:
docker-compose up --build
This command uses docker-compose to build (if necessary) and start
all defined services. It is useful for quickly starting your
development or production environment.
"""
command = ["docker-compose", "up", "--build"]
run_command(command, args.dry_run)
def down(args):
"""
Stop and remove the Docker container named 'portfolio'.
Commands:
docker stop portfolio
docker rm portfolio
These commands stop the running container and remove it from your Docker host.
The '-' prefix is used to ignore errors if the container is not running.
"""
command_stop = ["docker", "stop", "portfolio"]
command_rm = ["docker", "rm", "portfolio"]
run_command(command_stop, args.dry_run)
run_command(command_rm, args.dry_run)
def run_dev(args):
"""
Run the container in development mode with hot-reloading.
Command:
docker run -d -p 5000:5000 --name portfolio -v $(pwd)/app/:/app \
-e FLASK_APP=app.py -e FLASK_ENV=development application-portfolio
This command starts the container in detached mode (-d), maps port 5000,
mounts the local 'app/' directory into the container, and sets environment
variables to enable Flask's development mode.
"""
current_dir = os.getcwd()
volume_mapping = f"{current_dir}/app/:/app"
command = [
"docker", "run", "-d",
"-p", f"{PORT}:{PORT}",
"--name", "portfolio",
"-v", volume_mapping,
"-e", "FLASK_APP=app.py",
"-e", "FLASK_ENV=development",
"application-portfolio"
]
run_command(command, args.dry_run)
def run_prod(args):
"""
Run the container in production mode.
Command:
docker run -d -p 5000:5000 --name portfolio application-portfolio
This command starts the container in detached mode, mapping port 5000,
and runs the production version of the portfolio application.
"""
command = [
"docker", "run", "-d",
"-p", "{PORT}:{PORT}",
"--name", "portfolio",
"application-portfolio"
]
run_command(command, args.dry_run)
def logs(args):
"""
Display the logs of the 'portfolio' container.
Command:
docker logs -f portfolio
This command follows the logs (using -f) of the running container,
which is helpful for debugging and monitoring.
"""
command = ["docker", "logs", "-f", "portfolio"]
run_command(command, args.dry_run)
def dev(args):
"""
Run the application in development mode using docker-compose.
Command:
FLASK_ENV=development docker-compose up -d
This command sets the FLASK_ENV environment variable to 'development'
and starts the application using docker-compose, enabling hot-reloading.
"""
env = os.environ.copy()
env["FLASK_ENV"] = "development"
command = ["docker-compose", "up", "-d"]
print("Setting FLASK_ENV=development")
run_command(command, args.dry_run)
def prod(args):
"""
Run the application in production mode using docker-compose.
Command:
docker-compose up --build
This command builds the Docker image if needed and starts the application
using docker-compose for a production environment.
"""
command = ["docker-compose", "up", "--build"]
run_command(command, args.dry_run)
def cleanup(args):
"""
Remove all stopped Docker containers.
Command:
docker container prune -f
This command cleans up your Docker environment by forcefully removing
all stopped containers. It is useful to reclaim disk space and remove
unused containers.
"""
command = ["docker", "container", "prune", "-f"]
run_command(command, args.dry_run)
def delete_portfolio_container(dry_run=False):
"""
Force remove the portfolio container if it exists.
"""
print("Checking if 'portfolio' container exists to delete...")
command = ["docker", "rm", "-f", "portfolio"]
run_command(command, dry_run)
def browse(args):
"""
Open http://localhost:5000 in Chromium browser.
Command:
chromium http://localhost:5000
This command launches the Chromium browser to view the running application.
"""
command = ["chromium", f"http://localhost:{PORT}"]
run_command(command, args.dry_run)
def main():
parser = argparse.ArgumentParser(
description="CLI tool to manage the Portfolio CMS Docker application."
description="CLI proxy to Makefile targets for Portfolio CMS Docker app"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print the commands without executing them."
help="Print the generated Make command without executing it.",
)
parser.add_argument(
"--delete",
action="store_true",
help="Delete the existing 'portfolio' container before running the command."
)
subparsers = parser.add_subparsers(
title="Commands",
description="Available commands to manage the application",
dest="command"
title="Available commands",
dest="command",
required=True,
)
# Browse command
parser_browse = subparsers.add_parser(
"browse", help="Open application in Chromium browser."
)
parser_browse.set_defaults(func=browse)
targets = load_targets(MAKEFILE_PATH)
for name, help_text in targets:
sp = subparsers.add_parser(name, help=help_text)
sp.set_defaults(target=name)
# Build command
parser_build = subparsers.add_parser(
"build", help="Build the Docker image."
)
parser_build.set_defaults(func=build)
# Up command (docker-compose up)
parser_up = subparsers.add_parser(
"up", help="Start the application using docker-compose (with build)."
)
parser_up.set_defaults(func=up)
# Down command
parser_down = subparsers.add_parser(
"down", help="Stop and remove the Docker container."
)
parser_down.set_defaults(func=down)
# Run-dev command
parser_run_dev = subparsers.add_parser(
"run-dev", help="Run the container in development mode (with hot-reloading)."
)
parser_run_dev.set_defaults(func=run_dev)
# Run-prod command
parser_run_prod = subparsers.add_parser(
"run-prod", help="Run the container in production mode."
)
parser_run_prod.set_defaults(func=run_prod)
# Logs command
parser_logs = subparsers.add_parser(
"logs", help="Display the logs of the running container."
)
parser_logs.set_defaults(func=logs)
# Dev command (docker-compose with FLASK_ENV)
parser_dev = subparsers.add_parser(
"dev", help="Start the application in development mode using docker-compose."
)
parser_dev.set_defaults(func=dev)
# Prod command (docker-compose production)
parser_prod = subparsers.add_parser(
"prod", help="Start the application in production mode using docker-compose."
)
parser_prod.set_defaults(func=prod)
# Cleanup command
parser_cleanup = subparsers.add_parser(
"cleanup", help="Remove all stopped Docker containers."
)
parser_cleanup.set_defaults(func=cleanup)
args = parser.parse_args()
if args.command is None:
parser.print_help()
sys.exit(1)
cmd = ["make", args.target]
run_command(cmd, dry_run=args.dry_run)
if args.delete:
delete_portfolio_container(args.dry_run)
# Execute the chosen subcommand function
args.func(args)
if __name__ == "__main__":
main()

51
pyproject.toml Normal file
View File

@@ -0,0 +1,51 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "portfolio-ui"
version = "2.1.0"
description = "A lightweight YAML-driven portfolio and landing-page generator."
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
# 3.1 introduced TRUSTED_HOSTS; an older Flask accepts the config key and
# ignores it, which would silently disable the host-forgery check.
"flask>=3.1",
"pyyaml",
"requests",
]
[project.optional-dependencies]
dev = [
"babel",
"bandit",
"pip-audit",
"pycountry",
"ruff",
"yamllint",
]
[tool.setuptools]
py-modules = ["main"]
[tool.setuptools.packages.find]
include = ["app", "app.*"]
[tool.setuptools.package-data]
app = [
"config.sample.yaml",
"i18n/ui/*.yaml",
"i18n/content/*.yaml",
"templates/**/*.j2",
"static/css/*.css",
"static/js/*.js",
]
[tool.ruff]
target-version = "py312"
line-length = 88
extend-exclude = ["app/static/cache", "build"]
[tool.ruff.lint]
select = ["E", "F", "I"]

View File

@@ -1 +0,0 @@
python-dotenv

73
scripts/run-e2e.sh Executable file
View File

@@ -0,0 +1,73 @@
#!/bin/sh
# Run the Cypress suite against a Flask app started by this script.
#
# Server and browser share one shell, so this works where act cannot reach the
# host network. ELECTRON_RUN_AS_NODE is dropped before invoking Cypress because
# VS Code exports it to child processes, which makes Cypress' bundled Electron
# behave as Node and reject its own launch flags.
#
# Env:
# PORT required, the port Flask binds and Cypress targets
# PYTHON interpreter to run app.py with (default: python3)
# E2E_LOG where Flask's output goes (default: /tmp/portfolio-e2e-flask.log)
set -u
PORT="${PORT:?PORT must be set (see env.example)}"
PYTHON="${PYTHON:-python3}"
E2E_LOG="${E2E_LOG:-/tmp/portfolio-e2e-flask.log}"
FLASK_HOST=127.0.0.1
export PORT FLASK_HOST
APP_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")/../app" && pwd)"
BASE_URL="http://127.0.0.1:$PORT/"
# cypress.config.js defaults to localhost, which resolves to ::1 first on a
# dual-stack host — a different listener from the 127.0.0.1 one Flask binds and
# this script probes. Pin all three to the same origin.
export CYPRESS_baseUrl="$BASE_URL"
# A foreign listener would be tested instead of the working tree. The predicate
# is "something answers", deliberately without curl's -f: a foreign server that
# is still returning 5xx right now would otherwise pass the guard and then be
# picked up by the readiness probe once it recovers. --noproxy is what keeps
# that same looser predicate from matching an HTTP proxy instead of the port.
if curl -s --noproxy '*' --connect-timeout 2 --max-time 10 -o /dev/null "$BASE_URL"; then
echo "ERROR: something already serves port $PORT — Cypress would test that"
echo " instead of your working tree. Stop it first ('make down' for"
echo " the container, otherwise a stray 'python app.py')."
exit 1
fi
cd "$APP_DIR" || exit 1
$PYTHON app.py > "$E2E_LOG" 2>&1 &
flask_pid=$!
trap 'kill "$flask_pid" 2>/dev/null || true' EXIT INT TERM
echo "Waiting for $BASE_URL — follow with: tail -f $E2E_LOG"
attempt=0
while [ "$attempt" -lt 120 ]; do
# Liveness first: a server answering while ours is dead means we are about
# to hand Cypress somebody else's app.
if ! kill -0 "$flask_pid" 2>/dev/null; then
echo "ERROR: Flask exited during startup"
cat "$E2E_LOG"
exit 1
fi
if curl -sf --noproxy '*' --connect-timeout 2 --max-time 10 -o /dev/null "$BASE_URL"; then
break
fi
attempt=$((attempt + 1))
sleep 1
done
if ! curl -sf --noproxy '*' --connect-timeout 2 --max-time 10 -o /dev/null "$BASE_URL"; then
echo "ERROR: app never became ready"
cat "$E2E_LOG"
exit 1
fi
env -u ELECTRON_RUN_AS_NODE npx cypress run
status=$?
kill "$flask_pid" 2>/dev/null || true
exit "$status"

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,258 @@
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
import requests
REPO_ROOT = Path(__file__).resolve().parents[2]
app_module = None
flask_app = None
i18n = None
_origin = None
_workdir = None
def setUpModule():
"""Import the app with a disposable configuration as the working directory.
``app.app`` reads ``config.yaml`` relative to the process working directory
at import time, so the chdir has to happen before the import.
"""
global app_module, flask_app, i18n, _origin, _workdir
_origin = os.getcwd()
_workdir = tempfile.mkdtemp(prefix="portfolio-routes-")
shutil.copy(
REPO_ROOT / "app" / "config.sample.yaml", Path(_workdir) / "config.yaml"
)
os.chdir(_workdir)
from app import app as imported_module
from app.utils import i18n as imported_i18n
app_module = imported_module
flask_app = imported_module.app
i18n = imported_i18n
flask_app.config["NASA_API_KEY"] = None
def tearDownModule():
os.chdir(_origin)
shutil.rmtree(_workdir, ignore_errors=True)
class AppRouteMixin:
"""Shared setup. A mixin rather than a TestCase subclass, so that every
test class below still names ``unittest.TestCase`` as a direct base — the
lint guardrail in tests/lint/ does not resolve inherited aliases."""
def setUp(self):
self.client = flask_app.test_client()
self.addCleanup(i18n.clear_catalogs)
self.addCleanup(flask_app.config["TRANSLATED_CONFIG"].clear)
class TestRouting(AppRouteMixin, unittest.TestCase):
def test_unrelated_single_segment_paths_are_not_redirected(self):
for path in ("/robots.txt", "/favicon.ico", "/sitemap.xml"):
with self.subTest(path=path):
self.assertEqual(self.client.get(path).status_code, 404)
def test_language_path_redirects_to_the_canonical_trailing_slash(self):
response = self.client.get("/de")
self.assertEqual(response.status_code, 308)
self.assertTrue(response.headers["Location"].endswith("/de/"))
def test_supported_language_renders(self):
response = self.client.get("/de/")
self.assertEqual(response.status_code, 200)
self.assertIn('<html lang="de"', response.get_data(as_text=True))
def test_unsupported_language_is_not_found(self):
self.assertEqual(self.client.get("/xx/").status_code, 404)
class TestNegotiation(AppRouteMixin, unittest.TestCase):
def test_regional_tag_beats_a_lower_ranked_exact_match(self):
response = self.client.get("/", headers={"Accept-Language": "de-DE,en;q=0.8"})
self.assertIn('<html lang="de"', response.get_data(as_text=True))
def test_negotiated_route_declares_that_it_varies(self):
response = self.client.get("/", headers={"Accept-Language": "de-DE"})
self.assertEqual(response.headers.get("Vary"), "Accept-Language")
class TestEscaping(AppRouteMixin, unittest.TestCase):
def test_catalog_content_is_html_escaped(self):
i18n._catalogs["de"] = {"Copy": "<script>alert('ui')</script>"}
body = self.client.get("/de/").get_data(as_text=True)
self.assertNotIn("<script>alert('ui')</script>", body)
self.assertIn("&lt;script&gt;alert(&#39;ui&#39;)", body)
def test_configuration_content_is_html_escaped(self):
i18n._catalogs["de"] = {"Imprint": "<script>alert('config')</script>"}
body = self.client.get("/de/").get_data(as_text=True)
self.assertNotIn("<script>alert('config')</script>", body)
self.assertIn("&lt;script&gt;alert(&#39;config&#39;)", body)
class TestApodBackground(AppRouteMixin, unittest.TestCase):
def setUp(self):
super().setUp()
self.addCleanup(flask_app.config.__setitem__, "NASA_API_KEY", None)
flask_app.config["NASA_API_KEY"] = "key"
def test_no_request_is_made_without_a_key(self):
flask_app.config["NASA_API_KEY"] = None
with patch("app.app.requests.get") as get:
self.assertIsNone(app_module.apod_background())
get.assert_not_called()
def test_a_transport_failure_costs_the_background_not_the_page(self):
with patch(
"app.app.requests.get", side_effect=requests.ConnectionError("down")
):
self.assertIsNone(app_module.apod_background())
self.assertEqual(self.client.get("/en/").status_code, 200)
def test_an_error_response_costs_the_background_not_the_page(self):
refusal = Mock(ok=False)
refusal.json.return_value = {"media_type": "image", "url": "https://i.test/x"}
with patch("app.app.requests.get", return_value=refusal):
self.assertIsNone(app_module.apod_background())
def test_a_video_of_the_day_is_not_used_as_a_background(self):
answer = Mock(ok=True)
answer.json.return_value = {"media_type": "video", "url": "https://v.test/x"}
with patch("app.app.requests.get", return_value=answer):
self.assertIsNone(app_module.apod_background())
def test_an_image_of_the_day_is_used(self):
answer = Mock(ok=True)
answer.json.return_value = {"media_type": "image", "url": "https://i.test/x"}
with patch("app.app.requests.get", return_value=answer):
self.assertEqual(app_module.apod_background(), "https://i.test/x")
class TestExternalUrls(AppRouteMixin, unittest.TestCase):
def test_only_the_forwarded_scheme_is_trusted(self):
proxy = flask_app.wsgi_app
self.assertEqual(proxy.x_proto, 1)
self.assertEqual(
(proxy.x_for, proxy.x_host, proxy.x_port, proxy.x_prefix), (0, 0, 0, 0)
)
def test_forwarded_scheme_reaches_the_canonical_and_alternates(self):
body = self.client.get(
"/en/",
headers={"Host": "portfolio.example.org", "X-Forwarded-Proto": "https"},
).get_data(as_text=True)
self.assertIn(
'<link rel="canonical" href="https://portfolio.example.org/en/">', body
)
self.assertIn('hreflang="ja" href="https://portfolio.example.org/ja/"', body)
self.assertNotIn("http://portfolio.example.org", body)
def test_trusted_hosts_are_parsed_from_a_comma_separated_list(self):
self.assertEqual(
app_module.trusted_hosts("a.test, b.test ,"), ["a.test", "b.test"]
)
def test_an_unset_trusted_hosts_value_disables_the_check(self):
self.assertIsNone(app_module.trusted_hosts(""))
self.assertIsNone(app_module.trusted_hosts(" , "))
def test_a_forged_host_is_rejected_once_trusted_hosts_are_named(self):
self.addCleanup(flask_app.config.__setitem__, "TRUSTED_HOSTS", None)
flask_app.config["TRUSTED_HOSTS"] = ["portfolio.example.org"]
forged = self.client.get("/de", headers={"Host": "evil.test"})
honest = self.client.get("/de/", headers={"Host": "portfolio.example.org"})
self.assertEqual(forged.status_code, 400)
self.assertEqual(honest.status_code, 200)
def test_forwarded_host_is_not_trusted(self):
response = self.client.get(
"/de",
headers={"Host": "portfolio.example.org", "X-Forwarded-Host": "evil.test"},
)
self.assertNotIn("evil.test", response.headers["Location"])
body = self.client.get(
"/en/",
headers={"Host": "portfolio.example.org", "X-Forwarded-Host": "evil.test"},
).get_data(as_text=True)
self.assertNotIn("evil.test", body)
class TestConfigurationReload(AppRouteMixin, unittest.TestCase):
def test_reloading_the_configuration_drops_the_catalog_memo(self):
i18n.catalog("de")
self.assertIn("de", i18n._catalogs)
app_module.load_config(flask_app)
self.assertEqual(i18n._catalogs, {})
def test_reloading_the_configuration_drops_the_translation_memo(self):
self.client.get("/de/")
self.assertIn("de", flask_app.config["TRANSLATED_CONFIG"])
app_module.load_config(flask_app)
self.assertEqual(flask_app.config["TRANSLATED_CONFIG"], {})
class TestTrustedHostsWiring(unittest.TestCase):
def test_the_environment_reaches_the_flask_configuration(self):
script = (
"import json, sys;"
f"sys.path.insert(0, {str(REPO_ROOT)!r});"
"from app.app import app;"
"print('TRUSTED=' + json.dumps(app.config['TRUSTED_HOSTS']))"
)
result = subprocess.run(
[sys.executable, "-c", script],
cwd=_workdir,
env={**os.environ, "TRUSTED_HOSTS": "a.test, b.test"},
capture_output=True,
text=True,
check=True,
)
reported = [
line for line in result.stdout.splitlines() if line.startswith("TRUSTED=")
]
self.assertEqual(
json.loads(reported[-1][len("TRUSTED=") :]), ["a.test", "b.test"]
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,84 @@
"""Live network verification of the probe-first asset resolution path.
The unit tests in ``tests/unit/test_asset_resolver.py`` cover every
branch with mocks. This integration test exercises the real
``https://file.infinito.nexus/assets/img/logo.png`` endpoint that
``app/config.sample.yaml`` references for the Infinito.Nexus card. A
green run proves the production resolver behaviour end-to-end:
* probe (HEAD ``image/*``) succeeds against the canonical Infinito.Nexus
asset host, so ``external_url`` is populated directly and **no**
download is performed (the cache directory stays empty).
The download-fallback branch (probe fails → ``cache_manager.cache_file``
serves the asset locally) is intentionally not exercised against a live
endpoint here — it is covered by mocked unit tests because every
candidate "HEAD-fails, GET-succeeds" public URL we tried was flaky
(Nextcloud's ``/download`` endpoint sporadically times out on HEAD even
when GET works), which would make this guard test non-deterministic.
"""
from __future__ import annotations
import os
import unittest
from tempfile import TemporaryDirectory
from urllib.parse import urlparse
import requests
from app.utils.asset_resolver import resolve_asset_cache
from app.utils.cache_manager import CacheManager
INFINITO_LOGO_URL = "https://file.infinito.nexus/assets/img/logo.png"
_SKIP_LIVE_NETWORK = os.environ.get("PORTFOLIO_SKIP_LIVE_NETWORK_TESTS") == "1"
def _reachable(url: str) -> bool:
try:
return requests.head(url, allow_redirects=True, timeout=5).ok
except requests.RequestException:
return False
@unittest.skipIf(
_SKIP_LIVE_NETWORK,
"PORTFOLIO_SKIP_LIVE_NETWORK_TESTS=1 — skipping live network tests",
)
class TestAssetResolverLive(unittest.TestCase):
@classmethod
def setUpClass(cls):
if not _reachable(INFINITO_LOGO_URL):
raise unittest.SkipTest(
f"{urlparse(INFINITO_LOGO_URL).netloc} unreachable from this runner"
)
def test_infinito_nexus_logo_resolves_via_external_url_without_download(self):
with TemporaryDirectory() as tmp:
cache_dir = os.path.join(tmp, "cache")
cache_manager = CacheManager(cache_dir)
asset = {"source": INFINITO_LOGO_URL}
resolve_asset_cache(asset, cache_manager)
self.assertIsNone(
asset["cache"],
f"Expected probe-first path to skip download for {INFINITO_LOGO_URL}",
)
self.assertEqual(
asset["external_url"],
INFINITO_LOGO_URL,
"Probe-success URL must land in external_url so the template "
"embeds it directly instead of routing through /static/.",
)
self.assertEqual(
os.listdir(cache_dir),
[],
"Cache directory must stay empty when probe succeeds — "
"downloading would waste bandwidth.",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,72 @@
import re
import tomllib
import unittest
from pathlib import Path
def distributions(requirements):
"""Return the distribution names of PEP 508 requirement strings."""
return {
re.split(r"[<>=!~\[; ]", requirement, maxsplit=1)[0]
for requirement in requirements
}
class TestPythonPackaging(unittest.TestCase):
def setUp(self) -> None:
self.repo_root = Path(__file__).resolve().parents[2]
self.pyproject_path = self.repo_root / "pyproject.toml"
with self.pyproject_path.open("rb") as handle:
self.pyproject = tomllib.load(handle)
def test_pyproject_defines_build_system_and_runtime_dependencies(self):
build_system = self.pyproject["build-system"]
project = self.pyproject["project"]
self.assertEqual(build_system["build-backend"], "setuptools.build_meta")
self.assertIn("setuptools>=69", build_system["requires"])
self.assertGreaterEqual(
distributions(project["dependencies"]),
{"flask", "pyyaml", "requests"},
)
self.assertEqual(project["requires-python"], ">=3.12")
def test_flask_is_pinned_to_a_version_that_honours_trusted_hosts(self):
requirement = next(
item
for item in self.pyproject["project"]["dependencies"]
if item.startswith("flask")
)
self.assertIn(">=3.1", requirement)
def test_pyproject_defines_dev_dependencies_and_package_contents(self):
project = self.pyproject["project"]
setuptools_config = self.pyproject["tool"]["setuptools"]
package_find = setuptools_config["packages"]["find"]
package_data = setuptools_config["package-data"]["app"]
self.assertGreaterEqual(
distributions(project["optional-dependencies"]["dev"]),
{"bandit", "pip-audit", "ruff", "yamllint"},
)
self.assertEqual(setuptools_config["py-modules"], ["main"])
self.assertEqual(package_find["include"], ["app", "app.*"])
self.assertIn("config.sample.yaml", package_data)
self.assertIn("templates/**/*.j2", package_data)
self.assertIn("static/css/*.css", package_data)
self.assertIn("static/js/*.js", package_data)
def test_legacy_requirements_files_are_removed(self):
self.assertFalse((self.repo_root / "requirements.txt").exists())
self.assertFalse((self.repo_root / "requirements-dev.txt").exists())
self.assertFalse((self.repo_root / "app" / "requirements.txt").exists())
def test_package_init_files_exist(self):
self.assertTrue((self.repo_root / "app" / "__init__.py").is_file())
self.assertTrue((self.repo_root / "app" / "utils" / "__init__.py").is_file())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,43 @@
import unittest
from pathlib import Path
import yaml
SKIP_DIR_NAMES = {".git", ".ruff_cache", "__pycache__", "node_modules"}
SKIP_FILES = {"app/config.yaml"}
YAML_SUFFIXES = {".yml", ".yaml"}
class TestYamlSyntax(unittest.TestCase):
def test_all_repository_yaml_files_are_valid(self):
repo_root = Path(__file__).resolve().parents[2]
invalid_files = []
for path in repo_root.rglob("*"):
if not path.is_file() or path.suffix not in YAML_SUFFIXES:
continue
relative_path = path.relative_to(repo_root).as_posix()
if relative_path in SKIP_FILES:
continue
if any(part in SKIP_DIR_NAMES for part in path.parts):
continue
try:
with path.open("r", encoding="utf-8") as handle:
yaml.safe_load(handle)
except yaml.YAMLError as error:
invalid_files.append((relative_path, str(error).splitlines()[0]))
except Exception as error:
invalid_files.append((relative_path, f"Unexpected error: {error}"))
self.assertFalse(
invalid_files,
"Found invalid YAML files:\n"
+ "\n".join(f"- {path}: {error}" for path, error in invalid_files),
)
if __name__ == "__main__":
unittest.main()

1
tests/lint/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python3
import ast
import unittest
from pathlib import Path
class TestTestFilesContainUnittestTests(unittest.TestCase):
def setUp(self) -> None:
self.repo_root = Path(__file__).resolve().parents[2]
self.tests_dir = self.repo_root / "tests"
self.assertTrue(
self.tests_dir.is_dir(),
f"'tests' directory not found at: {self.tests_dir}",
)
def _iter_test_files(self) -> list[Path]:
return sorted(self.tests_dir.rglob("test_*.py"))
def _file_contains_runnable_unittest_test(self, path: Path) -> bool:
source = path.read_text(encoding="utf-8")
try:
tree = ast.parse(source, filename=str(path))
except SyntaxError as error:
raise AssertionError(f"SyntaxError in {path}: {error}") from error
testcase_aliases = {"TestCase"}
unittest_aliases = {"unittest"}
for node in tree.body:
if isinstance(node, ast.Import):
for import_name in node.names:
if import_name.name == "unittest":
unittest_aliases.add(import_name.asname or "unittest")
elif isinstance(node, ast.ImportFrom) and node.module == "unittest":
for import_name in node.names:
if import_name.name == "TestCase":
testcase_aliases.add(import_name.asname or "TestCase")
def is_testcase_base(base: ast.expr) -> bool:
if isinstance(base, ast.Name) and base.id in testcase_aliases:
return True
if isinstance(base, ast.Attribute) and base.attr == "TestCase":
return (
isinstance(base.value, ast.Name)
and base.value.id in unittest_aliases
)
return False
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and (
node.name.startswith("test_")
):
return True
for node in tree.body:
if not isinstance(node, ast.ClassDef):
continue
if not any(is_testcase_base(base) for base in node.bases):
continue
for item in node.body:
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and (
item.name.startswith("test_")
):
return True
return False
def test_all_test_py_files_contain_runnable_tests(self) -> None:
test_files = self._iter_test_files()
self.assertTrue(test_files, "No test_*.py files found under tests/")
offenders = []
for path in test_files:
if not self._file_contains_runnable_unittest_test(path):
offenders.append(path.relative_to(self.repo_root).as_posix())
self.assertFalse(
offenders,
"These test_*.py files do not define any unittest-runnable tests:\n"
+ "\n".join(f"- {path}" for path in offenders),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,25 @@
import unittest
from pathlib import Path
class TestTestFileNaming(unittest.TestCase):
def test_all_python_files_use_test_prefix(self):
tests_root = Path(__file__).resolve().parents[1]
invalid_files = []
for path in tests_root.rglob("*.py"):
if path.name == "__init__.py":
continue
if not path.name.startswith("test_"):
invalid_files.append(path.relative_to(tests_root).as_posix())
self.assertFalse(
invalid_files,
"The following Python files do not start with 'test_':\n"
+ "\n".join(f"- {path}" for path in invalid_files),
)
if __name__ == "__main__":
unittest.main()

Some files were not shown because too many files have changed in this diff Show More