mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-05-21 12:34:14 +00:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03b7cef90e | |||
| 4b29448b33 | |||
| 3f30621630 | |||
| 39a41e561c | |||
| 4424db22cb | |||
| 28a796e24f | |||
| f3c15e3e1c | |||
| 3301f8d95f | |||
| a575fddaa2 | |||
| c9fe7d8583 | |||
| 03f17a6e05 | |||
| 3132aab2a5 | |||
| 3d1db1f8ba | |||
| 58872ced81 | |||
| 13b3af3330 | |||
| eca7084f4e | |||
| 6861b2c0eb | |||
| 66b1f0d029 | |||
| a29a0b1862 | |||
| 252b50d2a7 | |||
| 2c61da9fc3 | |||
| 2d8185b747 | |||
| a47a5babce |
3
.claude/.gitignore
vendored
Normal file
3
.claude/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
!.settings.json
|
||||
52
.claude/settings.json
Normal file
52
.claude/settings.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read",
|
||||
"Edit",
|
||||
"Write",
|
||||
"Bash(*)",
|
||||
"Read(//tmp/**)",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)",
|
||||
"WebFetch(domain:api.github.com)",
|
||||
"WebFetch(domain:docs.docker.com)",
|
||||
"WebFetch(domain:pypi.org)",
|
||||
"WebFetch(domain:docs.cypress.io)",
|
||||
"WebFetch(domain:flask.palletsprojects.com)",
|
||||
"Skill(update-config)",
|
||||
"Skill(update-config:*)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push --force*)",
|
||||
"Bash(git reset --hard*)",
|
||||
"Bash(rm -rf*)",
|
||||
"Bash(sudo*)"
|
||||
],
|
||||
"ask": [
|
||||
"Bash(git push*)",
|
||||
"Bash(docker run*)",
|
||||
"Bash(curl*)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/tmp"
|
||||
]
|
||||
},
|
||||
"sandbox": {
|
||||
"enabled": true,
|
||||
"autoAllowBashIfSandboxed": true,
|
||||
"filesystem": {
|
||||
"allowWrite": [
|
||||
".",
|
||||
"/tmp"
|
||||
],
|
||||
"denyRead": [
|
||||
"~/.ssh",
|
||||
"~/.gnupg",
|
||||
"~/.kube",
|
||||
"~/.aws",
|
||||
"~/.config/gcloud"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
75
.github/workflows/ci.yml
vendored
75
.github/workflows/ci.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
@@ -9,59 +10,55 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
test-and-publish:
|
||||
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
|
||||
env:
|
||||
PORT: "5000"
|
||||
needs:
|
||||
- security
|
||||
- tests
|
||||
- lint
|
||||
if: github.event_name == 'push'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: pip install -r app/requirements.txt
|
||||
|
||||
- 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: 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:5000
|
||||
wait-on-timeout: 120
|
||||
|
||||
- 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" >> "$GITHUB_OUTPUT"
|
||||
echo "raw_tag=$SEMVER_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${SEMVER_TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "found=true"
|
||||
echo "raw_tag=$SEMVER_TAG"
|
||||
echo "version=${SEMVER_TAG#v}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
77
.github/workflows/lint.yml
vendored
Normal file
77
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
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-python:
|
||||
name: Lint Python
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install lint dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install ".[dev]"
|
||||
|
||||
- name: Ruff lint
|
||||
run: ruff check .
|
||||
|
||||
- name: Ruff format check
|
||||
run: ruff format --check .
|
||||
|
||||
lint-docker:
|
||||
name: Lint Dockerfile
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Run hadolint
|
||||
id: hadolint
|
||||
continue-on-error: true
|
||||
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5
|
||||
with:
|
||||
dockerfile: ./Dockerfile
|
||||
format: sarif
|
||||
output-file: hadolint-results.sarif
|
||||
failure-threshold: warning
|
||||
|
||||
- name: Upload hadolint SARIF
|
||||
if: always() && github.event_name == 'push'
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: hadolint-results.sarif
|
||||
wait-for-processing: true
|
||||
category: hadolint
|
||||
|
||||
- name: Fail on hadolint warnings
|
||||
if: always()
|
||||
run: python3 utils/check_hadolint_sarif.py hadolint-results.sarif
|
||||
48
.github/workflows/security.yml
vendored
Normal file
48
.github/workflows/security.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Security
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Run security scan
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
security-events: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
- language: python
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
queries: security-extended,security-and-quality
|
||||
|
||||
- name: Run manual build steps
|
||||
if: matrix.build-mode == 'manual'
|
||||
run: |
|
||||
echo "No manual build is configured for this repository."
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
194
.github/workflows/tests.yml
vendored
Normal file
194
.github/workflows/tests.yml
vendored
Normal 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
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -3,3 +3,10 @@ app/config.yaml
|
||||
app/static/cache/*
|
||||
.env
|
||||
app/cypress/screenshots/*
|
||||
.ruff_cache/
|
||||
app/node_modules/
|
||||
app/static/vendor/
|
||||
hadolint-results.sarif
|
||||
build/
|
||||
*.egg-info/
|
||||
app/core.*
|
||||
|
||||
13
AGENTS.md
Normal file
13
AGENTS.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Agent Instructions
|
||||
|
||||
## Pre-Commit Validation
|
||||
|
||||
- You MUST run `make test` before every commit whenever the staged change includes at least one file that is not `.md` or `.rst`, unless explicitly instructed otherwise.
|
||||
- You MUST commit only after all tests pass.
|
||||
- You MUST NOT commit automatically without explicit confirmation from the user.
|
||||
|
||||
## Vendor Assets
|
||||
|
||||
- Browser vendor assets (Bootstrap, Font Awesome, etc.) are managed via npm.
|
||||
- Run `npm install` inside `app/` to populate `app/static/vendor/` before starting the dev server or running e2e tests.
|
||||
- Never commit `app/node_modules/` or `app/static/vendor/` — both are gitignored and generated at build time.
|
||||
32
CHANGELOG.md
32
CHANGELOG.md
@@ -1,3 +1,35 @@
|
||||
## [2.0.0] - 2026-05-18
|
||||
|
||||
* * Asset resolution: new probe-first resolver tries a HEAD request and embeds reachable image URLs directly via a new external_url field, falling back to the cache-download path only when the probe fails; broken /static/https://... URLs no longer appear when the source cannot be downloaded
|
||||
* Template integration: an asset_src context processor in app.py picks external_url first and url_for(static, cache) second, so base.html.j2, navigation.html.j2, and card.html.j2 never wrap an absolute URL in the static prefix
|
||||
* Test coverage: 16 new unit tests cover every probe, cache, and fallback branch; a live integration test exercises https://file.infinito.nexus/assets/img/logo.png to prove the probe-first path works end-to-end without writing to the cache directory
|
||||
* Sample configuration: new Infinito.Nexus card in app/config.sample.yaml driven by the canonical file.infinito.nexus asset URL
|
||||
* SPOT for build variables: env.example is the single source of truth for IMAGE_NAME and PORT; the Makefile and docker-compose.yml reference both with no defaults and fail loudly when either variable is missing
|
||||
* Bootstrap targets: make env and make config materialise .env and app/config.yaml from their checked-in templates without overwriting existing files; build, build-no-cache, up, run-dev, run-prod, dev, prod, and browse depend on both so a fresh checkout runs in a single make invocation
|
||||
* Recipe sourcing: Makefile recipes load .env at recipe-execution time via a shared _require_env helper, so a freshly bootstrapped .env is picked up in the same make invocation that created it
|
||||
* README: PortUI screenshot added under the title
|
||||
* Lint: removed an unused sys import in the live integration test
|
||||
|
||||
|
||||
## [1.2.0] - 2026-05-11
|
||||
|
||||
* * Navigation behavior: Top-level dropdowns now open reliably on hover and click via Bootstrap, escape the header and navbar overflow clips, and flip between downward and upward based on whether more space is above or below the toggle
|
||||
* Compose-driven dependencies: docker-compose runs npm install inside the container on every up and persists node_modules plus static/vendor in named volumes, removing the host-side npm-install step from up, dev, prod, and test-e2e
|
||||
* Test coverage: New Cypress specs cover both header and footer dropdown directions, with a Jinja unit test guarding the data-bs-toggle attribute on top-level dropdown toggles
|
||||
* Harness configuration: Enabled the Claude Code sandbox with scoped filesystem rules, consolidated the bash allowlist behind a single wildcard, and gitignored local-only state under .claude
|
||||
|
||||
|
||||
## [1.1.0] - 2026-03-30
|
||||
|
||||
* *CI stabilization and modularization*: Split into reusable workflows (lint, security, tests) with correct permissions for CodeQL and SARIF uploads
|
||||
* *Modern Python packaging*: Migration to pyproject.toml and updated Dockerfile using Python 3.12
|
||||
* *Improved test coverage*: Added unit, integration, lint, security, and E2E tests using act
|
||||
* *Local vendor assets*: Replaced external CDNs with npm-based local asset pipeline
|
||||
* *Enhanced build workflow*: Extended Makefile with targets for test, lint, security, and CI plus vendor build process
|
||||
* *Frontend fix*: Prevented navbar wrapping and improved layout behavior
|
||||
* *Developer guidelines*: Introduced AGENTS.md and CLAUDE.md with enforced pre-commit rules
|
||||
|
||||
|
||||
## [1.0.0] - 2026-02-19
|
||||
|
||||
* Official Release🥳
|
||||
|
||||
5
CLAUDE.md
Normal file
5
CLAUDE.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Startup
|
||||
|
||||
You MUST read `AGENTS.md` and follow all instructions in it at the start of every conversation before doing anything else.
|
||||
24
Dockerfile
24
Dockerfile
@@ -1,14 +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
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
|
||||
4
MIRRORS
Normal file
4
MIRRORS
Normal file
@@ -0,0 +1,4 @@
|
||||
git@github.com:kevinveenbirkenbach/port-ui.git
|
||||
ssh://git@code.infinito.nexus:2201/kevinveenbirkenbach/port-ui.git
|
||||
ssh://git@git.veen.world:2201/kevinveenbirkenbach/port-ui.git
|
||||
|
||||
173
Makefile
173
Makefile
@@ -5,19 +5,63 @@ ifneq (,$(wildcard .env))
|
||||
export $(shell sed 's/=.*//' .env)
|
||||
endif
|
||||
|
||||
# Default port (can be overridden with PORT env var)
|
||||
PORT ?= 5000
|
||||
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
|
||||
|
||||
# Default port (can be overridden with PORT env var)
|
||||
.PHONY: build
|
||||
build:
|
||||
build: env config
|
||||
# Build the Docker image.
|
||||
docker build -t application-portfolio .
|
||||
@$(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:
|
||||
up: env config
|
||||
# Start the application using docker-compose with build.
|
||||
docker-compose up -d --build
|
||||
docker-compose up -d --build --force-recreate
|
||||
|
||||
.PHONY: down
|
||||
down:
|
||||
@@ -27,23 +71,25 @@ down:
|
||||
- docker-compose down
|
||||
|
||||
.PHONY: run-dev
|
||||
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) \
|
||||
-p "$$PORT:$$PORT" \
|
||||
--name portfolio \
|
||||
-v $(PWD)/app/:/app \
|
||||
-v "$(PWD)/app/:/app" \
|
||||
-e FLASK_APP=app.py \
|
||||
-e FLASK_ENV=development \
|
||||
application-portfolio
|
||||
"$$IMAGE_NAME"
|
||||
|
||||
.PHONY: run-prod
|
||||
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) \
|
||||
-p "$$PORT:$$PORT" \
|
||||
--name portfolio \
|
||||
application-portfolio
|
||||
"$$IMAGE_NAME"
|
||||
|
||||
.PHONY: logs
|
||||
logs:
|
||||
@@ -51,12 +97,12 @@ logs:
|
||||
docker logs -f portfolio
|
||||
|
||||
.PHONY: dev
|
||||
dev:
|
||||
dev: env config
|
||||
# Start the application in development mode using docker-compose.
|
||||
FLASK_ENV=development docker-compose up -d
|
||||
|
||||
.PHONY: prod
|
||||
prod:
|
||||
prod: env config
|
||||
# Start the application in production mode using docker-compose (with build).
|
||||
docker-compose up -d --build
|
||||
|
||||
@@ -71,12 +117,93 @@ delete:
|
||||
- docker rm -f portfolio
|
||||
|
||||
.PHONY: browse
|
||||
browse:
|
||||
# Open the application in the browser at http://localhost:$(PORT)
|
||||
chromium http://localhost:$(PORT)
|
||||
browse: env
|
||||
# Open the application in the browser at http://localhost:$$PORT
|
||||
@$(call _require_env,PORT); \
|
||||
chromium "http://localhost:$$PORT"
|
||||
|
||||
npm-install:
|
||||
cd app && npm install
|
||||
.PHONY: install
|
||||
install:
|
||||
# Install runtime Python dependencies from pyproject.toml.
|
||||
$(PYTHON) -m pip install -e .
|
||||
|
||||
test: npm-install
|
||||
cd app && npx cypress run --spec "cypress/e2e/**/*.spec.js"
|
||||
.PHONY: install-dev
|
||||
install-dev:
|
||||
# Install runtime and developer dependencies from pyproject.toml.
|
||||
$(PYTHON) -m pip install -e ".[dev]"
|
||||
|
||||
.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: 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-docker 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: test-e2e
|
||||
test-e2e:
|
||||
# Run Cypress end-to-end tests via 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.
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
# PortUI 🖥️✨
|
||||
|
||||
[](https://github.com/sponsors/kevinveenbirkenbach)
|
||||
[](https://www.patreon.com/c/kevinveenbirkenbach)
|
||||
[](https://buymeacoffee.com/kevinveenbirkenbach)
|
||||
[](https://s.veen.world/paypaldonate)
|
||||
[](https://github.com/sponsors/kevinveenbirkenbach) [](https://www.patreon.com/c/kevinveenbirkenbach) [](https://buymeacoffee.com/kevinveenbirkenbach) [](https://s.veen.world/paypaldonate)
|
||||
|
||||
A lightweight, Docker-powered portfolio/landing-page generator—fully customizable via YAML! Showcase your projects, skills, and online presence in minutes.
|
||||
|
||||

|
||||
|
||||
> 🚀 You can also pair PortUI with JavaScript for sleek, web-based desktop-style interfaces.
|
||||
> 💻 Example in action: [CyMaIS.Cloud](https://cymais.cloud/) (demo)
|
||||
> 🌐 Another live example: [veen.world](https://www.veen.world/) (Kevin’s personal site)
|
||||
|
||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Portfolio UI web application package."""
|
||||
103
app/app.py
103
app/app.py
@@ -1,18 +1,28 @@
|
||||
import logging
|
||||
import os
|
||||
from flask import Flask, render_template
|
||||
import yaml
|
||||
|
||||
import requests
|
||||
from utils.configuration_resolver import ConfigurationResolver
|
||||
import yaml
|
||||
from flask import Flask, current_app, render_template, url_for
|
||||
from markupsafe import Markup
|
||||
|
||||
try:
|
||||
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
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
FLASK_ENV = os.getenv("FLASK_ENV", "production")
|
||||
FLASK_PORT = int(os.getenv("PORT", 5000))
|
||||
print(f"🔧 Starting app on port {FLASK_PORT}, FLASK_ENV={FLASK_ENV}")
|
||||
from utils.configuration_resolver import ConfigurationResolver
|
||||
|
||||
from flask import Flask, render_template, current_app
|
||||
from markupsafe import Markup
|
||||
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()
|
||||
@@ -20,10 +30,11 @@ 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"]
|
||||
@@ -32,29 +43,18 @@ def load_config(app):
|
||||
resolver.resolve_links()
|
||||
app.config.update(resolver.get_config())
|
||||
|
||||
|
||||
def cache_icons_and_logos(app):
|
||||
"""Cache all icons and logos to local files, mit Fallback auf source."""
|
||||
"""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"):
|
||||
cached = cache_manager.cache_file(icon["source"])
|
||||
# Fallback: wenn cache_file None liefert, nutze weiterhin source
|
||||
icon["cache"] = cached or icon["source"]
|
||||
icon = card.get("icon")
|
||||
if icon:
|
||||
resolve_asset_cache(icon, cache_manager)
|
||||
|
||||
# Company-Logo
|
||||
company_logo = app.config["company"]["logo"]
|
||||
cached = cache_manager.cache_file(company_logo["source"])
|
||||
company_logo["cache"] = cached or company_logo["source"]
|
||||
|
||||
# Platform Favicon
|
||||
favicon = app.config["platform"]["favicon"]
|
||||
cached = cache_manager.cache_file(favicon["source"])
|
||||
favicon["cache"] = cached or favicon["source"]
|
||||
|
||||
# Platform Logo
|
||||
platform_logo = app.config["platform"]["logo"]
|
||||
cached = cache_manager.cache_file(platform_logo["source"])
|
||||
platform_logo["cache"] = cached or 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)
|
||||
|
||||
|
||||
# Initialize Flask app
|
||||
@@ -64,17 +64,24 @@ app = Flask(__name__)
|
||||
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)
|
||||
full_path = os.path.join(current_app.root_path, "static", path)
|
||||
try:
|
||||
with open(full_path, 'r', encoding='utf-8') as f:
|
||||
svg = f.read()
|
||||
return Markup(svg)
|
||||
except IOError:
|
||||
return Markup(f'<!-- SVG not found: {path} -->')
|
||||
return dict(include_svg=include_svg)
|
||||
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():
|
||||
@@ -83,22 +90,22 @@ def reload_config_in_dev():
|
||||
load_config(app)
|
||||
cache_icons_and_logos(app)
|
||||
|
||||
@app.route('/')
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
"""Render the main index page."""
|
||||
cards = app.config["cards"]
|
||||
lg_classes, md_classes = compute_card_classes(cards)
|
||||
# fetch NASA APOD URL only if key present
|
||||
apod_bg = None
|
||||
api_key = app.config.get("NASA_API_KEY")
|
||||
if api_key:
|
||||
resp = requests.get(
|
||||
"https://api.nasa.gov/planetary/apod",
|
||||
params={"api_key": api_key}
|
||||
params={"api_key": api_key},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.ok:
|
||||
data = resp.json()
|
||||
# only use if it's an image
|
||||
if data.get("media_type") == "image":
|
||||
apod_bg = data.get("url")
|
||||
|
||||
@@ -110,8 +117,14 @@ def index():
|
||||
platform=app.config["platform"],
|
||||
lg_classes=lg_classes,
|
||||
md_classes=md_classes,
|
||||
apod_bg=apod_bg
|
||||
apod_bg=apod_bg,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=(FLASK_ENV == "development"), host="0.0.0.0", port=FLASK_PORT)
|
||||
app.run(
|
||||
debug=(FLASK_ENV == "development"),
|
||||
host=FLASK_HOST,
|
||||
port=FLASK_PORT,
|
||||
use_reloader=False,
|
||||
)
|
||||
|
||||
@@ -198,6 +198,15 @@ accounts:
|
||||
url: https://s.veen.world/cloud
|
||||
|
||||
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:
|
||||
source: https://cloud.veen.world/s/logo_agile_coach_512x512/download
|
||||
title: Agile Coach
|
||||
|
||||
@@ -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', () => {
|
||||
const base = {
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('Navbar Logo Visibility', () => {
|
||||
|
||||
it('should become visible (opacity 1) after entering fullscreen', () => {
|
||||
cy.window().then(win => {
|
||||
win.fullscreen();
|
||||
win.enterFullscreen();
|
||||
});
|
||||
cy.get('#navbar_logo', { timeout: 4000 })
|
||||
.should('have.css', 'opacity', '1');
|
||||
@@ -23,7 +23,7 @@ describe('Navbar Logo Visibility', () => {
|
||||
|
||||
it('should become invisible again (opacity 0) after exiting fullscreen', () => {
|
||||
cy.window().then(win => {
|
||||
win.fullscreen();
|
||||
win.enterFullscreen();
|
||||
win.exitFullscreen();
|
||||
});
|
||||
cy.get('#navbar_logo', { timeout: 4000 })
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
{
|
||||
"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": {
|
||||
"cypress": "^14.5.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node scripts/copy-vendor.js",
|
||||
"postinstall": "node scripts/copy-vendor.js"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
flask
|
||||
requests
|
||||
pyyaml
|
||||
71
app/scripts/copy-vendor.js
Normal file
71
app/scripts/copy-vendor.js
Normal file
@@ -0,0 +1,71 @@
|
||||
'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', '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')
|
||||
);
|
||||
@@ -111,6 +111,15 @@ div#navbarNavfooter li.nav-item {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
main, footer, header, nav {
|
||||
position: relative;
|
||||
box-shadow:
|
||||
@@ -168,20 +177,22 @@ iframe{
|
||||
}
|
||||
|
||||
#navbar_logo {
|
||||
/* start invisible but in the layout (d-none will actually hide it) */
|
||||
opacity: 0;
|
||||
transition: opacity var(--anim-duration) ease-in-out;
|
||||
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 {
|
||||
overflow: hidden;
|
||||
/* choose a max-height that’s >= your tallest header/footer */
|
||||
max-height: 200px;
|
||||
padding: 1rem;
|
||||
@@ -190,9 +201,11 @@ footer {
|
||||
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 footer {
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,40 +109,60 @@ 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) {
|
||||
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) {
|
||||
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() {
|
||||
return window.innerWidth < 992; // Bootstrap breakpoint for 'lg'
|
||||
|
||||
@@ -6,25 +6,22 @@
|
||||
<link
|
||||
rel="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 -->
|
||||
<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.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="https://code.jquery.com/jquery-3.6.0.min.js"
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='vendor/jquery/jquery.min.js') }}"></script>
|
||||
</head>
|
||||
<body
|
||||
{% if apod_bg %}
|
||||
@@ -39,7 +36,7 @@
|
||||
<div class="container">
|
||||
<header class="header js-restore">
|
||||
<img
|
||||
src="{{ url_for('static', filename=platform.logo.cache) }}"
|
||||
src="{{ asset_src(platform.logo) }}"
|
||||
alt="logo"
|
||||
/>
|
||||
<h1>{{platform.titel}}</h1>
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
<div class="card-img-top">
|
||||
{% if card.icon.cache and card.icon.cache.endswith('.svg') %}
|
||||
{{ include_svg(card.icon.cache) }}
|
||||
{% elif card.icon.cache %}
|
||||
{% elif card.icon.cache or card.icon.external_url %}
|
||||
<img
|
||||
src="{{ url_for('static', filename=card.icon.cache) }}"
|
||||
src="{{ asset_src(card.icon) }}"
|
||||
alt="{{ card.title }}"
|
||||
style="width:100px; height:auto;"
|
||||
onerror="this.style.display='none'; this.nextElementSibling?.style.display='inline-block';">
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
{% if menu_type == "header" %}
|
||||
<a class="navbar-brand align-items-center d-flex js-restore" id="navbar_logo" href="#">
|
||||
<img
|
||||
src="{{ url_for('static', filename=platform.logo.cache) }}"
|
||||
src="{{ asset_src(platform.logo) }}"
|
||||
alt="{{ platform.titel }}"
|
||||
class="d-inline-block align-text-top"
|
||||
style="height:2rem">
|
||||
@@ -85,7 +85,7 @@
|
||||
{% 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">
|
||||
<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) }}
|
||||
{% else %}
|
||||
|
||||
1
app/utils/__init__.py
Normal file
1
app/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Utilities used by the Portfolio UI web application."""
|
||||
89
app/utils/asset_resolver.py
Normal file
89
app/utils/asset_resolver.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Split asset resolution into distinct fields so the template can
|
||||
render the right URL.
|
||||
|
||||
Strategy
|
||||
--------
|
||||
|
||||
1. **Probe** the source URL with a HEAD request. If it responds with an
|
||||
``image/*`` content type, embed it directly via ``external_url``
|
||||
(no download necessary — the browser fetches it itself).
|
||||
2. **Cache** as a fallback: when the probe fails (404, non-image,
|
||||
network error, …) try ``cache_manager.cache_file()`` to download
|
||||
and serve via ``/static/cache/<file>``.
|
||||
3. **External fallback**: if the download fails too, still expose the
|
||||
source URL via ``external_url`` so the browser shows at least the
|
||||
alt-text or broken-image icon instead of an empty ``src``.
|
||||
|
||||
Type safety
|
||||
-----------
|
||||
|
||||
``cache`` only ever holds a relative path the static handler can serve;
|
||||
absolute URLs land in ``external_url`` and are rendered as-is. The
|
||||
previous fallback ``asset['cache'] = cached or asset['source']`` mixed
|
||||
both shapes — and the template's ``url_for('static', filename=...)``
|
||||
wrapper produced broken ``/static/https://…`` URLs when the source
|
||||
was an absolute URL the container couldn't fetch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
_PROBE_TIMEOUT_SECONDS = 3
|
||||
|
||||
|
||||
def _probe_image_url(url: str) -> bool:
|
||||
"""Return True if a HEAD request to ``url`` succeeds with an
|
||||
``image/*`` content type, suggesting the URL is safe to embed
|
||||
directly as ``<img src>``."""
|
||||
try:
|
||||
resp = requests.head(url, allow_redirects=True, timeout=_PROBE_TIMEOUT_SECONDS)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException:
|
||||
return False
|
||||
content_type = resp.headers.get("Content-Type", "").lower()
|
||||
return content_type.split(";", 1)[0].strip().startswith("image/")
|
||||
|
||||
|
||||
def resolve_asset_cache(
|
||||
asset: dict,
|
||||
cache_manager,
|
||||
probe: Callable[[str], bool] = _probe_image_url,
|
||||
) -> None:
|
||||
"""Set ``asset['cache']`` (local, served via /static/) or
|
||||
``asset['external_url']`` (rendered as-is). Never both.
|
||||
|
||||
Tries the cheap direct-embed path first (``probe``) before falling
|
||||
back to downloading via ``cache_manager``. See module docstring."""
|
||||
source = asset.get("source")
|
||||
asset["cache"] = None
|
||||
asset["external_url"] = None
|
||||
if not source:
|
||||
return
|
||||
if probe(source):
|
||||
asset["external_url"] = source
|
||||
return
|
||||
cached = cache_manager.cache_file(source)
|
||||
if cached:
|
||||
asset["cache"] = cached
|
||||
else:
|
||||
asset["external_url"] = source
|
||||
|
||||
|
||||
def asset_src(asset: Optional[dict], url_for_static: Callable[[str], str]) -> str:
|
||||
"""Return the final URL for an asset dict prepared by
|
||||
:func:`resolve_asset_cache`. ``url_for_static`` is a callable that
|
||||
maps a relative cache path to its served URL (typically a partial
|
||||
of Flask's ``url_for('static', filename=...)``)."""
|
||||
if not asset:
|
||||
return ""
|
||||
external = asset.get("external_url")
|
||||
if external:
|
||||
return external
|
||||
cached = asset.get("cache")
|
||||
if cached:
|
||||
return url_for_static(cached)
|
||||
return ""
|
||||
@@ -1,7 +1,9 @@
|
||||
import os
|
||||
import hashlib
|
||||
import requests
|
||||
import mimetypes
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class CacheManager:
|
||||
def __init__(self, cache_dir="static/cache"):
|
||||
@@ -9,8 +11,7 @@ class CacheManager:
|
||||
self._ensure_cache_dir_exists()
|
||||
|
||||
def _ensure_cache_dir_exists(self):
|
||||
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):
|
||||
if os.path.exists(self.cache_dir):
|
||||
@@ -20,8 +21,10 @@ class CacheManager:
|
||||
os.remove(path)
|
||||
|
||||
def cache_file(self, file_url):
|
||||
# generate a short hash for filename
|
||||
hash_suffix = hashlib.blake2s(file_url.encode('utf-8'), digest_size=8).hexdigest()
|
||||
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]
|
||||
|
||||
@@ -31,7 +34,7 @@ class CacheManager:
|
||||
except requests.RequestException:
|
||||
return None
|
||||
|
||||
content_type = resp.headers.get('Content-Type', '')
|
||||
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)
|
||||
@@ -41,5 +44,4 @@ class CacheManager:
|
||||
for chunk in resp.iter_content(1024):
|
||||
f.write(chunk)
|
||||
|
||||
# return path relative to /static/
|
||||
return f"cache/{filename}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from pprint import pprint
|
||||
class ConfigurationResolver:
|
||||
"""
|
||||
A class to resolve `link` entries in a nested configuration structure.
|
||||
@@ -14,19 +13,6 @@ 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
|
||||
@@ -43,10 +29,17 @@ 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)
|
||||
else:
|
||||
@@ -55,15 +48,24 @@ class ConfigurationResolver:
|
||||
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:
|
||||
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)
|
||||
@@ -72,7 +74,9 @@ class ConfigurationResolver:
|
||||
self._recursive_resolve(item, root_config)
|
||||
|
||||
def _get_children(self, current):
|
||||
if isinstance(current, dict) and ("children" in current and current["children"]):
|
||||
if isinstance(current, dict) and (
|
||||
"children" in current and current["children"]
|
||||
):
|
||||
current = current["children"]
|
||||
return current
|
||||
|
||||
@@ -81,8 +85,13 @@ class ConfigurationResolver:
|
||||
|
||||
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):
|
||||
@@ -90,46 +99,44 @@ class ConfigurationResolver:
|
||||
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)
|
||||
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])}. "
|
||||
"No 'children' found in current dictionary. Path so far: "
|
||||
f"{' > '.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])}. "
|
||||
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)}. "
|
||||
|
||||
BIN
assets/img/screenshot.png
Normal file
BIN
assets/img/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1024 KiB |
@@ -1,15 +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
|
||||
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
|
||||
- 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:
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
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
|
||||
27
main.py
27
main.py
@@ -3,11 +3,11 @@
|
||||
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
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
MAKEFILE_PATH = Path(__file__).resolve().parent / "Makefile"
|
||||
@@ -21,16 +21,17 @@ def load_targets(makefile_path):
|
||||
"""
|
||||
targets = []
|
||||
pattern = re.compile(r"^([A-Za-z0-9_\-]+):")
|
||||
with open(makefile_path, 'r') as f:
|
||||
lines = f.readlines()
|
||||
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 = ''
|
||||
# look for next non-empty line
|
||||
if idx + 1 < len(lines) and lines[idx+1].lstrip().startswith('#'):
|
||||
help_text = lines[idx+1].lstrip('# ').strip()
|
||||
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
|
||||
|
||||
@@ -55,13 +56,13 @@ def main():
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print the generated Make command without executing it."
|
||||
help="Print the generated Make command without executing it.",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(
|
||||
title="Available commands",
|
||||
dest="command",
|
||||
required=True
|
||||
required=True,
|
||||
)
|
||||
|
||||
targets = load_targets(MAKEFILE_PATH)
|
||||
@@ -70,15 +71,9 @@ def main():
|
||||
sp.set_defaults(target=name)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
cmd = ["make", args.target]
|
||||
run_command(cmd, dry_run=args.dry_run)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pathlib import Path
|
||||
main()
|
||||
44
pyproject.toml
Normal file
44
pyproject.toml
Normal file
@@ -0,0 +1,44 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "portfolio-ui"
|
||||
version = "2.0.0"
|
||||
description = "A lightweight YAML-driven portfolio and landing-page generator."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"flask",
|
||||
"pyyaml",
|
||||
"requests",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"bandit",
|
||||
"pip-audit",
|
||||
"ruff",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["main"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["app", "app.*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
app = [
|
||||
"config.sample.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"]
|
||||
@@ -1 +0,0 @@
|
||||
python-dotenv
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
tests/integration/__init__.py
Normal file
1
tests/integration/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
84
tests/integration/test_asset_resolver_live.py
Normal file
84
tests/integration/test_asset_resolver_live.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Live network verification of the probe-first asset resolution path.
|
||||
|
||||
The unit tests in ``tests/unit/test_asset_resolver.py`` cover every
|
||||
branch with mocks. This integration test exercises the real
|
||||
``https://file.infinito.nexus/assets/img/logo.png`` endpoint that
|
||||
``app/config.sample.yaml`` references for the Infinito.Nexus card. A
|
||||
green run proves the production resolver behaviour end-to-end:
|
||||
|
||||
* probe (HEAD ``image/*``) succeeds against the canonical Infinito.Nexus
|
||||
asset host, so ``external_url`` is populated directly and **no**
|
||||
download is performed (the cache directory stays empty).
|
||||
|
||||
The download-fallback branch (probe fails → ``cache_manager.cache_file``
|
||||
serves the asset locally) is intentionally not exercised against a live
|
||||
endpoint here — it is covered by mocked unit tests because every
|
||||
candidate "HEAD-fails, GET-succeeds" public URL we tried was flaky
|
||||
(Nextcloud's ``/download`` endpoint sporadically times out on HEAD even
|
||||
when GET works), which would make this guard test non-deterministic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from tempfile import TemporaryDirectory
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from app.utils.asset_resolver import resolve_asset_cache
|
||||
from app.utils.cache_manager import CacheManager
|
||||
|
||||
INFINITO_LOGO_URL = "https://file.infinito.nexus/assets/img/logo.png"
|
||||
|
||||
_SKIP_LIVE_NETWORK = os.environ.get("PORTFOLIO_SKIP_LIVE_NETWORK_TESTS") == "1"
|
||||
|
||||
|
||||
def _reachable(url: str) -> bool:
|
||||
try:
|
||||
return requests.head(url, allow_redirects=True, timeout=5).ok
|
||||
except requests.RequestException:
|
||||
return False
|
||||
|
||||
|
||||
@unittest.skipIf(
|
||||
_SKIP_LIVE_NETWORK,
|
||||
"PORTFOLIO_SKIP_LIVE_NETWORK_TESTS=1 — skipping live network tests",
|
||||
)
|
||||
class TestAssetResolverLive(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not _reachable(INFINITO_LOGO_URL):
|
||||
raise unittest.SkipTest(
|
||||
f"{urlparse(INFINITO_LOGO_URL).netloc} unreachable from this runner"
|
||||
)
|
||||
|
||||
def test_infinito_nexus_logo_resolves_via_external_url_without_download(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
cache_dir = os.path.join(tmp, "cache")
|
||||
cache_manager = CacheManager(cache_dir)
|
||||
asset = {"source": INFINITO_LOGO_URL}
|
||||
|
||||
resolve_asset_cache(asset, cache_manager)
|
||||
|
||||
self.assertIsNone(
|
||||
asset["cache"],
|
||||
f"Expected probe-first path to skip download for {INFINITO_LOGO_URL}",
|
||||
)
|
||||
self.assertEqual(
|
||||
asset["external_url"],
|
||||
INFINITO_LOGO_URL,
|
||||
"Probe-success URL must land in external_url so the template "
|
||||
"embeds it directly instead of routing through /static/.",
|
||||
)
|
||||
self.assertEqual(
|
||||
os.listdir(cache_dir),
|
||||
[],
|
||||
"Cache directory must stay empty when probe succeeds — "
|
||||
"downloading would waste bandwidth.",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
54
tests/integration/test_python_packaging.py
Normal file
54
tests/integration/test_python_packaging.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import tomllib
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
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(
|
||||
set(project["dependencies"]),
|
||||
{"flask", "pyyaml", "requests"},
|
||||
)
|
||||
self.assertEqual(project["requires-python"], ">=3.12")
|
||||
|
||||
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(
|
||||
set(project["optional-dependencies"]["dev"]),
|
||||
{"bandit", "pip-audit", "ruff"},
|
||||
)
|
||||
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()
|
||||
43
tests/integration/test_yaml_syntax.py
Normal file
43
tests/integration/test_yaml_syntax.py
Normal 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
1
tests/lint/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
90
tests/lint/test_all_test_files_have_tests.py
Normal file
90
tests/lint/test_all_test_files_have_tests.py
Normal 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()
|
||||
25
tests/lint/test_test_file_naming.py
Normal file
25
tests/lint/test_test_file_naming.py
Normal 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()
|
||||
1
tests/security/__init__.py
Normal file
1
tests/security/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
57
tests/security/test_config_hygiene.py
Normal file
57
tests/security/test_config_hygiene.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class TestConfigHygiene(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.repo_root = Path(__file__).resolve().parents[2]
|
||||
self.sample_config_path = self.repo_root / "app" / "config.sample.yaml"
|
||||
|
||||
def _is_tracked(self, path: str) -> bool:
|
||||
result = subprocess.run(
|
||||
["git", "ls-files", "--error-unmatch", path],
|
||||
cwd=self.repo_root,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
def _find_values_for_key(self, data, key_name: str):
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
if key == key_name:
|
||||
yield value
|
||||
yield from self._find_values_for_key(value, key_name)
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
yield from self._find_values_for_key(item, key_name)
|
||||
|
||||
def test_runtime_only_files_are_ignored_and_untracked(self):
|
||||
gitignore_lines = (
|
||||
(self.repo_root / ".gitignore").read_text(encoding="utf-8").splitlines()
|
||||
)
|
||||
|
||||
self.assertIn("app/config.yaml", gitignore_lines)
|
||||
self.assertIn(".env", gitignore_lines)
|
||||
self.assertFalse(self._is_tracked("app/config.yaml"))
|
||||
self.assertFalse(self._is_tracked(".env"))
|
||||
|
||||
def test_sample_config_keeps_the_nasa_api_key_placeholder(self):
|
||||
with self.sample_config_path.open("r", encoding="utf-8") as handle:
|
||||
sample_config = yaml.safe_load(handle)
|
||||
|
||||
nasa_api_keys = list(self._find_values_for_key(sample_config, "nasa_api_key"))
|
||||
self.assertEqual(
|
||||
nasa_api_keys,
|
||||
["YOUR_REAL_KEY_HERE"],
|
||||
"config.sample.yaml should only contain the documented NASA API key "
|
||||
"placeholder.",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
43
tests/security/test_sample_config_urls.py
Normal file
43
tests/security/test_sample_config_urls.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ALLOWED_URL_PREFIXES = ("https://", "mailto:", "tel:")
|
||||
URL_KEYS = {"url", "imprint", "imprint_url"}
|
||||
|
||||
|
||||
class TestSampleConfigUrls(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
sample_config_path = repo_root / "app" / "config.sample.yaml"
|
||||
with sample_config_path.open("r", encoding="utf-8") as handle:
|
||||
self.sample_config = yaml.safe_load(handle)
|
||||
|
||||
def _iter_urls(self, data, path="root"):
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
next_path = f"{path}.{key}"
|
||||
if key in URL_KEYS and isinstance(value, str):
|
||||
yield next_path, value
|
||||
yield from self._iter_urls(value, next_path)
|
||||
elif isinstance(data, list):
|
||||
for index, item in enumerate(data):
|
||||
yield from self._iter_urls(item, f"{path}[{index}]")
|
||||
|
||||
def test_sample_config_urls_use_safe_schemes(self):
|
||||
invalid_urls = [
|
||||
f"{path} -> {url}"
|
||||
for path, url in self._iter_urls(self.sample_config)
|
||||
if not url.startswith(ALLOWED_URL_PREFIXES)
|
||||
]
|
||||
|
||||
self.assertFalse(
|
||||
invalid_urls,
|
||||
"The sample config contains URLs with unsupported schemes:\n"
|
||||
+ "\n".join(f"- {entry}" for entry in invalid_urls),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
1
tests/unit/__init__.py
Normal file
1
tests/unit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Unit test package for Portfolio UI."""
|
||||
188
tests/unit/test_asset_resolver.py
Normal file
188
tests/unit/test_asset_resolver.py
Normal file
@@ -0,0 +1,188 @@
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import requests
|
||||
|
||||
from app.utils.asset_resolver import (
|
||||
_probe_image_url,
|
||||
asset_src,
|
||||
resolve_asset_cache,
|
||||
)
|
||||
|
||||
|
||||
def _url_for_static(filename):
|
||||
return f"/static/{filename}"
|
||||
|
||||
|
||||
def _always_probes_false(_url):
|
||||
return False
|
||||
|
||||
|
||||
def _always_probes_true(_url):
|
||||
return True
|
||||
|
||||
|
||||
class TestResolveAssetCache(unittest.TestCase):
|
||||
def test_probe_success_skips_cache_and_embeds_directly(self):
|
||||
cache_manager = Mock()
|
||||
asset = {"source": "https://example.test/logo.png"}
|
||||
|
||||
resolve_asset_cache(asset, cache_manager, probe=_always_probes_true)
|
||||
|
||||
self.assertIsNone(asset["cache"])
|
||||
self.assertEqual(asset["external_url"], "https://example.test/logo.png")
|
||||
cache_manager.cache_file.assert_not_called()
|
||||
|
||||
def test_local_cache_path_assigned_when_probe_fails_but_download_succeeds(self):
|
||||
cache_manager = Mock()
|
||||
cache_manager.cache_file.return_value = "cache/logo_abc.png"
|
||||
asset = {"source": "https://example.test/logo.png"}
|
||||
|
||||
resolve_asset_cache(asset, cache_manager, probe=_always_probes_false)
|
||||
|
||||
self.assertEqual(asset["cache"], "cache/logo_abc.png")
|
||||
self.assertIsNone(asset["external_url"])
|
||||
cache_manager.cache_file.assert_called_once_with(
|
||||
"https://example.test/logo.png"
|
||||
)
|
||||
|
||||
def test_external_url_fallback_when_probe_and_download_both_fail(self):
|
||||
cache_manager = Mock()
|
||||
cache_manager.cache_file.return_value = None
|
||||
asset = {"source": "https://file.infinito.nexus/assets/img/logo.png"}
|
||||
|
||||
resolve_asset_cache(asset, cache_manager, probe=_always_probes_false)
|
||||
|
||||
self.assertIsNone(asset["cache"])
|
||||
self.assertEqual(
|
||||
asset["external_url"],
|
||||
"https://file.infinito.nexus/assets/img/logo.png",
|
||||
)
|
||||
|
||||
def test_missing_source_leaves_both_fields_none(self):
|
||||
cache_manager = Mock()
|
||||
probe = Mock()
|
||||
asset = {"class": "fa-solid fa-link"}
|
||||
|
||||
resolve_asset_cache(asset, cache_manager, probe=probe)
|
||||
|
||||
self.assertIsNone(asset["cache"])
|
||||
self.assertIsNone(asset["external_url"])
|
||||
cache_manager.cache_file.assert_not_called()
|
||||
probe.assert_not_called()
|
||||
|
||||
def test_empty_source_treated_as_missing(self):
|
||||
cache_manager = Mock()
|
||||
probe = Mock()
|
||||
asset = {"source": ""}
|
||||
|
||||
resolve_asset_cache(asset, cache_manager, probe=probe)
|
||||
|
||||
self.assertIsNone(asset["cache"])
|
||||
self.assertIsNone(asset["external_url"])
|
||||
cache_manager.cache_file.assert_not_called()
|
||||
probe.assert_not_called()
|
||||
|
||||
|
||||
class TestProbeImageUrl(unittest.TestCase):
|
||||
def _response(self, content_type, raise_exc=None):
|
||||
resp = Mock()
|
||||
if raise_exc:
|
||||
resp.raise_for_status.side_effect = raise_exc
|
||||
else:
|
||||
resp.raise_for_status.return_value = None
|
||||
resp.headers = {"Content-Type": content_type}
|
||||
return resp
|
||||
|
||||
@patch("app.utils.asset_resolver.requests.head")
|
||||
def test_returns_true_for_image_content_type(self, mock_head):
|
||||
mock_head.return_value = self._response("image/png")
|
||||
|
||||
self.assertTrue(_probe_image_url("https://example.test/logo.png"))
|
||||
|
||||
@patch("app.utils.asset_resolver.requests.head")
|
||||
def test_returns_true_for_svg_with_charset_suffix(self, mock_head):
|
||||
mock_head.return_value = self._response("image/svg+xml; charset=utf-8")
|
||||
|
||||
self.assertTrue(_probe_image_url("https://example.test/logo.svg"))
|
||||
|
||||
@patch("app.utils.asset_resolver.requests.head")
|
||||
def test_returns_false_for_non_image_content_type(self, mock_head):
|
||||
mock_head.return_value = self._response("text/html")
|
||||
|
||||
self.assertFalse(_probe_image_url("https://example.test/404"))
|
||||
|
||||
@patch("app.utils.asset_resolver.requests.head")
|
||||
def test_returns_false_when_http_status_not_ok(self, mock_head):
|
||||
mock_head.return_value = self._response(
|
||||
"image/png",
|
||||
raise_exc=requests.HTTPError("404 Not Found"),
|
||||
)
|
||||
|
||||
self.assertFalse(_probe_image_url("https://example.test/missing.png"))
|
||||
|
||||
@patch("app.utils.asset_resolver.requests.head")
|
||||
def test_returns_false_on_network_error(self, mock_head):
|
||||
mock_head.side_effect = requests.ConnectionError("dns failure")
|
||||
|
||||
self.assertFalse(_probe_image_url("https://unreachable.test/logo.png"))
|
||||
|
||||
@patch("app.utils.asset_resolver.requests.head")
|
||||
def test_follows_redirects_with_short_timeout(self, mock_head):
|
||||
mock_head.return_value = self._response("image/png")
|
||||
|
||||
_probe_image_url("https://example.test/logo.png")
|
||||
|
||||
mock_head.assert_called_once_with(
|
||||
"https://example.test/logo.png",
|
||||
allow_redirects=True,
|
||||
timeout=3,
|
||||
)
|
||||
|
||||
|
||||
class TestAssetSrc(unittest.TestCase):
|
||||
def test_returns_static_url_for_local_cache_path(self):
|
||||
asset = {"cache": "cache/logo_abc.png", "external_url": None}
|
||||
|
||||
self.assertEqual(
|
||||
asset_src(asset, _url_for_static),
|
||||
"/static/cache/logo_abc.png",
|
||||
)
|
||||
|
||||
def test_returns_absolute_url_unchanged_for_external_url(self):
|
||||
# Regression: previously the template wrapped this through
|
||||
# url_for('static', ...) and produced /static/https://... — a
|
||||
# URL-in-URL the browser resolved against the dashboard host.
|
||||
asset = {
|
||||
"cache": None,
|
||||
"external_url": "https://file.infinito.nexus/assets/img/logo.png",
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
asset_src(asset, _url_for_static),
|
||||
"https://file.infinito.nexus/assets/img/logo.png",
|
||||
)
|
||||
|
||||
def test_external_url_wins_over_cache_when_both_present(self):
|
||||
asset = {
|
||||
"cache": "cache/logo.png",
|
||||
"external_url": "https://override.test/logo.png",
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
asset_src(asset, _url_for_static),
|
||||
"https://override.test/logo.png",
|
||||
)
|
||||
|
||||
def test_returns_empty_string_when_neither_field_is_set(self):
|
||||
asset = {"cache": None, "external_url": None}
|
||||
|
||||
self.assertEqual(asset_src(asset, _url_for_static), "")
|
||||
|
||||
def test_returns_empty_string_for_none_or_empty_asset(self):
|
||||
self.assertEqual(asset_src(None, _url_for_static), "")
|
||||
self.assertEqual(asset_src({}, _url_for_static), "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
72
tests/unit/test_cache_manager.py
Normal file
72
tests/unit/test_cache_manager.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import requests
|
||||
|
||||
from app.utils.cache_manager import CacheManager
|
||||
|
||||
|
||||
class TestCacheManager(unittest.TestCase):
|
||||
def test_init_creates_cache_directory(self):
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
cache_dir = Path(temp_dir) / "cache"
|
||||
|
||||
self.assertFalse(cache_dir.exists())
|
||||
|
||||
CacheManager(str(cache_dir))
|
||||
|
||||
self.assertTrue(cache_dir.is_dir())
|
||||
|
||||
def test_clear_cache_removes_files_but_keeps_subdirectories(self):
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
cache_dir = Path(temp_dir) / "cache"
|
||||
nested_dir = cache_dir / "nested"
|
||||
nested_dir.mkdir(parents=True)
|
||||
file_path = cache_dir / "icon.png"
|
||||
file_path.write_bytes(b"icon")
|
||||
|
||||
manager = CacheManager(str(cache_dir))
|
||||
manager.clear_cache()
|
||||
|
||||
self.assertFalse(file_path.exists())
|
||||
self.assertTrue(nested_dir.is_dir())
|
||||
|
||||
@patch("app.utils.cache_manager.requests.get")
|
||||
def test_cache_file_downloads_and_stores_response(self, mock_get):
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
manager = CacheManager(str(Path(temp_dir) / "cache"))
|
||||
response = Mock()
|
||||
response.headers = {"Content-Type": "image/svg+xml; charset=utf-8"}
|
||||
response.iter_content.return_value = [b"<svg>ok</svg>"]
|
||||
response.raise_for_status.return_value = None
|
||||
mock_get.return_value = response
|
||||
|
||||
cached_path = manager.cache_file("https://example.com/logo/download")
|
||||
|
||||
self.assertIsNotNone(cached_path)
|
||||
self.assertTrue(cached_path.startswith("cache/logo_"))
|
||||
self.assertTrue(cached_path.endswith(".svg"))
|
||||
|
||||
stored_file = Path(manager.cache_dir) / Path(cached_path).name
|
||||
self.assertEqual(stored_file.read_bytes(), b"<svg>ok</svg>")
|
||||
mock_get.assert_called_once_with(
|
||||
"https://example.com/logo/download",
|
||||
stream=True,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
@patch("app.utils.cache_manager.requests.get")
|
||||
def test_cache_file_returns_none_when_request_fails(self, mock_get):
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
manager = CacheManager(str(Path(temp_dir) / "cache"))
|
||||
mock_get.side_effect = requests.RequestException("network")
|
||||
|
||||
cached_path = manager.cache_file("https://example.com/icon.png")
|
||||
|
||||
self.assertIsNone(cached_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
49
tests/unit/test_check_hadolint_sarif.py
Normal file
49
tests/unit/test_check_hadolint_sarif.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from utils import check_hadolint_sarif
|
||||
|
||||
|
||||
class TestCheckHadolintSarif(unittest.TestCase):
|
||||
def test_main_returns_zero_for_clean_sarif(self):
|
||||
sarif_payload = {
|
||||
"runs": [
|
||||
{
|
||||
"results": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
sarif_path = Path(temp_dir) / "clean.sarif"
|
||||
sarif_path.write_text(json.dumps(sarif_payload), encoding="utf-8")
|
||||
|
||||
exit_code = check_hadolint_sarif.main([str(sarif_path)])
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
|
||||
def test_main_returns_one_for_warnings_or_errors(self):
|
||||
sarif_payload = {
|
||||
"runs": [
|
||||
{
|
||||
"results": [
|
||||
{"level": "warning"},
|
||||
{"level": "error"},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
sarif_path = Path(temp_dir) / "warnings.sarif"
|
||||
sarif_path.write_text(json.dumps(sarif_payload), encoding="utf-8")
|
||||
|
||||
exit_code = check_hadolint_sarif.main([str(sarif_path)])
|
||||
|
||||
self.assertEqual(exit_code, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
39
tests/unit/test_compute_card_classes.py
Normal file
39
tests/unit/test_compute_card_classes.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import unittest
|
||||
|
||||
from app.utils.compute_card_classes import compute_card_classes
|
||||
|
||||
|
||||
class TestComputeCardClasses(unittest.TestCase):
|
||||
def test_single_card_uses_full_width_classes(self):
|
||||
lg_classes, md_classes = compute_card_classes([{"title": "One"}])
|
||||
|
||||
self.assertEqual(lg_classes, ["col-lg-12"])
|
||||
self.assertEqual(md_classes, ["col-md-12"])
|
||||
|
||||
def test_two_cards_split_evenly(self):
|
||||
lg_classes, md_classes = compute_card_classes([{}, {}])
|
||||
|
||||
self.assertEqual(lg_classes, ["col-lg-6", "col-lg-6"])
|
||||
self.assertEqual(md_classes, ["col-md-6", "col-md-6"])
|
||||
|
||||
def test_three_cards_use_thirds(self):
|
||||
lg_classes, md_classes = compute_card_classes([{}, {}, {}])
|
||||
|
||||
self.assertEqual(lg_classes, ["col-lg-4", "col-lg-4", "col-lg-4"])
|
||||
self.assertEqual(md_classes, ["col-md-6", "col-md-6", "col-md-12"])
|
||||
|
||||
def test_five_cards_use_balanced_large_layout(self):
|
||||
lg_classes, md_classes = compute_card_classes([{}, {}, {}, {}, {}])
|
||||
|
||||
self.assertEqual(
|
||||
lg_classes,
|
||||
["col-lg-6", "col-lg-6", "col-lg-4", "col-lg-4", "col-lg-4"],
|
||||
)
|
||||
self.assertEqual(
|
||||
md_classes,
|
||||
["col-md-6", "col-md-6", "col-md-6", "col-md-6", "col-md-12"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
74
tests/unit/test_configuration_resolver.py
Normal file
74
tests/unit/test_configuration_resolver.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import unittest
|
||||
|
||||
from app.utils.configuration_resolver import ConfigurationResolver
|
||||
|
||||
|
||||
class TestConfigurationResolver(unittest.TestCase):
|
||||
def test_resolve_links_replaces_mapping_link_with_target_object(self):
|
||||
config = {
|
||||
"profiles": [
|
||||
{"name": "Mastodon", "url": "https://example.com/@user"},
|
||||
],
|
||||
"featured": {"link": "profiles.mastodon"},
|
||||
}
|
||||
|
||||
resolver = ConfigurationResolver(config)
|
||||
resolver.resolve_links()
|
||||
|
||||
self.assertEqual(
|
||||
resolver.get_config()["featured"],
|
||||
{"name": "Mastodon", "url": "https://example.com/@user"},
|
||||
)
|
||||
|
||||
def test_resolve_links_expands_children_link_to_list_entries(self):
|
||||
config = {
|
||||
"accounts": {
|
||||
"children": [
|
||||
{"name": "Matrix", "url": "https://matrix.example"},
|
||||
{"name": "Signal", "url": "https://signal.example"},
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"children": [
|
||||
{"link": "accounts.children"},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
resolver = ConfigurationResolver(config)
|
||||
resolver.resolve_links()
|
||||
|
||||
self.assertEqual(
|
||||
resolver.get_config()["navigation"]["children"],
|
||||
[
|
||||
{"name": "Matrix", "url": "https://matrix.example"},
|
||||
{"name": "Signal", "url": "https://signal.example"},
|
||||
],
|
||||
)
|
||||
|
||||
def test_resolve_links_rejects_non_list_children(self):
|
||||
config = {"navigation": {"children": {"name": "Invalid"}}}
|
||||
|
||||
resolver = ConfigurationResolver(config)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
resolver.resolve_links()
|
||||
|
||||
def test_find_entry_handles_case_and_space_insensitive_paths(self):
|
||||
config = {
|
||||
"Social Networks": {
|
||||
"children": [
|
||||
{"name": "Friendica", "url": "https://friendica.example"},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
resolver = ConfigurationResolver(config)
|
||||
|
||||
entry = resolver._find_entry(config, "socialnetworks.friendica", False)
|
||||
|
||||
self.assertEqual(entry["url"], "https://friendica.example")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
45
tests/unit/test_export_runtime_requirements.py
Normal file
45
tests/unit/test_export_runtime_requirements.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
from utils import export_runtime_requirements
|
||||
|
||||
|
||||
class TestExportRuntimeRequirements(unittest.TestCase):
|
||||
def test_load_runtime_requirements_reads_project_dependencies(self):
|
||||
pyproject_content = """
|
||||
[project]
|
||||
dependencies = [
|
||||
"flask",
|
||||
"requests>=2",
|
||||
]
|
||||
""".lstrip()
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
pyproject_path = Path(temp_dir) / "pyproject.toml"
|
||||
pyproject_path.write_text(pyproject_content, encoding="utf-8")
|
||||
|
||||
requirements = export_runtime_requirements.load_runtime_requirements(
|
||||
pyproject_path
|
||||
)
|
||||
|
||||
self.assertEqual(requirements, ["flask", "requests>=2"])
|
||||
|
||||
def test_main_prints_requirements_from_selected_pyproject(self):
|
||||
pyproject_content = """
|
||||
[project]
|
||||
dependencies = [
|
||||
"pyyaml",
|
||||
]
|
||||
""".lstrip()
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
pyproject_path = Path(temp_dir) / "pyproject.toml"
|
||||
pyproject_path.write_text(pyproject_content, encoding="utf-8")
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
exit_code = export_runtime_requirements.main([str(pyproject_path)])
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
mock_print.assert_called_once_with("pyyaml")
|
||||
72
tests/unit/test_main.py
Normal file
72
tests/unit/test_main.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
import main as portfolio_main
|
||||
|
||||
|
||||
class TestMainCli(unittest.TestCase):
|
||||
def test_load_targets_parses_help_comments(self):
|
||||
makefile_content = """
|
||||
.PHONY: foo bar
|
||||
foo:
|
||||
\t# Run foo
|
||||
\t@echo foo
|
||||
|
||||
bar:
|
||||
\t@echo bar
|
||||
""".lstrip()
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
makefile_path = Path(temp_dir) / "Makefile"
|
||||
makefile_path.write_text(makefile_content, encoding="utf-8")
|
||||
|
||||
targets = portfolio_main.load_targets(makefile_path)
|
||||
|
||||
self.assertEqual(targets, [("foo", "Run foo"), ("bar", "")])
|
||||
|
||||
@patch("main.subprocess.check_call")
|
||||
def test_run_command_executes_subprocess(self, mock_check_call):
|
||||
portfolio_main.run_command(["make", "lint"])
|
||||
|
||||
mock_check_call.assert_called_once_with(["make", "lint"])
|
||||
|
||||
@patch("main.sys.exit", side_effect=SystemExit(7))
|
||||
@patch(
|
||||
"main.subprocess.check_call",
|
||||
side_effect=subprocess.CalledProcessError(7, ["make", "lint"]),
|
||||
)
|
||||
def test_run_command_exits_with_subprocess_return_code(
|
||||
self,
|
||||
_mock_check_call,
|
||||
mock_sys_exit,
|
||||
):
|
||||
with self.assertRaises(SystemExit) as context:
|
||||
portfolio_main.run_command(["make", "lint"])
|
||||
|
||||
self.assertEqual(context.exception.code, 7)
|
||||
mock_sys_exit.assert_called_once_with(7)
|
||||
|
||||
@patch("main.run_command")
|
||||
@patch("main.load_targets", return_value=[("lint", "Run lint suite")])
|
||||
def test_main_dispatches_selected_target(
|
||||
self, _mock_load_targets, mock_run_command
|
||||
):
|
||||
with patch("sys.argv", ["main.py", "lint"]):
|
||||
portfolio_main.main()
|
||||
|
||||
mock_run_command.assert_called_once_with(["make", "lint"], dry_run=False)
|
||||
|
||||
@patch("main.run_command")
|
||||
@patch("main.load_targets", return_value=[("lint", "Run lint suite")])
|
||||
def test_main_passes_dry_run_flag(self, _mock_load_targets, mock_run_command):
|
||||
with patch("sys.argv", ["main.py", "--dry-run", "lint"]):
|
||||
portfolio_main.main()
|
||||
|
||||
mock_run_command.assert_called_once_with(["make", "lint"], dry_run=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
78
tests/unit/test_navigation_template.py
Normal file
78
tests/unit/test_navigation_template.py
Normal 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()
|
||||
28
utils/check_hadolint_sarif.py
Normal file
28
utils/check_hadolint_sarif.py
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail when a hadolint SARIF report contains warnings or errors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = argv if argv is not None else sys.argv[1:]
|
||||
sarif_path = Path(args[0] if args else "hadolint-results.sarif")
|
||||
|
||||
with sarif_path.open("r", encoding="utf-8") as handle:
|
||||
sarif = json.load(handle)
|
||||
|
||||
results = sarif.get("runs", [{}])[0].get("results", [])
|
||||
levels = [result.get("level", "") for result in results]
|
||||
warnings = sum(1 for level in levels if level == "warning")
|
||||
errors = sum(1 for level in levels if level == "error")
|
||||
|
||||
print(f"SARIF results: total={len(results)} warnings={warnings} errors={errors}")
|
||||
return 1 if warnings + errors > 0 else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
30
utils/export_runtime_requirements.py
Normal file
30
utils/export_runtime_requirements.py
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Print runtime dependencies from pyproject.toml, one per line."""
|
||||
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_PYPROJECT_PATH = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
||||
|
||||
|
||||
def load_runtime_requirements(
|
||||
pyproject_path: Path = DEFAULT_PYPROJECT_PATH,
|
||||
) -> list[str]:
|
||||
with pyproject_path.open("rb") as handle:
|
||||
pyproject = tomllib.load(handle)
|
||||
return list(pyproject["project"]["dependencies"])
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = argv if argv is not None else sys.argv[1:]
|
||||
pyproject_path = Path(args[0]) if args else DEFAULT_PYPROJECT_PATH
|
||||
|
||||
for requirement in load_runtime_requirements(pyproject_path):
|
||||
print(requirement)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user