BUCKYVERSE

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):

Modified (apps/reader):

Config:


Task 1: Path mapping (mapPath)

Files: create apps/reader/src/lib/knowledge/mapPath.ts, mapPath.test.ts.

// 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');
  });
});
// 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);
}
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.

// 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>;
// 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();
  });
});
// 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]));
}
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.

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

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.

// 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)');
  });
});
// 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.

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.

Run: cd apps/reader && pnpm add gray-matter @vscode/markdown-it-katex katex

// 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"');
  });
});
// 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));
}
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.

// 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';
// 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"');
  });
});
// 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`);
    },
  };
}
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 };

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.

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.

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.)

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.

---
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>
---
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>
---
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>
git add apps/reader/src/pages/knowledge
git commit -m "feat(knowledge): landing + category index + article routes"

Task 9: Build smoke, Pagefind, deploy, verify

Run: cd apps/reader && pnpm build
Expected: succeeds. Then:

Run: cd apps/reader && node_modules/.bin/wrangler pages deploy dist --project-name buckyverse-future --branch main --commit-dirty=true
Report the deploy URL.

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)

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.