diff --git a/scripts/fetch-sources.mjs b/scripts/fetch-sources.mjs
index c8156fb..5e45f87 100644
--- a/scripts/fetch-sources.mjs
+++ b/scripts/fetch-sources.mjs
@@ -12,11 +12,12 @@
* Existing vendor clones are reused (delete vendor/ to force a refresh).
* Network failures are warnings, not errors — docs pages degrade gracefully.
*/
-import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
+import { existsSync, mkdirSync, readdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
-import { parse as parseYaml } from 'yaml';
+import { parseFrontmatter } from '../src/lib/frontmatter.mjs';
+import { RNP_EXTRA_SPARSE } from '../src/lib/vendor-source.mjs';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const vendorDir = join(root, 'vendor');
@@ -24,15 +25,9 @@ const softwareDir = join(root, 'src/content/software');
/** Extra files to check out per product, gitignore-style sparse patterns. */
const EXTRA_PATHS = {
- rnp: ['/src/rnp/rnp.1.adoc', '/src/rnpkeys/rnpkeys.1.adoc', '/src/lib/librnp.3.adoc'],
+ rnp: RNP_EXTRA_SPARSE,
};
-function frontMatter(file) {
- const raw = readFileSync(file, 'utf8');
- const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
- return m ? (parseYaml(m[1]) ?? {}) : {};
-}
-
function run(args, cwd) {
execFileSync('git', args, { cwd, stdio: 'pipe' });
}
@@ -40,7 +35,7 @@ function run(args, cwd) {
let failures = 0;
for (const file of readdirSync(softwareDir).filter((f) => f.endsWith('.md'))) {
- const fm = frontMatter(join(softwareDir, file));
+ const fm = parseFrontmatter(join(softwareDir, file)).frontMatter;
if (!fm.docs_repo) continue;
const name = file.replace(/\.md$/, '');
diff --git a/src/components/vue/EasterEggs.vue b/src/components/vue/EasterEggs.vue
index d73c48e..0a54c49 100644
--- a/src/components/vue/EasterEggs.vue
+++ b/src/components/vue/EasterEggs.vue
@@ -1,5 +1,6 @@
@@ -333,183 +186,14 @@ onUnmounted(() => {
:class="eggBackground"
aria-live="polite"
>
-
-
- {{ g }}{{ i < matrixGroups.length - 1 ? ' ' : '' }}
-
-
-
-
-
- {{ g }}{{ i < redactedGroups.length - 1 ? ' ' : '' }}
-
-
- Classified
-
-
-
-
-
-
- DON'T PANIC
-
-
- — in big, friendly letters on its cover
-
-
-
-
-
-
-
-
Powered by Thunderbird's wings.
-
-
-
-
-
-
-----BEGIN PGP MESSAGE-----
-
- {{ hex.slice(0, 48) }}
{{ hex.slice(48, 96) }}
{{ hex.slice(96) }}
-
-
-----END PGP MESSAGE-----
-
-
-
-
-
-
-
I'M SORRY, DAVE.
-
I'm afraid I can't do that.
-
-
-
-
-
-
> Open the pod bay doors, HAL.
-
— transmission pending —
-
-
-
-
-
- Harvest now.
-
-
- Decrypt later.
-
-
— the post-quantum adversary is patient
-
-
-
-
-
LibrePGP
-
— the open OpenPGP specification
-
- brought to you by g10 · Intevation · Ribose
-
-
-
-
-
-
- "Open source is a development methodology; free software is a social movement."
-
-
— Ribose · the people behind RNP
-
-
-
-
-
- "Privacy is the power to selectively reveal oneself to the world."
-
-
- — Eric Hughes · A Cypherpunk's Manifesto (1993)
-
-
-
-
-
-
-
- +
-
- →
-
-
-
- Public exchange, private result. (Diffie–Hellman, 1976)
-
-
-
-
-
-
-
- P
- {{ otpData.plain }}
-
-
- K
- {{ otpData.key }}
-
-
- C
- {{ otpData.cipher }}
-
-
-
- ⊕ — mathematically proven unbreakable. Use once.
-
-
-
-
-
-
-
- ³-rotor cipher · Wehrmacht, 1934–1945
-
-
-
-
-
-
- Munition
- ITAR · US Customs · 1993–1996
-
-
- PGP was a weapon. Zimmermann under investigation for 3 years.
-
-
+
+
+
-
{
···· ···· ···· ···· ···· ···· ···· ···· ···· ···· ···· ···· ···· ···· ···· ····
-
✨ {{ hexWord }}
@@ -551,51 +234,6 @@ onUnmounted(() => {
diff --git a/src/components/vue/HeroDecrypt.vue b/src/components/vue/HeroDecrypt.vue
index 1aab055..e0eb8c5 100644
--- a/src/components/vue/HeroDecrypt.vue
+++ b/src/components/vue/HeroDecrypt.vue
@@ -1,5 +1,6 @@
diff --git a/src/components/vue/SiteHeader.vue b/src/components/vue/SiteHeader.vue
index a5e9dab..a4cfd73 100644
--- a/src/components/vue/SiteHeader.vue
+++ b/src/components/vue/SiteHeader.vue
@@ -1,6 +1,7 @@
diff --git a/src/components/vue/fingerprint/eggs/ArmorEgg.vue b/src/components/vue/fingerprint/eggs/ArmorEgg.vue
new file mode 100644
index 0000000..770c444
--- /dev/null
+++ b/src/components/vue/fingerprint/eggs/ArmorEgg.vue
@@ -0,0 +1,13 @@
+
+
+
+
+
-----BEGIN PGP MESSAGE-----
+
+ {{ props.hex.slice(0, 48) }}
{{ props.hex.slice(48, 96) }}
{{ props.hex.slice(96) }}
+
+
-----END PGP MESSAGE-----
+
+
diff --git a/src/components/vue/fingerprint/eggs/DaveEgg.vue b/src/components/vue/fingerprint/eggs/DaveEgg.vue
new file mode 100644
index 0000000..ed6f86e
--- /dev/null
+++ b/src/components/vue/fingerprint/eggs/DaveEgg.vue
@@ -0,0 +1,10 @@
+
+
+
+
+
> Open the pod bay doors, HAL.
+
— transmission pending —
+
+
diff --git a/src/components/vue/fingerprint/eggs/DhEgg.vue b/src/components/vue/fingerprint/eggs/DhEgg.vue
new file mode 100644
index 0000000..5d63c93
--- /dev/null
+++ b/src/components/vue/fingerprint/eggs/DhEgg.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+ +
+
+ →
+
+
+
+ Public exchange, private result. (Diffie–Hellman, 1976)
+
+
+
+
+
diff --git a/src/components/vue/fingerprint/eggs/EnigmaEgg.vue b/src/components/vue/fingerprint/eggs/EnigmaEgg.vue
new file mode 100644
index 0000000..b8eeb4e
--- /dev/null
+++ b/src/components/vue/fingerprint/eggs/EnigmaEgg.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+ ³-rotor cipher · Wehrmacht, 1934–1945
+
+
+
+
+
diff --git a/src/components/vue/fingerprint/eggs/HalEgg.vue b/src/components/vue/fingerprint/eggs/HalEgg.vue
new file mode 100644
index 0000000..8c011c1
--- /dev/null
+++ b/src/components/vue/fingerprint/eggs/HalEgg.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
I'M SORRY, DAVE.
+
I'm afraid I can't do that.
+
+
+
+
+
diff --git a/src/components/vue/fingerprint/eggs/LibrepgpEgg.vue b/src/components/vue/fingerprint/eggs/LibrepgpEgg.vue
new file mode 100644
index 0000000..acf20e1
--- /dev/null
+++ b/src/components/vue/fingerprint/eggs/LibrepgpEgg.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
LibrePGP
+
— the open OpenPGP specification
+
+ brought to you by g10 · Intevation · Ribose
+
+
+
+
+
diff --git a/src/components/vue/fingerprint/eggs/MatrixEgg.vue b/src/components/vue/fingerprint/eggs/MatrixEgg.vue
new file mode 100644
index 0000000..995fc9d
--- /dev/null
+++ b/src/components/vue/fingerprint/eggs/MatrixEgg.vue
@@ -0,0 +1,52 @@
+
+
+
+
+ {{ g }}{{ i < matrixGroups.length - 1 ? ' ' : '' }}
+
+
+
+
diff --git a/src/components/vue/fingerprint/eggs/OtpEgg.vue b/src/components/vue/fingerprint/eggs/OtpEgg.vue
new file mode 100644
index 0000000..aaf2bdb
--- /dev/null
+++ b/src/components/vue/fingerprint/eggs/OtpEgg.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+ P
+ {{ otpData.plain }}
+
+
+ K
+ {{ otpData.key }}
+
+
+ C
+ {{ otpData.cipher }}
+
+
+
+ ⊕ — mathematically proven unbreakable. Use once.
+
+
+
diff --git a/src/components/vue/fingerprint/eggs/PanicEgg.vue b/src/components/vue/fingerprint/eggs/PanicEgg.vue
new file mode 100644
index 0000000..d3bcaed
--- /dev/null
+++ b/src/components/vue/fingerprint/eggs/PanicEgg.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+ DON'T PANIC
+
+
+ — in big, friendly letters on its cover
+
+
+
+
+
diff --git a/src/components/vue/fingerprint/eggs/PhilEgg.vue b/src/components/vue/fingerprint/eggs/PhilEgg.vue
new file mode 100644
index 0000000..db10c9b
--- /dev/null
+++ b/src/components/vue/fingerprint/eggs/PhilEgg.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+ Munition
+ ITAR · US Customs · 1993–1996
+
+
+ PGP was a weapon. Zimmermann under investigation for 3 years.
+
+
+
+
+
diff --git a/src/components/vue/fingerprint/eggs/PqcEgg.vue b/src/components/vue/fingerprint/eggs/PqcEgg.vue
new file mode 100644
index 0000000..cdcdaf2
--- /dev/null
+++ b/src/components/vue/fingerprint/eggs/PqcEgg.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+ Harvest now.
+
+
+ Decrypt later.
+
+
— the post-quantum adversary is patient
+
+
+
+
diff --git a/src/components/vue/fingerprint/eggs/PrivacyEgg.vue b/src/components/vue/fingerprint/eggs/PrivacyEgg.vue
new file mode 100644
index 0000000..acc477f
--- /dev/null
+++ b/src/components/vue/fingerprint/eggs/PrivacyEgg.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
+ "Privacy is the power to selectively reveal oneself to the world."
+
+
+ — Eric Hughes · A Cypherpunk's Manifesto (1993)
+
+
+
diff --git a/src/components/vue/fingerprint/eggs/RedactedEgg.vue b/src/components/vue/fingerprint/eggs/RedactedEgg.vue
new file mode 100644
index 0000000..a2eff32
--- /dev/null
+++ b/src/components/vue/fingerprint/eggs/RedactedEgg.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+ {{ g }}{{ i < redactedGroups.length - 1 ? ' ' : '' }}
+
+
+ Classified
+
+
+
+
+
diff --git a/src/components/vue/fingerprint/eggs/RiboseEgg.vue b/src/components/vue/fingerprint/eggs/RiboseEgg.vue
new file mode 100644
index 0000000..5e38f89
--- /dev/null
+++ b/src/components/vue/fingerprint/eggs/RiboseEgg.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
+ "Open source is a development methodology; free software is a social movement."
+
+
— Ribose · the people behind RNP
+
+
diff --git a/src/components/vue/fingerprint/eggs/ThunderbirdEgg.vue b/src/components/vue/fingerprint/eggs/ThunderbirdEgg.vue
new file mode 100644
index 0000000..dcdd215
--- /dev/null
+++ b/src/components/vue/fingerprint/eggs/ThunderbirdEgg.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
Powered by Thunderbird's wings.
+
+
+
+
+
diff --git a/src/components/vue/fingerprint/registry.ts b/src/components/vue/fingerprint/registry.ts
new file mode 100644
index 0000000..541cb8d
--- /dev/null
+++ b/src/components/vue/fingerprint/registry.ts
@@ -0,0 +1,133 @@
+import type { EggDef } from './types';
+
+import MatrixEgg from './eggs/MatrixEgg.vue';
+import RedactedEgg from './eggs/RedactedEgg.vue';
+import PanicEgg from './eggs/PanicEgg.vue';
+import ThunderbirdEgg from './eggs/ThunderbirdEgg.vue';
+import ArmorEgg from './eggs/ArmorEgg.vue';
+import HalEgg from './eggs/HalEgg.vue';
+import DaveEgg from './eggs/DaveEgg.vue';
+import PqcEgg from './eggs/PqcEgg.vue';
+import LibrepgpEgg from './eggs/LibrepgpEgg.vue';
+import RiboseEgg from './eggs/RiboseEgg.vue';
+import PrivacyEgg from './eggs/PrivacyEgg.vue';
+import DhEgg from './eggs/DhEgg.vue';
+import OtpEgg from './eggs/OtpEgg.vue';
+import EnigmaEgg from './eggs/EnigmaEgg.vue';
+import PhilEgg from './eggs/PhilEgg.vue';
+
+/**
+ * Each egg is one entry — trigger, duration, background, and the component that
+ * renders inside the fingerprint box. Add a new egg by creating `eggs/FooEgg.vue`
+ * and appending one entry here.
+ */
+export const EGG_REGISTRY: EggDef[] = [
+ {
+ name: 'matrix',
+ trigger: /\b(matrix|neo|wake\s*up|mr\.?\s*anderson)\b/i,
+ duration: 5000,
+ background: 'border-emerald-500/40 bg-[#00150a]',
+ component: MatrixEgg,
+ },
+ {
+ name: 'redacted',
+ trigger: /\b(snowden|nsa|prism|classified|top\s*secret|redact(ed)?)\b/i,
+ duration: 5000,
+ background: 'border-red-900/60 bg-black',
+ component: RedactedEgg,
+ },
+ {
+ name: 'panic',
+ trigger: /\b(42|don'?t\s*panic|hitchhiker|arthur\s*dent)\b/i,
+ duration: 5000,
+ background: 'border-amber-500/50 bg-[#fdf6e3]',
+ component: PanicEgg,
+ },
+ {
+ name: 'thunderbird',
+ trigger: /\bthunderbird\b/i,
+ duration: 5000,
+ background: 'border-line bg-surface-dim',
+ component: ThunderbirdEgg,
+ },
+ {
+ name: 'armor',
+ trigger: /\b(pgp\s*message|ascii\s*armor|openpgp|4880)\b/i,
+ duration: 5000,
+ background: 'border-line bg-surface-dim',
+ component: ArmorEgg,
+ },
+ {
+ name: 'hal',
+ trigger: /\b(hal(\s*9000)?|hal9000|9000|monolith|space\s*odyssey|2001)\b/i,
+ duration: 9000,
+ background: 'border-[#ff1744]/40 bg-[#1a0606]',
+ component: HalEgg,
+ sceneEvent: 'halScene',
+ },
+ {
+ name: 'dave',
+ trigger: /\b(dave(\s*bowman)?|bowman|pod\s*bay\s*doors?)\b/i,
+ duration: 11000,
+ background: 'border-cyan-900/50 bg-[#040810]',
+ component: DaveEgg,
+ sceneEvent: 'daveScene',
+ },
+ {
+ name: 'pqc',
+ trigger: /\b(pqc|post[-\s]*quantum|quantum|9980)\b/i,
+ duration: 5000,
+ background: 'border-cyan-500/40 bg-[#061a1f]',
+ component: PqcEgg,
+ },
+ {
+ name: 'librepgp',
+ trigger: /\blibrepgp\b/i,
+ duration: 5000,
+ background:
+ 'border-gold/50 bg-[color-mix(in_oklab,var(--gold)_14%,var(--surface-dim))]',
+ component: LibrepgpEgg,
+ },
+ {
+ name: 'ribose',
+ trigger: /\bribose\b/i,
+ duration: 5000,
+ background: 'border-line bg-surface-dim',
+ component: RiboseEgg,
+ },
+ {
+ name: 'privacy',
+ trigger: /\bprivacy\b/i,
+ duration: 5000,
+ background: 'border-line bg-surface-dim',
+ component: PrivacyEgg,
+ },
+ {
+ name: 'dh',
+ trigger: /\b(dh|diffie[\s-]*hellman)\b/i,
+ duration: 6000,
+ background: 'border-purple-500/40 bg-[#0e0420]',
+ component: DhEgg,
+ },
+ {
+ name: 'otp',
+ trigger: /\b(otp|one[\s-]*time[\s-]*pad|vernam)\b/i,
+ duration: 6000,
+ background: 'border-emerald-500/40 bg-[#021810]',
+ component: OtpEgg,
+ },
+ {
+ name: 'enigma',
+ trigger: /\benigma\b/i,
+ duration: 6000,
+ background: 'border-gray-500/40 bg-[#0a0a0c]',
+ component: EnigmaEgg,
+ },
+ {
+ name: 'phil',
+ trigger: /\b(phil|zimmermann|munitions?)\b/i,
+ duration: 6000,
+ background: 'border-amber-700/40 bg-[#f5edd6]',
+ component: PhilEgg,
+ },
+];
diff --git a/src/components/vue/fingerprint/types.ts b/src/components/vue/fingerprint/types.ts
new file mode 100644
index 0000000..2d53057
--- /dev/null
+++ b/src/components/vue/fingerprint/types.ts
@@ -0,0 +1,39 @@
+import type { Component } from 'vue';
+
+export type EggName =
+ | 'matrix'
+ | 'redacted'
+ | 'panic'
+ | 'thunderbird'
+ | 'armor'
+ | 'hal'
+ | 'dave'
+ | 'pqc'
+ | 'librepgp'
+ | 'ribose'
+ | 'privacy'
+ | 'dh'
+ | 'otp'
+ | 'enigma'
+ | 'phil';
+
+export interface EggProps {
+ /** Current input value (some eggs read it, e.g. otp). */
+ input: string;
+ /** Current SHA-256 hex (some eggs read it, e.g. armor). */
+ hex: string;
+}
+
+export interface EggDef {
+ name: EggName;
+ /** Input value is tested against this regex to trigger the egg. */
+ trigger: RegExp;
+ /** How long the egg stays active before auto-clearing (ms). */
+ duration: number;
+ /** Tailwind classes applied to the fingerprint box while this egg is active. */
+ background: string;
+ /** Vue component rendered inside the box. */
+ component: Component;
+ /** If set, dispatch this event when the egg fires (screenwide scene). */
+ sceneEvent?: 'halScene' | 'daveScene';
+}
diff --git a/src/content.config.ts b/src/content.config.ts
index 1a511d2..d0db697 100644
--- a/src/content.config.ts
+++ b/src/content.config.ts
@@ -2,12 +2,12 @@ import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
import { adocLoader } from './lib/asciidoc';
+import { RNP_VERSION, RNP_MAN_PAGES } from './lib/vendor-source.mjs';
/**
* Version of the RNP release whose man pages are rendered at /docs/.
- * Bump together with `docs_ref` in src/content/software/rnp.md.
+ * Derived from `docs_ref` in src/content/software/rnp.md via vendor-source.mjs.
*/
-const RNP_VERSION = '0.18.1';
const categories = z
.union([z.string(), z.array(z.string())])
@@ -157,8 +157,7 @@ const softwareDocs = defineCollection({
const manpages = defineCollection({
loader: adocLoader({
base: './vendor/rnp/src',
- include: (rel) =>
- ['rnp/rnp.1.adoc', 'rnpkeys/rnpkeys.1.adoc', 'lib/librnp.3.adoc'].includes(rel),
+ include: (rel) => RNP_MAN_PAGES.includes(rel),
attributes: {
'component-version': RNP_VERSION,
'release-version': RNP_VERSION,
diff --git a/src/lib/asciidoc.ts b/src/lib/asciidoc.ts
index a9a1057..8ffebf2 100644
--- a/src/lib/asciidoc.ts
+++ b/src/lib/asciidoc.ts
@@ -1,8 +1,8 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
-import { parse as parseYaml } from 'yaml';
import { load as loadAdoc } from 'asciidoctor';
import type { Loader } from 'astro/loaders';
+import { parseFrontmatter } from './frontmatter.mjs';
export interface SplitAdoc {
frontMatter: Record;
@@ -11,16 +11,7 @@ export interface SplitAdoc {
/** Split YAML front matter (Jekyll-style) from an AsciiDoc body. */
export function splitFrontMatter(raw: string): SplitAdoc {
- const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
- if (!m) return { frontMatter: {}, body: raw };
- let frontMatter: Record = {};
- try {
- frontMatter = (parseYaml(m[1]) ?? {}) as Record;
- } catch {
- // malformed front matter — treat the file as body-only
- return { frontMatter: {}, body: raw };
- }
- return { frontMatter, body: raw.slice(m[0].length) };
+ return parseFrontmatter(raw);
}
function decodeEntities(s: string): string {
@@ -60,16 +51,7 @@ export async function renderAdoc(body: string, attributes: Record]+>/g, '')
- .replace(/([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
- .replace(/(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
- .replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/"/g, '"')
- .trim();
+ const docTitle = docTitleRaw !== undefined ? decodeEntities(docTitleRaw) : undefined;
const html = (await doc.convert()) as unknown as string;
return { html, docTitle };
}
diff --git a/src/lib/docs.ts b/src/lib/docs.ts
new file mode 100644
index 0000000..58a4be9
--- /dev/null
+++ b/src/lib/docs.ts
@@ -0,0 +1,9 @@
+/** Sort order for docs entries within a product. */
+export function docSort(a: string, b: string): number {
+ if (a === 'README') return -1;
+ if (b === 'README') return 1;
+ const aDev = a.startsWith('develop/') ? 1 : 0;
+ const bDev = b.startsWith('develop/') ? 1 : 0;
+ if (aDev !== bDev) return aDev - bDev;
+ return a.localeCompare(b);
+}
diff --git a/src/lib/events.ts b/src/lib/events.ts
new file mode 100644
index 0000000..deb6e62
--- /dev/null
+++ b/src/lib/events.ts
@@ -0,0 +1,31 @@
+/**
+ * Central registry of window custom-event names used by Vue islands.
+ *
+ * Importing from here lets TypeScript flag typos and gives a single rename
+ * site per event. Both senders and listeners reference the same symbol.
+ */
+
+export const RNP_EVENTS = {
+ /** SiteHeader → SiteSearch: open the Cmd+K modal. */
+ openSearch: 'rnp:open-search',
+ /** FingerprintPlayground or EasterEggs → EasterEggs: full-screen oracle rain. */
+ hexRain: 'rnp:hex-rain',
+ /** FingerprintPlayground → EasterEggs: play the HAL 9000 scene. */
+ halScene: 'rnp:hal-scene',
+ /** FingerprintPlayground → EasterEggs: play the Dave Bowman scene. */
+ daveScene: 'rnp:dave-scene',
+ /** FingerprintPlayground or EasterEggs → HeroDecrypt: scramble to readable. */
+ replayHero: 'rnp:replay-hero',
+ /** FingerprintPlayground or EasterEggs → HeroDecrypt: readable to scrambled. */
+ encryptHero: 'rnp:encrypt-hero',
+} as const;
+
+/** sessionStorage keys (distinct from custom-event names). */
+export const RNP_STORAGE = {
+ helpHinted: 'rnp:help-hinted',
+} as const;
+
+/** Dispatch a registered event with no payload. */
+export function dispatch(name: (typeof RNP_EVENTS)[keyof typeof RNP_EVENTS]): void {
+ window.dispatchEvent(new CustomEvent(name));
+}
diff --git a/src/lib/frontmatter.mjs b/src/lib/frontmatter.mjs
new file mode 100644
index 0000000..29d552f
--- /dev/null
+++ b/src/lib/frontmatter.mjs
@@ -0,0 +1,21 @@
+/**
+ * YAML front-matter parser shared between the Astro layer (.ts) and the
+ * standalone Node scripts (.mjs). Kept in .mjs so fetch-sources.mjs can
+ * import it without a TS compiler.
+ */
+import { parse as parseYaml } from 'yaml';
+
+/**
+ * Split YAML front matter (Jekyll-style `---\n...\n---`) from a raw text body.
+ * Returns `{ frontMatter, body }`; if no front matter is present the whole
+ * input is returned as `body` and `frontMatter` is `{}`.
+ */
+export function parseFrontmatter(raw) {
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
+ if (!m) return { frontMatter: {}, body: raw };
+ try {
+ return { frontMatter: parseYaml(m[1]) ?? {}, body: raw.slice(m[0].length) };
+ } catch {
+ return { frontMatter: {}, body: raw };
+ }
+}
diff --git a/src/lib/vendor-source.mjs b/src/lib/vendor-source.mjs
new file mode 100644
index 0000000..3628e59
--- /dev/null
+++ b/src/lib/vendor-source.mjs
@@ -0,0 +1,31 @@
+/**
+ * Vendor-source conventions shared between content.config.ts and
+ * scripts/fetch-sources.mjs. Kept in .mjs so the standalone fetch script can
+ * import it without a TS compiler.
+ */
+import { readFileSync } from 'node:fs';
+import { parseFrontmatter } from './frontmatter.mjs';
+
+/** Project-relative directory sparse-cloned by fetch-sources.mjs. */
+export const VENDOR_DIR = 'vendor';
+
+/**
+ * Man-page sources for the RNP product, relative to `vendor/rnp/src/`.
+ * Sparse-checkout patterns are derived from this list.
+ */
+export const RNP_MAN_PAGES = ['rnp/rnp.1.adoc', 'rnpkeys/rnpkeys.1.adoc', 'lib/librnp.3.adoc'];
+
+/** Sparse-checkout patterns (repo-root relative) for RNP's extra paths. */
+export const RNP_EXTRA_SPARSE = RNP_MAN_PAGES.map((p) => `/src/${p}`);
+
+/**
+ * Pinned RNP release tag, read from `docs_ref` in `src/content/software/rnp.md`.
+ * `RNP_VERSION` (without the leading `v`) is derived from the tag so the two
+ * can no longer drift apart.
+ */
+const rnpFrontMatter = parseFrontmatter(
+ readFileSync('src/content/software/rnp.md', 'utf8'),
+).frontMatter;
+
+export const RNP_RELEASE_TAG = String(rnpFrontMatter.docs_ref ?? '').trim();
+export const RNP_VERSION = RNP_RELEASE_TAG.replace(/^v/, '');
diff --git a/src/pages/software/[id].astro b/src/pages/software/[id].astro
index 7d20c58..e78b155 100644
--- a/src/pages/software/[id].astro
+++ b/src/pages/software/[id].astro
@@ -5,22 +5,13 @@ import PageHero from '@/components/PageHero.astro';
import Prose from '@/components/Prose.astro';
import InstallTabs from '@/components/vue/InstallTabs.vue';
import { tagLabel } from '@/lib/tags';
+import { docSort } from '@/lib/docs';
export async function getStaticPaths() {
const products = await getCollection('software');
return products.map((entry) => ({ params: { id: entry.id }, props: { entry } }));
}
-/** README first, then alphabetical by slug, with develop/* pages last. */
-function docSort(a: string, b: string): number {
- if (a === 'README') return -1;
- if (b === 'README') return 1;
- const aDev = a.startsWith('develop/') ? 1 : 0;
- const bDev = b.startsWith('develop/') ? 1 : 0;
- if (aDev !== bDev) return aDev - bDev;
- return a.localeCompare(b);
-}
-
const { entry } = Astro.props;
const { data } = entry;
const { Content } = await render(entry);
diff --git a/src/pages/software/[product]/docs/[...slug].astro b/src/pages/software/[product]/docs/[...slug].astro
index 3ab5ed0..f147cf5 100644
--- a/src/pages/software/[product]/docs/[...slug].astro
+++ b/src/pages/software/[product]/docs/[...slug].astro
@@ -1,6 +1,7 @@
---
import { getCollection, render } from 'astro:content';
import DocsLayout from '@/layouts/DocsLayout.astro';
+import { docSort } from '@/lib/docs';
export async function getStaticPaths() {
const entries = await getCollection('softwareDocs');
@@ -15,16 +16,6 @@ export async function getStaticPaths() {
});
}
-/** README first, then alphabetical by slug, with develop/* pages last. */
-function docSort(a: string, b: string): number {
- if (a === 'README') return -1;
- if (b === 'README') return 1;
- const aDev = a.startsWith('develop/') ? 1 : 0;
- const bDev = b.startsWith('develop/') ? 1 : 0;
- if (aDev !== bDev) return aDev - bDev;
- return a.localeCompare(b);
-}
-
const { entry } = Astro.props;
const { product } = Astro.params;
const docPath = entry.id.split('/').slice(1).join('/');