11 Commits

Author SHA1 Message Date
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
23 changed files with 799 additions and 148 deletions

3
.claude/.gitignore vendored Normal file
View File

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

View File

@@ -4,52 +4,8 @@
"Read", "Read",
"Edit", "Edit",
"Write", "Write",
"Bash(git status*)", "Bash(*)",
"Bash(git log*)", "Read(//tmp/**)",
"Bash(git diff*)",
"Bash(git add*)",
"Bash(git commit*)",
"Bash(git checkout*)",
"Bash(git branch*)",
"Bash(git fetch*)",
"Bash(git stash*)",
"Bash(git -C:*)",
"Bash(make*)",
"Bash(python3*)",
"Bash(python*)",
"Bash(pip show*)",
"Bash(pip list*)",
"Bash(pip install*)",
"Bash(npm install*)",
"Bash(npm run*)",
"Bash(npx*)",
"Bash(docker pull*)",
"Bash(docker build*)",
"Bash(docker images*)",
"Bash(docker ps*)",
"Bash(docker inspect*)",
"Bash(docker logs*)",
"Bash(docker create*)",
"Bash(docker export*)",
"Bash(docker rm*)",
"Bash(docker rmi*)",
"Bash(docker stop*)",
"Bash(docker compose*)",
"Bash(docker-compose*)",
"Bash(docker container prune*)",
"Bash(grep*)",
"Bash(find*)",
"Bash(ls*)",
"Bash(cat*)",
"Bash(head*)",
"Bash(tail*)",
"Bash(wc*)",
"Bash(sort*)",
"Bash(tar*)",
"Bash(mkdir*)",
"Bash(cp*)",
"Bash(mv*)",
"Bash(jq*)",
"WebSearch", "WebSearch",
"WebFetch(domain:github.com)", "WebFetch(domain:github.com)",
"WebFetch(domain:raw.githubusercontent.com)", "WebFetch(domain:raw.githubusercontent.com)",
@@ -57,21 +13,28 @@
"WebFetch(domain:docs.docker.com)", "WebFetch(domain:docs.docker.com)",
"WebFetch(domain:pypi.org)", "WebFetch(domain:pypi.org)",
"WebFetch(domain:docs.cypress.io)", "WebFetch(domain:docs.cypress.io)",
"WebFetch(domain:flask.palletsprojects.com)" "WebFetch(domain:flask.palletsprojects.com)",
], "Skill(update-config)",
"ask": [ "Skill(update-config:*)"
"Bash(git push*)",
"Bash(docker run*)",
"Bash(curl*)"
], ],
"deny": [ "deny": [
"Bash(git push --force*)", "Bash(git push --force*)",
"Bash(git reset --hard*)", "Bash(git reset --hard*)",
"Bash(rm -rf*)", "Bash(rm -rf*)",
"Bash(sudo*)" "Bash(sudo*)"
],
"ask": [
"Bash(git push*)",
"Bash(docker run*)",
"Bash(curl*)"
],
"additionalDirectories": [
"/tmp"
] ]
}, },
"sandbox": { "sandbox": {
"enabled": true,
"autoAllowBashIfSandboxed": true,
"filesystem": { "filesystem": {
"allowWrite": [ "allowWrite": [
".", ".",

0
.codex Normal file
View File

View File

@@ -1,3 +1,24 @@
## [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 ## [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 * *CI stabilization and modularization*: Split into reusable workflows (lint, security, tests) with correct permissions for CodeQL and SARIF uploads

View File

@@ -5,24 +5,61 @@ ifneq (,$(wildcard .env))
export $(shell sed 's/=.*//' .env) export $(shell sed 's/=.*//' .env)
endif endif
# Default port (can be overridden with PORT env var)
PORT ?= 5000
PYTHON ?= python3 PYTHON ?= python3
ACT ?= act ACT ?= act
# Default port (can be overridden with PORT env var) # Bootstrap the local .env from the checked-in env.example template.
# Idempotent: leaves an existing .env untouched.
.PHONY: env
env:
@if [ -f .env ]; then \
echo ".env already exists — leaving it alone."; \
else \
cp env.example .env; \
echo "Created .env from env.example — review and adjust."; \
fi
# Bootstrap app/config.yaml from the checked-in app/config.sample.yaml
# template. Idempotent: leaves an existing config.yaml untouched. The
# Dockerfile COPYs the whole app/ directory at build time, so this file
# must exist before `make build` / `make up`.
.PHONY: config
config:
@if [ -f app/config.yaml ]; then \
echo "app/config.yaml already exists — leaving it alone."; \
else \
cp app/config.sample.yaml app/config.yaml; \
echo "Created app/config.yaml from app/config.sample.yaml — review and adjust."; \
fi
# Build/run recipes source .env at recipe-execution time (not at make
# parse time) so they work in the same invocation that bootstrapped
# .env via the `env` prereq. Without this, the inner $$IMAGE_NAME /
# $$PORT would be empty on the very first `make build` after a fresh
# checkout — the parse-time `include .env` happens before `env` runs.
define _require_env
if [ ! -f .env ]; then echo "ERROR: .env missing"; exit 1; fi; \
. ./.env; \
for v in $(1); do \
eval "val=\$$$$v"; \
[ -n "$$val" ] || { echo "ERROR: $$v is empty in .env (see env.example)"; exit 1; }; \
done
endef
.PHONY: build .PHONY: build
build: build: env config
# Build the Docker image. # Build the Docker image.
docker build -t application-portfolio . @$(call _require_env,IMAGE_NAME); \
docker build -t "$$IMAGE_NAME" .
.PHONY: build-no-cache .PHONY: build-no-cache
build-no-cache: build-no-cache: env config
# Build the Docker image without cache. # Build the Docker image without cache.
docker build --no-cache -t application-portfolio . @$(call _require_env,IMAGE_NAME); \
docker build --no-cache -t "$$IMAGE_NAME" .
.PHONY: up .PHONY: up
up: up: env config
# Start the application using docker-compose with build. # Start the application using docker-compose with build.
docker-compose up -d --build --force-recreate docker-compose up -d --build --force-recreate
@@ -34,23 +71,25 @@ down:
- docker-compose down - docker-compose down
.PHONY: run-dev .PHONY: run-dev
run-dev: run-dev: env config config
# Run the container in development mode (hot-reload). # Run the container in development mode (hot-reload).
@$(call _require_env,IMAGE_NAME PORT); \
docker run -d \ docker run -d \
-p $(PORT):$(PORT) \ -p "$$PORT:$$PORT" \
--name portfolio \ --name portfolio \
-v $(PWD)/app/:/app \ -v "$(PWD)/app/:/app" \
-e FLASK_APP=app.py \ -e FLASK_APP=app.py \
-e FLASK_ENV=development \ -e FLASK_ENV=development \
application-portfolio "$$IMAGE_NAME"
.PHONY: run-prod .PHONY: run-prod
run-prod: run-prod: env config config
# Run the container in production mode. # Run the container in production mode.
@$(call _require_env,IMAGE_NAME PORT); \
docker run -d \ docker run -d \
-p $(PORT):$(PORT) \ -p "$$PORT:$$PORT" \
--name portfolio \ --name portfolio \
application-portfolio "$$IMAGE_NAME"
.PHONY: logs .PHONY: logs
logs: logs:
@@ -58,12 +97,12 @@ logs:
docker logs -f portfolio docker logs -f portfolio
.PHONY: dev .PHONY: dev
dev: dev: env config
# Start the application in development mode using docker-compose. # Start the application in development mode using docker-compose.
FLASK_ENV=development docker-compose up -d FLASK_ENV=development docker-compose up -d
.PHONY: prod .PHONY: prod
prod: prod: env config
# Start the application in production mode using docker-compose (with build). # Start the application in production mode using docker-compose (with build).
docker-compose up -d --build docker-compose up -d --build
@@ -78,9 +117,10 @@ delete:
- docker rm -f portfolio - docker rm -f portfolio
.PHONY: browse .PHONY: browse
browse: browse: env
# Open the application in the browser at http://localhost:$(PORT) # Open the application in the browser at http://localhost:$$PORT
chromium http://localhost:$(PORT) @$(call _require_env,PORT); \
chromium "http://localhost:$$PORT"
.PHONY: install .PHONY: install
install: install:
@@ -92,11 +132,6 @@ install-dev:
# Install runtime and developer dependencies from pyproject.toml. # Install runtime and developer dependencies from pyproject.toml.
$(PYTHON) -m pip install -e ".[dev]" $(PYTHON) -m pip install -e ".[dev]"
.PHONY: npm-install
npm-install:
# Install Node.js dependencies for browser tests.
cd app && npm install
.PHONY: lint-actions .PHONY: lint-actions
lint-actions: lint-actions:
# Lint GitHub Actions workflows. # Lint GitHub Actions workflows.
@@ -145,7 +180,7 @@ security: install-dev test-security
$(PYTHON) -m pip_audit -r /tmp/portfolio-runtime-requirements.txt $(PYTHON) -m pip_audit -r /tmp/portfolio-runtime-requirements.txt
.PHONY: test-e2e .PHONY: test-e2e
test-e2e: npm-install test-e2e:
# Run Cypress end-to-end tests via act (stop portfolio container to free port first). # Run Cypress end-to-end tests via act (stop portfolio container to free port first).
-docker stop portfolio 2>/dev/null || true -docker stop portfolio 2>/dev/null || true
$(ACT) workflow_dispatch -W .github/workflows/tests.yml -j e2e $(ACT) workflow_dispatch -W .github/workflows/tests.yml -j e2e

View File

@@ -4,6 +4,8 @@
A lightweight, Docker-powered portfolio/landing-page generator—fully customizable via YAML! Showcase your projects, skills, and online presence in minutes. A lightweight, Docker-powered portfolio/landing-page generator—fully customizable via YAML! Showcase your projects, skills, and online presence in minutes.
![PortUI screenshot](assets/img/screenshot.png)
> 🚀 You can also pair PortUI with JavaScript for sleek, web-based desktop-style interfaces. > 🚀 You can also pair PortUI with JavaScript for sleek, web-based desktop-style interfaces.
> 💻 Example in action: [CyMaIS.Cloud](https://cymais.cloud/) (demo) > 💻 Example in action: [CyMaIS.Cloud](https://cymais.cloud/) (demo)
> 🌐 Another live example: [veen.world](https://www.veen.world/) (Kevins personal site) > 🌐 Another live example: [veen.world](https://www.veen.world/) (Kevins personal site)

View File

@@ -3,14 +3,16 @@ import os
import requests import requests
import yaml import yaml
from flask import Flask, current_app, render_template from flask import Flask, current_app, render_template, url_for
from markupsafe import Markup from markupsafe import Markup
try: try:
from app.utils.asset_resolver import asset_src, resolve_asset_cache
from app.utils.cache_manager import CacheManager from app.utils.cache_manager import CacheManager
from app.utils.compute_card_classes import compute_card_classes from app.utils.compute_card_classes import compute_card_classes
from app.utils.configuration_resolver import ConfigurationResolver from app.utils.configuration_resolver import ConfigurationResolver
except ImportError: # pragma: no cover - supports running from the app/ directory. except ImportError: # pragma: no cover - supports running from the app/ directory.
from utils.asset_resolver import asset_src, resolve_asset_cache
from utils.cache_manager import CacheManager from utils.cache_manager import CacheManager
from utils.compute_card_classes import compute_card_classes from utils.compute_card_classes import compute_card_classes
from utils.configuration_resolver import ConfigurationResolver from utils.configuration_resolver import ConfigurationResolver
@@ -43,24 +45,16 @@ def load_config(app):
def cache_icons_and_logos(app): def cache_icons_and_logos(app):
"""Cache all icons and logos to local files, with a source fallback.""" """Resolve every icon/logo/favicon to either a local cache path or
an external URL (see ``resolve_asset_cache``)."""
for card in app.config["cards"]: for card in app.config["cards"]:
icon = card.get("icon", {}) icon = card.get("icon")
if icon.get("source"): if icon:
cached = cache_manager.cache_file(icon["source"]) resolve_asset_cache(icon, cache_manager)
icon["cache"] = cached or icon["source"]
company_logo = app.config["company"]["logo"] resolve_asset_cache(app.config["company"]["logo"], cache_manager)
cached = cache_manager.cache_file(company_logo["source"]) resolve_asset_cache(app.config["platform"]["favicon"], cache_manager)
company_logo["cache"] = cached or company_logo["source"] resolve_asset_cache(app.config["platform"]["logo"], cache_manager)
favicon = app.config["platform"]["favicon"]
cached = cache_manager.cache_file(favicon["source"])
favicon["cache"] = cached or favicon["source"]
platform_logo = app.config["platform"]["logo"]
cached = cache_manager.cache_file(platform_logo["source"])
platform_logo["cache"] = cached or platform_logo["source"]
# Initialize Flask app # Initialize Flask app
@@ -83,7 +77,10 @@ def utility_processor():
except OSError: except OSError:
return "" return ""
return dict(include_svg=include_svg) def template_asset_src(asset):
return asset_src(asset, lambda filename: url_for("static", filename=filename))
return dict(include_svg=include_svg, asset_src=template_asset_src)
@app.before_request @app.before_request

View File

@@ -198,6 +198,15 @@ accounts:
url: https://s.veen.world/cloud url: https://s.veen.world/cloud
cards: cards:
- icon:
source: https://file.infinito.nexus/assets/img/logo.png
title: Infinito.Nexus
text: Open-source self-hosting stack — one-click deployable FOSS web apps with
shared identity, asset, and observability services. The platform that hosts
this site, including the dashboard you are looking at.
url: https://infinito.nexus
link_text: infinito.nexus
iframe: true
- icon: - icon:
source: https://cloud.veen.world/s/logo_agile_coach_512x512/download source: https://cloud.veen.world/s/logo_agile_coach_512x512/download
title: Agile Coach title: Agile Coach

View File

@@ -1,4 +1,70 @@
// cypress/e2e/dynamic_popup.spec.js // 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', () => { describe('Dynamic Popup', () => {
const base = { const base = {

View File

@@ -111,17 +111,13 @@ div#navbarNavfooter li.nav-item {
margin-right: 6px; margin-right: 6px;
} }
/* Prevent nav items from wrapping to a second line */ /* 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#navbarNavheader .navbar-nav,
div#navbarNavfooter .navbar-nav { div#navbarNavfooter .navbar-nav {
flex-wrap: nowrap; flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none; /* Firefox */
}
div#navbarNavheader .navbar-nav::-webkit-scrollbar,
div#navbarNavfooter .navbar-nav::-webkit-scrollbar {
display: none; /* Chrome/Safari */
} }
main, footer, header, nav { main, footer, header, nav {
@@ -197,7 +193,6 @@ iframe{
/* 1. Make sure headers and footers can collapse */ /* 1. Make sure headers and footers can collapse */
header, header,
footer { footer {
overflow: hidden;
/* choose a max-height thats >= your tallest header/footer */ /* choose a max-height thats >= your tallest header/footer */
max-height: 200px; max-height: 200px;
padding: 1rem; padding: 1rem;
@@ -206,9 +201,11 @@ footer {
padding var(--anim-duration) ease-in-out; padding var(--anim-duration) ease-in-out;
} }
/* 2. In fullscreen mode, collapse them */ /* 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 header,
body.fullscreen footer { body.fullscreen footer {
overflow: hidden;
max-height: 0; max-height: 0;
padding-top: 0; padding-top: 0;
padding-bottom: 0; padding-bottom: 0;

View File

@@ -1,9 +1,19 @@
/* Top-level dropdown menu */ /* Top-level dropdown menu — direction toggled by JS via .dropdown / .dropup */
.nav-item .dropdown-menu { .nav-item.dropdown > .dropdown-menu,
position: absolute; /* Important for positioning */ .nav-item.dropup > .dropdown-menu {
top: 100%; /* Default opening direction: downwards */ position: absolute;
left: 0; left: 0;
z-index: 1050; /* Ensures the menu appears above other elements */ z-index: 1050;
}
.nav-item.dropdown > .dropdown-menu {
top: 100%;
bottom: auto;
}
.nav-item.dropup > .dropdown-menu {
top: auto;
bottom: 100%;
} }
/* Submenu position */ /* Submenu position */

View File

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

View File

@@ -6,7 +6,7 @@
<link <link
rel="icon" rel="icon"
type="image/x-icon" type="image/x-icon"
href="{% if platform.favicon.cache %}{{ url_for('static', filename=platform.favicon.cache) }}{% endif %}" href="{{ asset_src(platform.favicon) }}"
> >
<!-- Bootstrap CSS only --> <!-- Bootstrap CSS only -->
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet"> <link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
@@ -36,7 +36,7 @@
<div class="container"> <div class="container">
<header class="header js-restore"> <header class="header js-restore">
<img <img
src="{{ url_for('static', filename=platform.logo.cache) }}" src="{{ asset_src(platform.logo) }}"
alt="logo" alt="logo"
/> />
<h1>{{platform.titel}}</h1> <h1>{{platform.titel}}</h1>

View File

@@ -4,9 +4,9 @@
<div class="card-img-top"> <div class="card-img-top">
{% if card.icon.cache and card.icon.cache.endswith('.svg') %} {% if card.icon.cache and card.icon.cache.endswith('.svg') %}
{{ include_svg(card.icon.cache) }} {{ include_svg(card.icon.cache) }}
{% elif card.icon.cache %} {% elif card.icon.cache or card.icon.external_url %}
<img <img
src="{{ url_for('static', filename=card.icon.cache) }}" src="{{ asset_src(card.icon) }}"
alt="{{ card.title }}" alt="{{ card.title }}"
style="width:100px; height:auto;" style="width:100px; height:auto;"
onerror="this.style.display='none'; this.nextElementSibling?.style.display='inline-block';"> onerror="this.style.display='none'; this.nextElementSibling?.style.display='inline-block';">

View File

@@ -56,7 +56,7 @@
{% if menu_type == "header" %} {% if menu_type == "header" %}
<a class="navbar-brand align-items-center d-flex js-restore" id="navbar_logo" href="#"> <a class="navbar-brand align-items-center d-flex js-restore" id="navbar_logo" href="#">
<img <img
src="{{ url_for('static', filename=platform.logo.cache) }}" src="{{ asset_src(platform.logo) }}"
alt="{{ platform.titel }}" alt="{{ platform.titel }}"
class="d-inline-block align-text-top" class="d-inline-block align-text-top"
style="height:2rem"> style="height:2rem">
@@ -85,7 +85,7 @@
{% else %} {% else %}
<!-- Dropdown Menu --> <!-- Dropdown Menu -->
<li class="nav-item dropdown"> <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"> <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 %} {% if item.icon is defined and item.icon.class is defined %}
{{ render_icon_and_name(item) }} {{ render_icon_and_name(item) }}
{% else %} {% else %}

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

BIN
assets/img/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1024 KiB

View File

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

View File

@@ -1,2 +1,6 @@
PORT=5001 PORT=5001
FLASK_ENV=production FLASK_ENV=production
# Single source of truth for the Docker image tag — read by both the
# Makefile (build/run-dev/run-prod) and docker-compose.yml so every
# build path produces and consumes the same tag.
IMAGE_NAME=portfolio

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "portfolio-ui" name = "portfolio-ui"
version = "1.1.0" version = "2.0.0"
description = "A lightweight YAML-driven portfolio and landing-page generator." description = "A lightweight YAML-driven portfolio and landing-page generator."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"

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,188 @@
import unittest
from unittest.mock import Mock, patch
import requests
from app.utils.asset_resolver import (
_probe_image_url,
asset_src,
resolve_asset_cache,
)
def _url_for_static(filename):
return f"/static/{filename}"
def _always_probes_false(_url):
return False
def _always_probes_true(_url):
return True
class TestResolveAssetCache(unittest.TestCase):
def test_probe_success_skips_cache_and_embeds_directly(self):
cache_manager = Mock()
asset = {"source": "https://example.test/logo.png"}
resolve_asset_cache(asset, cache_manager, probe=_always_probes_true)
self.assertIsNone(asset["cache"])
self.assertEqual(asset["external_url"], "https://example.test/logo.png")
cache_manager.cache_file.assert_not_called()
def test_local_cache_path_assigned_when_probe_fails_but_download_succeeds(self):
cache_manager = Mock()
cache_manager.cache_file.return_value = "cache/logo_abc.png"
asset = {"source": "https://example.test/logo.png"}
resolve_asset_cache(asset, cache_manager, probe=_always_probes_false)
self.assertEqual(asset["cache"], "cache/logo_abc.png")
self.assertIsNone(asset["external_url"])
cache_manager.cache_file.assert_called_once_with(
"https://example.test/logo.png"
)
def test_external_url_fallback_when_probe_and_download_both_fail(self):
cache_manager = Mock()
cache_manager.cache_file.return_value = None
asset = {"source": "https://file.infinito.nexus/assets/img/logo.png"}
resolve_asset_cache(asset, cache_manager, probe=_always_probes_false)
self.assertIsNone(asset["cache"])
self.assertEqual(
asset["external_url"],
"https://file.infinito.nexus/assets/img/logo.png",
)
def test_missing_source_leaves_both_fields_none(self):
cache_manager = Mock()
probe = Mock()
asset = {"class": "fa-solid fa-link"}
resolve_asset_cache(asset, cache_manager, probe=probe)
self.assertIsNone(asset["cache"])
self.assertIsNone(asset["external_url"])
cache_manager.cache_file.assert_not_called()
probe.assert_not_called()
def test_empty_source_treated_as_missing(self):
cache_manager = Mock()
probe = Mock()
asset = {"source": ""}
resolve_asset_cache(asset, cache_manager, probe=probe)
self.assertIsNone(asset["cache"])
self.assertIsNone(asset["external_url"])
cache_manager.cache_file.assert_not_called()
probe.assert_not_called()
class TestProbeImageUrl(unittest.TestCase):
def _response(self, content_type, raise_exc=None):
resp = Mock()
if raise_exc:
resp.raise_for_status.side_effect = raise_exc
else:
resp.raise_for_status.return_value = None
resp.headers = {"Content-Type": content_type}
return resp
@patch("app.utils.asset_resolver.requests.head")
def test_returns_true_for_image_content_type(self, mock_head):
mock_head.return_value = self._response("image/png")
self.assertTrue(_probe_image_url("https://example.test/logo.png"))
@patch("app.utils.asset_resolver.requests.head")
def test_returns_true_for_svg_with_charset_suffix(self, mock_head):
mock_head.return_value = self._response("image/svg+xml; charset=utf-8")
self.assertTrue(_probe_image_url("https://example.test/logo.svg"))
@patch("app.utils.asset_resolver.requests.head")
def test_returns_false_for_non_image_content_type(self, mock_head):
mock_head.return_value = self._response("text/html")
self.assertFalse(_probe_image_url("https://example.test/404"))
@patch("app.utils.asset_resolver.requests.head")
def test_returns_false_when_http_status_not_ok(self, mock_head):
mock_head.return_value = self._response(
"image/png",
raise_exc=requests.HTTPError("404 Not Found"),
)
self.assertFalse(_probe_image_url("https://example.test/missing.png"))
@patch("app.utils.asset_resolver.requests.head")
def test_returns_false_on_network_error(self, mock_head):
mock_head.side_effect = requests.ConnectionError("dns failure")
self.assertFalse(_probe_image_url("https://unreachable.test/logo.png"))
@patch("app.utils.asset_resolver.requests.head")
def test_follows_redirects_with_short_timeout(self, mock_head):
mock_head.return_value = self._response("image/png")
_probe_image_url("https://example.test/logo.png")
mock_head.assert_called_once_with(
"https://example.test/logo.png",
allow_redirects=True,
timeout=3,
)
class TestAssetSrc(unittest.TestCase):
def test_returns_static_url_for_local_cache_path(self):
asset = {"cache": "cache/logo_abc.png", "external_url": None}
self.assertEqual(
asset_src(asset, _url_for_static),
"/static/cache/logo_abc.png",
)
def test_returns_absolute_url_unchanged_for_external_url(self):
# Regression: previously the template wrapped this through
# url_for('static', ...) and produced /static/https://... — a
# URL-in-URL the browser resolved against the dashboard host.
asset = {
"cache": None,
"external_url": "https://file.infinito.nexus/assets/img/logo.png",
}
self.assertEqual(
asset_src(asset, _url_for_static),
"https://file.infinito.nexus/assets/img/logo.png",
)
def test_external_url_wins_over_cache_when_both_present(self):
asset = {
"cache": "cache/logo.png",
"external_url": "https://override.test/logo.png",
}
self.assertEqual(
asset_src(asset, _url_for_static),
"https://override.test/logo.png",
)
def test_returns_empty_string_when_neither_field_is_set(self):
asset = {"cache": None, "external_url": None}
self.assertEqual(asset_src(asset, _url_for_static), "")
def test_returns_empty_string_for_none_or_empty_asset(self):
self.assertEqual(asset_src(None, _url_for_static), "")
self.assertEqual(asset_src({}, _url_for_static), "")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,78 @@
import unittest
from html.parser import HTMLParser
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
class AnchorCollector(HTMLParser):
def __init__(self):
super().__init__()
self.anchors = []
def handle_starttag(self, tag, attrs):
if tag == "a":
self.anchors.append(dict(attrs))
class TestNavigationTemplate(unittest.TestCase):
def test_top_level_dropdowns_have_bootstrap_toggle_attribute(self):
template_dir = Path(__file__).resolve().parents[2] / "app" / "templates"
environment = Environment(
loader=FileSystemLoader(template_dir),
autoescape=select_autoescape(),
)
environment.globals["url_for"] = lambda _endpoint, filename: (
f"/static/{filename}"
)
environment.globals["asset_src"] = lambda asset: (
(asset or {}).get("external_url")
or (
f"/static/{(asset or {}).get('cache')}"
if (asset or {}).get("cache")
else ""
)
)
rendered = environment.get_template("moduls/navigation.html.j2").render(
menu_type="header",
platform={
"titel": "Portfolio",
"logo": {"cache": "logo.png"},
},
navigation={
"header": {
"children": [
{
"name": "Apps",
"description": "Application menu",
"icon": {"class": "fa-solid fa-grid"},
"children": [
{
"name": "Example",
"description": "Example app",
"icon": {"class": "fa-solid fa-link"},
"url": "https://example.test",
}
],
}
]
}
},
)
parser = AnchorCollector()
parser.feed(rendered)
dropdown_toggles = [
anchor
for anchor in parser.anchors
if "nav-link" in anchor.get("class", "")
and "dropdown-toggle" in anchor.get("class", "")
]
self.assertEqual(len(dropdown_toggles), 1)
self.assertEqual(dropdown_toggles[0].get("data-bs-toggle"), "dropdown")
if __name__ == "__main__":
unittest.main()