();
+ const entries: TocEntry[] = [];
+ for (const block of blocks) {
+ if (block.type !== "h2" && block.type !== "h3" && block.type !== "h4") continue;
+ let id = block.id;
+ if (!id) {
+ const base = slugify(block.text) || "section";
+ const n = (seen.get(base) ?? 0) + 1;
+ seen.set(base, n);
+ id = n === 1 ? base : `${base}-${n}`;
+ }
+ entries.push({
+ id,
+ text: block.text,
+ level: block.type === "h2" ? 2 : block.type === "h3" ? 3 : 4,
+ });
+ }
+ return entries;
+}
+
+const WORDS_PER_MINUTE = 200;
+
+function countWords(text: string): number {
+ const words = text.trim().split(/\s+/).filter(Boolean);
+ return words.length;
+}
+
+function blockWords(block: ContentBlock): number {
+ switch (block.type) {
+ case "h2":
+ case "h3":
+ case "h4":
+ case "p":
+ return countWords(block.text);
+ case "pullquote":
+ return countWords(block.text) + (block.cite ? countWords(block.cite) : 0);
+ case "ul":
+ case "ol":
+ return block.items.reduce((n, item) => n + countWords(item), 0);
+ case "blockquote":
+ return block.text.reduce((n, line) => n + countWords(line), 0);
+ case "code":
+ case "mermaid":
+ // Code reads slower but is skimmed; count lines as ~4 words each.
+ return (block.type === "code" ? block.text : block.code).split("\n").length * 4;
+ case "table":
+ return block.rows.reduce(
+ (n, row) => n + row.reduce((m, cell) => m + countWords(cell), 0),
+ block.headers.reduce((n, cell) => n + countWords(cell), 0),
+ );
+ case "figure":
+ return block.caption ? countWords(block.caption) : 0;
+ case "gallery":
+ return block.images.reduce((n, img) => n + (img.caption ? countWords(img.caption) : 0), 0);
+ case "callout":
+ case "footnote":
+ return block.blocks.reduce((n, b) => n + blockWords(b), 0);
+ case "stats":
+ return block.items.reduce((n, item) => n + countWords(item.label) + 1, 0);
+ case "math":
+ return countWords(block.tex);
+ default:
+ return 0;
+ }
+}
+
+/**
+ * Reading time at 200 wpm (minimum 1 minute). One implementation for the
+ * four repos currently re-deriving it.
+ */
+export function readingTime(blocks: ContentBlock[]): ReadingTimeResult {
+ const words = blocks.reduce((n, block) => n + blockWords(block), 0);
+ return { words, minutes: Math.max(1, Math.round(words / WORDS_PER_MINUTE)) };
+}
diff --git a/src/types.ts b/src/types.ts
index c85a27c..696e664 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -3,19 +3,55 @@
* Company/product surfaces only (blog · roadmap · changelog). Not UGC.
*/
-/** Long-form essay / blog body blocks (markdown → structured). */
+import type { Inline } from "./inline.js";
+import type { ChartSpec } from "./chart.js";
+
+export type CalloutKind = "note" | "tip" | "warn" | "danger";
+
+/**
+ * Long-form essay / blog body blocks (markdown → structured).
+ *
+ * v0.2 notes: the original ten block types are unchanged in shape — `id` and
+ * `spans` on them are OPTIONAL in the type (hand-built blocks from v0.1
+ * consumers still typecheck) but the parser always emits them. New block
+ * types added in v0.2 carry their fields as required.
+ */
export type ContentBlock =
- | { type: "h2"; text: string }
- | { type: "h3"; text: string }
- | { type: "ul"; items: string[] }
- | { type: "ol"; items: string[] }
- | { type: "blockquote"; text: string[] }
- | { type: "p"; text: string }
+ | { type: "h2"; text: string; id?: string; spans?: Inline[] }
+ | { type: "h3"; text: string; id?: string; spans?: Inline[] }
+ | { type: "h4"; text: string; id: string; spans: Inline[] }
+ | { type: "hr" }
+ | { type: "ul"; items: string[]; itemSpans?: Inline[][] }
+ | { type: "ol"; items: string[]; itemSpans?: Inline[][] }
+ | { type: "blockquote"; text: string[]; spans?: Inline[][] }
+ | { type: "p"; text: string; spans?: Inline[] }
| { type: "image"; alt: string; src: string }
+ | { type: "figure"; src: string; alt: string; caption?: string; spans?: Inline[] }
+ | { type: "gallery"; images: { src: string; alt: string; caption?: string }[] }
+ | { type: "callout"; kind: CalloutKind; title?: string; blocks: ContentBlock[] }
+ | { type: "pullquote"; text: string; spans: Inline[]; cite?: string }
| { type: "code"; lang: string; text: string }
+ | { type: "mermaid"; code: string }
+ | { type: "chart"; spec: ChartSpec }
+ | { type: "math"; tex: string; display: boolean }
+ | { type: "footnote"; id: string; blocks: ContentBlock[] }
+ | { type: "stats"; items: { value: string; label: string }[] }
| { type: "table"; headers: string[]; rows: string[][] }
| { type: "embed"; url: string };
+/** One table-of-contents entry, produced by `extractToc`. */
+export interface TocEntry {
+ id: string;
+ text: string;
+ level: 2 | 3 | 4;
+}
+
+/** Result of `readingTime` — words counted and minutes at the given pace. */
+export interface ReadingTimeResult {
+ words: number;
+ minutes: number;
+}
+
/** Minimal post frontmatter common across studio blogs. */
export interface BlogPostMeta {
slug: string;
diff --git a/styles.css b/styles.css
new file mode 100644
index 0000000..03af567
--- /dev/null
+++ b/styles.css
@@ -0,0 +1,852 @@
+/**
+ * bip-kit/styles.css — complete neutral default theme for bip-kit/react.
+ *
+ * Contract: every color, font, radius and measure is a CSS custom property.
+ * Consumers retheme var-by-var (light AND dark) without touching a single
+ * rule; the rules below never hardcode a color outside the token blocks.
+ *
+ * Dark mode: explicit `data-theme="dark"|"light"` on wins; with no
+ * attribute, the OS preference applies. Same convention the fleet uses.
+ */
+
+/* ── Tokens ─────────────────────────────────────────────────────────── */
+
+:root {
+ /* Typography */
+ --bp-font-sans:
+ ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ --bp-font-mono:
+ ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
+ --bp-size: 1.0625rem; /* 17px mobile; bumped at 720px */
+ --bp-leading: 1.7;
+ --bp-measure: 70ch;
+ --bp-flow: 1.5rem; /* vertical rhythm unit */
+
+ /* Shape */
+ --bp-radius: 12px;
+ --bp-radius-sm: 6px;
+
+ /* Core colors (light) */
+ --bp-bg: #fdfdfc;
+ --bp-surface: #f4f4f2;
+ --bp-fg: #1c1c1a;
+ --bp-fg-muted: #5c5c58;
+ --bp-fg-faint: #92928c;
+ --bp-border: #e4e4e0;
+ --bp-accent: #2456d6;
+ --bp-accent-contrast: #ffffff;
+
+ /* Callouts */
+ --bp-note: #2456d6;
+ --bp-note-bg: #eef2fd;
+ --bp-tip: #157347;
+ --bp-tip-bg: #e9f6ef;
+ --bp-warn: #9a6700;
+ --bp-warn-bg: #fdf5e2;
+ --bp-danger: #c0332b;
+ --bp-danger-bg: #fdeeec;
+
+ /* Categorical chart palette (color-blind aware, Tableau-derived) */
+ --bp-chart-1: #4e79a7;
+ --bp-chart-2: #f28e2b;
+ --bp-chart-3: #59a14f;
+ --bp-chart-4: #e15759;
+ --bp-chart-5: #b07aa1;
+ --bp-chart-6: #76b7b2;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --bp-bg: #141416;
+ --bp-surface: #1f1f23;
+ --bp-fg: #ececea;
+ --bp-fg-muted: #a4a4a0;
+ --bp-fg-faint: #6e6e6a;
+ --bp-border: #2e2e32;
+ --bp-accent: #82a5f5;
+ --bp-accent-contrast: #10101c;
+
+ --bp-note: #82a5f5;
+ --bp-note-bg: #1b2233;
+ --bp-tip: #5cc98d;
+ --bp-tip-bg: #16281e;
+ --bp-warn: #d9a53f;
+ --bp-warn-bg: #2b2415;
+ --bp-danger: #ef7b74;
+ --bp-danger-bg: #301b19;
+
+ --bp-chart-1: #7aa5d4;
+ --bp-chart-2: #f5a95b;
+ --bp-chart-3: #7fc27a;
+ --bp-chart-4: #ea817f;
+ --bp-chart-5: #c79cc0;
+ --bp-chart-6: #92cdc8;
+ }
+}
+
+[data-theme="dark"] {
+ --bp-bg: #141416;
+ --bp-surface: #1f1f23;
+ --bp-fg: #ececea;
+ --bp-fg-muted: #a4a4a0;
+ --bp-fg-faint: #6e6e6a;
+ --bp-border: #2e2e32;
+ --bp-accent: #82a5f5;
+ --bp-accent-contrast: #10101c;
+
+ --bp-note: #82a5f5;
+ --bp-note-bg: #1b2233;
+ --bp-tip: #5cc98d;
+ --bp-tip-bg: #16281e;
+ --bp-warn: #d9a53f;
+ --bp-warn-bg: #2b2415;
+ --bp-danger: #ef7b74;
+ --bp-danger-bg: #301b19;
+
+ --bp-chart-1: #7aa5d4;
+ --bp-chart-2: #f5a95b;
+ --bp-chart-3: #7fc27a;
+ --bp-chart-4: #ea817f;
+ --bp-chart-5: #c79cc0;
+ --bp-chart-6: #92cdc8;
+}
+
+/* ── Article base ───────────────────────────────────────────────────── */
+
+.bp-article {
+ max-width: var(--bp-measure);
+ margin-inline: auto;
+ padding-inline: 1rem;
+ font-family: var(--bp-font-sans);
+ font-size: var(--bp-size);
+ line-height: var(--bp-leading);
+ color: var(--bp-fg);
+ overflow-wrap: break-word;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+}
+
+@media (min-width: 720px) {
+ .bp-article {
+ --bp-size: 1.125rem; /* 18px on wide screens */
+ padding-inline: 0;
+ }
+}
+
+/* ── Flow & headings ────────────────────────────────────────────────── */
+
+.bp-p {
+ margin: 0 0 var(--bp-flow);
+}
+
+.bp-h2,
+.bp-h3,
+.bp-h4 {
+ font-family: var(--bp-font-sans);
+ color: var(--bp-fg);
+ line-height: 1.25;
+ letter-spacing: -0.015em;
+ text-wrap: balance;
+ scroll-margin-top: 5rem; /* anchored headings clear sticky headers */
+}
+
+.bp-h2 {
+ font-size: 1.625em;
+ font-weight: 700;
+ margin: calc(var(--bp-flow) * 2) 0 calc(var(--bp-flow) * 0.75);
+}
+
+.bp-h3 {
+ font-size: 1.3em;
+ font-weight: 650;
+ margin: calc(var(--bp-flow) * 1.6) 0 calc(var(--bp-flow) * 0.5);
+}
+
+.bp-h4 {
+ font-size: 1.1em;
+ font-weight: 650;
+ margin: calc(var(--bp-flow) * 1.3) 0 calc(var(--bp-flow) * 0.4);
+}
+
+.bp-hr {
+ border: 0;
+ border-top: 1px solid var(--bp-border);
+ margin: calc(var(--bp-flow) * 2) auto;
+ width: min(12rem, 60%);
+}
+
+/* ── Inline ─────────────────────────────────────────────────────────── */
+
+.bp-link {
+ color: var(--bp-accent);
+ text-decoration: underline;
+ text-decoration-thickness: 1px;
+ text-underline-offset: 0.2em;
+ text-decoration-color: color-mix(in srgb, var(--bp-accent) 45%, transparent);
+}
+
+.bp-link:hover {
+ text-decoration-color: var(--bp-accent);
+}
+
+.bp-inline-code {
+ font-family: var(--bp-font-mono);
+ font-size: 0.875em;
+ background: var(--bp-surface);
+ border: 1px solid var(--bp-border);
+ border-radius: var(--bp-radius-sm);
+ padding: 0.08em 0.35em;
+}
+
+.bp-fnref a {
+ color: var(--bp-accent);
+ text-decoration: none;
+ font-size: 0.8em;
+ padding-inline: 0.1em;
+}
+
+/* ── Lists ──────────────────────────────────────────────────────────── */
+
+.bp-ul,
+.bp-ol {
+ margin: 0 0 var(--bp-flow);
+ padding-inline-start: 1.4em;
+}
+
+.bp-li {
+ margin-block: 0.35em;
+}
+
+.bp-ul .bp-li::marker {
+ color: var(--bp-fg-faint);
+}
+
+.bp-ol .bp-li::marker {
+ color: var(--bp-fg-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+/* ── Quotes ─────────────────────────────────────────────────────────── */
+
+.bp-blockquote {
+ margin: 0 0 var(--bp-flow);
+ padding: 0.25rem 0 0.25rem 1.1rem;
+ border-inline-start: 3px solid var(--bp-border);
+ color: var(--bp-fg-muted);
+}
+
+.bp-blockquote .bp-p:last-child {
+ margin-bottom: 0;
+}
+
+.bp-pullquote {
+ margin: calc(var(--bp-flow) * 1.6) 0;
+ padding: 0;
+ text-align: center;
+}
+
+.bp-pullquote-text {
+ margin: 0;
+ font-size: 1.35em;
+ line-height: 1.45;
+ font-weight: 500;
+ letter-spacing: -0.01em;
+ text-wrap: balance;
+}
+
+.bp-pullquote-text::before {
+ content: "“";
+ display: block;
+ font-size: 2.2em;
+ line-height: 0.6;
+ color: var(--bp-fg-faint);
+ margin-bottom: 0.2em;
+}
+
+.bp-pullquote-cite {
+ margin-top: 0.75rem;
+ color: var(--bp-fg-muted);
+ font-size: 0.85em;
+}
+
+.bp-pullquote-cite::before {
+ content: "— ";
+}
+
+@media (min-width: 900px) {
+ .bp-pullquote {
+ margin-inline: -3rem; /* gentle breakout on wide screens */
+ }
+}
+
+/* ── Figures, galleries, lightbox ───────────────────────────────────── */
+
+.bp-figure {
+ margin: calc(var(--bp-flow) * 1.3) 0;
+}
+
+.bp-img {
+ display: block;
+ max-width: 100%;
+ height: auto;
+ border-radius: var(--bp-radius);
+ border: 1px solid var(--bp-border);
+}
+
+.bp-figcaption {
+ margin-top: 0.6rem;
+ text-align: center;
+ font-size: 0.85em;
+ color: var(--bp-fg-muted);
+}
+
+.bp-gallery {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 1rem;
+ margin: calc(var(--bp-flow) * 1.3) 0;
+}
+
+@media (min-width: 560px) {
+ .bp-gallery {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+.bp-gallery .bp-figure {
+ margin: 0;
+}
+
+.bp-lightbox-trigger {
+ display: block;
+ width: 100%;
+ padding: 0;
+ border: 0;
+ background: none;
+ cursor: zoom-in;
+}
+
+.bp-lightbox {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem 1rem;
+ background: color-mix(in srgb, var(--bp-bg) 20%, rgb(0 0 0 / 0.85));
+ cursor: zoom-out;
+}
+
+.bp-lightbox-img {
+ max-width: 100%;
+ max-height: 100%;
+ border-radius: var(--bp-radius-sm);
+}
+
+.bp-lightbox-close {
+ position: absolute;
+ top: 0.75rem;
+ right: 1rem;
+ border: 0;
+ background: none;
+ color: #fff;
+ font-size: 2rem;
+ line-height: 1;
+ cursor: pointer;
+ min-width: 44px;
+ min-height: 44px;
+}
+
+/* ── Callouts ───────────────────────────────────────────────────────── */
+
+.bp-callout {
+ margin: 0 0 var(--bp-flow);
+ padding: 0.9rem 1.1rem;
+ border-radius: var(--bp-radius);
+ border: 1px solid var(--bp-border);
+ border-inline-start: 3px solid var(--bp-note);
+ background: var(--bp-note-bg);
+}
+
+.bp-callout-title {
+ margin: 0 0 0.4rem;
+ font-size: 0.8em;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--bp-note);
+}
+
+.bp-callout-body > :last-child,
+.bp-callout-body > .bp-p:last-child {
+ margin-bottom: 0;
+}
+
+.bp-callout--tip {
+ border-inline-start-color: var(--bp-tip);
+ background: var(--bp-tip-bg);
+}
+.bp-callout--tip .bp-callout-title {
+ color: var(--bp-tip);
+}
+
+.bp-callout--warn {
+ border-inline-start-color: var(--bp-warn);
+ background: var(--bp-warn-bg);
+}
+.bp-callout--warn .bp-callout-title {
+ color: var(--bp-warn);
+}
+
+.bp-callout--danger {
+ border-inline-start-color: var(--bp-danger);
+ background: var(--bp-danger-bg);
+}
+.bp-callout--danger .bp-callout-title {
+ color: var(--bp-danger);
+}
+
+/* ── Code ───────────────────────────────────────────────────────────── */
+
+.bp-codeblock {
+ margin: calc(var(--bp-flow) * 1.2) 0;
+ border: 1px solid var(--bp-border);
+ border-radius: var(--bp-radius);
+ background: var(--bp-surface);
+ overflow: hidden;
+}
+
+.bp-codeblock-filename {
+ padding: 0.5rem 1rem;
+ font-family: var(--bp-font-mono);
+ font-size: 0.75em;
+ color: var(--bp-fg-muted);
+ border-bottom: 1px solid var(--bp-border);
+}
+
+.bp-codeblock-body {
+ position: relative;
+}
+
+.bp-pre,
+.bp-codeblock-highlighted pre {
+ margin: 0;
+ padding: 1rem;
+ overflow-x: auto;
+ font-family: var(--bp-font-mono);
+ font-size: 0.82em;
+ line-height: 1.6;
+ background: transparent !important; /* shiki inlines a bg; the frame owns it */
+}
+
+.bp-pre code,
+.bp-codeblock-highlighted code {
+ font-family: inherit;
+}
+
+/* Shiki dual themes: tokens carry --shiki-light/--shiki-dark vars. */
+.bp-codeblock-highlighted span {
+ color: var(--shiki-light);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) .bp-codeblock-highlighted span {
+ color: var(--shiki-dark);
+ }
+}
+
+[data-theme="dark"] .bp-codeblock-highlighted span {
+ color: var(--shiki-dark);
+}
+
+.bp-copy {
+ position: absolute;
+ top: 0.5rem;
+ right: 0.5rem;
+ z-index: 1;
+ padding: 0.25rem 0.6rem;
+ border: 1px solid var(--bp-border);
+ border-radius: var(--bp-radius-sm);
+ background: var(--bp-bg);
+ color: var(--bp-fg-muted);
+ font-family: var(--bp-font-sans);
+ font-size: 0.72rem;
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity 120ms ease;
+}
+
+.bp-codeblock-body:hover .bp-copy,
+.bp-copy:focus-visible,
+.bp-copy[data-copied] {
+ opacity: 1;
+}
+
+.bp-copy[data-copied] {
+ color: var(--bp-tip);
+ border-color: var(--bp-tip);
+}
+
+/* ── Mermaid & math ─────────────────────────────────────────────────── */
+
+.bp-mermaid {
+ display: flex;
+ justify-content: center;
+ overflow-x: auto;
+ margin: calc(var(--bp-flow) * 1.2) 0;
+ padding: 1rem;
+ border: 1px solid var(--bp-border);
+ border-radius: var(--bp-radius);
+ background: var(--bp-surface);
+}
+
+.bp-mermaid svg {
+ max-width: 100%;
+ height: auto;
+}
+
+.bp-mermaid--fallback,
+.bp-mermaid--error {
+ margin: 0;
+ font-family: var(--bp-font-mono);
+ font-size: 0.8em;
+ color: var(--bp-fg-muted);
+ white-space: pre;
+ overflow-x: auto;
+}
+
+.bp-math {
+ margin: calc(var(--bp-flow) * 1.2) 0;
+ overflow-x: auto;
+}
+
+.bp-math--display {
+ text-align: center;
+}
+
+.bp-math--fallback {
+ padding: 1rem;
+ border: 1px dashed var(--bp-border);
+ border-radius: var(--bp-radius);
+ background: var(--bp-surface);
+ font-family: var(--bp-font-mono);
+ font-size: 0.85em;
+ color: var(--bp-fg-muted);
+}
+
+/* ── Tables ─────────────────────────────────────────────────────────── */
+
+.bp-table-wrap {
+ margin: calc(var(--bp-flow) * 1.2) 0;
+ overflow-x: auto;
+ border: 1px solid var(--bp-border);
+ border-radius: var(--bp-radius);
+}
+
+.bp-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.9em;
+ line-height: 1.5;
+}
+
+.bp-table th,
+.bp-table td {
+ padding: 0.6rem 0.9rem;
+ text-align: left;
+ vertical-align: top;
+}
+
+.bp-table th {
+ font-weight: 650;
+ color: var(--bp-fg-muted);
+ font-size: 0.85em;
+ letter-spacing: 0.02em;
+ background: var(--bp-surface);
+ border-bottom: 1px solid var(--bp-border);
+}
+
+.bp-table td {
+ border-bottom: 1px solid var(--bp-border);
+}
+
+.bp-table tbody tr:last-child td {
+ border-bottom: 0;
+}
+
+/* ── Stats ──────────────────────────────────────────────────────────── */
+
+.bp-stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
+ gap: 1rem;
+ margin: calc(var(--bp-flow) * 1.3) 0;
+}
+
+.bp-stat {
+ padding: 1rem;
+ border: 1px solid var(--bp-border);
+ border-radius: var(--bp-radius);
+ background: var(--bp-surface);
+ text-align: center;
+}
+
+.bp-stat-value {
+ margin: 0;
+ font-size: 1.7em;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ font-variant-numeric: tabular-nums;
+}
+
+.bp-stat-label {
+ margin-top: 0.25rem;
+ font-size: 0.78em;
+ color: var(--bp-fg-muted);
+}
+
+/* ── Charts ─────────────────────────────────────────────────────────── */
+
+.bp-chart {
+ margin: calc(var(--bp-flow) * 1.3) 0;
+}
+
+.bp-chart-title {
+ margin-bottom: 0.6rem;
+ font-weight: 650;
+ font-size: 0.95em;
+}
+
+.bp-chart-frame {
+ position: relative;
+}
+
+.bp-chart-ylabel {
+ display: block;
+ margin-bottom: 0.25rem;
+ font-size: 0.75em;
+ color: var(--bp-fg-muted);
+}
+
+.bp-chart-svg {
+ display: block;
+ width: 100%;
+ height: auto;
+}
+
+.bp-chart-grid {
+ stroke: var(--bp-border);
+ stroke-width: 1;
+}
+
+.bp-chart-axis {
+ stroke: var(--bp-fg-faint);
+ stroke-width: 1;
+}
+
+.bp-chart-tick {
+ fill: var(--bp-fg-muted);
+ font-family: var(--bp-font-sans);
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+}
+
+.bp-chart-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem 1.2rem;
+ margin: 0.5rem 0 0;
+ padding: 0;
+ list-style: none;
+ font-size: 0.8em;
+ color: var(--bp-fg-muted);
+}
+
+.bp-chart-legend-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.bp-chart-swatch {
+ width: 0.7rem;
+ height: 0.7rem;
+ border-radius: 2px;
+}
+
+/* ── Embeds ─────────────────────────────────────────────────────────── */
+
+.bp-embed {
+ margin: calc(var(--bp-flow) * 1.3) 0;
+ aspect-ratio: 16 / 9;
+ border-radius: var(--bp-radius);
+ overflow: hidden;
+ border: 1px solid var(--bp-border);
+}
+
+.bp-embed-iframe {
+ width: 100%;
+ height: 100%;
+ border: 0;
+ display: block;
+}
+
+/* ── Footnotes ──────────────────────────────────────────────────────── */
+
+.bp-footnotes {
+ margin-top: calc(var(--bp-flow) * 2);
+ padding-top: var(--bp-flow);
+ border-top: 1px solid var(--bp-border);
+ font-size: 0.85em;
+ color: var(--bp-fg-muted);
+}
+
+.bp-footnotes-list {
+ margin: 0;
+ padding-inline-start: 1.4em;
+}
+
+.bp-footnote {
+ margin-block: 0.5em;
+}
+
+.bp-footnote:target {
+ color: var(--bp-fg);
+}
+
+.bp-footnote-body {
+ display: inline;
+}
+
+.bp-footnote-body .bp-p {
+ display: inline;
+ margin: 0;
+}
+
+.bp-fn-back {
+ color: var(--bp-accent);
+ text-decoration: none;
+}
+
+/* ── Islands: TOC & reading progress ────────────────────────────────── */
+
+.bp-toc {
+ font-size: 0.85rem;
+ line-height: 1.5;
+}
+
+@media (min-width: 1100px) {
+ .bp-toc {
+ position: sticky;
+ top: 5rem;
+ max-height: calc(100vh - 6rem);
+ overflow-y: auto;
+ }
+}
+
+.bp-toc-title {
+ margin: 0 0 0.5rem;
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--bp-fg-faint);
+}
+
+.bp-toc-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ border-inline-start: 1px solid var(--bp-border);
+}
+
+.bp-toc-item a {
+ display: block;
+ padding: 0.25rem 0 0.25rem 0.9rem;
+ color: var(--bp-fg-muted);
+ text-decoration: none;
+ border-inline-start: 2px solid transparent;
+ margin-inline-start: -1px;
+}
+
+.bp-toc-item--l3 a {
+ padding-inline-start: 1.8rem;
+}
+
+.bp-toc-item--l4 a {
+ padding-inline-start: 2.7rem;
+}
+
+.bp-toc-item a:hover {
+ color: var(--bp-fg);
+}
+
+.bp-toc-item--active a {
+ color: var(--bp-accent);
+ border-inline-start-color: var(--bp-accent);
+}
+
+.bp-progress {
+ position: fixed;
+ inset-inline: 0;
+ top: 0;
+ z-index: 50;
+ height: 2px;
+ background: transparent;
+ pointer-events: none;
+}
+
+.bp-progress-bar {
+ height: 100%;
+ background: var(--bp-accent);
+ transition: width 150ms ease-out;
+}
+
+/* ── Print ──────────────────────────────────────────────────────────── */
+
+@media print {
+ .bp-progress,
+ .bp-toc,
+ .bp-copy,
+ .bp-lightbox,
+ .bp-lightbox-close {
+ display: none !important;
+ }
+
+ .bp-article {
+ max-width: none;
+ font-size: 11pt;
+ }
+
+ .bp-figure,
+ .bp-codeblock,
+ .bp-callout,
+ .bp-stats,
+ .bp-chart,
+ .bp-table-wrap {
+ break-inside: avoid;
+ }
+
+ .bp-h2,
+ .bp-h3,
+ .bp-h4 {
+ break-after: avoid;
+ }
+
+ .bp-pre,
+ .bp-codeblock-highlighted pre {
+ white-space: pre-wrap;
+ }
+
+ .bp-link[href^="http"]::after {
+ content: " (" attr(href) ")";
+ font-size: 0.8em;
+ color: var(--bp-fg-muted);
+ word-break: break-all;
+ }
+
+ .bp-embed {
+ display: none;
+ }
+}
diff --git a/test/helpers.test.js b/test/helpers.test.js
new file mode 100644
index 0000000..eb770cf
--- /dev/null
+++ b/test/helpers.test.js
@@ -0,0 +1,69 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ parseContentBlocks,
+ extractToc,
+ readingTime,
+ slugify,
+ createSlugger,
+} from "../dist/index.js";
+
+// ── slugify ─────────────────────────────────────────────────────────────────
+
+test("slugify lowercases, hyphenates, and transliterates umlauts", () => {
+ assert.equal(slugify("Hello World!"), "hello-world");
+ assert.equal(slugify("Über die Straße"), "ueber-die-strasse");
+ assert.equal(slugify(" --- "), "");
+});
+
+test("createSlugger de-duplicates repeated slugs", () => {
+ const slug = createSlugger();
+ assert.equal(slug("Setup"), "setup");
+ assert.equal(slug("Setup"), "setup-2");
+ assert.equal(slug("!!!"), "section");
+});
+
+// ── extractToc ──────────────────────────────────────────────────────────────
+
+test("extractToc lists h2/h3/h4 with parser-assigned ids and levels", () => {
+ const blocks = parseContentBlocks("## One\n\ntext\n\n### One point one\n\n#### Deep\n\n## Two");
+ assert.deepEqual(extractToc(blocks), [
+ { id: "one", text: "One", level: 2 },
+ { id: "one-point-one", text: "One point one", level: 3 },
+ { id: "deep", text: "Deep", level: 4 },
+ { id: "two", text: "Two", level: 2 },
+ ]);
+});
+
+test("extractToc derives ids for hand-built v0.1 blocks without them", () => {
+ const toc = extractToc([
+ { type: "h2", text: "Alpha" },
+ { type: "h2", text: "Alpha" },
+ { type: "p", text: "not a heading" },
+ ]);
+ assert.deepEqual(
+ toc.map((t) => t.id),
+ ["alpha", "alpha-2"],
+ );
+});
+
+// ── readingTime ─────────────────────────────────────────────────────────────
+
+test("readingTime counts words across block types and floors at one minute", () => {
+ const short = readingTime(parseContentBlocks("Just a few words."));
+ assert.equal(short.words, 4);
+ assert.equal(short.minutes, 1);
+});
+
+test("readingTime scales with content at 200 wpm", () => {
+ const para = Array.from({ length: 100 }, () => "word").join(" ");
+ const body = Array.from({ length: 6 }, () => para).join("\n\n"); // 600 words
+ const rt = readingTime(parseContentBlocks(body));
+ assert.equal(rt.words, 600);
+ assert.equal(rt.minutes, 3);
+});
+
+test("readingTime reaches inside callouts and footnotes", () => {
+ const rt = readingTime(parseContentBlocks("> [!NOTE]\n> five words are in here"));
+ assert.equal(rt.words, 5);
+});
diff --git a/test/inline.test.js b/test/inline.test.js
new file mode 100644
index 0000000..08a4715
--- /dev/null
+++ b/test/inline.test.js
@@ -0,0 +1,94 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { parseInline, inlineToText } from "../dist/index.js";
+
+test("plain text is a single text span", () => {
+ assert.deepEqual(parseInline("just words"), [{ t: "text", text: "just words" }]);
+});
+
+test("**strong** and *em* and _em_ parse to nested spans", () => {
+ assert.deepEqual(parseInline("a **b** c"), [
+ { t: "text", text: "a " },
+ { t: "strong", children: [{ t: "text", text: "b" }] },
+ { t: "text", text: " c" },
+ ]);
+ assert.deepEqual(parseInline("*x*"), [{ t: "em", children: [{ t: "text", text: "x" }] }]);
+ assert.deepEqual(parseInline("_x_"), [{ t: "em", children: [{ t: "text", text: "x" }] }]);
+});
+
+test("em nests inside strong", () => {
+ assert.deepEqual(parseInline("**a *b* c**"), [
+ {
+ t: "strong",
+ children: [
+ { t: "text", text: "a " },
+ { t: "em", children: [{ t: "text", text: "b" }] },
+ { t: "text", text: " c" },
+ ],
+ },
+ ]);
+});
+
+test("inline code containing ** stays raw", () => {
+ assert.deepEqual(parseInline("run `a ** b` now"), [
+ { t: "text", text: "run " },
+ { t: "code", text: "a ** b" },
+ { t: "text", text: " now" },
+ ]);
+});
+
+test("code inside strong keeps its literal content", () => {
+ assert.deepEqual(parseInline("**use `x`**"), [
+ {
+ t: "strong",
+ children: [
+ { t: "text", text: "use " },
+ { t: "code", text: "x" },
+ ],
+ },
+ ]);
+});
+
+test("links carry href and parsed children", () => {
+ assert.deepEqual(parseInline("see [the **docs**](https://example.com/a)"), [
+ { t: "text", text: "see " },
+ {
+ t: "link",
+ href: "https://example.com/a",
+ children: [
+ { t: "text", text: "the " },
+ { t: "strong", children: [{ t: "text", text: "docs" }] },
+ ],
+ },
+ ]);
+});
+
+test("footnote references parse as footnoteRef, not link", () => {
+ assert.deepEqual(parseInline("fact[^1] more"), [
+ { t: "text", text: "fact" },
+ { t: "footnoteRef", id: "1" },
+ { t: "text", text: " more" },
+ ]);
+});
+
+test("unterminated markers stay literal text", () => {
+ assert.deepEqual(parseInline("a ** b"), [{ t: "text", text: "a ** b" }]);
+ assert.deepEqual(parseInline("a `b"), [{ t: "text", text: "a `b" }]);
+ assert.deepEqual(parseInline("[dangling](nope"), [{ t: "text", text: "[dangling](nope" }]);
+});
+
+test("intraword underscores and asterisks are not emphasis", () => {
+ assert.deepEqual(parseInline("snake_case_name"), [{ t: "text", text: "snake_case_name" }]);
+ assert.deepEqual(parseInline("2*3*4 = 24"), [{ t: "text", text: "2*3*4 = 24" }]);
+});
+
+test("javascript: hrefs survive parsing as data (renderer refuses them)", () => {
+ // The parser is a faithful reader; the RENDERER is the guard (safeHref).
+ const [link] = parseInline("[x](javascript:alert1)");
+ assert.equal(link.t, "link");
+ assert.equal(link.href, "javascript:alert1");
+});
+
+test("inlineToText flattens a span tree back to readable text", () => {
+ assert.equal(inlineToText(parseInline("a **b** `c` [d](e)[^1]")), "a b c d");
+});
diff --git a/test/package.test.js b/test/package.test.js
index 0082997..b9955c2 100644
--- a/test/package.test.js
+++ b/test/package.test.js
@@ -1,7 +1,15 @@
import { test, before } from "node:test";
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
-import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, existsSync } from "node:fs";
+import {
+ mkdtempSync,
+ mkdirSync,
+ writeFileSync,
+ readdirSync,
+ existsSync,
+ symlinkSync,
+ realpathSync,
+} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -19,7 +27,34 @@ import { join } from "node:path";
* it by bare specifier from outside the repo.
*/
-const PUBLIC_API = ["parseContentBlocks", "parseFrontmatter", "parseVideoEmbed", "videoEmbedSrc"];
+const PUBLIC_API = [
+ "parseContentBlocks",
+ "parseFrontmatter",
+ "parseInline",
+ "parseChartSpec",
+ "slugify",
+ "createSlugger",
+ "extractToc",
+ "readingTime",
+ "parseVideoEmbed",
+ "videoEmbedSrc",
+];
+
+const REACT_API = [
+ "ArticleBody",
+ "Figure",
+ "Gallery",
+ "Callout",
+ "PullQuote",
+ "Stats",
+ "Footnotes",
+ "CodeBlock",
+ "Chart",
+ "Toc",
+ "ReadingProgress",
+ "Lightbox",
+ "VideoEmbed",
+];
let workspace;
let installed;
@@ -42,12 +77,25 @@ before(() => {
// Each probe records failure as a *value*, never a throw. A broken exports map
// that crashes this hook would fail every assertion in the file at once and
// bury which entry point actually broke.
+ // The react subpath imports react/jsx-runtime — give the probe workspace the
+ // same react install this repo tests against.
+ for (const dep of ["react", "react-dom"]) {
+ symlinkSync(
+ realpathSync(join(process.cwd(), "node_modules", dep)),
+ join(workspace, "node_modules", dep),
+ "dir",
+ );
+ }
+
writeFileSync(
join(workspace, "probe.mjs"),
[
"const out = {};",
'try { out.resolved = import.meta.resolve("bip-kit"); } catch { out.resolved = null; }',
'try { out.exports = Object.keys(await import("bip-kit")).sort(); } catch { out.exports = null; }',
+ 'try { out.reactExports = Object.keys(await import("bip-kit/react")).sort(); } catch { out.reactExports = null; }',
+ 'try { out.mermaidExports = Object.keys(await import("bip-kit/react/mermaid")).sort(); } catch { out.mermaidExports = null; }',
+ 'try { out.stylesResolved = import.meta.resolve("bip-kit/styles.css"); } catch { out.stylesResolved = null; }',
"console.log(JSON.stringify(out));",
].join("\n"),
);
@@ -95,3 +143,20 @@ test("the tarball ships built output, not raw TypeScript sources", () => {
assert.ok(existsSync(join(installed, "dist", "index.js")), "dist/index.js missing");
assert.ok(!existsSync(join(installed, "src")), "src/ leaked into the tarball");
});
+
+test("the react subpath exposes the renderer API from a consumer install", () => {
+ assert.ok(probe.reactExports, 'importing "bip-kit/react" from a consumer install threw');
+ for (const name of REACT_API) {
+ assert.ok(probe.reactExports.includes(name), `"${name}" is missing from bip-kit/react`);
+ }
+});
+
+test("the mermaid island lives on its own subpath", () => {
+ assert.ok(probe.mermaidExports, 'importing "bip-kit/react/mermaid" threw');
+ assert.ok(probe.mermaidExports.includes("MermaidBlock"), "MermaidBlock missing");
+});
+
+test("styles.css is exported and shipped in the tarball", () => {
+ assert.ok(probe.stylesResolved, '"bip-kit/styles.css" did not resolve');
+ assert.ok(existsSync(join(installed, "styles.css")), "styles.css missing from the tarball");
+});
diff --git a/test/parse-content-v02.test.js b/test/parse-content-v02.test.js
new file mode 100644
index 0000000..082feea
--- /dev/null
+++ b/test/parse-content-v02.test.js
@@ -0,0 +1,286 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { parseContentBlocks, parseFrontmatter } from "../dist/index.js";
+
+// ── headings: ids + h4 ──────────────────────────────────────────────────────
+
+test("headings carry slug ids; #### is h4", () => {
+ const [h2, h3, h4] = parseContentBlocks("## Big Title\n### Sub Part\n#### Deep Dive");
+ assert.equal(h2.id, "big-title");
+ assert.equal(h3.id, "sub-part");
+ assert.deepEqual(h4, {
+ type: "h4",
+ text: "Deep Dive",
+ id: "deep-dive",
+ spans: [{ t: "text", text: "Deep Dive" }],
+ });
+});
+
+test("duplicate heading text de-duplicates ids", () => {
+ const blocks = parseContentBlocks("## Setup\n## Setup\n## Setup");
+ assert.deepEqual(
+ blocks.map((b) => b.id),
+ ["setup", "setup-2", "setup-3"],
+ );
+});
+
+test("umlaut headings slugify German-style (ä→ae, ß→ss)", () => {
+ const [a, b] = parseContentBlocks("## Über uns\n## Straße & Größe");
+ assert.equal(a.id, "ueber-uns");
+ assert.equal(b.id, "strasse-groesse");
+});
+
+test("headings and paragraphs carry parsed spans alongside untouched text", () => {
+ const [h, p] = parseContentBlocks("## The **bold** era\n\nBody with `code`.");
+ assert.equal(h.text, "The **bold** era");
+ assert.deepEqual(h.spans, [
+ { t: "text", text: "The " },
+ { t: "strong", children: [{ t: "text", text: "bold" }] },
+ { t: "text", text: " era" },
+ ]);
+ assert.equal(p.text, "Body with `code`.");
+ assert.deepEqual(p.spans, [
+ { t: "text", text: "Body with " },
+ { t: "code", text: "code" },
+ { t: "text", text: "." },
+ ]);
+});
+
+test("list items get itemSpans; blockquote lines get spans", () => {
+ const [ul] = parseContentBlocks("- plain\n- **bold** item");
+ assert.deepEqual(ul.itemSpans[1], [
+ { t: "strong", children: [{ t: "text", text: "bold" }] },
+ { t: "text", text: " item" },
+ ]);
+ const [bq] = parseContentBlocks("> a *quiet* line");
+ assert.equal(bq.type, "blockquote");
+ assert.deepEqual(bq.spans[0][1], { t: "em", children: [{ t: "text", text: "quiet" }] });
+});
+
+// ── hr ──────────────────────────────────────────────────────────────────────
+
+test("--- and *** on their own line are hr blocks", () => {
+ assert.deepEqual(parseContentBlocks("---"), [{ type: "hr" }]);
+ assert.deepEqual(parseContentBlocks("***"), [{ type: "hr" }]);
+});
+
+// ── figures & galleries ─────────────────────────────────────────────────────
+
+test('an image with a "caption" title becomes a figure with parsed caption spans', () => {
+ const [fig] = parseContentBlocks('');
+ assert.equal(fig.type, "figure");
+ assert.equal(fig.src, "/img/arch.png");
+ assert.equal(fig.alt, "diagram");
+ assert.equal(fig.caption, "The **full** system");
+ assert.deepEqual(fig.spans[1], { t: "strong", children: [{ t: "text", text: "full" }] });
+});
+
+test("a bare single image stays a v0.1 image block", () => {
+ assert.deepEqual(parseContentBlocks(""), [
+ { type: "image", alt: "alt", src: "/a.png" },
+ ]);
+});
+
+test("adjacent image lines merge into one gallery, captions kept", () => {
+ const [g] = parseContentBlocks('\n\n');
+ assert.equal(g.type, "gallery");
+ assert.deepEqual(g.images, [
+ { src: "/1.png", alt: "a", caption: undefined },
+ { src: "/2.png", alt: "b", caption: "two" },
+ { src: "/3.png", alt: "c", caption: undefined },
+ ]);
+});
+
+test("a blank line between images keeps them separate blocks (not a gallery)", () => {
+ const blocks = parseContentBlocks("\n\n");
+ assert.deepEqual(
+ blocks.map((b) => b.type),
+ ["image", "image"],
+ );
+});
+
+// ── callouts ────────────────────────────────────────────────────────────────
+
+test("GitHub callout syntax parses kind, optional title, and body blocks", () => {
+ const [c] = parseContentBlocks("> [!WARNING] Mind the gap\n> First line.\n>\n> Second para.");
+ assert.equal(c.type, "callout");
+ assert.equal(c.kind, "warn");
+ assert.equal(c.title, "Mind the gap");
+ assert.deepEqual(
+ c.blocks.map((b) => [b.type, b.text]),
+ [
+ ["p", "First line."],
+ ["p", "Second para."],
+ ],
+ );
+});
+
+test("all four callout kinds map from their GitHub aliases", () => {
+ for (const [marker, kind] of [
+ ["NOTE", "note"],
+ ["TIP", "tip"],
+ ["WARN", "warn"],
+ ["WARNING", "warn"],
+ ["CAUTION", "danger"],
+ ["DANGER", "danger"],
+ ]) {
+ const [c] = parseContentBlocks(`> [!${marker}]\n> body`);
+ assert.equal(c.type, "callout", marker);
+ assert.equal(c.kind, kind, marker);
+ assert.equal(c.title, undefined, marker);
+ }
+});
+
+test("callouts nest: a callout inside a callout", () => {
+ const [outer] = parseContentBlocks(
+ "> [!NOTE] Outer\n> Intro.\n> > [!TIP] Inner\n> > Nested body.",
+ );
+ assert.equal(outer.type, "callout");
+ const inner = outer.blocks.find((b) => b.type === "callout");
+ assert.ok(inner, "no nested callout parsed");
+ assert.equal(inner.kind, "tip");
+ assert.equal(inner.title, "Inner");
+ assert.deepEqual(inner.blocks[0].text, "Nested body.");
+});
+
+test("a plain quote without a marker is still a v0.1 blockquote", () => {
+ const [b] = parseContentBlocks("> just quoting\n> someone");
+ assert.equal(b.type, "blockquote");
+ assert.deepEqual(b.text, ["just quoting", "someone"]);
+});
+
+// ── pull quotes ─────────────────────────────────────────────────────────────
+
+test(">> lines form a pullquote; a trailing — line is the citation", () => {
+ const [pq] = parseContentBlocks(">> Simplicity scales,\n>> complexity compounds.\n>> — George");
+ assert.equal(pq.type, "pullquote");
+ assert.equal(pq.text, "Simplicity scales, complexity compounds.");
+ assert.equal(pq.cite, "George");
+ assert.equal(pq.spans[0].t, "text");
+});
+
+test("a pullquote without citation has cite undefined", () => {
+ const [pq] = parseContentBlocks(">> Just the words.");
+ assert.equal(pq.cite, undefined);
+ assert.equal(pq.text, "Just the words.");
+});
+
+// ── mermaid / chart / stats fences ──────────────────────────────────────────
+
+test("chart fence with JSON body parses to a validated spec", () => {
+ const src = [
+ "```chart",
+ '{ "kind": "line", "title": "Users", "series": [{ "name": "Weekly", "points": [["W1", 10], ["W2", 25]] }] }',
+ "```",
+ ].join("\n");
+ const [c] = parseContentBlocks(src);
+ assert.equal(c.type, "chart");
+ assert.equal(c.spec.kind, "line");
+ assert.deepEqual(c.spec.series[0].points, [
+ ["W1", 10],
+ ["W2", 25],
+ ]);
+});
+
+test("chart fence with line-format body parses kinds, labels and series", () => {
+ const src = [
+ "```chart",
+ "kind: bar",
+ "title: Weekly signups",
+ "ylabel: signups",
+ "series Organic: Jan=12, Feb=30",
+ "series Paid: Jan=4, Feb=9.5",
+ "```",
+ ].join("\n");
+ const [c] = parseContentBlocks(src);
+ assert.equal(c.spec.kind, "bar");
+ assert.equal(c.spec.title, "Weekly signups");
+ assert.equal(c.spec.yLabel, "signups");
+ assert.deepEqual(c.spec.series[1].points, [
+ ["Jan", 4],
+ ["Feb", 9.5],
+ ]);
+});
+
+test("broken chart specs THROW with the reason — never a silent drop", () => {
+ assert.throws(() => parseContentBlocks('```chart\n{ "kind": "pie", "series": [] }\n```'), /kind/);
+ assert.throws(() => parseContentBlocks("```chart\n{ not json\n```"), /invalid JSON/);
+ assert.throws(
+ () => parseContentBlocks("```chart\nkind: bar\nseries A: Jan=abc\n```"),
+ /label=number/,
+ );
+ assert.throws(() => parseContentBlocks("```chart\nkind: bar\nwat is this\n```"), /unrecognized/);
+ assert.throws(() => parseContentBlocks("```chart\nkind: bar\n```"), /non-empty/);
+});
+
+test("stats fence parses value | label pairs; malformed lines throw", () => {
+ const [s] = parseContentBlocks("```stats\n68 | essays shipped\n99.9% | uptime\n```");
+ assert.deepEqual(s, {
+ type: "stats",
+ items: [
+ { value: "68", label: "essays shipped" },
+ { value: "99.9%", label: "uptime" },
+ ],
+ });
+ assert.throws(() => parseContentBlocks("```stats\nno pipe here\n```"), /value \| label/);
+});
+
+// ── math ────────────────────────────────────────────────────────────────────
+
+test("$$..$$ on one line and $$-fenced blocks parse as display math", () => {
+ assert.deepEqual(parseContentBlocks("$$e = mc^2$$"), [
+ { type: "math", tex: "e = mc^2", display: true },
+ ]);
+ const [m] = parseContentBlocks("$$\n\\sum_{i=1}^n i\n$$");
+ assert.deepEqual(m, { type: "math", tex: "\\sum_{i=1}^n i", display: true });
+});
+
+// ── footnotes ───────────────────────────────────────────────────────────────
+
+test("footnote refs and defs round-trip on the same id", () => {
+ const blocks = parseContentBlocks(
+ "A claim[^src] needs proof.\n\n[^src]: The **primary** source.\n",
+ );
+ const p = blocks.find((b) => b.type === "p");
+ const ref = p.spans.find((s) => s.t === "footnoteRef");
+ const def = blocks.find((b) => b.type === "footnote");
+ assert.ok(ref, "no footnoteRef parsed");
+ assert.ok(def, "no footnote def parsed");
+ assert.equal(ref.id, def.id);
+ assert.equal(def.blocks[0].type, "p");
+ assert.equal(def.blocks[0].text, "The **primary** source.");
+});
+
+test("footnote definitions continue on indented lines", () => {
+ const [def] = parseContentBlocks("[^1]: First line.\n Continued line.");
+ assert.equal(def.type, "footnote");
+ assert.equal(def.blocks[0].text, "First line. Continued line.");
+});
+
+// ── frontmatter arrays ──────────────────────────────────────────────────────
+
+test("inline YAML arrays parse: tags: [a, b]", () => {
+ const { meta } = parseFrontmatter('---\ntags: [bitcoin, "build in public", ai]\n---\n');
+ assert.deepEqual(meta.tags, ["bitcoin", "build in public", "ai"]);
+});
+
+test("block YAML lists parse: dash items under an empty key", () => {
+ const { meta, body } = parseFrontmatter(
+ "---\ntitle: Post\ntags:\n - one\n - 'two words'\nauthor: Mao\n---\nBody",
+ );
+ assert.deepEqual(meta.tags, ["one", "two words"]);
+ assert.equal(meta.title, "Post");
+ assert.equal(meta.author, "Mao");
+ assert.equal(body, "Body");
+});
+
+test("empty inline array parses to an empty array", () => {
+ const { meta } = parseFrontmatter("---\ntags: []\n---\n");
+ assert.deepEqual(meta.tags, []);
+});
+
+test("scalar frontmatter files keep parsing exactly as v0.1 (strings)", () => {
+ const { meta } = parseFrontmatter("---\ntitle: Hello\ntags: a, b\n---\nBody");
+ assert.equal(meta.title, "Hello");
+ assert.equal(meta.tags, "a, b"); // string, NOT an array — compat
+});
diff --git a/test/parse-content.test.js b/test/parse-content.test.js
index 369bd9e..bcf800c 100644
--- a/test/parse-content.test.js
+++ b/test/parse-content.test.js
@@ -2,10 +2,26 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import { parseContentBlocks, parseFrontmatter } from "../dist/index.js";
-// ── parseContentBlocks ──────────────────────────────────────────────────────
+// ── parseContentBlocks: v0.1 compatibility goldens ──────────────────────────
+//
+// v0.2 ADDS fields (heading `id`, inline `spans`/`itemSpans`) but never
+// changes the v0.1 fields. These goldens assert exactly that: after dropping
+// the additive fields, the original shapes are byte-identical. The additive
+// fields themselves are asserted in parse-content-v02.test.js.
+
+const strip = (blocks) =>
+ blocks.map((block) => {
+ const rest = { ...block };
+ delete rest.spans;
+ delete rest.itemSpans;
+ delete rest.id;
+ return rest;
+ });
+
+const parseStripped = (body) => strip(parseContentBlocks(body));
test("headings: ## and ### become h2/h3, text trimmed", () => {
- const blocks = parseContentBlocks("## Roadmap \n### Q3 ");
+ const blocks = parseStripped("## Roadmap \n### Q3 ");
assert.deepEqual(blocks, [
{ type: "h2", text: "Roadmap" },
{ type: "h3", text: "Q3" },
@@ -13,7 +29,7 @@ test("headings: ## and ### become h2/h3, text trimmed", () => {
});
test("consecutive plain lines join into one paragraph", () => {
- const blocks = parseContentBlocks("First line\nsecond line\n\nNew paragraph");
+ const blocks = parseStripped("First line\nsecond line\n\nNew paragraph");
assert.deepEqual(blocks, [
{ type: "p", text: "First line second line" },
{ type: "p", text: "New paragraph" },
@@ -28,7 +44,7 @@ test("CRLF input parses the same as LF", () => {
});
test("unordered list collects consecutive - items", () => {
- const blocks = parseContentBlocks("- one\n- two\n\n- three");
+ const blocks = parseStripped("- one\n- two\n\n- three");
assert.deepEqual(blocks, [
{ type: "ul", items: ["one", "two"] },
{ type: "ul", items: ["three"] },
@@ -36,12 +52,12 @@ test("unordered list collects consecutive - items", () => {
});
test("ordered list collects consecutive numbered items", () => {
- const blocks = parseContentBlocks("1. first\n2. second\n10. tenth");
+ const blocks = parseStripped("1. first\n2. second\n10. tenth");
assert.deepEqual(blocks, [{ type: "ol", items: ["first", "second", "tenth"] }]);
});
test("blockquote keeps one entry per quoted line", () => {
- const blocks = parseContentBlocks("> line one\n> line two");
+ const blocks = parseStripped("> line one\n> line two");
assert.deepEqual(blocks, [{ type: "blockquote", text: ["line one", "line two"] }]);
});
@@ -50,10 +66,9 @@ test("fenced code keeps language, inner newlines, and indentation", () => {
assert.deepEqual(blocks, [{ type: "code", lang: "ts", text: "const a = 1;\n indented();" }]);
});
-test('mermaid fences survive as code blocks with lang "mermaid"', () => {
+test("mermaid fences are first-class mermaid blocks since v0.2", () => {
const [block] = parseContentBlocks("```mermaid\ngraph TD; A-->B\n```");
- assert.equal(block.type, "code");
- assert.equal(block.lang, "mermaid");
+ assert.deepEqual(block, { type: "mermaid", code: "graph TD; A-->B" });
});
test("an unterminated fence consumes to end of input without crashing", () => {
@@ -76,7 +91,7 @@ test("GFM table needs a separator row; cells are trimmed", () => {
});
test("a pipe line without a separator row is a paragraph, not a table", () => {
- const blocks = parseContentBlocks("| just | text |");
+ const blocks = parseStripped("| just | text |");
assert.deepEqual(blocks, [{ type: "p", text: "| just | text |" }]);
});
@@ -96,17 +111,17 @@ test("lone YouTube and Vimeo URLs become embed blocks", () => {
});
test("URLs from non-allowlisted hosts stay paragraphs", () => {
- const blocks = parseContentBlocks("https://evil.example.com/watch?v=abc");
+ const blocks = parseStripped("https://evil.example.com/watch?v=abc");
assert.deepEqual(blocks, [{ type: "p", text: "https://evil.example.com/watch?v=abc" }]);
});
test("a URL inside prose does not split the paragraph", () => {
- const blocks = parseContentBlocks("Watch https://youtu.be/dQw4w9WgXcQ for context");
+ const blocks = parseStripped("Watch https://youtu.be/dQw4w9WgXcQ for context");
assert.deepEqual(blocks, [{ type: "p", text: "Watch https://youtu.be/dQw4w9WgXcQ for context" }]);
});
test("a paragraph ends where the next block type starts, without a blank line", () => {
- const blocks = parseContentBlocks("Some text\n- item");
+ const blocks = parseStripped("Some text\n- item");
assert.deepEqual(blocks, [
{ type: "p", text: "Some text" },
{ type: "ul", items: ["item"] },
diff --git a/test/react-degrade.test.js b/test/react-degrade.test.js
new file mode 100644
index 0000000..60b5d53
--- /dev/null
+++ b/test/react-degrade.test.js
@@ -0,0 +1,57 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { renderToString } from "react-dom/server";
+import { parseContentBlocks } from "../dist/index.js";
+import { ArticleBody, CodeBlock, MathBlock } from "../dist/react/index.js";
+
+/**
+ * Degradation paths WITHOUT the optional peers. shiki and katex ARE installed
+ * as devDependencies here, so the missing-peer path is forced through the
+ * documented test seam: BIPKIT_DISABLE_PEERS (checked per load, not at
+ * import time — setting it here affects exactly this process).
+ */
+process.env.BIPKIT_DISABLE_PEERS = "all";
+
+test("code degrades to an honest mono , still with copy button + filename", async () => {
+ const blocks = parseContentBlocks("```ts src/x.ts\nconst a: number = 1;\n```");
+ const html = renderToString(await ArticleBody({ blocks }));
+ assert.doesNotMatch(html, /bp-codeblock-highlighted/);
+ assert.doesNotMatch(html, /--shiki/);
+ assert.match(html, //);
+ assert.match(html, /language-ts/);
+ assert.match(html, /class="bp-copy"/);
+ assert.match(html, /src\/x\.ts/);
+});
+
+test("math degrades to styled source with a data attribute — no crash", async () => {
+ const blocks = parseContentBlocks("$$e = mc^2$$");
+ const html = renderToString(await ArticleBody({ blocks }));
+ assert.doesNotMatch(html, /katex/);
+ assert.match(html, /bp-math--fallback/);
+ assert.match(html, /data-math="display"/);
+ assert.match(html, /e = mc\^2/);
+});
+
+test("standalone CodeBlock and MathBlock degrade the same way", async () => {
+ const code = renderToString(await CodeBlock({ block: { lang: "js", text: "1" } }));
+ assert.match(code, //);
+ const math = renderToString(await MathBlock({ block: { tex: "x^2", display: true } }));
+ assert.match(math, /bp-math--fallback/);
+});
+
+test("mermaid without an override renders its source fallback (peer never loads in SSR)", async () => {
+ const blocks = parseContentBlocks("```mermaid\ngraph TD; A-->B\n```");
+ const html = renderToString(await ArticleBody({ blocks }));
+ assert.match(html, /data-mermaid/);
+ assert.match(html, /graph TD/);
+});
+
+test("a full document still renders end-to-end with zero peers", async () => {
+ const blocks = parseContentBlocks(
+ "## T\n\ntext[^1]\n\n```js\n1\n```\n\n$$x$$\n\n> [!NOTE]\n> n\n\n[^1]: def",
+ );
+ const html = renderToString(await ArticleBody({ blocks }));
+ assert.match(html, /bp-article/);
+ assert.match(html, /bp-callout--note/);
+ assert.match(html, /bp-footnotes/);
+});
diff --git a/test/react-render.test.js b/test/react-render.test.js
new file mode 100644
index 0000000..2042d2f
--- /dev/null
+++ b/test/react-render.test.js
@@ -0,0 +1,224 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { createElement } from "react";
+import { renderToString } from "react-dom/server";
+import { parseContentBlocks, extractToc } from "../dist/index.js";
+import {
+ ArticleBody,
+ Toc,
+ ReadingProgress,
+ Lightbox,
+ CopyButton,
+ CodeBlock,
+ Chart,
+ VideoEmbed,
+} from "../dist/react/index.js";
+
+/**
+ * renderToString smoke tests WITH the optional peers installed (shiki and
+ * katex are devDependencies here). ArticleBody is an async server component:
+ * awaiting the component function yields a fully synchronous element tree
+ * that react-dom/server can render — the same contract Next.js RSC uses.
+ */
+
+const FULL_DOC = `
+## Intro **section**
+
+A paragraph with **bold**, *em*, \`code\`, a [link](https://example.com) and a ref[^1].
+
+### Lists
+
+- one
+- **two**
+
+1. first
+2. second
+
+#### Deep heading
+
+---
+
+> plain quote line
+
+> [!TIP] Try this
+> Callout body with a nested quote.
+
+>> The pull quote itself.
+>> — Someone
+
+
+
+
+
+
+
+
+\`\`\`ts src/example.ts
+const x: number = 1;
+\`\`\`
+
+\`\`\`mermaid
+graph TD; A-->B
+\`\`\`
+
+\`\`\`chart
+kind: bar
+title: Signups
+series Organic: Jan=10, Feb=20
+series Paid: Jan=5, Feb=8
+\`\`\`
+
+\`\`\`stats
+68 | essays
+2 | stars
+\`\`\`
+
+$$e = mc^2$$
+
+| Col A | Col B |
+|-------|-------|
+| a1 | b1 |
+
+https://youtu.be/dQw4w9WgXcQ
+
+[^1]: The footnote **definition**.
+`;
+
+test("ArticleBody renders every block type (peers present)", async () => {
+ const blocks = parseContentBlocks(FULL_DOC);
+ const html = renderToString(await ArticleBody({ blocks }));
+
+ // headings + ids
+ assert.match(html, /