diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
new file mode 100644
index 0000000..e135dfd
--- /dev/null
+++ b/.github/workflows/deploy-pages.yml
@@ -0,0 +1,55 @@
+name: Deploy GitHub Pages
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ - name: Configure GitHub Pages
+ id: pages
+ uses: actions/configure-pages@v5
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build site
+ run: BASE_PATH="${{ steps.pages.outputs.base_path }}" npm run build
+
+ - name: Upload Pages artifact
+ uses: actions/upload-pages-artifact@v4
+ with:
+ path: ./dist
+
+ deploy:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ needs: build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a18ecac
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+dist
+node_modules
+playwright-report
+test-results
diff --git a/README.md b/README.md
index f15c5e8..1360e82 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,18 @@ schema/ ← Configuration — structure, conventions, workflows
| **Query** | Search wiki → synthesize answer with citations → optionally write back as new page |
| **Lint** | Check contradictions, orphans, stale claims, missing cross-refs, data gaps |
+## Frontend
+
+The repository now includes a static frontend build for GitHub Pages.
+
+- `npm install`
+- `npm run build` to generate `dist/`
+- `npm run preview` to serve the generated site locally at `http://127.0.0.1:4173`
+- `npm run test:e2e` to run the Playwright smoke test against the local preview server
+- `.github/workflows/deploy-pages.yml` deploys `dist/` from `main`
+
+Architecture notes, inspiration references, and content assumptions live in [docs/frontend.md](docs/frontend.md).
+
## Why this works
> "Humans abandon wikis because the maintenance burden grows faster than the value. LLMs don't get bored."
diff --git a/docs/frontend.md b/docs/frontend.md
new file mode 100644
index 0000000..26537c4
--- /dev/null
+++ b/docs/frontend.md
@@ -0,0 +1,49 @@
+# Frontend Architecture
+
+The rara-wiki frontend is a small static generator rather than a full framework app.
+
+## Why this direction
+
+- The repository is markdown-first and already has a clean content split: `wiki/`, `raw/`, `schema/`, and `README.md`.
+- GitHub Pages prefers a deterministic static output with low operational overhead.
+- A custom Node build keeps the implementation transparent: the rendering rules for frontmatter, wikilinks, backlinks, source references, and collection pages all live in one build pipeline.
+
+## Reference projects
+
+These projects informed the direction, but rara-wiki intentionally keeps a narrower implementation surface:
+
+- [Quartz](https://github.com/jackyzha0/quartz): markdown-native digital garden with strong graph, backlink, and wiki-link conventions.
+- [Obsidian Digital Garden](https://github.com/oleeskild/obsidian-digital-garden): public publishing pattern for a markdown knowledge base with lightweight page metadata and navigation.
+- [Jekyll Garden](https://github.com/Jekyll-Garden/jekyll-garden.github.io): an Obsidian-to-site presentation style that makes note networks browseable on static hosting.
+- [Dendron](https://github.com/dendronhq/dendron): schema-aware knowledge base design, especially around backlinks, hierarchy, and refactor-safe linking.
+
+## What the build generates
+
+- A home page from `README.md`
+- Page routes for every markdown file under `wiki/`, `raw/`, and `schema/`
+- Collection indexes for wiki pages, raw sources, schema docs, and tags
+- Client-side search via a generated JSON index
+- Backlinks computed from `[[wikilinks]]`
+- Frontmatter surfaces for tags, sources, date, and status
+- Base-path-safe asset and page URLs for GitHub Pages project deployments
+
+## Key files
+
+- `scripts/build.mjs`: content ingestion, link resolution, HTML generation, search index generation
+- `scripts/preview.mjs`: simple local static preview server
+- `site/site.css`: layout, theming, responsive design, and typography
+- `site/site.js`: theme toggle and client-side search
+- `tests/wiki.spec.js`: Playwright smoke coverage for search, wikilinks, backlinks, and theme persistence
+- `.github/workflows/deploy-pages.yml`: build and deployment workflow for GitHub Pages
+
+## Content assumptions
+
+- Wiki links are written as `[[page-name]]` or `[[path/to/page]]`
+- Tags come from frontmatter
+- Source links in frontmatter can be URLs or paths to markdown pages inside the repo
+- `wiki/log.md` acts as the recent updates stream surfaced in the UI
+
+## Verification
+
+- `npm run build` verifies the static site generator output
+- `npm run test:e2e` runs a browser smoke test with Playwright against the preview server
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..9424135
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,289 @@
+{
+ "name": "rara-wiki-site",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "rara-wiki-site",
+ "dependencies": {
+ "gray-matter": "^4.0.3",
+ "markdown-it": "^14.1.0",
+ "markdown-it-anchor": "^9.2.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.54.2"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
+ "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@types/linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/markdown-it": {
+ "version": "14.1.2",
+ "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
+ "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@types/linkify-it": "^5",
+ "@types/mdurl": "^2"
+ }
+ },
+ "node_modules/@types/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/argparse": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+ "license": "MIT",
+ "dependencies": {
+ "sprintf-js": "~1.0.2"
+ }
+ },
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "license": "BSD-2-Clause",
+ "bin": {
+ "esparse": "bin/esparse.js",
+ "esvalidate": "bin/esvalidate.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "license": "MIT",
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gray-matter": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
+ "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-yaml": "^3.13.1",
+ "kind-of": "^6.0.2",
+ "section-matter": "^1.0.0",
+ "strip-bom-string": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.0"
+ }
+ },
+ "node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/js-yaml": {
+ "version": "3.14.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
+ "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^1.0.7",
+ "esprima": "^4.0.0"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/kind-of": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
+ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
+ "license": "MIT",
+ "dependencies": {
+ "uc.micro": "^2.0.0"
+ }
+ },
+ "node_modules/markdown-it": {
+ "version": "14.1.1",
+ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
+ "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1",
+ "entities": "^4.4.0",
+ "linkify-it": "^5.0.0",
+ "mdurl": "^2.0.0",
+ "punycode.js": "^2.3.1",
+ "uc.micro": "^2.1.0"
+ },
+ "bin": {
+ "markdown-it": "bin/markdown-it.mjs"
+ }
+ },
+ "node_modules/markdown-it-anchor": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-9.2.0.tgz",
+ "integrity": "sha512-sa2ErMQ6kKOA4l31gLGYliFQrMKkqSO0ZJgGhDHKijPf0pNFM9vghjAh3gn26pS4JDRs7Iwa9S36gxm3vgZTzg==",
+ "license": "Unlicense",
+ "peerDependencies": {
+ "@types/markdown-it": "*",
+ "markdown-it": "*"
+ }
+ },
+ "node_modules/markdown-it/node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
+ },
+ "node_modules/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
+ "license": "MIT"
+ },
+ "node_modules/playwright": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/punycode.js": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+ "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/section-matter": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
+ "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
+ "license": "MIT",
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "kind-of": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/sprintf-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/strip-bom-string": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
+ "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/uc.micro": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+ "license": "MIT"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..b55b46c
--- /dev/null
+++ b/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "rara-wiki-site",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "node scripts/build.mjs",
+ "dev": "npm run build && node scripts/preview.mjs",
+ "preview": "node scripts/preview.mjs",
+ "test:e2e": "playwright test"
+ },
+ "dependencies": {
+ "gray-matter": "^4.0.3",
+ "markdown-it": "^14.1.0",
+ "markdown-it-anchor": "^9.2.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.59.1"
+ }
+}
diff --git a/playwright.config.js b/playwright.config.js
new file mode 100644
index 0000000..fb7e2dc
--- /dev/null
+++ b/playwright.config.js
@@ -0,0 +1,18 @@
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./tests",
+ timeout: 60_000,
+ fullyParallel: false,
+ reporter: "list",
+ use: {
+ baseURL: "http://127.0.0.1:4173",
+ trace: "on-first-retry"
+ },
+ webServer: {
+ command: "npm run preview",
+ url: "http://127.0.0.1:4173",
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000
+ }
+});
diff --git a/scripts/build.mjs b/scripts/build.mjs
new file mode 100644
index 0000000..2960bba
--- /dev/null
+++ b/scripts/build.mjs
@@ -0,0 +1,896 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import matter from "gray-matter";
+import MarkdownIt from "markdown-it";
+import markdownItAnchor from "markdown-it-anchor";
+
+const root = process.cwd();
+const distDir = path.join(root, "dist");
+const assetDir = path.join(root, "site");
+const markdownRoots = ["wiki", "raw", "schema"];
+const basePath = normalizeBasePath(process.env.BASE_PATH || "");
+let runtimePageMap = new Map();
+
+const md = new MarkdownIt({
+ html: true,
+ linkify: false,
+ typographer: true
+}).use(markdownItAnchor, {
+ slugify: slugify,
+ permalink: markdownItAnchor.permalink.headerLink()
+});
+
+const defaultLinkOpen =
+ md.renderer.rules.link_open ??
+ ((tokens, idx, options, env, self) => self.renderToken(tokens, idx, options));
+
+md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
+ const href = tokens[idx].attrGet("href") || "";
+ const resolved = rewriteStandardLink(href, env.page);
+ if (resolved) {
+ tokens[idx].attrSet("href", resolved);
+ }
+
+ const finalHref = tokens[idx].attrGet("href") || "";
+ if (/^https?:\/\//.test(finalHref)) {
+ tokens[idx].attrSet("target", "_blank");
+ tokens[idx].attrSet("rel", "noreferrer");
+ }
+
+ return defaultLinkOpen(tokens, idx, options, env, self);
+};
+
+await buildSite();
+
+async function buildSite() {
+ const markdownFiles = await collectMarkdownFiles();
+ const pages = await Promise.all(markdownFiles.map(loadPage));
+ const basenameCounts = countBasenames(pages);
+ const pageMap = new Map();
+
+ for (const page of pages) {
+ page.aliases = [page.key];
+ if (basenameCounts.get(page.basenameKey) === 1) {
+ page.aliases.push(page.basenameKey);
+ }
+ for (const alias of page.aliases) {
+ pageMap.set(alias, page);
+ }
+ }
+
+ runtimePageMap = pageMap;
+
+ for (const page of pages) {
+ page.outgoing = collectWikilinks(page.body).map((link) => {
+ const target = resolvePage(link.target, page, pageMap);
+ return {
+ label: link.label,
+ rawTarget: link.target,
+ target
+ };
+ });
+ }
+
+ for (const page of pages) {
+ page.backlinks = [];
+ }
+
+ for (const page of pages) {
+ for (const link of page.outgoing) {
+ if (link.target) {
+ link.target.backlinks.push(page);
+ }
+ }
+ }
+
+ await fs.rm(distDir, { recursive: true, force: true });
+ await fs.mkdir(distDir, { recursive: true });
+ await copyAssets();
+
+ const logPage = pageMap.get("wiki/log");
+ const logEntries = parseLogEntries(logPage?.body || "");
+ const wikiPages = pages
+ .filter((page) => page.area === "wiki")
+ .sort(sortPagesByDate);
+ const tagMap = buildTagMap(pages);
+ const searchIndex = buildSearchIndex(pages);
+
+ await fs.writeFile(
+ path.join(distDir, "assets", "search-index.json"),
+ `${JSON.stringify(searchIndex, null, 2)}\n`,
+ "utf8"
+ );
+
+ const siteContext = {
+ basePath,
+ pages,
+ tagMap,
+ wikiPages,
+ logEntries
+ };
+
+ for (const page of pages) {
+ const html = renderMarkdownPage(page, siteContext, pageMap);
+ await writeOutput(page.outputFile, html);
+ }
+
+ await writeOutput(
+ path.join(distDir, "wiki", "index.html"),
+ renderCollectionPage("Wiki", "Knowledge pages, concepts, syntheses, and log entries.", wikiPages, siteContext)
+ );
+ await writeOutput(
+ path.join(distDir, "raw", "index.html"),
+ renderCollectionPage(
+ "Raw Sources",
+ "Source material preserved in the repository before it is compiled into the wiki.",
+ pages.filter((page) => page.area === "raw").sort(sortPagesByDate),
+ siteContext
+ )
+ );
+ await writeOutput(
+ path.join(distDir, "schema", "index.html"),
+ renderCollectionPage(
+ "Schema",
+ "Operating conventions, workflows, and structure definitions for the wiki.",
+ pages.filter((page) => page.area === "schema").sort(sortPagesByDate),
+ siteContext
+ )
+ );
+ await writeOutput(
+ path.join(distDir, "tags", "index.html"),
+ renderTagsIndexPage(siteContext)
+ );
+
+ for (const [tag, taggedPages] of tagMap.entries()) {
+ await writeOutput(
+ path.join(distDir, "tags", slugify(tag), "index.html"),
+ renderTagPage(tag, taggedPages, siteContext)
+ );
+ }
+
+ await writeOutput(
+ path.join(distDir, "404.html"),
+ renderShell({
+ title: "Not Found",
+ description: "The page you requested does not exist in Rara Wiki.",
+ currentNav: "",
+ content: `
+ 404 The site built correctly, but this route does not map to a markdown page yet. ${escapeHtml(page.type)} ${escapeHtml(page.description || "Markdown-native knowledge, compiled into a navigable wiki.")} Collection ${escapeHtml(description)} Catalog A compact view into the concepts, domains, and workflows used across the wiki. Tag ${pages.length} page${pages.length === 1 ? "" : "s"} carry this tag.Page not found
+ ${escapeHtml(page.title)}
+ ${escapeHtml(title)}
+ Tags
+ ${escapeHtml(tag)}
+
No backlinks yet. This page has not been referenced from another wiki page.
' + } +${escapeHtml(page.type)}
+${escapeHtml(page.description || "No summary yet.")}
+No pages matched “${escapeHtml(query)}”.
`; + return; + } + + resultsList.innerHTML = hits + .map( + ({ entry }) => ` + + ${escapeHtml(entry.title)} + ${escapeHtml(entry.type)}${entry.tags.length ? ` · ${escapeHtml(entry.tags.join(", "))}` : ""} +${escapeHtml(snippet(entry, query))}
+ + ` + ) + .join(""); + searchPanel.hidden = false; +}); + +function scoreEntry(entry, query) { + let score = 0; + const haystack = `${entry.title} ${entry.description} ${entry.tags.join(" ")} ${entry.text}`.toLowerCase(); + if (entry.title.toLowerCase().includes(query)) { + score += 4; + } + if (entry.tags.join(" ").toLowerCase().includes(query)) { + score += 3; + } + if (entry.description.toLowerCase().includes(query)) { + score += 2; + } + if (haystack.includes(query)) { + score += 1; + } + return score; +} + +function snippet(entry, query) { + const haystack = entry.text.replace(/\s+/g, " ").trim(); + const index = haystack.toLowerCase().indexOf(query); + if (index === -1) { + return entry.description; + } + const start = Math.max(0, index - 70); + const end = Math.min(haystack.length, index + 110); + return `${start > 0 ? "…" : ""}${haystack.slice(start, end)}${end < haystack.length ? "…" : ""}`; +} + +function escapeHtml(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/tests/wiki.spec.js b/tests/wiki.spec.js new file mode 100644 index 0000000..df036c9 --- /dev/null +++ b/tests/wiki.spec.js @@ -0,0 +1,42 @@ +import { expect, test } from "@playwright/test"; + +test("renders the wiki UI and core interactions", async ({ page }) => { + const searchIndexResponse = page.waitForResponse((response) => response.url().endsWith("/assets/search-index.json")); + + await page.goto("/"); + await searchIndexResponse; + + await expect(page.locator(".site-header .brand")).toBeVisible(); + await expect(page.locator(".site-nav").getByRole("link", { name: "Wiki", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Toggle theme" })).toBeVisible(); + + const searchInput = page.getByLabel("Search"); + await searchInput.fill("codex"); + + const searchResults = page.locator("#search-results"); + await expect(searchResults).toBeVisible(); + + const codexHit = searchResults.getByRole("link", { name: /oh-my-codex \(OMX\)/ }); + await expect(codexHit).toBeVisible(); + await codexHit.click(); + + await expect(page).toHaveURL(/\/wiki\/oh-my-codex\/$/); + await expect(page.locator(".hero h1")).toHaveText("oh-my-codex (OMX)"); + + const rootTheme = page.locator("html"); + await page.getByRole("button", { name: "Toggle theme" }).click(); + await expect(rootTheme).toHaveAttribute("data-theme", "dark"); + + await page.reload(); + await expect(rootTheme).toHaveAttribute("data-theme", "dark"); + + const backlinksPanel = page.locator(".rail-column section").filter({ + has: page.getByRole("heading", { name: "Backlinks", exact: true }) + }); + const wikiIndexBacklink = backlinksPanel.getByRole("link", { name: "Wiki Index", exact: true }); + await expect(wikiIndexBacklink).toBeVisible(); + await wikiIndexBacklink.click(); + + await expect(page).toHaveURL(/\/wiki\/index\/$/); + await expect(page.locator(".hero h1")).toHaveText("Wiki Index"); +});