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

+

Page not found

+

The site built correctly, but this route does not map to a markdown page yet.

+
+ Back to home + Browse wiki pages +
+
+ `, + rail: renderSidebar(siteContext, "") + }) + ); +} + +async function collectMarkdownFiles() { + const files = ["README.md"]; + for (const dir of markdownRoots) { + const dirPath = path.join(root, dir); + try { + files.push(...(await walkMarkdown(dirPath))); + } catch (error) { + if (error.code !== "ENOENT") { + throw error; + } + } + } + return files.map((file) => path.relative(root, file)); +} + +async function walkMarkdown(dir) { + const results = []; + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...(await walkMarkdown(fullPath))); + continue; + } + if (entry.isFile() && entry.name.endsWith(".md")) { + results.push(fullPath); + } + } + return results; +} + +async function loadPage(relativeFile) { + const fullPath = path.join(root, relativeFile); + const raw = await fs.readFile(fullPath, "utf8"); + const parsed = matter(raw); + const key = normalizeKey(relativeFile.replace(/\.md$/i, "")); + const area = relativeFile === "README.md" ? "home" : relativeFile.split(path.sep)[0]; + const basenameKey = path.posix.basename(key); + const title = parsed.data.title || extractTitle(parsed.content) || humanizeTitle(basenameKey); + const description = summarize(parsed.content); + const date = parsed.data.date + ? parsed.data.date instanceof Date + ? parsed.data.date.toISOString().slice(0, 10) + : String(parsed.data.date) + : null; + const tags = normalizeList(parsed.data.tags); + const sources = normalizeList(parsed.data.sources); + const status = parsed.data.status ? String(parsed.data.status) : null; + const outputFile = + relativeFile === "README.md" + ? path.join(distDir, "index.html") + : path.join(distDir, relativeFile.replace(/\.md$/i, ""), "index.html"); + + return { + area, + basenameKey, + body: parsed.content.trim(), + date, + description, + file: relativeFile, + key, + outputFile, + status, + sources, + tags, + title, + type: derivePageType(area, tags, key) + }; +} + +function countBasenames(pages) { + const counts = new Map(); + for (const page of pages) { + counts.set(page.basenameKey, (counts.get(page.basenameKey) || 0) + 1); + } + return counts; +} + +function collectWikilinks(markdown) { + const links = []; + const regex = /\[\[([^[\]]+?)\]\]/g; + let match; + while ((match = regex.exec(markdown)) !== null) { + const [target, label] = match[1].split("|"); + links.push({ + target: target.trim(), + label: (label || target).trim() + }); + } + return links; +} + +function resolvePage(rawTarget, currentPage, pageMap) { + const target = normalizeKey(rawTarget); + const candidates = new Set([target]); + + if (!target.includes("/")) { + if (currentPage.area !== "home") { + candidates.add(normalizeKey(`${currentPage.area}/${target}`)); + } + candidates.add(normalizeKey(`wiki/${target}`)); + candidates.add(normalizeKey(`raw/${target}`)); + candidates.add(normalizeKey(`schema/${target}`)); + } + + for (const candidate of candidates) { + if (pageMap.has(candidate)) { + return pageMap.get(candidate); + } + } + + return null; +} + +function renderMarkdownPage(page, siteContext, pageMap) { + const markdown = injectWikilinks(page.body, page, pageMap); + const contentHtml = md.render(markdown, { page }); + const heroMeta = renderHeroMeta(page); + const rail = renderPageRail(page); + const introPanels = page.area === "home" ? renderHomePanels(siteContext) : ""; + const collections = + page.area === "home" + ? renderCollectionHighlights(siteContext) + : ""; + const sources = renderSourceLinks(page, pageMap); + + return renderShell({ + title: page.title, + description: page.description, + currentNav: page.area, + content: ` +
+

${escapeHtml(page.type)}

+

${escapeHtml(page.title)}

+

