#!/usr/bin/env node /* * gen-data.mjs — single source of truth: the LibrePortal repo. * * Reads every containers//.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`);