node-red-contrib-knx-ultimate
Version:
Control your KNX and KNX Secure intallation via Node-Red! A bunch of KNX nodes, with integrated Philips HUE control and ETS group address importer. Easy to use and highly configurable.
470 lines (416 loc) β’ 15.1 kB
JavaScript
/**
* Lightweight documentation preview server that mimics the GitHub Pages baseurl.
* It turns docs/index.md into docs/_site/index.html (no Jekyll required)
* and serves everything from docs/_site at http://127.0.0.1:4000/node-red-contrib-knx-ultimate/.
*/
const fs = require('fs');
const path = require('path');
const http = require('http');
const marked = require('marked');
const yaml = require('js-yaml');
const PORT = Number(process.env.DOCS_PREVIEW_PORT || 4000);
const BASE_URL = '/node-red-contrib-knx-ultimate';
const DOCS_DIR = path.join(__dirname, '..', 'docs');
const SITE_DIR = path.join(DOCS_DIR, '_site');
const TEMPLATE_FILE = path.join(__dirname, 'dev-serve-template.html');
const LANG_FILE = path.join(DOCS_DIR, '_data', 'languages.yml');
const TRANSLATIONS_FILE = path.join(DOCS_DIR, '_data', 'translations.yml');
const NAV_FILE = path.join(DOCS_DIR, '_data', 'wiki-nav.json');
const ASSETS_SOURCE = path.join(DOCS_DIR, 'assets');
const ASSETS_TARGET = path.join(SITE_DIR, 'assets');
const WIKI_DIR = path.join(DOCS_DIR, 'wiki');
let languagesCache = null;
let translationsCache = null;
let navCache = null;
let templateCache = null;
let navUrlCache = null;
const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.webp': 'image/webp',
'.xml': 'application/xml; charset=utf-8',
'.txt': 'text/plain; charset=utf-8'
};
function parseFrontMatter(source) {
const lines = source.split(/\r?\n/);
if (lines[0] !== '---') {
return { data: {}, body: source };
}
let idx = 1;
while (idx < lines.length && lines[idx] !== '---') {
idx += 1;
}
const fm = lines.slice(1, idx).join('\n');
const body = lines.slice(idx + 1).join('\n');
let data = {};
try {
data = yaml.load(fm) || {};
} catch (err) {
console.warn('Could not parse front matter:', err);
}
return { data, body };
}
async function ensureLandingPages() {
const entries = await fs.promises.readdir(DOCS_DIR, { withFileTypes: true });
const render = typeof marked.parse === 'function' ? marked.parse.bind(marked) : marked;
templateCache = templateCache || await fs.promises.readFile(TEMPLATE_FILE, 'utf8').catch(() => null);
const template = templateCache;
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!/^index(.[\w-]+)?\.md$/i.test(entry.name)) continue;
const filePath = path.join(DOCS_DIR, entry.name);
const markdown = await fs.promises.readFile(filePath, 'utf8');
const { data, body } = parseFrontMatter(markdown);
const html = render(body, {
headerIds: false,
mangle: false
});
let permalink = typeof data.permalink === 'string' ? data.permalink.trim() : '/';
if (permalink === '') permalink = '/';
if (!permalink.startsWith('/')) permalink = `/${permalink}`;
if (permalink !== '/' && !permalink.endsWith('/')) {
permalink = `${permalink}/`;
}
const relative = permalink === '/' ? '' : permalink.replace(/^\//, '').replace(/\/$/, '');
const outputDir = relative ? path.join(SITE_DIR, relative) : SITE_DIR;
const outputFile = path.join(outputDir, 'index.html');
await fs.promises.mkdir(outputDir, { recursive: true });
const finalHtml = html.replace(/{{\s*site\.baseurl\s*}}/g, BASE_URL);
if (template) {
const lang = (data.lang || 'en').toString();
const headerHtml = buildHeaderHtml(lang);
const navHtml = buildSidebarHtml(lang, data.title || 'Docs', permalink);
const pageHtml = template
.replace('HEADER_PLACEHOLDER', headerHtml)
.replace('NAV_PLACEHOLDER', navHtml)
.replace('CONTENT_PLACEHOLDER', finalHtml);
await fs.promises.writeFile(outputFile, pageHtml, 'utf8');
} else {
await fs.promises.writeFile(outputFile, finalHtml, 'utf8');
}
console.log(`Generated ${path.relative(process.cwd(), outputFile)}`);
}
}
async function copyAssets() {
async function copyRecursive(src, dest) {
const stat = await fs.promises.stat(src);
if (stat.isDirectory()) {
await fs.promises.mkdir(dest, { recursive: true });
const entries = await fs.promises.readdir(src);
for (const entry of entries) {
await copyRecursive(path.join(src, entry), path.join(dest, entry));
}
} else if (stat.isFile()) {
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
await fs.promises.copyFile(src, dest);
}
}
try {
await copyRecursive(ASSETS_SOURCE, ASSETS_TARGET);
console.log(`Synced assets -> ${path.relative(process.cwd(), ASSETS_TARGET)}`);
} catch (err) {
console.warn('Failed to sync docs assets:', err.message);
}
}
async function ensureWikiPages () {
async function walk (dir) {
const entries = await fs.promises.readdir(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
await walk(fullPath)
continue
}
if (!entry.name.endsWith('.md')) continue
const raw = await fs.promises.readFile(fullPath, 'utf8')
const { data, body } = parseFrontMatter(raw)
const render = typeof marked.parse === 'function' ? marked.parse.bind(marked) : marked
const htmlBody = render(body, {
headerIds: false,
mangle: false
})
const lang = (data.lang || 'en').toString()
let permalink = data.permalink || ''
if (!permalink) {
const relative = path.relative(WIKI_DIR, fullPath).replace(/\\/g, '/')
const slug = relative.replace(/\.md$/i, '')
permalink = `/wiki/${slug}/`
}
if (!permalink.endsWith('/')) {
permalink = `${permalink}/`
}
templateCache = templateCache || await fs.promises.readFile(TEMPLATE_FILE, 'utf8').catch(() => null)
if (!templateCache) {
continue
}
const headerHtml = buildHeaderHtml(lang)
const navHtml = buildSidebarHtml(lang, data.title || 'Docs', permalink)
const pageHtml = templateCache
.replace('HEADER_PLACEHOLDER', headerHtml)
.replace('NAV_PLACEHOLDER', navHtml)
.replace('CONTENT_PLACEHOLDER', htmlBody)
const outputDir = path.join(SITE_DIR, permalink.replace(/^\//, ''))
await fs.promises.mkdir(outputDir, { recursive: true })
const outputFile = path.join(outputDir, 'index.html')
await fs.promises.writeFile(outputFile, pageHtml, 'utf8')
console.log(`Rendered wiki page -> ${path.relative(process.cwd(), outputFile)}`)
}
}
if (fs.existsSync(WIKI_DIR)) {
await walk(WIKI_DIR)
}
}
function buildHeaderHtml (lang) {
const taglines = {
en: 'Node-RED meets KNX',
it: 'Node-RED incontra KNX',
de: 'Node-RED trifft KNX',
fr: 'Node-RED rencontre KNX',
es: 'Node-RED se encuentra con KNX',
'zh-CN': 'Node-RED δΈ KNX ηΈι'
}
const tagline = taglines[lang] || taglines.en
const homeHref = `${BASE_URL}/`
const logoSrc = `${BASE_URL}/logo.png`
return `<div class="wiki-header">
<div class="wiki-header__card">
<div class="wiki-header__logo">
<a class="wiki-logo" href="${homeHref}">
<img src="${logoSrc}" alt="KNX Ultimate logo">
</a>
</div>
<div class="wiki-header__tagline">${tagline}</div>
</div>
</div>`
}
function loadLanguages() {
if (!languagesCache) {
const raw = fs.readFileSync(LANG_FILE, 'utf8');
languagesCache = yaml.load(raw) || {};
}
return languagesCache;
}
function loadTranslations() {
if (!translationsCache) {
const raw = fs.readFileSync(TRANSLATIONS_FILE, 'utf8');
translationsCache = yaml.load(raw) || {};
}
return translationsCache;
}
function loadNav() {
if (!navCache) {
const raw = fs.readFileSync(NAV_FILE, 'utf8');
navCache = JSON.parse(raw);
navUrlCache = collectNavUrls(navCache);
}
return navCache;
}
function getNavUrlsForLang(lang) {
loadNav();
return navUrlCache.get(lang) || new Set();
}
function normaliseWikiUrl(url) {
if (!url) return '';
let normalised = url.trim();
if (!normalised.startsWith('/')) {
normalised = `/${normalised}`;
}
if (!normalised.endsWith('/')) {
normalised += '/';
}
return normalised;
}
function collectNavUrls(navData) {
const cache = new Map();
for (const [lang, sections] of Object.entries(navData)) {
const urls = new Set();
(sections || []).forEach((section) => {
(section.items || []).forEach((item) => {
if (!item.external && item.url) {
urls.add(normaliseWikiUrl(item.url));
}
});
});
cache.set(lang, urls);
}
return cache;
}
function buildSidebarHtml(lang, pageTitle, currentUrl) {
const languages = loadLanguages();
const translations = loadTranslations();
const navData = loadNav();
const langKey = languages[lang] ? lang : Object.keys(languages)[0];
const languageLabel =
translations.language_label && translations.language_label[langKey]
? translations.language_label[langKey]
: 'Language';
const normalizedCurrent = currentUrl === '/' ? '/' : currentUrl.replace(/\/$/, '');
const isWikiPage = normalizedCurrent.startsWith('/wiki/');
const currentSlug = isWikiPage ? normalizedCurrent.replace(/^\/wiki\//, '') : '';
let rootSlug = currentSlug;
if (isWikiPage) {
for (const [code, info] of Object.entries(languages)) {
const prefix = info.prefix || '';
if (prefix && currentSlug.startsWith(prefix)) {
rootSlug = currentSlug.slice(prefix.length);
break;
}
}
}
const languageLinks = Object.entries(languages)
.map(([code, info]) => {
const label = info.short || info.label || code;
const available = getNavUrlsForLang(code);
let targetUrl;
if (!isWikiPage) {
const homeItem = (navData[code] && navData[code][0] && navData[code][0].items && navData[code][0].items[0]) || null;
if (homeItem && !homeItem.external) {
targetUrl = normaliseWikiUrl(homeItem.url);
} else {
const prefix = info.prefix || '';
targetUrl = prefix ? normaliseWikiUrl(`/${prefix}/`) : '/';
}
} else {
const prefix = info.prefix || '';
const candidateSlug = prefix ? `${prefix}${rootSlug}` : rootSlug;
targetUrl = normaliseWikiUrl(`/wiki/${candidateSlug}/`);
if (!available.has(targetUrl) && available.size) {
const fallback = normaliseWikiUrl(`/wiki/${rootSlug}/`);
if (available.has(fallback)) {
targetUrl = fallback;
} else {
targetUrl = Array.from(available)[0];
}
}
}
const href = `${BASE_URL}${targetUrl}`;
if (code === langKey) {
return `<span class="wiki-nav__language wiki-nav__language--active">${label}</span>`;
}
return `<a class="wiki-nav__language" href="${href}">${label}</a>`;
})
.join('');
const sections = (navData[langKey] || [])
.map((section, sectionIdx) => {
let sectionHasActive = false;
const itemsHtml = (section.items || [])
.map((item) => {
if (item.external) {
return `<li><a class="wiki-nav__link" href="${item.url}" rel="noopener">${item.label}</a></li>`;
}
const itemUrlNormalized = normaliseWikiUrl(item.url);
const href = `${BASE_URL}${itemUrlNormalized}`;
const isActive = itemUrlNormalized.replace(/\/$/, '') === normalizedCurrent;
if (isActive) {
sectionHasActive = true;
}
const activeClass = isActive ? ' wiki-nav__link--active' : '';
return `<li><a class="wiki-nav__link${activeClass}" href="${href}">${item.label}</a></li>`;
})
.join('');
const openAttr = sectionHasActive || sectionIdx === 0 ? ' open' : '';
return `<details class="wiki-nav__group"${openAttr}>
<summary>${section.title}</summary>
<ul class="wiki-nav__list">
${itemsHtml}
</ul>
</details>`;
})
.join('');
return `<aside class="wiki-sidebar">
<nav class="wiki-nav">
<div class="wiki-nav__languages" role="navigation" aria-label="${languageLabel}">
<span class="wiki-nav__languages-label">π ${languageLabel}</span>
${languageLinks}
</div>
<div class="wiki-nav__groups">
${sections}
</div>
</nav>
</aside>`;
}
function resolveFilePath(requestPath) {
let localPath = requestPath;
if (localPath.startsWith(BASE_URL)) {
localPath = localPath.slice(BASE_URL.length) || '/';
}
if (localPath === '/' || localPath === '') {
localPath = '/index.html';
} else if (localPath.endsWith('/')) {
localPath += 'index.html';
}
const normalised = path.normalize(localPath).replace(/^(\.\.[/\\])+/, '');
const absolute = path.join(SITE_DIR, normalised);
if (!absolute.startsWith(SITE_DIR)) {
return null;
}
return absolute;
}
function contentType(filePath) {
const ext = path.extname(filePath).toLowerCase();
return MIME_TYPES[ext] || 'application/octet-stream';
}
async function handleRequest(req, res) {
const { pathname } = new URL(req.url, 'http://localhost');
if (pathname === '/' && BASE_URL.length) {
res.writeHead(302, { Location: `${BASE_URL}/` });
res.end();
return;
}
const filePath = resolveFilePath(pathname);
if (!filePath) {
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('403 Forbidden');
return;
}
try {
const stat = await fs.promises.stat(filePath);
if (!stat.isFile()) {
throw new Error('Not a file');
}
res.writeHead(200, { 'Content-Type': contentType(filePath) });
fs.createReadStream(filePath).pipe(res);
} catch {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(`404 Not Found: ${pathname}`);
}
}
(async () => {
try {
await ensureLandingPages();
await copyAssets();
await ensureWikiPages();
} catch (err) {
console.error('Failed to generate docs/_site/index.html');
console.error(err);
process.exit(1);
}
const server = http.createServer((req, res) => {
handleRequest(req, res).catch((err) => {
console.error('Unexpected error while serving request:', err);
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('500 Internal Server Error');
});
});
server.listen(PORT, '127.0.0.1', () => {
console.log('');
console.log('Docs preview server running!');
console.log(` Local preview : http://127.0.0.1:${PORT}${BASE_URL}/`);
console.log('');
});
const shutdown = () => {
console.log('\nShutting down docs preview server.');
server.close(() => process.exit(0));
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
})();