${escapeHtml(page.description || "Markdown-native knowledge, compiled into a navigable wiki.")}

+
${heroMeta}
+
+ ${introPanels} + ${collections} +
+
+ ${contentHtml} +
+ ${sources} +
+ `, + rail: renderSidebar(siteContext, page.area) + rail + }); +} + +function renderCollectionPage(title, description, pages, siteContext) { + return renderShell({ + title, + description, + currentNav: title.toLowerCase(), + content: ` +
+

Collection

+

${escapeHtml(title)}

+

${escapeHtml(description)}

+
+
+ ${renderPageCards(groupPages(pages))} +
+ `, + rail: renderSidebar(siteContext, title.toLowerCase()) + }); +} + +function renderTagsIndexPage(siteContext) { + const tags = [...siteContext.tagMap.entries()].sort((a, b) => a[0].localeCompare(b[0])); + return renderShell({ + title: "Tags", + description: "Browse Rara Wiki by tag.", + currentNav: "tags", + content: ` +
+

Catalog

+

Tags

+

A compact view into the concepts, domains, and workflows used across the wiki.

+
+
+ ${tags + .map( + ([tag, pages]) => ` + + ${escapeHtml(tag)} + ${pages.length} + + ` + ) + .join("")} +
+ `, + rail: renderSidebar(siteContext, "tags") + }); +} + +function renderTagPage(tag, pages, siteContext) { + return renderShell({ + title: `Tag: ${tag}`, + description: `Pages tagged ${tag}.`, + currentNav: "tags", + content: ` +
+

Tag

+

${escapeHtml(tag)}

+

${pages.length} page${pages.length === 1 ? "" : "s"} carry this tag.

+
+
+ ${renderFlatCards(pages)} +
+ `, + rail: renderSidebar(siteContext, "tags") + }); +} + +function renderShell({ title, description, currentNav, content, rail }) { + return ` + + + + + ${escapeHtml(title)} | Rara Wiki + + + + + +
+
+ +
+
+ ${content} +
+ +
+ + +`; +} + +function renderNavLink(label, href, active) { + return `${label}`; +} + +function renderSidebar(siteContext, currentNav) { + const latestPages = siteContext.wikiPages.slice(0, 5); + const topTags = [...siteContext.tagMap.entries()] + .sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0])) + .slice(0, 8); + const logEntries = siteContext.logEntries.slice(0, 4); + + return ` +
+

Collections

+ +
+
+

Latest updates

+ +
+
+

Field tags

+
+ ${topTags + .map( + ([tag, pages]) => ` + + ${escapeHtml(tag)} + ${pages.length} + + ` + ) + .join("")} +
+
+
+

Recent pages

