1// A chapter: prose with its labs dropped in where the text calls for them.
2//
3// The prose is one markdown file per chapter, with a line
4//
5// <!--lab:convergence-->
6//
7// wherever a lab belongs. Splitting on those markers keeps the writing in one
8// readable file instead of scattered through JSX.
9import { useMemo } from 'react'
10import Markdown from './Markdown.tsx'
11import Lab from './Lab.tsx'
12import type { Chapter } from './types.ts'
14const MARKER = /<!--\s*lab:([a-z0-9_-]+)\s*-->/gi
16export default function ChapterView({ chapter }: { chapter: Chapter }) {
17 const pieces = useMemo(() => {
18 const out: ({ md: string } | { labId: string })[] = []
19 let last = 0
20 MARKER.lastIndex = 0
21 let m: RegExpExecArray | null
22 while ((m = MARKER.exec(chapter.text)) !== null) {
23 out.push({ md: chapter.text.slice(last, m.index) })
24 out.push({ labId: m[1] })
25 last = m.index + m[0].length
26 }
27 out.push({ md: chapter.text.slice(last) })
28 return out
29 }, [chapter.text])
31 return (
32 <article className="chapter">
33 <header className="chapter-head">
34 <div className="chapter-part">{chapter.part}</div>
35 <h2>{chapter.title}</h2>
36 <p className="chapter-blurb">{chapter.blurb}</p>
37 </header>
38 {pieces.map((p, i) => {
39 if ('md' in p) return p.md.trim() === '' ? null : <Markdown key={i} src={p.md} />
40 const lab = chapter.labs.find((l) => l.id === p.labId)
41 if (!lab) return <p key={i} className="lab-error">missing lab: {p.labId}</p>
42 return <Lab key={`${chapter.id}-${lab.id}`} spec={lab} />
43 })}
44 </article>
45 )
46}