Knowledge Layer Loader (SP4) — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Render the 378 llm-wiki articles (.wiki/wiki/ topics/concepts/references) as static /knowledge/<category>/<slug> pages on the Astro reader, with the projector's link/path logic ported to TypeScript so URLs match the existing Wiki.js projection.
Architecture: Pure TS modules in apps/reader/src/lib/knowledge/ port dev/wikijs/scripts/project-llm-wiki.exs (map_path, slug index + dedupe, rewrite_links). A knowledgeLoader walks .wiki/wiki/{topics,concepts,references}, parses frontmatter (gray-matter), builds a global slug index, renders markdown→HTML (markdown-it + KaTeX) with links rewritten, and emits one knowledge collection entry per article. Static routes render landing, per-category index, and per-article pages. Mirrors the SP3 src/lib/dictionary + dictionaryLoader patterns.
Tech Stack: Astro 5 · TypeScript · Vitest · markdown-it (installed) · gray-matter (new) · @vscode/markdown-it-katex + katex (new) · Pagefind.
Spec: docs/superpowers/specs/2026-07-06-knowledge-layer-loader-design.md.
File structure
New (apps/reader):
knowledge.config.ts—.wiki/root path, category list, graph URL.src/lib/knowledge/types.ts—KnowledgeArticle,SlugIndex.src/lib/knowledge/mapPath.ts(+.test.ts) — rel →knowledge/<cat>/<slug>.src/lib/knowledge/slugIndex.ts(+.test.ts) — global slug→path map + collision dedupe.src/lib/knowledge/citeSource.ts(+.test.ts) — book/source ref →/read/<slug>or text.src/lib/knowledge/rewriteLinks.ts(+.test.ts) —[[wikilinks]]+ relative.md.src/lib/knowledge/renderArticle.ts(+.test.ts) — markdown+KaTeX → rewrite.src/loaders/knowledgeLoader.ts— content-layer loader over.wiki/wiki/.src/pages/knowledge/index.astro— landing.src/pages/knowledge/[category]/index.astro— category index.src/pages/knowledge/[category]/[slug].astro— article.public/katex/katex.min.css+public/katex/fonts/**— self-hosted KaTeX assets.
Modified (apps/reader):
package.json— addgray-matter,@vscode/markdown-it-katex,katex.src/content.config.ts— add theknowledgecollection.src/layouts/SiteLayout.astro— link the KaTeX CSS.
Config:
WIKI_DIR— path to.wiki(default resolves to buckyverse repo.wiki).GRAPH_URL—https://graph.buckyverse.org.
Task 1: Path mapping (mapPath)
Files: create apps/reader/src/lib/knowledge/mapPath.ts, mapPath.test.ts.
- Step 1: Write the failing test (ports the projector's map_path cases)
// apps/reader/src/lib/knowledge/mapPath.test.ts
import { describe, it, expect } from 'vitest';
import { mapPath } from './mapPath';
describe('mapPath', () => {
it('flattens wiki/<flatten-subsection> to knowledge/<subsection>', () => {
expect(mapPath('wiki/topics/thomas-edison.md')).toBe('knowledge/topics/thomas-edison');
expect(mapPath('wiki/concepts/tensegrity.md')).toBe('knowledge/concepts/tensegrity');
expect(mapPath('wiki/references/foo.md')).toBe('knowledge/references/foo');
});
it('preserves non-flatten paths under the namespace', () => {
expect(mapPath('raw/articles/foo.md')).toBe('knowledge/raw/articles/foo');
expect(mapPath('inventory/items/i.md')).toBe('knowledge/inventory/items/i');
});
it('renames _index to index', () => {
expect(mapPath('wiki/topics/_index.md')).toBe('knowledge/topics/index');
expect(mapPath('_index.md')).toBe('knowledge/index');
});
it('keeps wiki segment for the compiled-layer index (no root collision)', () => {
expect(mapPath('wiki/_index.md')).toBe('knowledge/wiki/index');
});
it('handles top-level files', () => {
expect(mapPath('config.md')).toBe('knowledge/config');
});
});
-
Step 2: Run, verify FAIL:
cd apps/reader && pnpm vitest run src/lib/knowledge/mapPath.test.ts -
Step 3: Implement
mapPath.ts
// apps/reader/src/lib/knowledge/mapPath.ts
// Ports project-llm-wiki.exs map_path/flatten_wiki/index_rename.
export const NAMESPACE = 'knowledge';
const FLATTEN = new Set(['topics', 'concepts', 'references', 'theses']);
function flattenWiki(segs: string[]): string[] {
if (segs.length >= 2 && segs[0] === 'wiki' && FLATTEN.has(segs[1])) return segs.slice(1);
return segs;
}
function indexRename(segs: string[]): string[] {
if (segs.length && segs[segs.length - 1] === '_index') {
return [...segs.slice(0, -1), 'index'];
}
return segs;
}
/** Source rel path (relative to .wiki/) → "knowledge/<...>" wiki path (no leading slash). */
export function mapPath(rel: string): string {
const segs = indexRename(flattenWiki(rel.replace(/\.md$/, '').split('/')));
return [NAMESPACE, ...segs].join('/');
}
/** The site URL for a source rel path. */
export function knowledgeUrl(rel: string): string {
return '/' + mapPath(rel);
}
-
Step 4: Run, verify PASS (5 tests).
-
Step 5: Commit
git add apps/reader/src/lib/knowledge/mapPath.ts apps/reader/src/lib/knowledge/mapPath.test.ts
git commit -m "feat(knowledge): port map_path to TS"
Task 2: Types + slug index
Files: create apps/reader/src/lib/knowledge/types.ts, slugIndex.ts, slugIndex.test.ts.
- Step 1: Write the types
// apps/reader/src/lib/knowledge/types.ts
export interface KnowledgeArticle {
slug: string; // filename without .md, e.g. 'synergetics'
category: string; // 'topics' | 'concepts' | 'references'
rel: string; // path relative to .wiki, e.g. 'wiki/topics/synergetics.md'
path: string; // site url, e.g. '/knowledge/topics/synergetics'
title: string;
summary: string;
tags: string[];
aliases: string[];
updated?: string;
confidence?: string;
volatility?: string;
html: string;
}
// slug -> site url ('/knowledge/<cat>/<slug>')
export type SlugIndex = Map<string, string>;
- Step 2: Write the failing test
// apps/reader/src/lib/knowledge/slugIndex.test.ts
import { describe, it, expect, vi } from 'vitest';
import { buildSlugIndex } from './slugIndex';
describe('buildSlugIndex', () => {
it('maps each slug to its knowledge url', () => {
const idx = buildSlugIndex([
{ slug: 'synergetics', rel: 'wiki/topics/synergetics.md' },
{ slug: 'tensegrity', rel: 'wiki/concepts/tensegrity.md' },
]);
expect(idx.get('synergetics')).toBe('/knowledge/topics/synergetics');
expect(idx.get('tensegrity')).toBe('/knowledge/concepts/tensegrity');
});
it('on slug collision keeps the shallower rel and warns', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const idx = buildSlugIndex([
{ slug: 'x', rel: 'wiki/topics/sub/x.md' },
{ slug: 'x', rel: 'wiki/topics/x.md' },
]);
expect(idx.get('x')).toBe('/knowledge/topics/x'); // shallower wins
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});
-
Step 3: Run, verify FAIL.
-
Step 4: Implement
slugIndex.ts
// apps/reader/src/lib/knowledge/slugIndex.ts
import { knowledgeUrl } from './mapPath';
import type { SlugIndex } from './types';
/** Build slug -> url. On collision keep the shallower rel (fewer segments), warn. */
export function buildSlugIndex(items: Array<{ slug: string; rel: string }>): SlugIndex {
const depth = (rel: string) => rel.split('/').length;
const bySlug = new Map<string, { rel: string; url: string }>();
for (const it of items) {
const url = knowledgeUrl(it.rel);
const prev = bySlug.get(it.slug);
if (!prev) {
bySlug.set(it.slug, { rel: it.rel, url });
} else if (depth(it.rel) < depth(prev.rel)) {
console.warn(`WARN slug collision: ${prev.rel} kept ${it.rel}`);
bySlug.set(it.slug, { rel: it.rel, url });
} else {
console.warn(`WARN slug collision: ${it.rel} kept ${prev.rel}`);
}
}
return new Map([...bySlug].map(([slug, v]) => [slug, v.url]));
}
-
Step 5: Run, verify PASS.
-
Step 6: Commit
git add apps/reader/src/lib/knowledge/types.ts apps/reader/src/lib/knowledge/slugIndex.ts apps/reader/src/lib/knowledge/slugIndex.test.ts
git commit -m "feat(knowledge): article types + slug index with collision dedupe"
Task 3: Cite a source (citeSource)
Relative .md links that escape .wiki/ point at a repo book/source dir (e.g. ../../../grunch_of_giants/grunch_of_giants.md). Map to /read/<slug> when that slug is a hosted book, else return plain text.
Files: create apps/reader/src/lib/knowledge/citeSource.ts, citeSource.test.ts.
- Step 1: Write the failing test
// apps/reader/src/lib/knowledge/citeSource.test.ts
import { describe, it, expect } from 'vitest';
import { citeSource } from './citeSource';
const HOSTED = new Set(['grunch_of_giants', 'synergetics']);
describe('citeSource', () => {
it('links a hosted book source to /read/<slug>', () => {
// resolvedRepoPath is repo-root-relative (outside .wiki)
expect(citeSource('grunch_of_giants/grunch_of_giants.md', HOSTED)).toEqual({
href: '/read/grunch_of_giants',
});
});
it('returns text (no href) for an unhosted source', () => {
expect(citeSource('some_unhosted_book/index.md', HOSTED)).toEqual({ href: null });
});
});
-
Step 2: Run, verify FAIL.
-
Step 3: Implement
citeSource.ts
// apps/reader/src/lib/knowledge/citeSource.ts
/** A repo-root-relative source path -> a /read link when the first segment is a
* hosted book slug, else no href (caller renders plain text). */
export function citeSource(repoRelPath: string, hostedSlugs: Set<string>): { href: string | null } {
const slug = repoRelPath.replace(/^\/+/, '').split('/')[0] ?? '';
return { href: slug && hostedSlugs.has(slug) ? `/read/${slug}` : null };
}
-
Step 4: Run, verify PASS.
-
Step 5: Commit
git add apps/reader/src/lib/knowledge/citeSource.ts apps/reader/src/lib/knowledge/citeSource.test.ts
git commit -m "feat(knowledge): cite hosted-book sources to /read"
Task 4: Link rewriting (rewriteLinks)
Ports rewrite_links. Operates on markdown (before markdown-it), like the projector: [[slug|label]]/[[slug]] via the slug index; relative ](x.md) resolved against the article's dir and branched by where it lands.
Files: create apps/reader/src/lib/knowledge/rewriteLinks.ts, rewriteLinks.test.ts.
- Step 1: Write the failing test
// apps/reader/src/lib/knowledge/rewriteLinks.test.ts
import { describe, it, expect } from 'vitest';
import { rewriteLinks } from './rewriteLinks';
const index = new Map<string, string>([
['synergetics', '/knowledge/topics/synergetics'],
['tensegrity', '/knowledge/concepts/tensegrity'],
]);
const hosted = new Set(['grunch_of_giants']);
// current article rel (relative to .wiki): wiki/topics/foo.md
const REL = 'wiki/topics/foo.md';
const run = (md: string) => rewriteLinks(md, REL, index, hosted);
describe('rewriteLinks', () => {
it('resolves a labeled wikilink via the index', () => {
expect(run('see [[synergetics|Synergetics]] now')).toBe('see [Synergetics](/knowledge/topics/synergetics) now');
});
it('resolves a bare wikilink', () => {
expect(run('[[tensegrity]]')).toBe('[tensegrity](/knowledge/concepts/tensegrity)');
});
it('degrades an unknown wikilink to plain text', () => {
expect(run('[[ghost|Ghost]] and [[nope]]')).toBe('Ghost and nope');
});
it('rewrites a relative link inside wiki/ to /knowledge', () => {
expect(run('a [x](../concepts/tensegrity.md) b')).toBe('a [x](/knowledge/concepts/tensegrity) b');
});
it('strips a link into raw/ provenance to text', () => {
expect(run('a [src](../../raw/repos/synergetics.md) b')).toBe('a src b');
});
it('links a source that escapes into a hosted book dir to /read', () => {
expect(run('a [g](../../../grunch_of_giants/grunch_of_giants.md) b')).toBe('a [g](/read/grunch_of_giants) b');
});
it('leaves absolute/http links untouched', () => {
expect(run('[u](https://x.com) [v](/read/y)')).toBe('[u](https://x.com) [v](/read/y)');
});
});
-
Step 2: Run, verify FAIL.
-
Step 3: Implement
rewriteLinks.ts
// apps/reader/src/lib/knowledge/rewriteLinks.ts
import path from 'node:path';
import { mapPath } from './mapPath';
import { citeSource } from './citeSource';
import type { SlugIndex } from './types';
const WIKILINK_LABELED = /\[\[([a-z0-9\-_]+)\|([^\]]+)\]\]/g;
const WIKILINK_BARE = /\[\[([a-z0-9\-_]+)\]\]/g;
const RELMD = /\]\(([^)\s]+\.md)\)/g;
const FLATTEN_CATS = /^wiki\/(topics|concepts|references|theses)\//;
/** Rewrite [[wikilinks]] and relative .md links in an article's markdown.
* @param rel article path relative to .wiki (e.g. 'wiki/topics/foo.md') */
export function rewriteLinks(md: string, rel: string, index: SlugIndex, hosted: Set<string>): string {
let out = md.replace(WIKILINK_LABELED, (_m, slug, label) => linkOrText(index.get(slug), label));
out = out.replace(WIKILINK_BARE, (_m, slug) => linkOrText(index.get(slug), slug));
out = out.replace(RELMD, (full, target: string) => {
if (/^(https?:)?\/\//.test(target) || target.startsWith('/')) return full;
return '](' + resolveRel(target, rel, hosted) + ')';
});
// resolveRel may return a bare text (no href) for stripped links; handle that by
// converting the whole [text](stripped) into text — done in a second pass:
return out.replace(/\[([^\]]*)\]\( STRIP \)/g, '$1');
}
function linkOrText(url: string | undefined, text: string): string {
return url ? `[${text}](${url})` : text;
}
function resolveRel(target: string, rel: string, hosted: Set<string>): string {
// Resolve relative to the article's dir, as a POSIX path rooted at .wiki.
const baseDir = path.posix.dirname(rel); // e.g. 'wiki/topics'
const resolved = path.posix.normalize(path.posix.join(baseDir, target)); // may start with '../'
if (resolved.startsWith('../')) {
// Escapes .wiki -> repo book/source dir.
const repoRel = resolved.replace(/^(\.\.\/)+/, '');
const { href } = citeSource(repoRel, hosted);
return href ?? ' STRIP ';
}
if (resolved.startsWith('raw/') || resolved.startsWith('inventory/') || resolved.startsWith('datasets/')) {
return ' STRIP '; // provenance/tracking not rendered
}
if (FLATTEN_CATS.test(resolved)) {
return '/' + mapPath(resolved); // rendered knowledge page
}
return ' STRIP '; // other wiki/* not rendered as a knowledge article
}
Note the STRIP sentinel: a relative link with no valid target is marked, then the whole [text](sentinel) is replaced by just text in the final pass so no broken link remains.
-
Step 4: Run, verify PASS (7 tests). If the STRIP second-pass regex needs adjusting to satisfy the raw/-strip test, fix minimally.
-
Step 5: Commit
git add apps/reader/src/lib/knowledge/rewriteLinks.ts apps/reader/src/lib/knowledge/rewriteLinks.test.ts
git commit -m "feat(knowledge): port link rewriting (wikilinks + relative md)"
Task 5: Render an article (markdown + KaTeX + link rewrite)
Files: modify apps/reader/package.json; create apps/reader/src/lib/knowledge/renderArticle.ts, renderArticle.test.ts.
- Step 1: Add deps
Run: cd apps/reader && pnpm add gray-matter @vscode/markdown-it-katex katex
- Step 2: Write the failing test
// apps/reader/src/lib/knowledge/renderArticle.test.ts
import { describe, it, expect } from 'vitest';
import { renderArticle } from './renderArticle';
const index = new Map<string, string>([['synergetics', '/knowledge/topics/synergetics']]);
const hosted = new Set<string>();
describe('renderArticle', () => {
it('renders markdown, resolves wikilinks, and renders KaTeX', () => {
const md = '# T\n\nsee [[synergetics|Synergetics]] and $x^2$';
const html = renderArticle(md, 'wiki/topics/foo.md', index, hosted);
expect(html).toContain('href="/knowledge/topics/synergetics"');
expect(html).toContain('class="katex"');
});
});
-
Step 3: Run, verify FAIL.
-
Step 4: Implement
renderArticle.ts
// apps/reader/src/lib/knowledge/renderArticle.ts
import MarkdownIt from 'markdown-it';
import katex from '@vscode/markdown-it-katex';
import { rewriteLinks } from './rewriteLinks';
import type { SlugIndex } from './types';
const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
// @vscode/markdown-it-katex default export is the plugin function.
md.use((katex as unknown as (m: MarkdownIt) => void));
/** Article markdown -> HTML. Links rewritten first (on markdown), then KaTeX + md. */
export function renderArticle(markdown: string, rel: string, index: SlugIndex, hosted: Set<string>): string {
return md.render(rewriteLinks(markdown, rel, index, hosted));
}
-
Step 5: Run, verify PASS. If the KaTeX plugin import shape differs (some builds export
{ default }), adjust the import toimport katex from '@vscode/markdown-it-katex'vsimport { default as katex }somd.usereceives the plugin function; the test (class="katex") is the check. -
Step 6: Commit
git add apps/reader/package.json ../../pnpm-lock.yaml apps/reader/src/lib/knowledge/renderArticle.ts apps/reader/src/lib/knowledge/renderArticle.test.ts
git commit -m "feat(knowledge): render article markdown with KaTeX + rewritten links"
Task 6: The knowledge loader + collection
Files: create apps/reader/knowledge.config.ts, apps/reader/src/loaders/knowledgeLoader.ts, knowledgeLoader.test.ts; modify apps/reader/src/content.config.ts.
- Step 1: Config
// apps/reader/knowledge.config.ts
import path from 'node:path';
export const WIKI_DIR = path.resolve(process.cwd(), '../../.wiki');
export const CATEGORIES = ['topics', 'concepts', 'references'] as const;
export const GRAPH_URL = 'https://graph.buckyverse.org';
- Step 2: Write the failing test for the pure entry-builder
// apps/reader/src/loaders/knowledgeLoader.test.ts
import { describe, it, expect } from 'vitest';
import { buildArticle } from './knowledgeLoader';
const index = new Map<string, string>([['synergetics', '/knowledge/topics/synergetics']]);
const hosted = new Set<string>();
describe('buildArticle', () => {
it('parses frontmatter + renders body into a KnowledgeArticle', () => {
const raw = [
'---',
'title: "Synergetics"',
'category: topic',
'summary: "S"',
'tags: [geometry, design-science]',
'updated: 2026-06-27',
'confidence: high',
'volatility: cold',
'---',
'# Synergetics',
'',
'body with [[synergetics|self]]',
].join('\n');
const a = buildArticle(raw, 'wiki/topics/synergetics.md', index, hosted);
expect(a.slug).toBe('synergetics');
expect(a.category).toBe('topics');
expect(a.path).toBe('/knowledge/topics/synergetics');
expect(a.title).toBe('Synergetics');
expect(a.summary).toBe('S');
expect(a.tags).toEqual(['geometry', 'design-science']);
expect(a.confidence).toBe('high');
expect(a.html).toContain('href="/knowledge/topics/synergetics"');
});
});
-
Step 3: Run, verify FAIL.
-
Step 4: Implement
knowledgeLoader.ts
// apps/reader/src/loaders/knowledgeLoader.ts
import { readFile } from 'node:fs/promises';
import { glob } from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';
import type { Loader } from 'astro/loaders';
import { BOOKS } from '../../books.config';
import { WIKI_DIR, CATEGORIES } from '../../knowledge.config';
import { buildSlugIndex } from '../lib/knowledge/slugIndex';
import { renderArticle } from '../lib/knowledge/renderArticle';
import { mapPath } from '../lib/knowledge/mapPath';
import type { KnowledgeArticle, SlugIndex } from '../lib/knowledge/types';
const HOSTED = new Set(BOOKS.map((b) => b.slug));
function firstString(...xs: unknown[]): string {
for (const x of xs) if (typeof x === 'string' && x.trim()) return x.trim();
return '';
}
function asArray(x: unknown): string[] {
return Array.isArray(x) ? x.map(String) : [];
}
/** Pure: one raw .md + its rel (relative to .wiki) -> a KnowledgeArticle. */
export function buildArticle(raw: string, rel: string, index: SlugIndex, hosted: Set<string>): KnowledgeArticle {
const { data, content } = matter(raw);
const slug = path.basename(rel).replace(/\.md$/, '');
const category = rel.split('/')[1] ?? ''; // wiki/<category>/...
return {
slug,
category,
rel,
path: '/' + mapPath(rel),
title: firstString(data.title, humanize(slug)),
summary: firstString(data.summary, data.description),
tags: asArray(data.tags),
aliases: asArray(data.aliases),
updated: typeof data.updated === 'string' ? data.updated : undefined,
confidence: typeof data.confidence === 'string' ? data.confidence : undefined,
volatility: typeof data.volatility === 'string' ? data.volatility : undefined,
html: renderArticle(content, rel, index, hosted),
};
}
function humanize(slug: string): string {
return slug.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
export function knowledgeLoader(): Loader {
return {
name: 'buckyverse-knowledge-loader',
load: async ({ store, logger }) => {
store.clear();
// Discover all article rels (relative to .wiki), excluding _index.md.
const rels: string[] = [];
for (const cat of CATEGORIES) {
for await (const p of glob(`wiki/${cat}/**/*.md`, { cwd: WIKI_DIR })) {
if (!p.endsWith('_index.md')) rels.push(p);
}
}
rels.sort();
// First pass: slug index over all articles.
const index = buildSlugIndex(
rels.map((rel) => ({ slug: path.basename(rel).replace(/\.md$/, ''), rel })),
);
// Second pass: render + store.
for (const rel of rels) {
const raw = await readFile(path.join(WIKI_DIR, rel), 'utf8');
const article = buildArticle(raw, rel, index, HOSTED);
store.set({ id: article.path, data: article });
}
logger.info(`knowledge: ${rels.length} articles across ${CATEGORIES.length} categories`);
},
};
}
-
Step 5: Run, verify the unit test PASSES.
-
Step 6: Register the collection in
src/content.config.ts. Add import:
import { knowledgeLoader } from './loaders/knowledgeLoader';
Add collection:
const knowledge = defineCollection({
loader: knowledgeLoader(),
schema: z.object({
slug: z.string(),
category: z.string(),
rel: z.string(),
path: z.string(),
title: z.string(),
summary: z.string(),
tags: z.array(z.string()),
aliases: z.array(z.string()),
updated: z.string().optional(),
confidence: z.string().optional(),
volatility: z.string().optional(),
html: z.string(),
}),
});
And add to the export: export const collections = { chapters, catalog, dictionary, knowledge };
-
Step 7: Verify the loader runs against real content:
cd apps/reader && pnpm astro sync→ expect no error; the loader should load ~378 articles (report the actual count). Ifglobfromnode:fs/promisesis unavailable in the Node version, replace it with a small recursivereaddirwalk (same output: rels relative to WIKI_DIR). -
Step 8: Run full suite
cd apps/reader && pnpm test→ all pass. -
Step 9: Commit
git add apps/reader/knowledge.config.ts apps/reader/src/loaders/knowledgeLoader.ts apps/reader/src/loaders/knowledgeLoader.test.ts apps/reader/src/content.config.ts
git commit -m "feat(knowledge): content-layer loader + collection"
Task 7: KaTeX CSS in the layout
Files: create apps/reader/public/katex/ assets; modify apps/reader/src/layouts/SiteLayout.astro.
- Step 1: Copy self-hosted KaTeX assets into public/
Run:
cd apps/reader
mkdir -p public/katex/fonts
cp node_modules/katex/dist/katex.min.css public/katex/katex.min.css
cp node_modules/katex/dist/fonts/*.woff2 public/katex/fonts/
Expected: public/katex/katex.min.css + several .woff2 fonts exist.
- Step 2: Link the CSS in
SiteLayout.astro
Open apps/reader/src/layouts/SiteLayout.astro, find its <head>, and add (once):
<link rel="stylesheet" href="/katex/katex.min.css" />
(Self-hosted; no external CDN, CSP-safe. The .woff2 under /katex/fonts/ resolve via the CSS's relative fonts/ refs.)
-
Step 3: Verify the CSS ships — after a build (Task 9),
test -f dist/katex/katex.min.css. -
Step 4: Commit
git add apps/reader/public/katex apps/reader/src/layouts/SiteLayout.astro
git commit -m "feat(knowledge): self-host KaTeX CSS + fonts"
Task 8: The knowledge routes
Files: create apps/reader/src/pages/knowledge/index.astro, [category]/index.astro, [category]/[slug].astro.
- Step 1: Article route
[category]/[slug].astro
---
import { getCollection } from 'astro:content';
import SiteLayout from '../../../layouts/SiteLayout.astro';
export async function getStaticPaths() {
const all = await getCollection('knowledge');
return all.map((a) => ({ params: { category: a.data.category, slug: a.data.slug }, props: { a: a.data } }));
}
const { a } = Astro.props;
const meta = [a.updated && `updated ${a.updated}`, a.confidence, a.volatility].filter(Boolean).join(' · ');
---
<SiteLayout title={a.title}>
<main class="kb-article" data-pagefind-body>
<h1>{a.title}</h1>
{a.summary && <p class="kb-summary">{a.summary}</p>}
{meta && <p class="kb-meta">{meta}</p>}
<article set:html={a.html} />
{a.tags.length > 0 && (
<p class="kb-tags">{a.tags.map((t) => <span class="kb-tag">{t}</span>)}</p>
)}
</main>
</SiteLayout>
<style>
.kb-article { max-width: 72ch; margin: 0 auto; padding: 1rem; }
.kb-summary { font-size: 1.05rem; color: var(--fg, #1a1a1a); }
.kb-meta { color: var(--muted, #666); font-size: .85rem; }
.kb-tag { display: inline-block; margin-right: .4rem; padding: .1rem .5rem;
border: 1px solid var(--rule, #e4e0d8); border-radius: 1rem; font-size: .8rem; }
.kb-article :global(img) { max-width: 100%; height: auto; }
</style>
- Step 2: Category index
[category]/index.astro
---
import { getCollection } from 'astro:content';
import SiteLayout from '../../../layouts/SiteLayout.astro';
import { CATEGORIES } from '../../../../knowledge.config';
export async function getStaticPaths() {
const all = await getCollection('knowledge');
return CATEGORIES.map((category) => ({
params: { category },
props: {
category,
items: all
.filter((a) => a.data.category === category)
.map((a) => a.data)
.sort((x, y) => x.title.localeCompare(y.title)),
},
}));
}
const { category, items } = Astro.props;
---
<SiteLayout title={`Knowledge — ${category}`}>
<main class="kb-index">
<h1>{category}</h1>
<p>{items.length} articles</p>
<ul class="kb-list">
{items.map((a) => (
<li><a href={a.path}>{a.title}</a>{a.summary && <span class="kb-snip"> — {a.summary}</span>}</li>
))}
</ul>
</main>
</SiteLayout>
<style>
.kb-index { max-width: 72ch; margin: 0 auto; padding: 1rem; }
.kb-list { line-height: 1.6; }
.kb-snip { color: var(--muted, #666); }
</style>
- Step 3: Landing
index.astro
---
import { getCollection } from 'astro:content';
import SiteLayout from '../../layouts/SiteLayout.astro';
import { CATEGORIES, GRAPH_URL } from '../../../knowledge.config';
const all = await getCollection('knowledge');
const counts = CATEGORIES.map((c) => ({ c, n: all.filter((a) => a.data.category === c).length }));
---
<SiteLayout title="Knowledge">
<main class="kb-home">
<h1>Knowledge</h1>
<p>The synthesized idea-space compiled from the Buckyverse corpus — {all.length} articles.</p>
<ul class="kb-cats">
{counts.map(({ c, n }) => (
<li><a href={`/knowledge/${c}/`}>{c}</a> <span class="kb-n">{n}</span></li>
))}
</ul>
<p class="kb-graph"><a href={GRAPH_URL}>Explore the relationship graph →</a></p>
</main>
</SiteLayout>
<style>
.kb-home { max-width: 72ch; margin: 0 auto; padding: 1rem; }
.kb-cats { list-style: none; padding: 0; }
.kb-cats li { padding: .4rem 0; }
.kb-n { color: var(--muted, #666); }
.kb-graph { margin-top: 1.5rem; }
</style>
- Step 4: Commit
git add apps/reader/src/pages/knowledge
git commit -m "feat(knowledge): landing + category index + article routes"
Task 9: Build smoke, Pagefind, deploy, verify
- Step 1: Full build
Run: cd apps/reader && pnpm build
Expected: succeeds. Then:
-
find dist -type f | wc -l→ report; must be well under 20,000 (expected ~9,000). -
test -f dist/knowledge/index.html && echo OK -
test -f dist/knowledge/topics/synergetics/index.html && echo OK(or another known topic — list one vials dist/knowledge/topics | head) -
A resolved wikilink:
grep -o 'href="/knowledge/[^"]*"' dist/knowledge/topics/synergetics/index.html | head -2 -
KaTeX shipped:
test -f dist/katex/katex.min.css && echo CSS_OK -
KaTeX rendered somewhere:
grep -rl 'class="katex"' dist/knowledge/ | head -1(a math article) -
Step 2: Pagefind + unit suite
-
ls dist/pagefind/ | head→ pagefind assets present. -
cd apps/reader && pnpm test→ all pass (report counts). -
Step 3: Deploy
Run: cd apps/reader && node_modules/.bin/wrangler pages deploy dist --project-name buckyverse-future --branch main --commit-dirty=true
Report the deploy URL.
-
Step 4: Verify live
-
curl -s -o /dev/null -w '%{http_code}\n' https://future.buckyverse.org/knowledge/→ 200 -
curl -s -o /dev/null -w '%{http_code}\n' https://future.buckyverse.org/knowledge/topics/→ 200 -
curl -s -o /dev/null -w '%{http_code}\n' https://future.buckyverse.org/knowledge/topics/synergetics/→ 200 -
curl -s https://future.buckyverse.org/knowledge/ | grep -o 'graph.buckyverse.org' | head -1→graph.buckyverse.org -
Step 5: Commit any tweaks
git add -A apps/reader
git commit -m "chore(knowledge): SP4 build + deploy verification" || echo "nothing to commit"
Self-review notes (coverage against the spec)
- §2 source + URL scheme → Task 1 (
mapPath), Task 6 (loader over.wiki/wiki/{topics,concepts,references}). ✅ - §3 architecture (mapPath, slugIndex+dedupe, rewriteLinks, renderArticle, loader) → Tasks 1–6. ✅
- §4 routes + citations → Tasks 3 (citeSource), 8 (pages). ✅
- §5 KaTeX → Tasks 5, 7. ✅
- §6 design system (SiteLayout reuse) → Task 8. ✅
- §7 redirects → near-identity; recorded in spec, no code task (SP6 applies it). ✅
- §8 scope (exclude raw/inventory/theses/_index) → Task 6 loader globs only the 3 categories, excludes
_index.md. ✅ - §9 testing → Tasks 1–6 units + Task 9 build/live. ✅
- §10 risks (link fidelity, collisions, cite best-effort, KaTeX CSP) → Tasks 1–5 tests + Task 7 self-host. ✅
Deferred to SP5: cross-corpus deep links (knowledge→book passage, knowledge→dictionary card). Parallel track: .wiki/ ingest continues; re-running the build picks up new articles.