// Fetch the app repositories whose Markdown this site renders. // // Each app's docs/PRIVACY.md is the single copy of that policy: the app repo // owns it, and this site renders it through a content collection (see // src/content.config.ts). Nothing here is authored in this repository, and // nothing is written back — this only makes the sources present at build time. // // It runs from `prebuild` and `predev`, NOT from CI: Coolify builds the site // straight from the repo, so a checkout that only happened in a Gitea job // would never reach the deployed page. // // Refs default to `main`. Point a source at a branch with e.g. // AGENDULA_REF=feat/caldav-sync, or at a working copy on this machine with // AGENDULA_LOCAL=/path/to/agendula (which skips the network entirely). // // The guardrail is the point of the script: if a policy source is missing, // empty, or malformed, the build FAILS. A privacy page that silently renders // nothing is the one outcome this design exists to prevent. import { execFileSync } from 'node:child_process'; import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const EXTERNAL = join(ROOT, 'external'); const SOURCES = [ { name: 'calendula', repo: 'https://codeberg.org/jlmakiola/calendula.git', raw: (ref) => `https://codeberg.org/jlmakiola/calendula/raw/branch/${ref}/docs/PRIVACY.md`, file: 'docs/PRIVACY.md', }, { name: 'agendula', repo: 'https://codeberg.org/jlmakiola/agendula.git', raw: (ref) => `https://codeberg.org/jlmakiola/agendula/raw/branch/${ref}/docs/PRIVACY.md`, file: 'docs/PRIVACY.md', }, ]; const REQUIRED_FRONTMATTER = ['title', 'description', 'updated']; const MIN_BODY_CHARS = 400; const env = (name, suffix) => process.env[`${name.toUpperCase()}_${suffix}`]?.trim() || ''; const log = (msg) => console.log(`[external] ${msg}`); const git = (args, cwd) => execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim(); /** Shallow single-branch clone, or a shallow fetch if it is already there. */ function syncRepo(source, ref, dir) { if (existsSync(join(dir, '.git'))) { git(['fetch', '--depth', '1', 'origin', ref], dir); git(['checkout', '--detach', 'FETCH_HEAD'], dir); return `fetched ${ref}`; } rmSync(dir, { recursive: true, force: true }); git(['clone', '--depth', '1', '--single-branch', '--branch', ref, source.repo, dir]); return `cloned ${ref}`; } /** Last resort when git is unavailable or the clone fails: the raw file. */ async function fetchRaw(source, ref, dir) { const res = await fetch(source.raw(ref), { headers: { Accept: 'text/plain' } }); if (!res.ok) throw new Error(`raw fetch returned HTTP ${res.status}`); const body = await res.text(); const target = join(dir, source.file); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, body); return `fetched ${source.file} at ${ref} over HTTPS`; } /** Parse just enough frontmatter to know the entry will satisfy the schema. */ function validate(source, dir) { const path = join(dir, source.file); if (!existsSync(path)) return `${source.file} is missing`; const text = readFileSync(path, 'utf8'); const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); if (!match) return `${source.file} has no frontmatter block`; const [, frontmatter, body] = match; const missing = REQUIRED_FRONTMATTER.filter( (key) => !new RegExp(`^${key}:\\s*\\S`, 'm').test(frontmatter) ); if (missing.length) return `${source.file} frontmatter is missing: ${missing.join(', ')}`; // Strip HTML comments — the maintainer note must not count as content. const prose = body.replace(//g, '').trim(); if (prose.length < MIN_BODY_CHARS) return `${source.file} body is empty or too short`; if (!/^##\s/m.test(prose)) return `${source.file} body has no sections`; return null; } let failed = false; mkdirSync(EXTERNAL, { recursive: true }); for (const source of SOURCES) { const dir = join(EXTERNAL, source.name); const local = env(source.name, 'LOCAL'); const ref = env(source.name, 'REF') || 'main'; if (local) { // Development escape hatch: render a working copy on this machine, for a // policy edit that is not pushed yet. Never set in CI or on the server. const target = isAbsolute(local) ? local : resolve(ROOT, local); if (!existsSync(target)) { console.error(`[external] ${source.name}: ${source.name.toUpperCase()}_LOCAL points at ${target}, which does not exist`); failed = true; continue; } if (existsSync(dir) || lstatSync(dir, { throwIfNoEntry: false })) { rmSync(dir, { recursive: true, force: true }); } symlinkSync(target, dir, 'dir'); log(`${source.name}: local override -> ${target}`); } else { try { log(`${source.name}: ${syncRepo(source, ref, dir)}`); } catch (error) { const reason = (error.stderr?.toString() || error.message).split('\n')[0]; log(`${source.name}: git failed (${reason}); falling back to the raw file`); try { log(`${source.name}: ${await fetchRaw(source, ref, dir)}`); } catch (fallbackError) { log(`${source.name}: fallback failed (${fallbackError.message})`); } } } const problem = validate(source, dir); if (problem) { console.error(`[external] ${source.name}: ${problem}`); failed = true; } else { log(`${source.name}: ${source.file} ok`); } } if (failed) { console.error( '\n[external] A privacy policy source is unusable, so the build stops here.\n' + ' These pages must never render empty — fix the source, or set\n' + ' _REF / _LOCAL if you are working against a branch.' ); process.exit(1); }