+ +
+ `; +} + +function renderPageRail(page) { + const backlinks = page.backlinks + .sort((a, b) => a.title.localeCompare(b.title)) + .map( + (source) => ` +
  • + ${escapeHtml(source.title)} + ${escapeHtml(source.type)} +
  • + ` + ) + .join(""); + + return ` +
    +

    Page metadata

    + +
    +
    +

    Backlinks

    + ${ + backlinks + ? `` + : '

    No backlinks yet. This page has not been referenced from another wiki page.

    ' + } +
    + `; +} + +function renderHeroMeta(page) { + const parts = []; + if (page.date) { + parts.push(`${escapeHtml(page.date)}`); + } + if (page.status) { + parts.push(`${escapeHtml(page.status)}`); + } + if (page.tags.length) { + parts.push( + page.tags + .map((tag) => `${escapeHtml(tag)}`) + .join("") + ); + } + return parts.join(""); +} + +function renderHomePanels(siteContext) { + const totalPages = siteContext.pages.length; + const wikiPages = siteContext.pages.filter((page) => page.area === "wiki").length; + const rawPages = siteContext.pages.filter((page) => page.area === "raw").length; + const schemaPages = siteContext.pages.filter((page) => page.area === "schema").length; + + return ` +
    +
    + Total pages + ${totalPages} +
    +
    + Wiki notes + ${wikiPages} +
    +
    + Raw sources + ${rawPages} +
    +
    + Schema docs + ${schemaPages} +
    +
    + `; +} + +function renderCollectionHighlights(siteContext) { + const conceptPages = siteContext.pages.filter((page) => page.type === "Concept").slice(0, 6); + const schemaPages = siteContext.pages.filter((page) => page.area === "schema").slice(0, 4); + return ` +
    +
    +

    Concept trail

    +
    + ${renderFlatCards(conceptPages)} +
    +
    +
    +

    Operating schema

    +
    + ${renderFlatCards(schemaPages)} +
    +
    +
    + `; +} + +function renderPageCards(groups) { + return groups + .map( + ([group, pages]) => ` +
    +

    ${escapeHtml(group)}

    +
    + ${renderFlatCards(pages)} +
    +
    + ` + ) + .join(""); +} + +function renderFlatCards(pages) { + return pages + .map( + (page) => ` + +

    ${escapeHtml(page.type)}

    +

    ${escapeHtml(page.title)}

    +

    ${escapeHtml(page.description || "No summary yet.")}

    +
    + ${page.tags.slice(0, 4).map((tag) => `${escapeHtml(tag)}`).join("")} +
    +
    + ` + ) + .join(""); +} + +function groupPages(pages) { + const groups = new Map(); + for (const page of pages) { + const bucket = groups.get(page.type) || []; + bucket.push(page); + groups.set(page.type, bucket); + } + return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0])); +} + +function renderSourceLinks(page, pageMap) { + if (!page.sources.length) { + return ""; + } + return ` +
    +

    Sources

    + +
    + `; +} + +function injectWikilinks(markdown, page, pageMap) { + return markdown.replace(/\[\[([^[\]]+?)\]\]/g, (match, inner) => { + const [rawTarget, rawLabel] = inner.split("|"); + const target = resolvePage(rawTarget.trim(), page, pageMap); + const label = (rawLabel || rawTarget).trim(); + + if (!target) { + return `${escapeHtml(label)}`; + } + + return `[${escapeMarkdownLabel(label)}](${pageUrl(target)})`; + }); +} + +function rewriteStandardLink(href, page) { + if (!href || href.startsWith("#") || /^([a-z]+:)?\/\//i.test(href) || href.startsWith("mailto:")) { + return null; + } + + const [targetPart, hash = ""] = href.split("#"); + const rawTarget = targetPart.endsWith(".md") + ? targetPart + : `${targetPart}`; + const currentDir = page.file === "README.md" ? "" : path.posix.dirname(page.file); + const absoluteTarget = normalizeKey( + path.posix.normalize(path.posix.join(currentDir.replaceAll(path.sep, "/"), rawTarget)) + ); + const directTarget = normalizeKey(rawTarget); + const resolved = + directTarget && directTarget !== "." + ? null + : null; + + const lookup = [absoluteTarget, directTarget].find((candidate) => candidate && candidate !== "."); + if (!lookup) { + return null; + } + + const candidatePage = runtimePageMap.get(lookup); + if (!candidatePage) { + return null; + } + + return `${pageUrl(candidatePage)}${hash ? `#${slugify(hash)}` : ""}`; +} + +function buildTagMap(pages) { + const tagMap = new Map(); + for (const page of pages) { + for (const tag of page.tags) { + const bucket = tagMap.get(tag) || []; + bucket.push(page); + tagMap.set(tag, bucket.sort(sortPagesByDate)); + } + } + return tagMap; +} + +function buildSearchIndex(pages) { + return pages.map((page) => ({ + title: page.title, + type: page.type, + url: pageUrl(page), + tags: page.tags, + text: page.body.replace(/\s+/g, " ").trim(), + description: page.description + })); +} + +function parseLogEntries(markdown) { + const entries = []; + const regex = /^##\s+\[(.+?)\]\s+(.+)$/gm; + let match; + while ((match = regex.exec(markdown)) !== null) { + entries.push({ + date: match[1], + title: match[2] + }); + } + return entries; +} + +async function copyAssets() { + await fs.mkdir(path.join(distDir, "assets"), { recursive: true }); + await fs.copyFile(path.join(assetDir, "site.css"), path.join(distDir, "assets", "site.css")); + await fs.copyFile(path.join(assetDir, "site.js"), path.join(distDir, "assets", "site.js")); +} + +async function writeOutput(targetFile, contents) { + await fs.mkdir(path.dirname(targetFile), { recursive: true }); + await fs.writeFile(targetFile, contents, "utf8"); +} + +function derivePageType(area, tags, key) { + if (area === "home") { + return "Overview"; + } + if (area === "raw") { + return "Raw Source"; + } + if (area === "schema") { + return "Schema"; + } + if (key === "wiki/log") { + return "Changelog"; + } + if (tags.includes("entity")) { + return "Entity"; + } + if (tags.includes("synthesis")) { + return "Synthesis"; + } + if (tags.includes("source")) { + return "Source"; + } + if (tags.includes("concept")) { + return "Concept"; + } + return "Knowledge"; +} + +function pageUrl(page) { + if (page.area === "home") { + return withBasePath("/"); + } + return withBasePath(`/${page.file.replace(/\\/g, "/").replace(/\.md$/i, "/")}`); +} + +function normalizeKey(value) { + return value + .replace(/\\/g, "/") + .replace(/^\.\//, "") + .replace(/^\//, "") + .replace(/\.md$/i, "") + .replace(/\/$/, "") + .toLowerCase(); +} + +function normalizeList(value) { + if (!value) { + return []; + } + return Array.isArray(value) ? value.map(String) : [String(value)]; +} + +function normalizeBasePath(value) { + if (!value || value === "/") { + return ""; + } + return `/${value.replace(/^\/+|\/+$/g, "")}`; +} + +function withBasePath(relativePath) { + const normalized = relativePath.startsWith("/") ? relativePath : `/${relativePath}`; + if (!basePath) { + return normalized; + } + return `${basePath}${normalized === "/" ? "" : normalized}`; +} + +function extractTitle(markdown) { + const match = markdown.match(/^#\s+(.+)$/m); + return match ? match[1].trim() : ""; +} + +function summarize(markdown) { + const cleaned = markdown + .replace(/^---[\s\S]*?---/m, "") + .replace(/[#>*`\[\]\-]/g, " ") + .replace(/\s+/g, " ") + .trim(); + return cleaned.slice(0, 180); +} + +function humanizeTitle(value) { + return value + .replace(/[-_]/g, " ") + .replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +function slugify(value) { + return value + .toString() + .toLowerCase() + .trim() + .replace(/['"]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function escapeHtml(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function escapeMarkdownLabel(value) { + return value.replace(/([\[\]])/g, "\\$1"); +} + +function sortPagesByDate(a, b) { + const left = a.date || ""; + const right = b.date || ""; + if (left !== right) { + return right.localeCompare(left); + } + return a.title.localeCompare(b.title); +} diff --git a/scripts/preview.mjs b/scripts/preview.mjs new file mode 100644 index 0000000..b9f06ae --- /dev/null +++ b/scripts/preview.mjs @@ -0,0 +1,78 @@ +import http from "node:http"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { spawn } from "node:child_process"; + +const root = process.cwd(); +const distDir = path.join(root, "dist"); +const port = Number(process.env.PORT || "4173"); +const host = process.env.HOST || "127.0.0.1"; + +await runBuild(); + +const server = http.createServer(async (request, response) => { + const url = new URL(request.url, `http://127.0.0.1:${port}`); + let target = path.join(distDir, decodeURIComponent(url.pathname)); + + try { + const stats = await fs.stat(target); + if (stats.isDirectory()) { + target = path.join(target, "index.html"); + } + } catch { + if (!path.extname(target)) { + target = path.join(target, "index.html"); + } + } + + try { + const contents = await fs.readFile(target); + response.writeHead(200, { + "content-type": contentType(target) + }); + response.end(contents); + } catch { + const notFound = await fs.readFile(path.join(distDir, "404.html")); + response.writeHead(404, { + "content-type": "text/html; charset=utf-8" + }); + response.end(notFound); + } +}); + +server.listen(port, host, () => { + process.stdout.write(`Previewing rara-wiki at http://${host}:${port}\n`); +}); + +async function runBuild() { + await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [path.join("scripts", "build.mjs")], { + cwd: root, + stdio: "inherit" + }); + + child.on("exit", (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(`build failed with exit code ${code}`)); + }); + }); +} + +function contentType(file) { + if (file.endsWith(".css")) { + return "text/css; charset=utf-8"; + } + if (file.endsWith(".js")) { + return "text/javascript; charset=utf-8"; + } + if (file.endsWith(".json")) { + return "application/json; charset=utf-8"; + } + if (file.endsWith(".svg")) { + return "image/svg+xml"; + } + return "text/html; charset=utf-8"; +} diff --git a/site/site.css b/site/site.css new file mode 100644 index 0000000..1580739 --- /dev/null +++ b/site/site.css @@ -0,0 +1,501 @@ +:root { + color-scheme: light; + --bg: #f5efe4; + --bg-strong: #f0e6d6; + --surface: rgba(255, 251, 244, 0.76); + --surface-strong: rgba(255, 248, 238, 0.92); + --border: rgba(77, 49, 25, 0.12); + --text: #2c1c10; + --muted: #6f5746; + --accent: #b1512d; + --accent-strong: #7f2c1f; + --shadow: 0 24px 60px rgba(66, 39, 20, 0.12); + --radius: 24px; + --font-sans: "Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif; + --font-serif: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif; + --font-mono: "IBM Plex Mono", "SFMono-Regular", "Menlo", monospace; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #161211; + --bg-strong: #231a18; + --surface: rgba(34, 27, 24, 0.88); + --surface-strong: rgba(41, 32, 29, 0.94); + --border: rgba(237, 207, 181, 0.12); + --text: #f8eedf; + --muted: #c5b39d; + --accent: #f18f55; + --accent-strong: #ffc299; + --shadow: 0 24px 60px rgba(0, 0, 0, 0.28); +} + +* { + box-sizing: border-box; +} + +html { + background: var(--bg); +} + +body { + margin: 0; + min-height: 100vh; + color: var(--text); + background: + radial-gradient(circle at top left, rgba(231, 148, 91, 0.16), transparent 28rem), + radial-gradient(circle at top right, rgba(103, 140, 115, 0.12), transparent 30rem), + linear-gradient(180deg, var(--bg), var(--bg-strong)); + font-family: var(--font-sans); + line-height: 1.65; +} + +.canvas { + position: fixed; + inset: auto; + pointer-events: none; + filter: blur(8px); +} + +.canvas-one { + inset: 6rem auto auto 6vw; + width: 22rem; + height: 22rem; + border-radius: 999px; + background: rgba(232, 155, 102, 0.18); +} + +.canvas-two { + inset: auto 8vw 5rem auto; + width: 18rem; + height: 18rem; + border-radius: 28% 72% 72% 28%; + background: rgba(73, 112, 92, 0.14); +} + +a { + color: inherit; + text-decoration-color: rgba(177, 81, 45, 0.36); + text-underline-offset: 0.18em; +} + +a:hover { + text-decoration-color: currentColor; +} + +.site-header { + position: sticky; + top: 0; + z-index: 10; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1.25rem 4vw; + backdrop-filter: blur(16px); + background: rgba(245, 239, 228, 0.72); + border-bottom: 1px solid var(--border); +} + +:root[data-theme="dark"] .site-header { + background: rgba(22, 18, 17, 0.72); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 0.9rem; + text-decoration: none; +} + +.brand strong, +.hero h1, +.page-card h3, +.panel h2, +.collection-section h2, +.prose h1, +.prose h2, +.prose h3 { + font-family: var(--font-serif); + letter-spacing: -0.02em; +} + +.brand small { + display: block; + color: var(--muted); +} + +.brand-mark { + display: grid; + place-items: center; + width: 3rem; + height: 3rem; + border-radius: 1rem; + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + color: #fff7f0; + font-weight: 700; + box-shadow: var(--shadow); +} + +.site-nav, +.header-tools { + display: flex; + align-items: center; + gap: 0.8rem; +} + +.site-nav a { + padding: 0.55rem 0.85rem; + border-radius: 999px; + text-decoration: none; + color: var(--muted); +} + +.site-nav a[aria-current="page"] { + color: var(--text); + background: rgba(177, 81, 45, 0.12); +} + +.search-field { + display: inline-flex; + align-items: center; + gap: 0.7rem; + padding: 0.55rem 0.85rem; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface-strong); +} + +.search-field span { + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--muted); +} + +.search-field input { + min-width: 16rem; + border: 0; + outline: none; + background: transparent; + color: var(--text); + font: inherit; +} + +.theme-toggle, +.button { + padding: 0.7rem 1rem; + border: 1px solid transparent; + border-radius: 999px; + font: inherit; + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + color: #fff6ef; + cursor: pointer; + text-decoration: none; +} + +.button.ghost, +.theme-toggle { + background: var(--surface-strong); + border-color: var(--border); + color: var(--text); +} + +.site-layout { + display: grid; + grid-template-columns: minmax(0, 1.75fr) minmax(18rem, 0.95fr); + gap: 1.5rem; + padding: 2rem 4vw 4rem; +} + +.content-column, +.rail-column { + display: grid; + gap: 1.5rem; + align-content: start; +} + +.hero, +.panel, +.page-frame, +.page-card { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow); +} + +.hero { + padding: clamp(1.5rem, 4vw, 3rem); +} + +.hero h1 { + margin: 0.3rem 0 0.75rem; + font-size: clamp(2.4rem, 5vw, 4.4rem); + line-height: 0.94; +} + +.hero-copy { + max-width: 52rem; + font-size: 1.05rem; + color: var(--muted); +} + +.hero-meta, +.hero-actions, +.chip-row, +.tag-row, +.summary-grid { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.hero-meta span, +.hero-meta a, +.chip-row span, +.tag-pill { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.45rem 0.7rem; + border-radius: 999px; + background: rgba(177, 81, 45, 0.08); + text-decoration: none; +} + +.eyebrow { + margin: 0; + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--accent); +} + +.summary-grid, +.split-panels, +.collection-grid, +.card-grid { + display: grid; + gap: 1rem; +} + +.summary-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.split-panels { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.card-grid, +.compact-grid { + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); +} + +.panel, +.page-frame { + padding: 1.25rem; +} + +.page-frame { + display: grid; + gap: 1.25rem; +} + +.collection-section { + display: grid; + gap: 1rem; +} + +.page-card { + display: grid; + gap: 0.6rem; + padding: 1.1rem; + color: inherit; + text-decoration: none; + transition: transform 160ms ease, border-color 160ms ease; +} + +.page-card:hover { + transform: translateY(-3px); + border-color: rgba(177, 81, 45, 0.34); +} + +.page-card h3, +.panel h2, +.collection-section h2 { + margin: 0; +} + +.page-card p { + margin: 0; + color: var(--muted); +} + +.stat-card { + min-height: 8rem; +} + +.stat-card span { + color: var(--muted); +} + +.stat-card strong { + font-size: clamp(2rem, 5vw, 3rem); + line-height: 1; +} + +.list-plain { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 0.7rem; +} + +.list-plain li { + display: flex; + justify-content: space-between; + gap: 0.8rem; + align-items: baseline; +} + +.compact li { + font-size: 0.94rem; +} + +.compact span, +.muted { + color: var(--muted); +} + +.source-panel { + padding-top: 0; +} + +.prose { + max-width: 75ch; +} + +.prose h1, +.prose h2, +.prose h3, +.prose h4 { + scroll-margin-top: 6rem; +} + +.prose p, +.prose ul, +.prose ol, +.prose blockquote { + color: var(--text); +} + +.prose code, +.prose pre { + font-family: var(--font-mono); +} + +.prose :not(pre) > code { + padding: 0.15rem 0.35rem; + border-radius: 0.4rem; + background: rgba(177, 81, 45, 0.08); +} + +.prose pre { + overflow-x: auto; + padding: 1rem; + border-radius: 1rem; + background: rgba(39, 24, 15, 0.92); + color: #f9efe4; +} + +.prose blockquote { + margin-left: 0; + padding-left: 1rem; + border-left: 3px solid rgba(177, 81, 45, 0.4); +} + +.prose table { + width: 100%; + border-collapse: collapse; +} + +.prose th, +.prose td { + padding: 0.75rem; + border-bottom: 1px solid var(--border); + text-align: left; +} + +.broken-link { + color: var(--accent); + font-style: italic; +} + +.search-results-list { + display: grid; + gap: 0.8rem; +} + +.search-hit { + display: grid; + gap: 0.35rem; + padding: 0.9rem; + border-radius: 1rem; + background: rgba(177, 81, 45, 0.07); + text-decoration: none; +} + +.search-hit p, +.search-hit small { + margin: 0; + color: var(--muted); +} + +@media (max-width: 1100px) { + .site-layout, + .split-panels { + grid-template-columns: 1fr; + } + + .summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .site-header { + padding: 1rem 1rem 1.25rem; + } + + .site-nav, + .header-tools, + .search-field { + width: 100%; + } + + .site-nav { + order: 3; + overflow-x: auto; + } + + .search-field input { + min-width: 0; + width: 100%; + } + + .site-layout { + padding: 1rem 1rem 3rem; + } + + .summary-grid { + grid-template-columns: 1fr; + } + + .hero h1 { + font-size: 2.4rem; + } +} diff --git a/site/site.js b/site/site.js new file mode 100644 index 0000000..b5aab91 --- /dev/null +++ b/site/site.js @@ -0,0 +1,104 @@ +const root = document.documentElement; +const body = document.body; +const themeToggle = document.querySelector("#theme-toggle"); +const searchInput = document.querySelector("#search-input"); +const searchPanel = document.querySelector("#search-results"); +const resultsList = document.querySelector(".search-results-list"); + +const savedTheme = localStorage.getItem("rara-wiki-theme"); +if (savedTheme) { + root.dataset.theme = savedTheme; +} + +themeToggle?.addEventListener("click", () => { + const nextTheme = root.dataset.theme === "dark" ? "light" : "dark"; + root.dataset.theme = nextTheme; + localStorage.setItem("rara-wiki-theme", nextTheme); +}); + +let searchIndex = []; +const basePath = body.dataset.basePath || ""; + +fetch(`${basePath}/assets/search-index.json`) + .then((response) => response.json()) + .then((data) => { + searchIndex = data; + }) + .catch(() => { + searchIndex = []; + }); + +searchInput?.addEventListener("input", () => { + const query = searchInput.value.trim().toLowerCase(); + if (!query) { + searchPanel.hidden = true; + resultsList.innerHTML = ""; + return; + } + + const hits = searchIndex + .map((entry) => ({ + entry, + score: scoreEntry(entry, query) + })) + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 8); + + if (!hits.length) { + searchPanel.hidden = false; + resultsList.innerHTML = `

    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"); +});