LibrePortal/site/scripts/gen-data.mjs
librelad c1863b3e00 feat(site): data-driven Eleventy marketing site
A nebula-themed marketing/get site under site/, matching the dashboard
(aurora background, glass cards, system fonts — no Google/third-party
calls). The app grid + category filters are generated from the repo:
scripts/gen-data.mjs reads each containers/<app>/<app>.config and emits the
data Eleventy renders. `npm run build` -> static site in dist/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 00:45:01 +01:00

79 lines
3.4 KiB
JavaScript

#!/usr/bin/env node
/*
* gen-data.mjs — single source of truth: the LibrePortal repo.
*
* Reads every containers/<app>/<app>.config METADATA block and emits
* src/_data/apps.json (array of apps, used by the app grid)
* src/_data/categories.json (categories present, with friendly names + counts)
*
* Run via `npm run data` (also runs automatically before `npm run build`).
* Add an app to the repo -> rebuild -> the website updates itself.
*/
import { readdirSync, readFileSync, existsSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, '..', '..'); // site/scripts -> repo root
const containersDir = join(repoRoot, 'containers');
const dataDir = join(here, '..', 'src', '_data');
const iconsDir = join(here, '..', 'src', 'assets', 'apps');
// Only these metadata fields are pulled from each .config. LONG_DESCRIPTION must
// precede DESCRIPTION in the alternation so it wins the match.
const FIELD_RE = /^CFG_.+?_(LONG_DESCRIPTION|DESCRIPTION|CATEGORY|TITLE|URL)\s*=\s*"?(.*?)"?\s*$/;
const CATEGORY_NAMES = {
recommended: 'Recommended', communication: 'Communication', development: 'Development',
knowledge: 'Knowledge', media: 'Media', monitoring: 'Monitoring', storage: 'Storage',
network: 'Network', security: 'Security', productivity: 'Productivity', finance: 'Finance',
ai: 'AI', utilities: 'Utilities', privacy: 'Privacy', social: 'Social', system: 'System',
};
const titleCase = (s) => s.replace(/[-_]/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase());
function parseConfig(text) {
const out = {};
for (const raw of text.split(/\r?\n/)) {
const m = FIELD_RE.exec(raw.trim());
if (m && out[m[1]] === undefined) out[m[1]] = m[2];
}
return out;
}
const apps = [];
for (const slug of readdirSync(containersDir)) {
const dir = join(containersDir, slug);
if (!statSync(dir).isDirectory()) continue;
const cfg = join(dir, `${slug}.config`);
if (!existsSync(cfg)) continue;
const meta = parseConfig(readFileSync(cfg, 'utf8'));
if (!meta.TITLE) continue; // skip anything without a display title (infra without metadata)
const cats = (meta.CATEGORY || 'utilities').split(',').map((s) => s.trim()).filter(Boolean);
apps.push({
slug,
title: meta.TITLE,
category: cats[0], // primary category
categories: cats, // an app can belong to several
description: meta.DESCRIPTION || '',
longDescription: meta.LONG_DESCRIPTION || meta.DESCRIPTION || '',
url: meta.URL || '',
icon: existsSync(join(iconsDir, `${slug}.svg`)) ? `assets/apps/${slug}.svg` : 'assets/apps/default.svg',
});
}
apps.sort((a, b) => a.title.localeCompare(b.title));
const catMap = new Map();
for (const a of apps) {
for (const cat of a.categories) {
const c = catMap.get(cat) || { id: cat, name: CATEGORY_NAMES[cat] || titleCase(cat), count: 0 };
c.count++;
catMap.set(cat, c);
}
}
const categories = [...catMap.values()].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
mkdirSync(dataDir, { recursive: true });
writeFileSync(join(dataDir, 'apps.json'), JSON.stringify(apps, null, 2) + '\n');
writeFileSync(join(dataDir, 'categories.json'), JSON.stringify(categories, null, 2) + '\n');
console.log(`${apps.length} apps across ${categories.length} categories → src/_data/{apps,categories}.json`);