docs: publish public guides, screenshots and Git-synced portal documentation
Tests / test (push) Failing after 50s
Tests / test (push) Failing after 50s
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
state/
|
||||
preview/
|
||||
preview-state/
|
||||
qa/
|
||||
backups/
|
||||
failure-test-*/
|
||||
guides/
|
||||
@@ -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; }
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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);
|
||||
@@ -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)); }
|
||||
@@ -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
|
||||
@@ -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 };
|
||||
}
|
||||
Generated
+523
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>`;
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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, /<script>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:'' }, 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 & 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' }, 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'); });
|
||||
@@ -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 }); }
|
||||
}
|
||||
Reference in New Issue
Block a user