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,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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user