BUCKYVERSE

Synergetics Dictionary Loader (SP3) — 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: Bring the ~21,188 Synergetics Dictionary cards onto the static Astro reader as 24 grouped letter pages with deep-linkable per-card anchors, per-card search, and R2-served images — under the Cloudflare 20k-file limit.

Architecture: A build-time Node script bundles the dict repo's already-generated per-card markdown into 24 per-letter JSON shards (committed into buckyverse — the "import sink"). A new Astro content-layer dictionaryLoader reads the shards and emits one collection entry per letter-group. /dictionary/[letter] renders every card as a <section id="C#####"> with content-visibility:auto; markdown→HTML happens at build via markdown-it, then cheerio rewrites intra-dictionary links to /dictionary/<letter>#<id> and card-image refs to R2 URLs. Card images bulk-upload to R2 once, out of the deploy.

Tech Stack: Astro 5 · TypeScript · Vitest · markdown-it (new dep) · cheerio (existing) · Pagefind · Cloudflare R2 + wrangler.

Spec: docs/superpowers/specs/2026-07-06-synergetics-dictionary-loader-design.md. This plan refines spec §2 toward the spec's documented fallback (consume the generated markdown), bundled into per-letter shards for the file-count win — the body is the proven markdown projection; cross-refs/nav/image ride embedded in it and are rewritten by the reader.


File structure

New (apps/reader):

Modified (apps/reader):

New (buckyverse root):

Config/env:


Task 1: Shard builder script (generated markdown → per-letter JSON)

Files:

