docs: publish public guides, screenshots and Git-synced portal documentation
Tests / test (push) Failing after 50s

This commit is contained in:
dh
2026-10-06 14:32:05 +00:00
parent 9fbc52ee47
commit faa905ab09
29 changed files with 1973 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules/
state/
preview/
preview-state/
qa/
backups/
failure-test-*/
guides/
+190
View File
@@ -0,0 +1,190 @@
# Public Enelix Documentation Publisher
## Context and decision
The license portal already serves public static files and exposes its effective
catalog at `GET /api/catalog`. Documentation is therefore built as static HTML;
the existing portal process, authentication, database and payment code are not
modified. Generic Utils runtime code is not coupled to the documentation service.
Source ownership:
- `docs/public/*.md` in Enelix-EMS: installation, operator guides, FAQ and pricing explanation.
- `docs/public/images/*.png`: explicitly reviewed real Symcon screenshots, allowlisted in `images.mjs`.
- Explicit README/interface allowlists in `config.mjs`: original EMS and Utils references.
- Backend catalog: every monetary value, product name, SKU, term and effective VAT rate.
- This directory: rendering, navigation, publication and tests.
The default source branch is `develop`, visibly identified as Testing. The
publisher reads separate shallow bare mirrors; it never changes existing
developer checkouts. All sources are pinned to the commits captured at the start
of a successful run. Neither unpublished worktrees nor internal handoffs, open
issues, credentials, operational notes or arbitrary new files are published.
Alternative considered: adding server-side documentation routes. Not selected:
the current static mount enables this rollout without a portal restart or a new
public service. A separately maintained copy of prices was rejected because it
would drift from checkout. The browser refreshes the same catalog endpoint every
30 seconds while visible, on reappearance and on manual refresh.
## Public routes
- `/docs/index.html`
- `/docs/erste-schritte.html`
- `/docs/installation.html`
- `/docs/manager.html`
- `/docs/verbraucher.html`
- `/docs/utils.html`
- `/docs/faq.html`
- `/docs/preise.html`
- `/docs/ems/*.html` and `/docs/utils/*.html`
- `/docs/manifest.json`: published source commits and file mappings.
- `/docs-status.json`: last attempted/successful synchronization, no internal errors.
The portal's anonymous login screen links to documentation, FAQ and prices.
Authenticated sidebar navigation additionally links to documentation and prices.
Within the EMS references, Manager remains a direct link; consumer modules and
technical interfaces are nested in native, keyboard-accessible disclosure groups.
The current page's group opens on the server, also without JavaScript. Search
expands matching groups and restores their previous state when cleared. All
page URLs remain unchanged; new references without a group stay accessible.
`assets/portal-links.css` contains only the added navigation styling.
`portal-navigation.patch` records the exact additive portal HTML change; check
it against the current portal before applying it and copy the stylesheet to
`public/docs-portal.css`. Never replace the full portal HTML from another checkout.
## Install and publish
Deploy the reviewed publisher files to `/home/agent/services/public-docs` as
the unprivileged `agent` user. Do not copy `node_modules`, state, preview, QA or
backups into a repository or the public directory. Install locked dependencies:
```sh
npm ci --ignore-scripts
npm test
```
For rendering/rollback checks without changing public pages:
```sh
node preview.mjs
node verify.mjs
node browser-test.mjs
```
Publication uses the existing public mount at
`/home/agent/services/license/public`. The portal container's UID must be able to
read generated directories and files. No credentials are included in the
publisher: Git uses the server's preconfigured access. Never put credentials in
repository URLs or public output.
Install the two supplied units through `systemctl --user link`, reload the user
manager, start `enelix-public-docs.service` once and enable
`enelix-public-docs.timer`. Only these user units are affected. The timer checks
Git five minutes after completion, with at most fifteen seconds of jitter.
The successful first run can take longer while fetching both repositories.
`DOCS_BRANCH` accepts `develop`, `beta` or `main`; reading a source branch does
not merge or modify it. `DOCS_PUBLIC_DIR` and `DOCS_STATE_DIR` support isolated
tests. Keep the chosen channel explicit in the service configuration.
## Git-only operation and historical bootstrap
The production service does not enable `DOCS_ALLOW_DRAFTS`. All approved guides
and screenshots must exist in the selected Git branch. A missing source aborts
publication and preserves the last good release; deployment copies cannot
silently replace deleted Git content.
The initial 2026-10-06 deployment used explicitly labelled drafts while Git
publication awaited authorization. Daniel approved commit and push on the same
day. The bootstrap fallback remains available only through the explicit
`DOCS_ALLOW_DRAFTS=true` setting, including isolated previews. It reads missing
sources from the deployment-only `guides/` directory and labels them as drafts.
Do not treat these bootstrap copies as a second long-term editing location.
When completing a bootstrap deployment, push the approved sources first, verify
that publication uses their Git commits, then install the production unit without
the draft setting and reload the user service manager.
## Security and resilience
Markdown is rendered with pinned Marked and sanitized with sanitize-html.
Raw HTML and remote images are disabled; link schemes and local mappings are
restricted. Relative links to unpublished files are plain text. The existing
portal CSP remains active; there is no inline JavaScript or browser-side Git
credential. Every page is readable without JavaScript except the live price
table, which shows an explicit notice and a catalog link in that case.
Only the exact local PNG paths in `images.mjs` render as screenshots. The
publisher reads binary files at the same EMS commit as the guides, checks regular
file type, PNG signature and dimensions, and publishes content-hashed filenames.
Image hashes, dimensions and source revisions are recorded in the manifest.
Missing or invalid images fail publication and preserve the last good release.
Draft bootstrap images follow the same explicit opt-in as the draft guides.
Screenshot refresh is intentionally reviewed, not a live camera into Symcon.
Capture through the local-only web console in a separate browser session, without
saving configuration or invoking regulation. Select useful crops, inspect them
for license codes, installation IDs, accounts and private plant details, then
replace the approved PNGs in Git and update the dated captions. Never publish
unreviewed captures automatically. Current screenshots show the demo object tree
and the manager basic settings in Symcon 8.0 on 2026-10-06. Test values are not
recommended installation settings; reference READMEs may evolve faster than the
dated screenshots.
Publication is a complete immutable release followed by an atomic `docs`
symlink switch. Failed fetches or rendering leave the previous release intact.
Public status reports failed or overdue checks. Stale prices are removed when
the catalog cannot be obtained or validated; no hardcoded fallback is used.
The sync lock prevents overlapping manual and timer runs. If the process is
forcibly terminated, inspect process state before removing its stale lock from
the publisher state directory. Generated releases are retained to permit
rollback; disk usage should be monitored and old releases pruned only after
checking that they are not current or needed for rollback.
## Verification and rollback
`npm test` covers relative and cross-repository links, HTML sanitization,
allowlist isolation, headings/tables, price validation, VAT and periods.
`node verify.mjs` validates all published links and deliberately exercises a
source failure against an isolated mirror, proving last-good preservation.
`node browser-test.mjs` checks desktop, 390px and 320px layouts, search, automatic
catalog refresh, newly returned products, VAT toggle, API failure/recovery and
JavaScript-free documentation. Mocks affect only a fresh browser context, never
the production catalog. Screenshots remain private in `qa/`.
`node verify.mjs live` and `node browser-test.mjs --live` perform anonymous checks
on the actual domain. The tests must not log in or mutate live application data.
For rollback, first stop and disable only the documentation timer. Restore the
previous documentation symlink atomically. For full removal, revert only the
added portal navigation and stylesheet link after checking for concurrent edits;
do not overwrite the portal with an older full backup if other edits occurred.
No database, Nginx or IP-Symcon rollback is required because those were unchanged.
## Initial deployment evidence
2026-10-06: 29 pages, 1,116 valid local links, 12 unit tests passed, isolated
failure recovery passed, desktop/mobile browser tests passed both in preview and
against the public domain. The initial sources were EMS
`58f91e431936fedca3e59178037b939f420ce3e3` and Utils
`82860c0eb938173841088f935a306729b6ce9a1c`. These are dated evidence, not permanent
source pins. The price API initially returned 15 products and was accessed
without login.
Portal navigation backup:
`/home/agent/services/public-docs/backups/2026-10-06T12-58-13-558Z/index.html`.
The backup is private and must never be copied into `public/`.
## Pull request summary
Add public documentation and FAQ from reviewed Git-backed sources, plus a live
backend-driven price table. Preserve existing portal authentication and data;
publish static releases atomically with a five-minute user timer. No schema or
module migration and no beta/main changes. Include the end-user introduction,
guided first steps, reviewed Symcon screenshots and grouped module navigation.
Production publication requires versioned guides and screenshots, without draft
fallback.
@@ -0,0 +1,3 @@
.public-doc-links { display:flex; flex-wrap:wrap; gap:12px 24px; margin:0 0 25px; font-size:14px; font-weight:650; }
.public-doc-links a { color:var(--petrol); text-decoration:underline; text-underline-offset:4px; }
.sidebar .public-doc-link { display:flex; align-items:center; }
+59
View File
@@ -0,0 +1,59 @@
export function validateCatalog(value) {
if (!value || value.currency !== 'CHF' || value.prices !== 'net' || !Number.isFinite(value.vatRate) || value.vatRate < 0 || value.vatRate > 100 || !value.items || typeof value.items !== 'object' || Array.isArray(value.items) || !Object.keys(value.items).length) throw new Error('invalid_catalog');
for (const item of Object.values(value.items)) {
if (!item || typeof item.name !== 'string' || !item.name || typeof item.sku !== 'string' || !Number.isSafeInteger(item.unitAmount) || item.unitAmount < 0 || !Number.isSafeInteger(item.setupAmount) || item.setupAmount < 0 || (item.termMonths !== null && (!Number.isSafeInteger(item.termMonths) || item.termMonths < 1))) throw new Error('invalid_catalog_item');
}
return value;
}
export const grossAmount = (net, rate) => net + Math.round(net * rate / 100);
export const termLabel = months => months === null ? 'Einmalig' : months === 12 ? 'Pro Jahr' : `Pro ${months} Monate`;
if (typeof document !== 'undefined') {
const list = document.getElementById('price-list');
const status = document.getElementById('price-status');
const refresh = document.getElementById('refresh-prices');
let catalog = null;
let pending = false;
const money = new Intl.NumberFormat('de-CH', { style:'currency', currency:'CHF' });
function el(tag, text, className) { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; if (className) node.className = className; return node; }
function render() {
list.replaceChildren();
if (!catalog) return;
const gross = document.querySelector('[name="tax"]:checked').value === 'gross';
const amount = value => money.format((gross ? grossAmount(value, catalog.vatRate) : value) / 100);
const table = el('table', undefined, 'price-table');
table.append(el('caption', `${gross ? 'Inklusive' : 'Zuzüglich'} ${new Intl.NumberFormat('de-CH').format(catalog.vatRate)} % MwSt. · Preise je Einheit`));
const head = el('thead'), row = el('tr');
for (const text of ['Produkt', 'Laufzeit', 'Lizenz', 'Ersteinrichtung']) { const th = el('th', text); th.scope = 'col'; row.append(th); }
head.append(row); table.append(head);
const body = el('tbody');
for (const item of Object.values(catalog.items)) {
const tr = el('tr'), name = el('th', item.name); name.scope = 'row'; name.append(el('small', item.sku));
tr.append(name, el('td', termLabel(item.termMonths)), el('td', amount(item.unitAmount), 'amount'), el('td', amount(item.setupAmount), 'amount'));
body.append(tr);
}
table.append(body);
const wrapper = el('div', undefined, 'table-scroll'); wrapper.tabIndex = 0; wrapper.setAttribute('role', 'region'); wrapper.setAttribute('aria-label', 'Aktuelle Preise'); wrapper.append(table); list.append(wrapper);
}
async function load() {
if (pending) return;
pending = true; refresh.disabled = true;
try {
const response = await fetch('/api/catalog', { cache:'no-store', credentials:'omit', signal:AbortSignal.timeout(10000) });
if (!response.ok) throw new Error('catalog_unavailable');
catalog = validateCatalog(await response.json());
render(); status.classList.remove('error');
status.textContent = `Stand ${new Date().toLocaleTimeString('de-CH')} · automatisch alle 30 Sekunden aktualisiert.`;
} catch {
catalog = null; list.replaceChildren(); status.classList.add('error');
status.textContent = 'Aktuelle Preise sind vorübergehend nicht erreichbar. Bitte erneut aktualisieren. Es werden keine veralteten Ersatzpreise angezeigt.';
} finally { pending = false; refresh.disabled = false; }
}
refresh.addEventListener('click', load);
document.querySelectorAll('[name="tax"]').forEach(input => input.addEventListener('change', render));
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
window.addEventListener('pageshow', event => { if (event.persisted) load(); });
setInterval(() => { if (!document.hidden) load(); }, 30000);
load();
}
+88
View File
@@ -0,0 +1,88 @@
:root { --ink:#172124; --muted:#59696d; --line:#d8e0de; --paper:#fff; --canvas:#f2f5f4; --petrol:#2f6b66; --lime:#d5df38; --danger:#9a3939; font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; color:var(--ink); background:var(--paper); letter-spacing:0; }
* { box-sizing:border-box; }
body { margin:0; min-width:320px; }
a { color:var(--petrol); text-underline-offset:3px; }
a:hover { color:#153f3b; }
button,input { font:inherit; }
button,a,input,summary { -webkit-tap-highlight-color:transparent; }
:focus-visible { outline:3px solid #497fbe; outline-offset:3px; }
.skip { position:fixed; top:-60px; left:16px; z-index:100; background:#fff; padding:12px; }
.skip:focus { top:8px; }
.topbar { position:sticky; top:0; z-index:10; display:flex; align-items:center; justify-content:space-between; gap:16px; min-height:76px; padding:14px 32px; background:#fff; border-bottom:1px solid var(--line); }
.brand { display:flex; align-items:center; gap:10px; text-decoration:none; color:var(--ink); }
.brand-mark { display:grid; place-items:center; width:38px; height:38px; border-radius:4px; background:var(--lime); font-weight:850; font-size:23px; }
.brand strong { display:block; font-size:24px; line-height:1; }
.brand small { display:block; margin-top:5px; font-size:12px; color:var(--muted); }
.topbar nav { display:flex; align-items:center; flex-wrap:wrap; gap:24px; font-size:14px; font-weight:650; }
.topbar nav a { text-decoration:none; }
.portal-link { padding-left:24px; border-left:1px solid var(--line); }
.docs-layout { display:grid; grid-template-columns:248px minmax(0,850px) minmax(170px,220px); max-width:1500px; margin:auto; }
.sidebar { position:sticky; top:76px; height:calc(100dvh - 76px); overflow-y:auto; padding:26px 20px; border-right:1px solid var(--line); background:var(--canvas); }
#docs-menu > summary { display:none; }
.sidebar section { margin:24px 0; }
.sidebar h2,.contents h2 { font-size:12px; color:var(--muted); font-weight:700; margin:0 0 10px; }
.sidebar ul { list-style:none; margin:0; padding:0; }
.sidebar li { margin:2px 0; }
.sidebar a { display:block; padding:8px 10px; font-size:13px; line-height:1.35; color:#425456; text-decoration:none; border-left:3px solid transparent; overflow-wrap:anywhere; transition:background .15s; }
.sidebar a:hover { background:#e5edeb; }
.sidebar a[aria-current] { background:#fff; border-left-color:var(--petrol); color:var(--petrol); font-weight:750; }
.nav-folder > summary { display:list-item; list-style-position:inside; min-height:36px; padding:8px 10px; font-size:13px; line-height:1.5; color:#425456; font-weight:650; cursor:pointer; overflow-wrap:anywhere; border-left:3px solid transparent; }
.nav-folder > summary:hover { background:#e5edeb; }
.nav-folder > summary::marker { color:var(--petrol); }
.nav-folder[data-active] > summary { color:var(--petrol); border-left-color:var(--petrol); }
.nav-folder > ul { margin:4px 0 8px 14px; padding-left:6px; border-left:1px solid #c4d3cd; }
.search-label { display:block; font-size:12px; margin-bottom:8px; font-weight:650; }
#doc-search { width:100%; border:1px solid #b4c5c0; border-radius:4px; padding:10px; background:#fff; font-size:13px; }
#search-result { font-size:12px; color:var(--muted); }
main { min-width:0; padding:30px 44px 56px; }
.page-meta { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; margin-bottom:24px; color:var(--muted); font-size:12px; }
.channel { padding:5px 8px; background:#edf3e5; color:#385c28; border-radius:3px; font-weight:650; }
.sync-status { margin:16px 0 30px; color:var(--muted); font-size:12px; line-height:1.5; }
.sync-status.warning { border-left:3px solid #aa731f; padding-left:10px; color:#80570e; }
article { font-size:15px; line-height:1.75; overflow-wrap:anywhere; }
article h1 { font-size:34px; line-height:1.18; margin:0 0 20px; font-weight:760; color:#173f3c; }
article h2 { font-size:23px; line-height:1.3; margin:38px 0 16px; padding-top:14px; border-top:1px solid var(--line); }
article h3 { font-size:18px; line-height:1.4; margin:28px 0 12px; }
article h4 { font-size:16px; }
article h1,article h2,article h3,article h4 { scroll-margin-top:100px; }
article p { margin:12px 0 18px; }
article li { margin:7px 0; padding-left:3px; }
article ul,article ol { padding-left:23px; }
article blockquote { margin:24px 0; padding:4px 18px; border-left:3px solid var(--petrol); background:#eff5f2; color:#314e46; }
article pre { overflow:auto; padding:18px; border:1px solid var(--line); background:var(--canvas); font-size:12px; line-height:1.65; border-radius:4px; }
article code { font-family:ui-monospace,"SFMono-Regular",Consolas,monospace; font-size:.86em; background:#f0f3f1; padding:2px 4px; border-radius:3px; }
article pre code { padding:0; }
.heading-anchor { padding-left:8px; opacity:0; font-weight:400; text-decoration:none; }
h1:hover .heading-anchor,h2:hover .heading-anchor,h3:hover .heading-anchor,.heading-anchor:focus { opacity:1; }
.table-scroll { max-width:100%; overflow-x:auto; margin:20px 0; }
table { border-collapse:collapse; width:100%; font-size:13px; line-height:1.55; text-align:left; }
th { color:#284a45; background:#eef4f1; font-weight:700; }
th,td { padding:12px; border-bottom:1px solid var(--line); vertical-align:top; }
article table code { overflow-wrap:normal; }
.unpublished { color:inherit; }
.doc-image { display:block; margin-top:22px; border:1px solid var(--line); border-radius:4px; overflow:hidden; }
.doc-image img { display:block; max-width:100%; width:100%; height:auto; }
.image-caption { display:block; margin-top:8px; color:var(--muted); font-size:12px; line-height:1.6; }
.contents { position:sticky; top:76px; align-self:start; max-height:calc(100dvh - 76px); overflow:auto; padding:36px 20px 25px 0; }
.contents a { display:block; border-left:1px solid var(--line); padding:6px 12px; color:var(--muted); font-size:12px; line-height:1.5; text-decoration:none; overflow-wrap:anywhere; }
.contents a:hover { border-left-color:var(--petrol); color:var(--petrol); }
footer { margin-top:48px; padding-top:20px; border-top:1px solid var(--line); font-size:12px; color:var(--muted); line-height:1.7; overflow-wrap:anywhere; }
footer p { margin:4px 0; }
.price-toolbar { display:flex; flex-wrap:wrap; align-items:center; gap:16px; justify-content:space-between; }
.price-toolbar fieldset { display:flex; gap:14px; border:0; padding:0; margin:0; font-size:14px; }
.price-toolbar legend { font-size:12px; color:var(--muted); margin-bottom:5px; }
.price-toolbar label { display:flex; align-items:center; gap:6px; }
.price-toolbar button { background:#fff; border:1px solid var(--line); border-radius:4px; padding:9px 12px; color:var(--petrol); cursor:pointer; }
.price-toolbar button:disabled { opacity:.5; cursor:wait; }
.price-table th:not(:first-child),.price-table td:not(:first-child) { text-align:right; }
.price-table td:nth-child(2) { white-space:nowrap; }
.price-table small { display:block; color:var(--muted); font-weight:400; font-size:11px; }
.price-table .amount { white-space:nowrap; }
.price-table caption { text-align:left; margin-bottom:12px; font-size:13px; color:var(--muted); }
#price-status { font-size:13px; color:var(--muted); }
#price-status.error { color:var(--danger); }
[hidden] { display:none!important; }
@media (max-width:1150px) { .docs-layout { grid-template-columns:228px minmax(0,1fr); } .contents { display:none; } main { padding:28px 36px; } }
@media (max-width:720px) { .topbar { position:relative; padding:16px; flex-wrap:wrap; } .topbar nav { gap:20px; font-size:13px; width:100%; justify-content:space-between; } .portal-link { border:0; padding:0; } .docs-layout { display:block; } .sidebar { position:relative; top:0; height:auto; border:0; border-bottom:1px solid var(--line); padding:0 20px; } #docs-menu > summary { display:list-item; padding:15px 0; font-weight:650; cursor:pointer; font-size:14px; } #docs-menu[open] { padding-bottom:20px; } .sidebar nav { display:block; } .sidebar section { margin:16px 0 0; } .sidebar a,.nav-folder > summary { min-height:40px; } main { padding:24px 20px 40px; } article h1 { font-size:29px; } article h2 { font-size:21px; } article { font-size:15px; } .sync-status { margin-bottom:24px; } .price-table { min-width:570px; } article h1,article h2,article h3 { scroll-margin-top:20px; } }
@media print { .topbar,.sidebar,.contents,.price-toolbar,.heading-anchor { display:none; } .docs-layout { display:block; } main { padding:0; } .table-scroll { overflow:visible; } article { font-size:11px; } a { color:inherit; } }
@media (prefers-reduced-motion:reduce) { * { transition:none!important; scroll-behavior:auto!important; } }
+47
View File
@@ -0,0 +1,47 @@
const menu = document.getElementById('docs-menu');
const mobile = matchMedia('(max-width:720px)');
const setMenu = () => { menu.open = !mobile.matches; };
setMenu();
mobile.addEventListener('change', setMenu);
const search = document.getElementById('doc-search');
const folders = [...menu.querySelectorAll('.nav-folder')];
let beforeSearch = null;
const normalize = value => value.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
search.addEventListener('input', () => {
const query = normalize(search.value.trim());
if (query && !beforeSearch) beforeSearch = new Map(folders.map(folder => [folder, folder.open]));
let count = 0;
for (const item of document.querySelectorAll('[data-search]')) {
item.hidden = !normalize(item.dataset.search).includes(query);
if (!item.hidden) count++;
}
for (const folder of folders) {
const matches = [...folder.querySelectorAll('[data-search]')].some(item => !item.hidden);
folder.parentElement.hidden = !matches;
if (query) folder.open = matches;
else if (beforeSearch) folder.open = beforeSearch.get(folder);
}
if (!query) beforeSearch = null;
for (const group of menu.querySelectorAll('section')) group.hidden = ![...group.querySelectorAll('[data-search]')].some(item => !item.hidden);
const result = document.getElementById('search-result');
result.hidden = !query;
result.textContent = count ? `${count} passende Themen` : 'Keine passenden Themen gefunden.';
});
async function syncStatus() {
const label = document.getElementById('sync-status');
try {
const response = await fetch('/docs-status.json', { cache:'no-store', credentials:'omit', signal:AbortSignal.timeout(8000) });
if (!response.ok) throw new Error();
const status = await response.json();
const date = new Date(status.lastSuccess);
if (!Number.isFinite(date.valueOf())) throw new Error();
const stale = !status.ok || Date.now() - date.valueOf() > 15 * 60 * 1000;
label.classList.toggle('warning', stale);
label.textContent = `${stale ? 'Git-Abgleich verzögert. Letzter erfolgreicher Abgleich' : 'Git zuletzt geprüft'}: ${date.toLocaleString('de-CH')}${status.drafts ? ' · neue Anleitungen noch nicht in Git veröffentlicht' : ''}.`;
} catch {
label.classList.add('warning');
label.textContent = 'Der aktuelle Git-Abgleichstatus ist nicht erreichbar. Quellstand dieser Seite siehe unten.';
}
}
syncStatus();
setInterval(() => { if (!document.hidden) syncStatus(); }, 60000);
+143
View File
@@ -0,0 +1,143 @@
import { chromium } from 'playwright';
import fs from 'node:fs/promises';
import path from 'node:path';
import http from 'node:http';
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
const root = path.dirname(fileURLToPath(import.meta.url));
await fs.mkdir(path.join(root, 'qa'), { recursive:true });
const catalogResponse = await fetch('https://license.enelix.ch/api/catalog');
assert.equal(catalogResponse.status, 200);
const fixture = await catalogResponse.json();
const live = process.argv.includes('--live');
const server = http.createServer(async (req, res) => {
const pathname = new URL(req.url, 'http://localhost').pathname;
if (pathname === '/api/catalog') { res.writeHead(200, { 'content-type':'application/json' }); return res.end(JSON.stringify(fixture)); }
const file = path.join(root, 'preview', pathname);
if (!file.startsWith(path.join(root, 'preview') + '/')) { res.writeHead(404); return res.end(); }
try {
const content = await fs.readFile(file);
const type = { '.html':'text/html', '.js':'text/javascript', '.css':'text/css', '.json':'application/json', '.png':'image/png' }[path.extname(file)] || 'application/octet-stream';
res.writeHead(200, { 'content-type':type }); res.end(content);
} catch { res.writeHead(404); res.end(); }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const origin = live ? 'https://license.enelix.ch' : `http://127.0.0.1:${server.address().port}`;
process.env.TMPDIR = path.join(root, 'qa/runtime');
await fs.mkdir(process.env.TMPDIR, { recursive:true });
const standalone = (await import('@sparticuz/chromium')).default;
const browser = await chromium.launch({ headless:true, args:standalone.args.filter(arg => !['--single-process', '--disable-web-security', '--allow-running-insecure-content', '--disable-site-isolation-trials'].includes(arg) && !arg.startsWith('--disable-features=')), executablePath:await standalone.executablePath() });
const errors = [];
try {
for (const [label,width,height] of [['desktop',1440,1000], ['mobile',390,844], ['small-mobile',320,740]]) {
const context = await browser.newContext({ viewport:{width,height} });
const page = await context.newPage();
await page.clock.install();
page.on('pageerror', error => errors.push(error.message));
await page.goto(`${origin}/docs/index.html`);
assert.match(await page.locator('article h1').textContent(), /Enelix verstehen/);
assert.match(await page.locator('article').textContent(), /Energiemanagementsystem/);
await page.screenshot({ path:path.join(root, `qa/docs-${label}.png`) });
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${label} overview overflow`);
if (width < 720) { assert.equal(await page.locator('#docs-menu').getAttribute('open'), null); await page.locator('#docs-menu > summary').click(); }
const consumers = page.locator('[data-folder="verbraucher"]');
const interfaces = page.locator('[data-folder="schnittstellen"]');
const folderToggle = consumers.locator(':scope > summary');
assert.equal(await consumers.getAttribute('open'), null);
assert.equal(await consumers.locator('a').count(), 8);
assert.equal(await page.locator('nav[aria-label="Dokumentation"] a[href="/docs/ems/Manager.html"]:visible').count(), 1);
await folderToggle.focus(); await folderToggle.press('Enter');
assert.equal(await consumers.evaluate(el => el.open), true);
await page.screenshot({ path:path.join(root, `qa/navigation-${label}.png`) });
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${label} expanded navigation overflow`);
await folderToggle.press('Space');
assert.equal(await consumers.evaluate(el => el.open), false);
await page.locator('#doc-search').fill('Waermepumpe');
assert.equal(await consumers.evaluate(el => el.open), true);
assert.equal(await consumers.locator('a[href="/docs/ems/Waermepumpe.html"]:visible').count(), 1);
assert.equal(await interfaces.isVisible(), false);
await page.locator('#doc-search').fill('Schnittstellen');
assert.equal(await consumers.isVisible(), false);
assert.equal(await interfaces.evaluate(el => el.open), true);
assert.equal(await interfaces.locator('[data-search]:visible').count(), 3);
await page.locator('#doc-search').fill('');
assert.equal(await consumers.evaluate(el => el.open), false);
assert.equal(await interfaces.evaluate(el => el.open), false);
await folderToggle.click();
await page.locator('#doc-search').fill('Shelly');
await page.locator('#doc-search').fill('');
assert.equal(await consumers.evaluate(el => el.open), true, 'Search preserves manually expanded folders');
await page.locator('#doc-search').fill('Shelly');
assert.match(await page.locator('#search-result').textContent(), /passende Themen/);
assert.ok(await page.locator('[data-search]:visible').count() >= 1);
await page.locator('#doc-search').fill('unfindable-text-xxx');
assert.match(await page.locator('#search-result').textContent(), /Keine/);
assert.equal(await page.locator('#docs-menu section:visible').count(), 0);
await page.locator('#doc-search').fill('');
await consumers.locator('a[href="/docs/ems/Waermepumpe.html"]').click();
await page.waitForURL('**/docs/ems/Waermepumpe.html');
assert.equal(await consumers.evaluate(el => el.open), true, 'Current page opens its folder');
assert.equal(await consumers.locator('a[aria-current="page"]').getAttribute('href'), '/docs/ems/Waermepumpe.html');
for (const name of ['index','erste-schritte','manager']) {
await page.goto(`${origin}/docs/${name}.html`);
const images = page.locator('article img');
assert.ok(await images.count() > 0, `${name}: missing screenshot`);
for (const img of await images.all()) {
await img.scrollIntoViewIfNeeded();
await img.evaluate(el => el.decode());
assert.ok(await img.evaluate(el => el.naturalWidth >= 100 && el.naturalHeight >= 100));
assert.match(await img.getAttribute('src'), /^\/docs\/images\/symcon-[a-z-]+-[a-f0-9]{16}\.png$/);
assert.ok(await img.getAttribute('alt'));
}
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${label} ${name} image overflow`);
await page.screenshot({ path:path.join(root, `qa/${name}-image-${label}.png`) });
}
await page.goto(`${origin}/docs/preise.html`);
await page.waitForSelector('.price-table');
assert.equal(await page.locator('.price-table tbody tr').count(), Object.keys(fixture.items).length);
assert.ok((await page.locator('.price-table tbody tr').first().textContent()).includes((fixture.items.manager_standard.unitAmount / 100).toFixed(2)));
await page.locator('[value="gross"]').check();
const expectedGross = ((fixture.items.manager_standard.unitAmount + Math.round(fixture.items.manager_standard.unitAmount * fixture.vatRate / 100)) / 100).toFixed(2);
assert.ok((await page.locator('.price-table tbody tr').first().textContent()).includes(expectedGross));
await page.screenshot({ path:path.join(root, `qa/prices-${label}.png`) });
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${label} prices overflow`);
const changed = structuredClone(fixture); changed.items.manager_standard.unitAmount = 50000;
changed.items.new_test = { sku:'NEW', name:'Backend-Neuprodukt', unitAmount:1234, setupAmount:0, termMonths:6 };
await page.route('**/api/catalog', route => route.fulfill({ json:changed }));
await page.clock.fastForward(31000);
await page.waitForFunction(count => document.querySelectorAll('.price-table tbody tr').length === count, Object.keys(changed.items).length);
assert.ok((await page.locator('.price-table tbody tr').first().textContent()).includes(((50000 + Math.round(50000 * fixture.vatRate / 100)) / 100).toFixed(2)));
await page.unroute('**/api/catalog');
await page.route('**/api/catalog', route => route.fulfill({ status:503, body:'unavailable' }));
await page.locator('#refresh-prices').click();
await page.waitForSelector('#price-status.error');
assert.equal(await page.locator('.price-table').count(), 0);
await page.unroute('**/api/catalog');
await page.locator('#refresh-prices').click(); await page.waitForSelector('.price-table');
await page.goto(`${origin}/docs/ems/Manager.html`);
await page.screenshot({ path:path.join(root, `qa/manager-${label}.png`) });
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${label} reference overflow`);
const noJs = await browser.newContext({ javaScriptEnabled:false, viewport:{width,height} });
const staticPage = await noJs.newPage(); await staticPage.goto(`${origin}/docs/installation.html`);
assert.match(await staticPage.locator('article').textContent(), /Bibliotheken hinzufügen/);
const staticFolder = staticPage.locator('[data-folder="verbraucher"]');
await staticFolder.locator(':scope > summary').click();
assert.equal(await staticFolder.evaluate(el => el.open), true);
await staticFolder.locator('a[href="/docs/ems/Batterie.html"]').click();
await staticPage.waitForURL('**/docs/ems/Batterie.html');
assert.equal(await staticFolder.evaluate(el => el.open), true);
assert.equal(await staticFolder.locator('a[aria-current="page"]').getAttribute('href'), '/docs/ems/Batterie.html');
await noJs.close();
if (live) {
await page.goto(origin + '/');
await page.waitForSelector('#loginForm:visible');
assert.equal(await page.locator('.public-doc-links a').count(), 3);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${label} login overflow`);
await page.screenshot({ path:path.join(root, `qa/portal-${label}.png`) });
}
await context.close();
console.log(`${label}: folders, keyboard navigation, search expansion/reset, active branch, screenshots, prices, API recovery, layout and no-JS passed`);
}
assert.deepEqual(errors, []);
} finally { await browser.close(); await new Promise(resolve => server.close(resolve)); }
+21
View File
@@ -0,0 +1,21 @@
export const repositories = [
{ id: 'ems', name: 'Enelix-EMS', url: 'https://git.belevo.ch/ENELIX/Enelix-EMS.git', pages: [
['README.md', 'Enelix EMS', 'ems/index.html'],
['docs/module/README.md', 'EMS-Modulübersicht', 'ems/module.html'],
...['Manager', 'Batterie', 'Wassererwaermer', 'Pufferspeicher', 'Verbraucher-1-Stufig', 'Waermepumpe', 'Ladestation-Stand-Alone', 'Ladestation-Gateway', 'Easee-Gateway'].map(name => [`docs/module/${name}/README.md`, name, `ems/${name}.html`]),
['docs/Schnittstelle.md', 'EMS-Schnittstelle', 'ems/schnittstelle.html'],
['docs/Schnittstelle-Batterie.md', 'Batterieschnittstelle', 'ems/batterieschnittstelle.html'],
['docs/Schnittstelle-Easee-Gateway.md', 'Easee-Schnittstelle', 'ems/easee-schnittstelle.html']
] },
{ id: 'utils', name: 'Enelix-Utils', url: 'https://git.belevo.ch/ENELIX/Enelix-Utils.git', pages: [
['README.md', 'Enelix Utils', 'utils/index.html'],
['docs/module/README.md', 'Utils-Modulübersicht', 'utils/module.html'],
...['Energiediagramm', 'Shelly-Modul', 'Verbrauchskostenreport', 'CC100-Hardware', 'Virtuelle-Batterie', 'VGT-Schnittstelle'].map(name => [`docs/module/${name}/README.md`, name, `utils/${name}.html`])
] }
];
export const guides = [
['index', 'Enelix verstehen'], ['erste-schritte', 'Erste Schritte'], ['installation', 'Installation'], ['manager', 'Manager bedienen'],
['verbraucher', 'Verbraucher einrichten'], ['utils', 'Utils nutzen'],
['faq', 'Häufige Fragen'], ['preise', 'Preise']
];
@@ -0,0 +1,11 @@
[Unit]
Description=Publish Enelix public documentation from Git
[Service]
Type=oneshot
WorkingDirectory=/home/agent/services/public-docs
ExecStart=/usr/bin/node /home/agent/services/public-docs/sync.mjs
Environment=DOCS_BRANCH=develop
UMask=0022
TimeoutStartSec=240
NoNewPrivileges=true
@@ -0,0 +1,11 @@
[Unit]
Description=Check Enelix documentation sources every five minutes
[Timer]
OnBootSec=90
OnUnitInactiveSec=5min
RandomizedDelaySec=15
Unit=enelix-public-docs.service
[Install]
WantedBy=timers.target
+17
View File
@@ -0,0 +1,17 @@
import { createHash } from 'node:crypto';
// Only these reviewed screenshots can leave the source repository.
export const screenshotSources = [
'docs/public/images/symcon-objektbaum.png',
'docs/public/images/symcon-manager.png'
];
export function screenshotInfo(source, data) {
if (!screenshotSources.includes(source) || !Buffer.isBuffer(data) || data.length < 45 || data.length > 4_000_000) throw new Error('Invalid screenshot source');
if (!data.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex')) || data.readUInt32BE(8) !== 13 || data.toString('ascii', 12, 16) !== 'IHDR' || data.toString('ascii', data.length - 8, data.length - 4) !== 'IEND') throw new Error('Expected PNG screenshot');
const width = data.readUInt32BE(16), height = data.readUInt32BE(20);
if (width < 100 || height < 100 || width > 5000 || height > 5000) throw new Error('Invalid screenshot dimensions');
const sha256 = createHash('sha256').update(data).digest('hex');
const stem = source.split('/').pop().replace(/\.png$/, '');
return { repo:'Enelix-EMS', source, output:`images/${stem}-${sha256.slice(0, 16)}.png`, width, height, sha256 };
}
+523
View File
@@ -0,0 +1,523 @@
{
"name": "enelix-public-docs",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "enelix-public-docs",
"version": "1.0.0",
"dependencies": {
"marked": "15.0.12",
"sanitize-html": "2.18.0"
},
"devDependencies": {
"@sparticuz/chromium": "153.0.0",
"playwright": "1.63.0"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@sparticuz/chromium": {
"version": "153.0.0",
"resolved": "https://registry.npmjs.org/@sparticuz/chromium/-/chromium-153.0.0.tgz",
"integrity": "sha512-jiGWqSvPMdu4+P9TilYqAR7yEGPEHINx+lCvE/NqNO/EHYzF/x1hUiI5T6ioVIt7dEy7n88byyT0vk+dnsgQwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tar-fs": "^3.1.2"
},
"engines": {
"node": "^22.17.0 || >=24.0.0"
}
},
"node_modules/b4a": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz",
"integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==",
"dev": true,
"license": "Apache-2.0",
"peerDependencies": {
"react-native-b4a": "*"
},
"peerDependenciesMeta": {
"react-native-b4a": {
"optional": true
}
}
},
"node_modules/bare-events": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz",
"integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==",
"dev": true,
"license": "Apache-2.0",
"peerDependencies": {
"bare-abort-controller": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
}
}
},
"node_modules/bare-fs": {
"version": "4.8.2",
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.2.tgz",
"integrity": "sha512-+ZI68KHMUvosXfKbg/UOHK0tbCdRnegbvPEdEcZ3Nd6TetieQsJPRXBRXPdLyy8+3VSEbPXtsumTpEtt78xv9w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.5.4",
"bare-path": "^3.0.0",
"bare-stream": "^2.6.4",
"bare-url": "^2.2.2",
"fast-fifo": "^1.3.2"
},
"engines": {
"bare": ">=1.28.0"
},
"peerDependencies": {
"bare-buffer": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
}
}
},
"node_modules/bare-path": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz",
"integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/bare-stream": {
"version": "2.13.4",
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz",
"integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.8.1",
"streamx": "^2.25.0",
"teex": "^1.0.1"
},
"peerDependencies": {
"bare-abort-controller": "*",
"bare-buffer": "*",
"bare-events": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
},
"bare-buffer": {
"optional": true
},
"bare-events": {
"optional": true
}
}
},
"node_modules/bare-url": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz",
"integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"bare-path": "^3.0.0"
}
},
"node_modules/dayjs": {
"version": "1.11.23",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dom-serializer": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/domhandler": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^3.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^3.0.0",
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"dev": true,
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/events-universal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.7.0"
}
},
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
"dev": true,
"license": "MIT"
},
"node_modules/htmlparser2": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"domutils": "^4.0.2",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/is-plain-object": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz",
"integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/launder": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.2.tgz",
"integrity": "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg==",
"license": "MIT",
"dependencies": {
"dayjs": "^1.11.23"
}
},
"node_modules/marked": {
"version": "15.0.12",
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/nanoid": {
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": {
"version": "8.5.29",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.19",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/sanitize-html": {
"version": "2.18.0",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.0.tgz",
"integrity": "sha512-CvY+PV+NBhxe3BnjFI5f//vEDKLULm9OlZArso7gdgVsWvanBuq9PNDx+/2llfL+8XFYa/UNBxvqRBg3TWU2Ug==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.3.1",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^12.0.0",
"is-plain-object": "^5.1.0",
"launder": "^1.7.2",
"parse-srcset": "^1.0.2",
"postcss": "^8.5.27"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/streamx": {
"version": "2.28.1",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz",
"integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"events-universal": "^1.0.0",
"fast-fifo": "^1.3.2",
"text-decoder": "^1.1.0"
}
},
"node_modules/tar-fs": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz",
"integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"pump": "^3.0.0",
"tar-stream": "^3.1.5"
},
"optionalDependencies": {
"bare-fs": "^4.0.1",
"bare-path": "^3.0.0"
}
},
"node_modules/tar-stream": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.2.tgz",
"integrity": "sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg==",
"dev": true,
"license": "MIT",
"dependencies": {
"b4a": "^1.9.0",
"bare-fs": "^4.8.2",
"fast-fifo": "^1.3.2",
"streamx": "^2.28.1"
}
},
"node_modules/teex": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
"integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"streamx": "^2.12.5"
}
},
"node_modules/text-decoder": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
"integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.6.4"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC"
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "enelix-public-docs",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "node --test tests/*.test.mjs",
"sync": "node sync.mjs"
},
"engines": { "node": ">=22" },
"dependencies": {
"marked": "15.0.12",
"sanitize-html": "2.18.0"
},
"devDependencies": {
"playwright": "1.63.0",
"@sparticuz/chromium": "153.0.0"
}
}
+23
View File
@@ -0,0 +1,23 @@
diff --git a/public/index.html b/public/index.html
index 8d70f40..779b440 100644
--- a/public/index.html
+++ b/public/index.html
@@ -7,4 +7,5 @@
<title>Enelix EMS Portal</title>
<link rel="stylesheet" href="/styles.css?v=20260929-3">
+ <link rel="stylesheet" href="/docs-portal.css?v=20261006">
</head>
<body>
@@ -20,4 +21,5 @@
<section class="auth-workspace">
<div class="auth-box">
+ <nav class="public-doc-links" aria-label="Öffentliche Informationen"><a href="/docs/index.html">Dokumentation</a><a href="/docs/faq.html">FAQ</a><a href="/docs/preise.html">Preise</a></nav>
<div class="auth-tabs" id="authTabs" role="tablist" aria-label="Anmeldung">
<button class="auth-tab active" id="loginTab" type="button">Anmelden</button>
@@ -74,4 +76,6 @@
<button class="nav-item" data-view="faults">Störüberwachung</button>
<button class="nav-item" data-view="account">Konto</button>
+ <a class="nav-item public-doc-link" href="/docs/index.html">Dokumentation</a>
+ <a class="nav-item public-doc-link" href="/docs/preise.html">Preise</a>
</nav>
<div class="workspace-switcher"><label>Organisation<select id="workspaceSelect"></select></label><small id="workspaceRole">Eigentümer</small></div>
+7
View File
@@ -0,0 +1,7 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.dirname(fileURLToPath(import.meta.url));
process.env.DOCS_PUBLIC_DIR = path.join(root, 'preview');
process.env.DOCS_STATE_DIR = path.join(root, 'preview-state');
process.env.DOCS_ALLOW_DRAFTS = 'true';
await import('./sync.mjs');
+105
View File
@@ -0,0 +1,105 @@
import { posix } from 'node:path';
import { Marked } from 'marked';
import sanitizeHtml from 'sanitize-html';
export const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
export const slug = value => value.toLowerCase().replace(/<[^>]*>/g, '').replace(/[^\p{L}\p{N}\s_-]/gu, '').trim().replace(/\s/g, '-');
export function resolveLink(href, page, pages) {
if (!href || /[\u0000-\u0020\\]/.test(href)) return null;
if (href.startsWith('#')) return href;
if (href === '/') return '/';
const [path, anchor] = href.split('#', 2);
let key;
if (/^https:\/\/git\.belevo\.ch\/ENELIX\/(Enelix-EMS|Enelix-Utils)\/src\/branch\/[^/]+\//.test(path)) {
const match = path.match(/^https:\/\/git\.belevo\.ch\/ENELIX\/(Enelix-EMS|Enelix-Utils)\/src\/branch\/[^/]+\/(.+)$/);
key = `${match[1]}/${match[2]}`;
} else if (/^https:\/\//.test(href)) {
try { const url = new URL(href); return url.username || url.password ? null : url.href; } catch { return null; }
} else if (/^[\w+.-]+:|^\/\//.test(href) || path.startsWith('/')) return null;
else key = posix.normalize(posix.join(page.repo, posix.dirname(page.source), path));
const target = pages.find(p => `${p.repo}/${p.source}` === key);
return target ? `/docs/${target.output}${anchor ? `#${anchor}` : ''}` : null;
}
export function resolveImage(href, page, images) {
if (!/^images\/[a-z0-9-]+\.png$/.test(href || '') || page.repo !== 'Enelix-EMS' || !/^docs\/public\/[a-z0-9-]+\.md$/.test(page.source)) return null;
return images.find(image => image.repo === page.repo && image.source === `docs/public/${href}`) || null;
}
export function renderMarkdown(page, pages, images = []) {
const toc = [];
const ids = new Map();
const marked = new Marked({ gfm: true, renderer: {
html() { return ''; },
image({ href, text, title }) {
const image = resolveImage(href, page, images);
if (!image) return `<span>${escape(text)}</span>`;
return `<a class="doc-image" href="/docs/${escape(image.output)}" aria-label="${escape(text)}: Bild in voller Größe öffnen"><img src="/docs/${escape(image.output)}" alt="${escape(text)}" width="${image.width}" height="${image.height}" loading="lazy" decoding="async"></a>${title ? `<span class="image-caption">${escape(title)}</span>` : ''}`;
},
heading({ tokens, depth }) {
const text = this.parser.parseInline(tokens);
const base = slug(sanitizeHtml(text, { allowedTags: [], allowedAttributes: {} }));
const count = ids.get(base) || 0;
ids.set(base, count + 1);
const id = `${base}${count ? `-${count}` : ''}`;
if (depth === 2) toc.push({ id, title: sanitizeHtml(text, { allowedTags: [], allowedAttributes: {} }) });
return `<h${depth} id="${escape(id)}">${text}<a class="heading-anchor" href="#${escape(id)}" aria-label="Link zu diesem Abschnitt">#</a></h${depth}>`;
},
link({ href, tokens }) {
const label = this.parser.parseInline(tokens);
const target = resolveLink(href, page, pages);
return target ? `<a href="${escape(target)}">${label}</a>` : `<span class="unpublished" title="Nicht Teil der öffentlichen Dokumentation">${label}</span>`;
},
table(token) { return `<div class="table-scroll" tabindex="0" role="region" aria-label="Referenztabelle">${this.constructor.prototype.table.call(this, token)}</div>`; }
} });
const html = sanitizeHtml(marked.parse(page.markdown), {
allowedTags: [...sanitizeHtml.defaults.allowedTags, 'h1', 'h2', 'span', 'img'],
allowedAttributes: { a: ['href', 'class', 'aria-label'], '*': ['id'], span: ['class', 'title'], img:['src','alt','width','height','loading','decoding'], div: ['class', 'tabindex', 'role', 'aria-label'], th: ['align'], td: ['align'], code: ['class'] },
allowedSchemes: ['https'], allowProtocolRelative: false
});
return { html, toc };
}
const pricing = `<section class="prices" aria-labelledby="catalog-heading"><h2 id="catalog-heading">Aktueller Katalog</h2>
<div class="price-toolbar"><fieldset><legend>Preisanzeige</legend><label><input type="radio" name="tax" value="net" checked> Netto</label><label><input type="radio" name="tax" value="gross"> Inkl. MwSt.</label></fieldset>
<button type="button" id="refresh-prices" aria-label="Preise aktualisieren" title="Preise aktualisieren">↻ <span>Aktualisieren</span></button></div>
<p id="price-status" role="status">Aktuelle Preise werden geladen.</p><div id="price-list"></div>
<noscript><p>Für aktuelle Preise ist JavaScript erforderlich. <a href="/api/catalog">Aktuellen Katalog abrufen</a>.</p></noscript></section>`;
const referenceFolders = [
{ id:'verbraucher', title:'Verbraucher', outputs:['Batterie', 'Wassererwaermer', 'Pufferspeicher', 'Verbraucher-1-Stufig', 'Waermepumpe', 'Ladestation-Stand-Alone', 'Ladestation-Gateway', 'Easee-Gateway'].map(name => `ems/${name}.html`) },
{ id:'schnittstellen', title:'Schnittstellen', outputs:['ems/schnittstelle.html', 'ems/batterieschnittstelle.html', 'ems/easee-schnittstelle.html'] }
];
export function renderNavigation(current, pages) {
const link = (page, folder = '') => `<li data-search="${escape(`${folder} ${page.title} ${page.markdown.match(/^##? .+$/gm)?.join(' ') || ''}`.toLowerCase())}"><a href="/docs/${escape(page.output)}"${page.output === current.output ? ' aria-current="page"' : ''}>${escape(page.title)}</a></li>`;
return ['Anleitungen', 'Enelix EMS', 'Enelix Utils'].map(group => {
const members = pages.filter(page => page.group === group);
const rendered = new Set();
const entries = members.map(page => {
const folder = referenceFolders.find(item => item.outputs.includes(page.output));
if (!folder) return link(page);
if (rendered.has(folder.id)) return '';
rendered.add(folder.id);
const children = members.filter(child => folder.outputs.includes(child.output));
const active = children.some(child => child.output === current.output);
return `<li class="nav-folder-item"><details class="nav-folder" data-folder="${folder.id}"${active ? ' open data-active="true"' : ''}><summary>${folder.title}</summary><ul>${children.map(child => link(child, folder.title)).join('')}</ul></details></li>`;
}).join('');
return `<section><h2>${group}</h2><ul>${entries}</ul></section>`;
}).join('');
}
export function renderPage(page, pages, manifest, assetVersion) {
const { html, toc } = renderMarkdown(page, pages, manifest.images || []);
const isPrices = page.output === 'preise.html';
const nav = renderNavigation(page, pages);
const source = page.commit ? `${page.repo} · ${page.commit.slice(0, 12)}` : 'Redaktioneller Entwurf · Git-Veröffentlichung ausstehend';
return `<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="${escape(page.title)}: Enelix-Anleitung und Modulreferenz für IP-Symcon."><title>${escape(page.title)} | Enelix Dokumentation</title><link rel="stylesheet" href="/docs/site-${assetVersion}.css"><link rel="canonical" href="https://license.enelix.ch/docs/${escape(page.output)}"></head>
<body><a class="skip" href="#content">Zum Inhalt</a><header class="topbar"><a class="brand" href="/docs/index.html"><span class="brand-mark" aria-hidden="true">E</span><span><strong>enelix</strong><small>Dokumentation</small></span></a><nav aria-label="Hauptnavigation"><a href="/docs/index.html">Doku</a><a href="/docs/faq.html">FAQ</a><a href="/docs/preise.html">Preise</a><a class="portal-link" href="/">Zum Portal <span aria-hidden="true">↗</span></a></nav></header>
<div class="docs-layout"><aside class="sidebar"><details id="docs-menu" open><summary>Inhalt und Suche</summary><label class="search-label" for="doc-search">Dokumentation durchsuchen</label><input type="search" id="doc-search" placeholder="Thema oder Modul suchen" autocomplete="off"><p id="search-result" role="status" hidden></p><nav aria-label="Dokumentation">${nav}</nav></details></aside>
<main id="content" tabindex="-1"><div class="page-meta"><span>Wissen / ${escape(page.group)}</span><span class="channel">${escape(manifest.branch)} · ${manifest.branch === 'develop' ? 'Testing' : manifest.branch === 'beta' ? 'Beta' : 'Stable'}</span></div>
<article>${isPrices ? html.replace(/(<h2[^>]*>)/, `${pricing}$1`) : html}</article><footer><p>${escape(source)}</p><p>${escape(page.source)} · <a href="/docs/faq.html">Fragen und Antworten</a></p><p id="sync-status" class="sync-status" role="status">Git-Abgleich: ${escape(manifest.checkedAt)}${manifest.drafts ? ' · neue Anleitungen als Entwurf' : ''}</p></footer></main>
<aside class="contents"><nav aria-label="Auf dieser Seite"><h2>Auf dieser Seite</h2>${toc.map(item => `<a href="#${escape(item.id)}">${item.title}</a>`).join('')}</nav></aside></div>
<script src="/docs/site-${assetVersion}.js" defer></script>${isPrices ? `<script src="/docs/prices-${assetVersion}.js" type="module"></script>` : ''}</body></html>`;
}
+142
View File
@@ -0,0 +1,142 @@
import fs from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import { repositories, guides } from './config.mjs';
import { renderPage } from './render.mjs';
import { screenshotSources, screenshotInfo } from './images.mjs';
const root = path.dirname(fileURLToPath(import.meta.url));
const publicDir = path.resolve(process.env.DOCS_PUBLIC_DIR || '/home/agent/services/license/public');
const stateDir = path.resolve(process.env.DOCS_STATE_DIR || path.join(root, 'state'));
const branch = process.env.DOCS_BRANCH || 'develop';
if (!['develop', 'beta', 'main'].includes(branch)) throw new Error('Unsupported documentation branch');
const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding:'utf8', timeout:60000, maxBuffer:4e6, stdio:['ignore', 'pipe', 'pipe'], env:{ ...process.env, GIT_TERMINAL_PROMPT:'0' } });
const hash = value => createHash('sha256').update(value).digest('hex').slice(0, 16);
const statusPath = path.join(publicDir, 'docs-status.json');
await fs.mkdir(stateDir, { recursive:true, mode:0o700 });
await fs.mkdir(publicDir, { recursive:true, mode:0o755 });
let lock;
try { lock = await fs.open(path.join(stateDir, 'sync.lock'), 'wx', 0o600); }
catch (error) { if (error.code === 'EEXIST') { console.error('Documentation sync already running; inspect stale lock after interruption.'); process.exit(1); } throw error; }
async function atomicWrite(target, content) {
const temporary = `${target}.${randomUUID()}.tmp`;
await fs.writeFile(temporary, content, { mode:0o644 });
await fs.rename(temporary, target);
}
try {
const pages = [];
const commits = {};
const mirrors = {};
for (const repo of repositories) {
const mirror = path.join(stateDir, `${repo.id}.git`);
await fs.mkdir(mirror, { recursive:true, mode:0o700 });
if (!existsSync(path.join(mirror, 'HEAD'))) {
git(mirror, 'init', '--bare');
git(mirror, 'remote', 'add', 'origin', repo.url);
}
// A separate read-only mirror never changes a developer checkout.
if (git(mirror, 'remote', 'get-url', 'origin').trim() !== repo.url) throw new Error('Unexpected source remote');
git(mirror, 'fetch', '--quiet', '--depth=1', 'origin', `refs/heads/${branch}`);
const commit = git(mirror, 'rev-parse', 'FETCH_HEAD').trim();
if (!/^[a-f0-9]{40}$/.test(commit)) throw new Error('Invalid source revision');
commits[repo.name] = commit;
mirrors[repo.name] = mirror;
for (const [source, title, output] of repo.pages) {
const entry = git(mirror, 'ls-tree', commit, '--', source);
if (!/^100644 blob |^100755 blob /.test(entry)) throw new Error(`Missing regular documentation source: ${repo.name}/${source}`);
const markdown = git(mirror, 'show', `${commit}:${source}`);
if (markdown.length > 400000 || /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}|\bENX-(?:[A-Z0-9]{4}-){3}[A-Z0-9]{4}\b/.test(markdown.replaceAll('ENX-XXXX-XXXX-XXXX-XXXX', ''))) throw new Error('Public documentation safety check failed');
pages.push({ repo:repo.name, source, title, output, markdown, commit, group:repo.id === 'ems' ? 'Enelix EMS' : 'Enelix Utils' });
}
}
let drafts = false;
for (const [name, title] of guides) {
const source = `docs/public/${name}.md`;
let markdown, commit = commits['Enelix-EMS'];
try { markdown = git(mirrors['Enelix-EMS'], 'show', `${commit}:${source}`); }
catch {
// Temporary bootstrap only until the newly written guides are approved for Git.
if (process.env.DOCS_ALLOW_DRAFTS !== 'true') throw new Error(`Guide not published in Git: ${source}`);
markdown = await fs.readFile(path.join(root, 'guides', `${name}.md`), 'utf8');
commit = null; drafts = true;
}
pages.unshift({ repo:'Enelix-EMS', source, title, output:`${name}.html`, markdown, commit, group:'Anleitungen' });
}
pages.sort((a, b) => a.group === 'Anleitungen' && b.group === 'Anleitungen' ? guides.findIndex(([key]) => `${key}.html` === a.output) - guides.findIndex(([key]) => `${key}.html` === b.output) : 0);
const images = [], imageData = new Map();
for (const source of screenshotSources) {
let commit = commits['Enelix-EMS'], data;
const mirror = mirrors['Enelix-EMS'];
const entry = git(mirror, 'ls-tree', commit, '--', source);
if (entry) {
if (!/^100644 blob |^100755 blob /.test(entry)) throw new Error('Screenshot must be a regular file');
data = execFileSync('git', ['show', `${commit}:${source}`], { cwd:mirror, timeout:60000, maxBuffer:4_000_000, stdio:['ignore','pipe','pipe'] });
} else {
if (process.env.DOCS_ALLOW_DRAFTS !== 'true') throw new Error('Screenshot not published in Git');
const local = path.join(root, 'guides/images', path.basename(source));
if (!(await fs.lstat(local)).isFile()) throw new Error('Draft screenshot must be a regular file');
data = await fs.readFile(local);
commit = null; drafts = true;
}
const image = { ...screenshotInfo(source, data), commit };
images.push(image); imageData.set(image.output, data);
}
const assets = {};
for (const name of ['site.css', 'site.js', 'prices.js']) assets[name] = await fs.readFile(path.join(root, 'assets', name), 'utf8');
const engine = await fs.readFile(path.join(root, 'render.mjs'), 'utf8') + await fs.readFile(path.join(root, 'images.mjs'), 'utf8');
const version = hash(JSON.stringify({ pages, assets, images, engine, branch }));
const checkedAt = new Date().toISOString();
const manifest = { branch, checkedAt, commits, drafts, version, images, pages:pages.map(({ repo, source, output, commit }) => ({ repo, source, output, commit })) };
const releases = path.join(publicDir, '.documentation-releases');
const release = path.join(releases, version);
const current = path.join(publicDir, 'docs');
await fs.mkdir(releases, { recursive:true, mode:0o755 });
if (!existsSync(release)) {
const staging = path.join(releases, `.staging-${randomUUID()}`);
await fs.mkdir(staging, { mode:0o755 });
try {
for (const page of pages) {
const target = path.join(staging, page.output);
await fs.mkdir(path.dirname(target), { recursive:true, mode:0o755 });
await fs.writeFile(target, renderPage(page, pages, manifest, version), { mode:0o644 });
}
for (const [name, content] of Object.entries(assets)) {
const extension = path.extname(name), stem = path.basename(name, extension);
await fs.writeFile(path.join(staging, `${stem}-${version}${extension}`), content, { mode:0o644 });
}
await fs.mkdir(path.join(staging, 'images'), { mode:0o755 });
for (const [output, data] of imageData) await fs.writeFile(path.join(staging, output), data, { mode:0o644 });
// Cached HTML may still reference the previous release's fingerprinted assets.
if (existsSync(current)) for (const name of await fs.readdir(current)) {
if (/^(site|prices)-[a-f0-9]{16}\.(css|js)$/.test(name)) await fs.copyFile(path.join(current, name), path.join(staging, name));
}
const previousImages = path.join(current, 'images');
if (existsSync(previousImages)) for (const name of await fs.readdir(previousImages)) {
if (/^symcon-[a-z-]+-[a-f0-9]{16}\.png$/.test(name)) await fs.copyFile(path.join(previousImages, name), path.join(staging, 'images', name));
}
await fs.writeFile(path.join(staging, 'manifest.json'), JSON.stringify(manifest), { mode:0o644 });
await fs.rename(staging, release);
} catch (error) { await fs.rm(staging, { recursive:true, force:true }); throw error; }
}
const existing = await fs.lstat(current).catch(error => { if (error.code !== 'ENOENT') throw error; return null; });
if (existing && !existing.isSymbolicLink()) throw new Error('Refusing to replace an existing documentation directory');
const temporaryLink = path.join(publicDir, `.docs-${randomUUID()}`);
await fs.symlink(path.relative(publicDir, release), temporaryLink);
await fs.rename(temporaryLink, current);
await atomicWrite(statusPath, JSON.stringify({ ok:true, lastAttempt:checkedAt, lastSuccess:checkedAt, branch, commits, drafts, version }));
console.log(JSON.stringify({ ok:true, branch, commits, drafts, version, pages:pages.length }));
} catch {
let previous = {};
try { previous = JSON.parse(await fs.readFile(statusPath, 'utf8')); } catch { /* No successful publication yet. */ }
await atomicWrite(statusPath, JSON.stringify({ ...previous, ok:false, lastAttempt:new Date().toISOString() }));
console.error('Documentation sync failed; last successful publication retained. Check source availability, guide publication and permissions.');
process.exitCode = 1;
} finally {
await lock.close();
await fs.unlink(path.join(stateDir, 'sync.lock'));
}
+92
View File
@@ -0,0 +1,92 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { renderMarkdown, renderPage, renderNavigation, resolveLink, resolveImage } from '../render.mjs';
import { screenshotInfo, screenshotSources } from '../images.mjs';
import { validateCatalog, grossAmount, termLabel } from '../assets/prices.js';
import { repositories } from '../config.mjs';
const page = { repo:'Enelix-EMS', source:'docs/public/manager.md', output:'manager.html', title:'Manager', group:'Anleitungen', markdown:'# Manager\n\n## Einrichten\n\nInhalt.', commit:'a'.repeat(40) };
const targets = [page, { repo:'Enelix-EMS', source:'docs/module/Manager/README.md', output:'ems/Manager.html' }, { repo:'Enelix-Utils', source:'docs/module/Energiediagramm/README.md', output:'utils/Energiediagramm.html' }];
test('relative README links remain public', () => assert.equal(resolveLink('../module/Manager/README.md#variablen', page, targets), '/docs/ems/Manager.html#variablen'));
test('cross-repository links remain public', () => assert.equal(resolveLink('../../../Enelix-Utils/docs/module/Energiediagramm/README.md', page, targets), '/docs/utils/Energiediagramm.html'));
test('private and unsafe targets are not published', () => {
for (const href of ['../NETPLAN_V4_AGENT_HANDOFF.md', '../Offene-Punkte.md', 'javascript:alert(1)', '//evil.example', 'data:text/html,hello', 'https://user:pass@example.com', '/srv/agent/.netrc']) assert.equal(resolveLink(href, page, targets), null);
});
test('markdown escapes scripts, event handlers and injected attributes', () => {
const { html } = renderMarkdown({ ...page, markdown:'# Test\n\n<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>\n\n[bad](javascript:alert(1))\n\n[bad](data:text/html,test)\n\n**bold**\n\n```html\n<script>literal</script>\n```' }, targets);
assert.doesNotMatch(html, /<script|onerror=|href="javascript:|href="data:/i);
assert.match(html, /<strong>bold<\/strong>/);
assert.match(html, /&lt;script&gt;literal/);
});
test('tables, duplicate headings and contents are rendered', () => {
const result = renderMarkdown({ ...page, markdown:'# Name\n\n## Gleich\n\n## Gleich\n\n| Name | Wert |\n| --- | --- |\n| Test | 12 |' }, targets);
assert.match(result.html, /table-scroll/); assert.match(result.html, /<table>/); assert.deepEqual(result.toc.map(x => x.id), ['gleich', 'gleich-1']);
});
test('HTML has public navigation and no login dependencies', () => {
const html = renderPage(page, [page], { branch:'develop', checkedAt:'2026-10-06T12:00:00Z' }, '123456');
assert.match(html, /lang="de"/); assert.match(html, /Testing/); assert.match(html, /href="\/docs\/preise.html"/); assert.doesNotMatch(html, /api\/session|app\.js/);
});
test('publication allowlist excludes internal operations', () => {
for (const repo of repositories) for (const [source] of repo.pages) assert.doesNotMatch(source, /HANDOFF|AGENT|Offene-Punkte|\.env|services|data\//);
});
const navigationPages = repositories.flatMap(repo => repo.pages.map(([source,title,output]) => ({ repo:repo.name, source, title, output, markdown:`# ${title}`, group:repo.id === 'ems' ? 'Enelix EMS' : 'Enelix Utils' })));
test('navigation groups all consumers and interfaces without duplicating or changing URLs', () => {
const html = renderNavigation(page, navigationPages);
for (const item of navigationPages) assert.equal(html.split(`href="/docs/${item.output}"`).length - 1, 1);
const consumers = html.match(/data-folder="verbraucher"[^>]*>(.*?)<\/details>/s)[1];
assert.equal((consumers.match(/data-search=/g) || []).length, 8);
assert.match(consumers, /ems\/Batterie.html/); assert.match(consumers, /ems\/Easee-Gateway.html/);
assert.doesNotMatch(consumers, /ems\/Manager.html|ems\/schnittstelle.html/);
assert.match(html, /<summary>Schnittstellen<\/summary>/);
assert.doesNotMatch(html, /data-folder="[^"]+" open/);
});
test('active reference opens only its own folder in server-rendered HTML', () => {
const html = renderNavigation({ output:'ems/Waermepumpe.html' }, navigationPages);
assert.match(html, /data-folder="verbraucher" open data-active="true"/);
assert.match(html, /href="\/docs\/ems\/Waermepumpe.html" aria-current="page"/);
assert.doesNotMatch(html, /data-folder="schnittstellen" open/);
assert.equal((html.match(/aria-current="page"/g) || []).length, 1);
});
test('unclassified future references remain directly accessible', () => {
const extra = { ...page, group:'Enelix EMS', output:'ems/NewModule.html', title:'New module' };
assert.match(renderNavigation(extra, [...navigationPages,extra]), /href="\/docs\/ems\/NewModule.html" aria-current="page"/);
});
const screenshot = { repo:'Enelix-EMS', source:'docs/public/images/symcon-manager.png', output:'images/symcon-manager-1234567890abcdef.png', width:1000, height:600 };
test('only approved local screenshots render with dimensions and escaped captions', () => {
const { html } = renderMarkdown({ ...page, markdown:'![Manager](images/symcon-manager.png "Test & Ansicht")' }, targets, [screenshot]);
assert.match(html, /<img src="\/docs\/images\/symcon-manager-1234567890abcdef.png"/);
assert.match(html, /width="1000" height="600" loading="lazy"/);
assert.match(html, /Test &amp; Ansicht/);
assert.match(html, /Bild in voller Größe öffnen/);
});
test('external, unapproved, traversal and raw HTML images stay blocked', () => {
for (const href of ['https://example.com/pixel.png','data:image/png;base64,aaaa','images/private.png','../public/images/symcon-manager.png','images/../images/symcon-manager.png','images/%2e%2e/symcon-manager.png','/docs/images/symcon-manager.png','images\\symcon-manager.png']) {
assert.equal(resolveImage(href, page, [screenshot]), null);
}
assert.equal(resolveImage('images/symcon-manager.png', { ...page, repo:'Enelix-Utils' }, [screenshot]), null);
const { html } = renderMarkdown({ ...page, markdown:'<img src="images/symcon-manager.png" onerror="alert(1)">\n\n![Remote](https://example.com/pixel.png)' }, targets, [screenshot]);
assert.doesNotMatch(html, /<img|onerror/);
});
test('screenshots are fingerprinted and reject non-PNG or unreasonable dimensions', () => {
const data = Buffer.alloc(45);
Buffer.from('89504e470d0a1a0a','hex').copy(data);
data.writeUInt32BE(13,8); data.write('IHDR',12); data.writeUInt32BE(950,16); data.writeUInt32BE(550,20); data.write('IEND',37);
const info = screenshotInfo(screenshotSources[0], data);
assert.equal(info.width, 950); assert.match(info.output, /-[a-f0-9]{16}\.png$/);
assert.throws(() => screenshotInfo('docs/private.png', data));
assert.throws(() => screenshotInfo(screenshotSources[0], Buffer.from('<svg></svg>')));
data.writeUInt32BE(100000, 16);
assert.throws(() => screenshotInfo(screenshotSources[0], data));
});
const catalog = { currency:'CHF', prices:'net', vatRate:8.1, items:{ sample:{ name:'Sample', sku:'SKU', unitAmount:10000, setupAmount:0, termMonths:12 } } };
test('catalog reflects arbitrary valid backend products', () => assert.equal(validateCatalog(catalog).items.sample.unitAmount, 10000));
test('zero prices are valid and distinct from missing prices', () => {
assert.equal(validateCatalog({ ...catalog, items:{ zero:{ ...catalog.items.sample, unitAmount:0 } } }).items.zero.unitAmount, 0);
assert.throws(() => validateCatalog({ ...catalog, items:{ broken:{ ...catalog.items.sample, unitAmount:undefined } } }));
});
test('invalid prices, currencies and periods fail closed', () => {
for (const patch of [{ currency:'USD' }, { vatRate:NaN }, { vatRate:101 }, { items:{} }, { prices:'gross' }]) assert.throws(() => validateCatalog({ ...catalog, ...patch }));
for (const patch of [{ unitAmount:-1 }, { unitAmount:1.5 }, { termMonths:0 }, { setupAmount:null }]) assert.throws(() => validateCatalog({ ...catalog, items:{ item:{ ...catalog.items.sample, ...patch } } }));
});
test('VAT follows backend rate and rounding contract', () => { assert.equal(grossAmount(10000, 8.1), 10810); assert.equal(grossAmount(4195, 8.1), 4535); assert.equal(grossAmount(100, 0), 100); });
test('period labels distinguish recurring from one-time', () => { assert.equal(termLabel(null), 'Einmalig'); assert.equal(termLabel(12), 'Pro Jahr'); assert.equal(termLabel(6), 'Pro 6 Monate'); });
+54
View File
@@ -0,0 +1,54 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
const root = path.dirname(fileURLToPath(import.meta.url));
const dir = process.argv[2] === 'live' ? '/home/agent/services/license/public' : path.join(root, 'preview');
const manifest = JSON.parse(await fs.readFile(path.join(dir, 'docs/manifest.json'), 'utf8'));
let links = 0;
for (const page of manifest.pages) {
const html = await fs.readFile(path.join(dir, 'docs', page.output), 'utf8');
assert.match(html, /<h1 /);
assert.doesNotMatch(html, /<script[^>]*>[^<]+<\/script>|href="javascript:|onerror=/i);
for (const match of html.matchAll(/(?:href|src)="(\/docs\/[^"#]+)(?:#[^"]*)?"/g)) { await fs.access(path.join(dir, match[1])); links++; }
}
console.log(JSON.stringify({ pages:manifest.pages.length, validPublicLinks:links, commits:manifest.commits, drafts:manifest.drafts }));
for (const image of manifest.images || []) {
const data = await fs.readFile(path.join(dir, 'docs', image.output));
assert.equal(createHash('sha256').update(data).digest('hex'), image.sha256);
if (process.argv[2] === 'live') {
const response = await fetch(`https://license.enelix.ch/docs/${image.output}`);
assert.equal(response.status, 200); assert.match(response.headers.get('content-type'), /image\/png/);
assert.equal(createHash('sha256').update(Buffer.from(await response.arrayBuffer())).digest('hex'), image.sha256);
}
}
if (process.argv[2] === 'live') {
for (const route of ['/docs/index.html', '/docs/erste-schritte.html', '/docs/installation.html', '/docs/faq.html', '/docs/preise.html', '/docs/ems/Manager.html', '/docs/utils/Shelly-Modul.html', '/api/catalog', '/api/session', '/']) {
const response = await fetch(`https://license.enelix.ch${route}`, { signal:AbortSignal.timeout(15000) });
assert.equal(response.status, 200, route);
const body = await response.text();
if (route === '/api/session') assert.equal(JSON.parse(body).authenticated, false);
if (route.startsWith('/docs/')) { assert.match(body, /Enelix Dokumentation/); assert.equal(response.headers.get('cache-control'), 'no-store'); }
if (route === '/') assert.match(body, /public-doc-links/);
console.log(JSON.stringify({ anonymous:route, status:response.status }));
}
} else {
const before = createHash('sha256').update(await fs.readFile(path.join(dir, 'docs/index.html'))).digest('hex');
const failureState = await fs.mkdtemp(path.join(root, 'failure-test-'));
try {
const mirror = path.join(failureState, 'ems.git'); await fs.mkdir(mirror);
execFileSync('git', ['init', '--bare', '--quiet'], { cwd:mirror });
execFileSync('git', ['remote', 'add', 'origin', 'https://invalid.example/documentation.git'], { cwd:mirror });
const result = spawnSync(process.execPath, [path.join(root, 'sync.mjs')], { encoding:'utf8', env:{ ...process.env, DOCS_PUBLIC_DIR:dir, DOCS_STATE_DIR:failureState, DOCS_ALLOW_DRAFTS:'true' } });
assert.equal(result.status, 1);
const after = createHash('sha256').update(await fs.readFile(path.join(dir, 'docs/index.html'))).digest('hex');
assert.equal(after, before);
const status = JSON.parse(await fs.readFile(path.join(dir, 'docs-status.json'), 'utf8'));
assert.equal(status.ok, false); assert.ok(status.lastSuccess);
console.log('Failure regression passed: last-good pages retained, failure visible, lock released.');
await assert.rejects(fs.access(path.join(failureState, 'sync.lock')));
} finally { await fs.rm(failureState, { recursive:true, force:true }); }
}