// apps/reader/scripts/build-dictionary-shards.mjs
// Bundle the dict repo's generated per-card markdown into one JSON shard per
// letter-group. Run when the dictionary content changes. Pure file processing —
// the card body stays as its proven markdown projection; the reader renders it.
import { readdir, readFile, mkdir, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import os from 'node:os';

const CARDS_DIR =
  process.env.DICT_CARDS_DIR ||
  path.join(os.homedir(), 'Projects/elib-synergetics-dictionary/output/generated/markdown/cards');
// Shards live in the buckyverse repo root (two dirs up from apps/reader).
const OUT_DIR = path.resolve(process.cwd(), '../../synergetics_dictionary/shards');

/** Split YAML-ish frontmatter from a card .md; return { fm, body }. */
function splitFrontmatter(md) {
  const m = md.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
  if (!m) return { fm: {}, body: md };
  const fm = {};
  for (const line of m[1].split('\n')) {
    const kv = line.match(/^(\w+):\s*(.*)$/);
    if (kv) fm[kv[1]] = kv[2].replace(/^["']|["']$/g, '');
  }
  return { fm, body: m[2] };
}

/** First `# Heading` line → term title. */
function extractTerm(body) {
  const h = body.match(/^#\s+(.+?)\s*$/m);
  return h ? h[1].trim() : '';
}

async function main() {
  if (!existsSync(CARDS_DIR)) {
    console.error(`Cards dir not found: ${CARDS_DIR}. Set DICT_CARDS_DIR.`);
    process.exit(1);
  }
  await mkdir(OUT_DIR, { recursive: true });
  const letters = (await readdir(CARDS_DIR, { withFileTypes: true }))
    .filter((d) => d.isDirectory())
    .map((d) => d.name)
    .sort();

  let total = 0;
  for (const letter of letters) {
    const dir = path.join(CARDS_DIR, letter);
    const files = (await readdir(dir)).filter((f) => /^C\d+\.md$/.test(f)).sort();
    const cards = [];
    for (const f of files) {
      const raw = await readFile(path.join(dir, f), 'utf8');
      const { fm, body } = splitFrontmatter(raw);
      const id = fm.id || f.replace(/\.md$/, '');
      cards.push({ id, term: extractTerm(body), letter, bodyMarkdown: body.trim() });
    }
    await writeFile(path.join(OUT_DIR, `${letter}.json`), JSON.stringify({ letter, cards }, null, 0));
    console.log(`${letter}: ${cards.length} cards`);
    total += cards.length;
  }
  console.log(`Wrote ${letters.length} shards, ${total} cards → ${OUT_DIR}`);
}

main();

In apps/reader/package.json "scripts", add:

"shards": "node scripts/build-dictionary-shards.mjs"

Run: cd apps/reader && pnpm shards
Expected: prints per-letter counts and Wrote 24 shards, 21188 cards. Then:
Run: ls ../../synergetics_dictionary/shards | wc -l → 24
Run: node -e "const s=require('../../synergetics_dictionary/shards/xyz.json');console.log(s.letter,s.cards.length,s.cards[0].id,JSON.stringify(s.cards[0].term))"
Expected: xyz 188 C21001 "XYZ" (or the real first term).

cd /Volumes/Storage/belmendo/Projects/buckyverse.org
git add apps/reader/scripts/build-dictionary-shards.mjs apps/reader/package.json synergetics_dictionary/shards
git commit -m "feat(dictionary): build per-letter card shards from generated markdown"

Task 2: Dictionary types + shard parser

Files:

// apps/reader/src/lib/dictionary/types.ts
export interface DictCard {
  id: string;          // 'C18234'
  term: string;        // card title
  letter: string;      // letter-group, e.g. 's'
  bodyMarkdown: string;
}
export interface LetterGroupShard {
  letter: string;
  cards: DictCard[];
}
// apps/reader/src/lib/dictionary/parseShard.test.ts
import { describe, it, expect } from 'vitest';
import { parseShard } from './parseShard';

describe('parseShard', () => {
  it('parses a valid shard and preserves card order', () => {
    const json = JSON.stringify({
      letter: 's',
      cards: [
        { id: 'C1', term: 'Synergy', letter: 's', bodyMarkdown: '# Synergy\n\ntext' },
        { id: 'C2', term: 'System', letter: 's', bodyMarkdown: '# System' },
      ],
    });
    const g = parseShard(json);
    expect(g.letter).toBe('s');
    expect(g.cards.map((c) => c.id)).toEqual(['C1', 'C2']);
    expect(g.cards[0].term).toBe('Synergy');
  });

  it('throws on a malformed shard (missing cards array)', () => {
    expect(() => parseShard(JSON.stringify({ letter: 's' }))).toThrow(/cards/);
  });
});

Run: cd apps/reader && pnpm vitest run src/lib/dictionary/parseShard.test.ts
Expected: FAIL — cannot find module ./parseShard.

// apps/reader/src/lib/dictionary/parseShard.ts
import type { LetterGroupShard, DictCard } from './types';

export function parseShard(jsonText: string): LetterGroupShard {
  const raw = JSON.parse(jsonText) as unknown;
  if (!raw || typeof raw !== 'object') throw new Error('shard: not an object');
  const obj = raw as Record<string, unknown>;
  if (typeof obj.letter !== 'string') throw new Error('shard: missing letter');
  if (!Array.isArray(obj.cards)) throw new Error('shard: missing cards array');
  const cards: DictCard[] = obj.cards.map((c, i) => {
    const card = c as Record<string, unknown>;
    if (typeof card.id !== 'string') throw new Error(`shard ${obj.letter}: card ${i} missing id`);
    return {
      id: card.id,
      term: typeof card.term === 'string' ? card.term : '',
      letter: typeof card.letter === 'string' ? card.letter : (obj.letter as string),
      bodyMarkdown: typeof card.bodyMarkdown === 'string' ? card.bodyMarkdown : '',
    };
  });
  return { letter: obj.letter, cards };
}

Run: cd apps/reader && pnpm vitest run src/lib/dictionary/parseShard.test.ts
Expected: PASS (2 tests).

git add apps/reader/src/lib/dictionary/types.ts apps/reader/src/lib/dictionary/parseShard.ts apps/reader/src/lib/dictionary/parseShard.test.ts
git commit -m "feat(dictionary): shard types + parser"

Files:

The generated markdown links to other cards as /synergetics-dictionary/<letter>/<id>.md and images as /synergetics_dictionary/images/cards/<letter>/<id>.... After markdown→HTML these are <a href>/<img src>; rewrite them.

// apps/reader/src/lib/dictionary/rewriteCardHtml.test.ts
import { describe, it, expect } from 'vitest';
import { rewriteCardHtml } from './rewriteCardHtml';

const IMG_BASE = 'https://downloads.buckyverse.org/dictionary/images';

describe('rewriteCardHtml', () => {
  it('rewrites a card link to a /dictionary anchor', () => {
    const html = '<p>See <a href="/synergetics-dictionary/xyz/C21115.md">Zero</a></p>';
    const out = rewriteCardHtml(html, IMG_BASE);
    expect(out).toContain('href="/dictionary/xyz#C21115"');
    expect(out).not.toContain('.md');
  });

  it('rewrites a card image ref to the R2 base and lazy-loads it', () => {
    const html = '<p><img src="/synergetics_dictionary/images/cards/xyz/C21147.jpg" alt="C21147"></p>';
    const out = rewriteCardHtml(html, IMG_BASE);
    expect(out).toContain(`src="${IMG_BASE}/xyz/C21147.jpg"`);
    expect(out).toContain('loading="lazy"');
  });

  it('leaves external links untouched', () => {
    const html = '<a href="https://example.com">x</a>';
    expect(rewriteCardHtml(html, IMG_BASE)).toContain('href="https://example.com"');
  });
});

Run: cd apps/reader && pnpm vitest run src/lib/dictionary/rewriteCardHtml.test.ts
Expected: FAIL — cannot find module.

// apps/reader/src/lib/dictionary/rewriteCardHtml.ts
import * as cheerio from 'cheerio';

const CARD_LINK = /^\/synergetics-dictionary\/([a-z]+)\/(C\d+)\.md$/;
const CARD_IMG = /^\/synergetics_dictionary\/images\/cards\/(.+)$/;

/** Rewrite generated-markdown card links → /dictionary/<letter>#<id> and card
 *  image refs → the R2 base (lazy-loaded). imageBase has no trailing slash. */
export function rewriteCardHtml(html: string, imageBase: string): string {
  const $ = cheerio.load(html, { xml: { xmlMode: false, decodeEntities: false } });
  $('a[href]').each((_, el) => {
    const m = ($(el).attr('href') || '').match(CARD_LINK);
    if (m) $(el).attr('href', `/dictionary/${m[1]}#${m[2]}`);
  });
  $('img[src]').each((_, el) => {
    const m = ($(el).attr('src') || '').match(CARD_IMG);
    if (m) {
      $(el).attr('src', `${imageBase}/${m[1]}`);
      $(el).attr('loading', 'lazy');
    }
  });
  const body = $('body');
  return (body.length ? body.html() : $.root().html()) ?? '';
}

Run: cd apps/reader && pnpm vitest run src/lib/dictionary/rewriteCardHtml.test.ts
Expected: PASS (3 tests).

git add apps/reader/src/lib/dictionary/rewriteCardHtml.ts apps/reader/src/lib/dictionary/rewriteCardHtml.test.ts
git commit -m "feat(dictionary): rewrite card links + image refs"

Task 4: Render a card to a Pagefind-indexed section

Files:

Run: cd apps/reader && pnpm add markdown-it && pnpm add -D @types/markdown-it
Expected: both added to package.json.

// apps/reader/src/lib/dictionary/renderCard.test.ts
import { describe, it, expect } from 'vitest';
import { renderCard } from './renderCard';

const IMG_BASE = 'https://downloads.buckyverse.org/dictionary/images';
const card = {
  id: 'C21147',
  term: 'Zero = Normal',
  letter: 'xyz',
  bodyMarkdown:
    '# Zero = Normal\n\nSee [Zero](/synergetics-dictionary/xyz/C21115.md)\n\n![C21147](/synergetics_dictionary/images/cards/xyz/C21147.jpg)',
};

describe('renderCard', () => {
  it('wraps the card as a Pagefind sub-result section with an id anchor', () => {
    const html = renderCard(card, IMG_BASE);
    expect(html).toContain('<section id="C21147"');
    expect(html).toContain('data-pagefind-body');
    expect(html).toContain('data-pagefind-meta="title:Zero = Normal"');
  });

  it('renders markdown, rewrites links + images', () => {
    const html = renderCard(card, IMG_BASE);
    expect(html).toContain('href="/dictionary/xyz#C21115"');
    expect(html).toContain(`src="${IMG_BASE}/xyz/C21147.jpg"`);
  });
});

Run: cd apps/reader && pnpm vitest run src/lib/dictionary/renderCard.test.ts
Expected: FAIL — cannot find module ./renderCard.

// apps/reader/src/lib/dictionary/renderCard.ts
import MarkdownIt from 'markdown-it';
import type { DictCard } from './types';
import { rewriteCardHtml } from './rewriteCardHtml';

// Preserve the RBF ASCII diagrams (indented code) and inline HTML the generator
// may emit; no auto-linkification (card links are explicit).
const md = new MarkdownIt({ html: true, linkify: false, breaks: false });

/** One card → a self-contained, Pagefind-indexable <section> with a #<id> anchor. */
export function renderCard(card: DictCard, imageBase: string): string {
  const inner = rewriteCardHtml(md.render(card.bodyMarkdown), imageBase);
  const term = card.term.replace(/"/g, '&quot;');
  return (
    `<section id="${card.id}" class="dict-card" data-pagefind-body ` +
    `data-pagefind-meta="title:${term}">` +
    inner +
    `</section>`
  );
}

Run: cd apps/reader && pnpm vitest run src/lib/dictionary/renderCard.test.ts
Expected: PASS (2 tests).

git add apps/reader/package.json apps/reader/pnpm-lock.yaml apps/reader/src/lib/dictionary/renderCard.ts apps/reader/src/lib/dictionary/renderCard.test.ts
git commit -m "feat(dictionary): render card to a Pagefind section"

Task 5: The dictionary content-layer loader

Files:

// apps/reader/dictionary.config.ts
import path from 'node:path';
// Shards live in the buckyverse repo root; build cwd is apps/reader.
export const SHARD_DIR = path.resolve(process.cwd(), '../../synergetics_dictionary/shards');
export const IMAGE_BASE = (
  (import.meta.env?.PUBLIC_DICT_IMAGE_BASE as string | undefined) ??
  'https://downloads.buckyverse.org/dictionary/images'
).replace(/\/$/, '');
// apps/reader/src/loaders/dictionaryLoader.test.ts
import { describe, it, expect } from 'vitest';
import { buildLetterEntry } from './dictionaryLoader';

describe('buildLetterEntry', () => {
  it('renders a letter shard into one entry with cardCount and joined html', () => {
    const shard = {
      letter: 's',
      cards: [
        { id: 'C1', term: 'Synergy', letter: 's', bodyMarkdown: '# Synergy\n\nA' },
        { id: 'C2', term: 'System', letter: 's', bodyMarkdown: '# System\n\nB' },
      ],
    };
    const entry = buildLetterEntry(shard, 'https://img');
    expect(entry.letter).toBe('s');
    expect(entry.cardCount).toBe(2);
    expect(entry.cards).toHaveLength(2);
    expect(entry.cards[0]).toEqual({ id: 'C1', term: 'Synergy' });
    expect(entry.html).toContain('<section id="C1"');
    expect(entry.html).toContain('<section id="C2"');
  });
});

Run: cd apps/reader && pnpm vitest run src/loaders/dictionaryLoader.test.ts
Expected: FAIL — cannot find module.

// apps/reader/src/loaders/dictionaryLoader.ts
import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
import type { Loader } from 'astro/loaders';
import { parseShard } from '../lib/dictionary/parseShard';
import { renderCard } from '../lib/dictionary/renderCard';
import type { LetterGroupShard } from '../lib/dictionary/types';
import { SHARD_DIR, IMAGE_BASE } from '../../dictionary.config';

export interface LetterEntry {
  letter: string;
  cardCount: number;
  cards: Array<{ id: string; term: string }>; // lightweight TOC for the page
  html: string;                               // all card <section>s concatenated
}

/** Pure: shard → one rendered letter entry. Unit-tested. */
export function buildLetterEntry(shard: LetterGroupShard, imageBase: string): LetterEntry {
  return {
    letter: shard.letter,
    cardCount: shard.cards.length,
    cards: shard.cards.map((c) => ({ id: c.id, term: c.term })),
    html: shard.cards.map((c) => renderCard(c, imageBase)).join('\n'),
  };
}

export function dictionaryLoader(): Loader {
  return {
    name: 'buckyverse-dictionary-loader',
    load: async ({ store, logger }) => {
      store.clear();
      const files = (await readdir(SHARD_DIR)).filter((f) => f.endsWith('.json')).sort();
      let total = 0;
      for (const f of files) {
        const shard = parseShard(await readFile(path.join(SHARD_DIR, f), 'utf8'));
        const entry = buildLetterEntry(shard, IMAGE_BASE);
        store.set({ id: entry.letter, data: entry });
        total += entry.cardCount;
      }
      logger.info(`dictionary: ${files.length} letter groups, ${total} cards`);
    },
  };
}

Run: cd apps/reader && pnpm vitest run src/loaders/dictionaryLoader.test.ts
Expected: PASS.

In apps/reader/src/content.config.ts, add the import and collection (mirrors catalog):

import { dictionaryLoader } from './loaders/dictionaryLoader';

const dictionary = defineCollection({
  loader: dictionaryLoader(),
  schema: z.object({
    letter: z.string(),
    cardCount: z.number(),
    cards: z.array(z.object({ id: z.string(), term: z.string() })),
    html: z.string(),
  }),
});

Then add dictionary to the exported collections:

export const collections = { chapters, catalog, dictionary };
git add apps/reader/dictionary.config.ts apps/reader/src/loaders/dictionaryLoader.ts apps/reader/src/loaders/dictionaryLoader.test.ts apps/reader/src/content.config.ts
git commit -m "feat(dictionary): content-layer loader + collection"

Task 6: The letter-group route

Files:

---
// apps/reader/src/pages/dictionary/[letter].astro
import { getCollection } from 'astro:content';
import SiteLayout from '../../layouts/SiteLayout.astro';

export async function getStaticPaths() {
  const groups = await getCollection('dictionary');
  return groups.map((g) => ({ params: { letter: g.data.letter }, props: { g: g.data } }));
}
const { g } = Astro.props;
---
<SiteLayout title={`Dictionary — ${g.letter.toUpperCase()}`}>
  <main class="dict-letter">
    <h1>Synergetics Dictionary — {g.letter.toUpperCase()}</h1>
    <p class="dict-count">{g.cardCount} cards</p>
    <div class="dict-cards" set:html={g.html} />
  </main>
</SiteLayout>

<style>
  .dict-letter { max-width: 72ch; margin: 0 auto; padding: 1rem; }
  /* Offscreen cards cost nothing to render — key to the big letters (s = 2293). */
  :global(.dict-card) {
    content-visibility: auto;
    contain-intrinsic-size: auto 320px;
    padding-block: 1.25rem;
    border-top: 1px solid var(--rule, #e4e0d8);
    scroll-margin-top: 4rem; /* so #id anchors clear the top bar */
  }
  :global(.dict-card img) { max-width: 100%; height: auto; }
</style>

Run: cd apps/reader && pnpm shards && pnpm build
Expected: build succeeds; dist/dictionary/xyz/index.html exists.
Run: grep -c 'class="dict-card"' dist/dictionary/xyz/index.html
Expected: 188 (matches the xyz card count).
Run: grep -o 'id="C21002"' dist/dictionary/xyz/index.html | head -1
Expected: id="C21002".

git add apps/reader/src/pages/dictionary/'[letter].astro'
git commit -m "feat(dictionary): letter-group route with content-visibility cards"

Task 7: The dictionary landing page

Files:

---
// apps/reader/src/pages/dictionary/index.astro
import { getCollection } from 'astro:content';
import SiteLayout from '../../layouts/SiteLayout.astro';

const groups = (await getCollection('dictionary')).sort((a, b) =>
  a.data.letter.localeCompare(b.data.letter),
);
const totalCards = groups.reduce((n, g) => n + g.data.cardCount, 0);
---
<SiteLayout title="Synergetics Dictionary">
  <main class="dict-index">
    <h1>Synergetics Dictionary</h1>
    <p>{totalCards.toLocaleString()} cards across {groups.length} letter groups.</p>
    <nav class="dict-az" aria-label="Letter groups">
      <ul>
        {groups.map((g) => (
          <li>
            <a href={`/dictionary/${g.data.letter}`}>
              <span class="dict-az-letter">{g.data.letter.toUpperCase()}</span>
              <span class="dict-az-count">{g.data.cardCount}</span>
            </a>
          </li>
        ))}
      </ul>
    </nav>
  </main>
</SiteLayout>

<style>
  .dict-index { max-width: 72ch; margin: 0 auto; padding: 1rem; }
  .dict-az ul { list-style: none; padding: 0; display: grid;
    grid-template-columns: repeat(auto-fill, minmax(5rem, 1fr)); gap: .5rem; }
  .dict-az a { display: flex; justify-content: space-between; padding: .6rem .8rem;
    border: 1px solid var(--rule, #e4e0d8); border-radius: .4rem; text-decoration: none; }
  .dict-az-count { color: var(--muted, #666); font-variant-numeric: tabular-nums; }
</style>

Run: cd apps/reader && pnpm build
Run: grep -c 'dict-az-letter' dist/dictionary/index.html
Expected: 24.

git add apps/reader/src/pages/dictionary/index.astro
git commit -m "feat(dictionary): A–Z landing page"

Task 8: Re-home the front-matter pages

The dictionary's front-matter (How-to-Use, Sources-Cited, Preamble, Contents-of-the-Set, Some-Perspectives, Annotated-Bibliography, …) currently lives as markdown at buckyverse root synergetics-dictionary/*.md and synergetics_dictionary/*.md. Surface them under /dictionary/.

Files:

// apps/reader/dictionary-frontmatter.config.ts
import path from 'node:path';
const ROOT = path.resolve(process.cwd(), '../../synergetics-dictionary');
// slug (URL) → repo markdown file
export const FRONT_MATTER: Array<{ slug: string; file: string; title: string }> = [
  { slug: 'how-to-use', file: path.join(ROOT, 'How-to-Use-the-Synergetics-Dictionary.md'), title: 'How to Use the Synergetics Dictionary' },
  { slug: 'preamble', file: path.join(ROOT, 'Preamble-with-a-Few-Explanations.md'), title: 'Preamble' },
  { slug: 'contents-of-the-set', file: path.join(ROOT, 'Contents-of-the-Set.md'), title: 'Contents of the Set' },
  { slug: 'sources-cited', file: path.join(ROOT, 'Sources-Cited-List-of-Titles.md'), title: 'Sources Cited' },
  { slug: 'some-perspectives', file: path.join(ROOT, 'Some-Perspectives-of-Fullers-Mathematics.md'), title: "Some Perspectives of Fuller's Mathematics" },
  { slug: 'annotated-bibliography', file: path.join(ROOT, 'An-Annotated-Bibliography-of-Books-by-R-Buckminster-Fuller.md'), title: 'An Annotated Bibliography of Books by R. Buckminster Fuller' },
];
---
// apps/reader/src/pages/dictionary/about/[page].astro
import { readFile } from 'node:fs/promises';
import MarkdownIt from 'markdown-it';
import SiteLayout from '../../../layouts/SiteLayout.astro';
import { rewriteCardHtml } from '../../../lib/dictionary/rewriteCardHtml';
import { IMAGE_BASE } from '../../../../dictionary.config';
import { FRONT_MATTER } from '../../../../dictionary-frontmatter.config';

const md = new MarkdownIt({ html: true, linkify: false });

export async function getStaticPaths() {
  return Promise.all(
    FRONT_MATTER.map(async (fm) => {
      const raw = await readFile(fm.file, 'utf8');
      const body = raw.replace(/^---\n[\s\S]*?\n---\n?/, ''); // drop WikiJS frontmatter
      const html = rewriteCardHtml(md.render(body), IMAGE_BASE);
      return { params: { page: fm.slug }, props: { title: fm.title, html } };
    }),
  );
}
const { title, html } = Astro.props;
---
<SiteLayout title={title}>
  <main class="dict-about" data-pagefind-body>
    <article set:html={html} />
  </main>
</SiteLayout>
<style>.dict-about { max-width: 72ch; margin: 0 auto; padding: 1rem; }</style>

Run: cd apps/reader && pnpm build
Run: test -f dist/dictionary/about/how-to-use/index.html && echo OK
Expected: OK.

git add apps/reader/dictionary-frontmatter.config.ts apps/reader/src/pages/dictionary/about/'[page].astro'
git commit -m "feat(dictionary): re-home front-matter pages under /dictionary/about"

Task 9: Upload card images to R2

Files:

The ~21,193 card JPGs live in the dict repo at content/images/cards/** (or buckyverse synergetics_dictionary/images/cards/**). Upload once to R2 under dictionary/images/<letter>/<id>.jpg so IMAGE_BASE resolves.

#!/usr/bin/env bash
# apps/reader/scripts/upload-dictionary-images-to-r2.sh
# One-time bulk upload of dictionary card images to R2. Requires wrangler auth.
set -euo pipefail
SRC="${DICT_IMAGES_DIR:-$HOME/Projects/elib-synergetics-dictionary/content/images/cards}"
BUCKET="${R2_BUCKET:-buckyverse-downloads}"
[ -d "$SRC" ] || { echo "Images dir not found: $SRC (set DICT_IMAGES_DIR)"; exit 1; }
count=0
while IFS= read -r -d '' f; do
  rel="${f#"$SRC"/}"                 # e.g. xyz/C21147.jpg
  key="dictionary/images/${rel}"
  npx --no-install wrangler r2 object put "${BUCKET}/${key}" --file "$f" --content-type image/jpeg >/dev/null
  count=$((count+1))
  [ $((count % 500)) -eq 0 ] && echo "uploaded $count…"
done < <(find "$SRC" -type f -name '*.jpg' -print0)
echo "Done: uploaded $count images to r2://${BUCKET}/dictionary/images/"

Run: chmod +x apps/reader/scripts/upload-dictionary-images-to-r2.sh
Run (single-file smoke): upload one known image, then:
curl -s -o /dev/null -w '%{http_code}\n' https://downloads.buckyverse.org/dictionary/images/xyz/C21147.jpg
Expected: 200 after the real run. (Full run is long; run it in the background.)

git add apps/reader/scripts/upload-dictionary-images-to-r2.sh
git commit -m "feat(dictionary): R2 bulk-upload script for card images"

Task 10: Dictionary redirect rules (for SP6)

Files:

Cloudflare Pages _redirects supports :splat/placeholders but cannot map a path to a #fragment per-card with a wildcard. So: redirect the per-card and index URLs to the letter page; the card anchor is best-effort via a param. Redirect the letter-index and front-matter exactly.

Create apps/reader/public/_redirects (or append) with:

# Synergetics Dictionary (Wiki.js → Astro). Activated at SP6 cutover.
/synergetics-dictionary/:letter/index            /dictionary/:letter            301
/synergetics-dictionary/:letter/:card            /dictionary/:letter#:card      301
/en/synergetics-dictionary/:letter/index         /dictionary/:letter            301
/en/synergetics-dictionary/:letter/:card         /dictionary/:letter#:card      301
/synergetics-dictionary/How-to-Use-the-Synergetics-Dictionary   /dictionary/about/how-to-use   301

Run: cd apps/reader && pnpm build
Run: head -3 dist/_redirects
Expected: the dictionary comment + first rule appear (Astro copies public/** verbatim).

git add apps/reader/public/_redirects
git commit -m "feat(dictionary): Wiki.js→Astro redirect rules (SP6)"

Task 11: Full build smoke + Pagefind per-card results

Files: none (verification + one deploy)

Run: cd apps/reader && pnpm shards && pnpm build
Expected: succeeds. File-budget check:
Run: find dist -type f | wc -l
Expected: previous count (~8,545) + ~40, still well under 20,000.

Run: grep -rl 'data-pagefind-body' dist/dictionary/ | head
Expected: letter pages listed.
Run: ls dist/pagefind/ | head
Expected: pagefind.js and index fragments present (cards indexed).

Run: cd apps/reader && pnpm test
Expected: all prior tests + the 4 new dictionary test files PASS.

Run: cd apps/reader && node_modules/.bin/wrangler pages deploy dist --project-name buckyverse-future --branch main --commit-dirty=true
Then:
Run: curl -s -o /dev/null -w '%{http_code}\n' https://future.buckyverse.org/dictionary/
Run: curl -s -o /dev/null -w '%{http_code}\n' https://future.buckyverse.org/dictionary/xyz
Run: curl -s https://future.buckyverse.org/dictionary/xyz | grep -o 'id="C21002"' | head -1
Expected: 200, 200, id="C21002".

git add -A apps/reader
git commit -m "chore(dictionary): SP3 build + deploy verification"

Self-review notes (coverage against the spec)

Deferred to SP5 (not this plan): cross-references from cards into book passages / .wiki/ concepts. Parallel track: re-running Task 1 (shards) + Task 9 (images) when dictionary content changes.