@panaversity/ksor 0.0.38 → 0.0.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,276 @@
1
+ "use client";
2
+
3
+ import { ExternalLink, Play } from "lucide-react";
4
+ import { useCallback, useEffect, useRef, useState, type ReactElement } from "react";
5
+
6
+ import { Button } from "@/components/ui/button";
7
+
8
+ /**
9
+ * An interactive page the document points at.
10
+ *
11
+ * Authored as an ordinary link (see lib/embed-rule.ts); rendered here as a
12
+ * frame the reader loads by asking. The click is not politeness — the
13
+ * scaffold's browser test asserts ZERO external requests on a built page, and
14
+ * that guarantee is what makes this record work offline, behind a firewall,
15
+ * and without telling a third party which document someone is reading. An
16
+ * always-on frame would break it on every page carrying one.
17
+ *
18
+ * Two things the panel says out loud, because they are the reason for the
19
+ * click. It NAMES the host, so a reader consents to a party rather than to a
20
+ * button. And it says the page is not part of the record: an embed carries no
21
+ * provenance claim, cannot be cited, and can change under the document without
22
+ * anyone reviewing it. The link out is always there and costs nothing, because
23
+ * a plain `<a>` is not a request.
24
+ */
25
+
26
+ /**
27
+ * A framed page may not scroll the document that hosts it.
28
+ *
29
+ * `scrollIntoView` scrolls EVERY ancestor scrolling box, and a frame's
30
+ * ancestors include the host page — so a page that auto-scrolls its own log
31
+ * throws the reader somewhere else entirely. Six of the seven sims this was
32
+ * built for do exactly that, and clicking one moved the page 5,807px (found
33
+ * live).
34
+ *
35
+ * The call still does its real job inside the frame; only the part that moved
36
+ * the host is undone, in the same task, so nothing is painted in between.
37
+ *
38
+ * Same-origin only, which is all it can be: a cross-origin frame's prototypes
39
+ * are not reachable, and it cannot scroll us either.
40
+ */
41
+ function containScrolling(doc: Document | null): void {
42
+ const view = doc?.defaultView;
43
+ if (!view) return;
44
+ const proto = view.Element.prototype as Element & {
45
+ scrollIntoView: (...args: unknown[]) => void;
46
+ __ksorContained?: boolean;
47
+ };
48
+ if (proto.__ksorContained) return;
49
+ const native = proto.scrollIntoView;
50
+ proto.scrollIntoView = function contained(this: Element, ...args: unknown[]): void {
51
+ const { scrollX, scrollY } = window;
52
+ native.apply(this, args);
53
+ if (window.scrollX !== scrollX || window.scrollY !== scrollY) {
54
+ window.scrollTo(scrollX, scrollY);
55
+ }
56
+ };
57
+ proto.__ksorContained = true;
58
+ }
59
+
60
+ /**
61
+ * Close the gutter a framed page leaves around itself.
62
+ *
63
+ * A page that does not reset `body { margin }` keeps the user agent's 8px, and
64
+ * that band shows whatever is behind the frame — so a sim painted near-black
65
+ * sat inside a ring of near-white. Matching the colour instead was tried and
66
+ * does not work: these pages paint below the body, so `html`, `body` and even
67
+ * the wrapper all compute to transparent (measured on the one that has the
68
+ * gutter), and chasing the colour down the tree would be guesswork.
69
+ *
70
+ * Zeroing it is the honest fix, and it is what six of the seven sims already
71
+ * do in their own stylesheet — this only makes the seventh agree. Presentation
72
+ * of the frame is the host's to decide; the page's content is untouched.
73
+ */
74
+ function closeGutter(doc: Document | null): void {
75
+ const body = doc?.body;
76
+ if (!body) return;
77
+ body.style.margin = "0";
78
+ }
79
+
80
+ export function Embed({
81
+ url,
82
+ host,
83
+ label,
84
+ owned,
85
+ }: {
86
+ readonly url: string;
87
+ readonly host: string;
88
+ readonly label: string;
89
+ /** "true" when the page is carried IN the record and served from here. */
90
+ readonly owned?: string;
91
+ }): ReactElement {
92
+ const [loaded, setLoaded] = useState(false);
93
+ const [height, setHeight] = useState<number | null>(null);
94
+ const frame = useRef<HTMLIFrameElement>(null);
95
+ const isOwned = owned === "true";
96
+
97
+ /**
98
+ * Fit the frame to the page it holds, so nothing scrolls in a box and no
99
+ * band of dead space sits under it.
100
+ *
101
+ * Measure the body's CHILDREN, never `documentElement.scrollHeight`. These
102
+ * pages set `min-height: 100vh`, and inside a frame the viewport IS the
103
+ * frame — so the document's scroll height is just the frame's own height
104
+ * echoed back, and a frame sized from it grows without bound. Watched that
105
+ * run away before the CSS explained it (2026-08-24). The children do not
106
+ * depend on the frame: measured across all seven sims of the record this was
107
+ * built for, each returned the same height at a 300px frame and a 1400px
108
+ * one.
109
+ *
110
+ * Possible at all only because a carried page is SAME-ORIGIN. A cross-origin
111
+ * frame refuses `contentDocument`, so it keeps the ratio box and this
112
+ * returns without touching anything.
113
+ */
114
+ const fit = useCallback((): void => {
115
+ let doc: Document | null = null;
116
+ try {
117
+ doc = frame.current?.contentDocument ?? null;
118
+ } catch {
119
+ return; // cross-origin: not ours to measure
120
+ }
121
+ const body = doc?.body;
122
+ if (!body) return;
123
+ // NOT `instanceof HTMLElement`. The elements inside a frame belong to the
124
+ // frame's realm, so they are instances of ITS HTMLElement and never of this
125
+ // one — the filter matched nothing, and the frame kept the ratio box while
126
+ // looking as though measuring had simply not helped (found live).
127
+ const blocks = [...body.children].filter(
128
+ (el): el is HTMLElement => typeof (el as HTMLElement).offsetHeight === "number",
129
+ );
130
+ if (blocks.length === 0) return;
131
+ const bottom = Math.max(...blocks.map((el) => el.offsetTop + el.offsetHeight));
132
+ // The body's own bottom edge, padding AND margin. A page that does not
133
+ // reset `body { margin }` keeps the user agent's 8px, and leaving the
134
+ // margin out left exactly that much overflowing — one scrollbar, on the
135
+ // one sim of seven whose stylesheet omits the reset (found live).
136
+ // `offsetTop` already carries the top margin.
137
+ const style = getComputedStyle(body);
138
+ const edge =
139
+ (Number.parseFloat(style.paddingBottom) || 0) + (Number.parseFloat(style.marginBottom) || 0);
140
+ const measured = Math.ceil(bottom + edge);
141
+ // GROW-ONLY. A running page changes height every beat — measured
142
+ // oscillating 504 to 562 on one sim — and following it exactly would
143
+ // shift everything below the frame while someone is reading. The high
144
+ // water mark costs a little slack after a shrink and never a scrollbar.
145
+ if (measured > 0)
146
+ setHeight((current) => (current === null ? measured : Math.max(current, measured)));
147
+ }, []);
148
+
149
+ const watcher = useRef<ResizeObserver | null>(null);
150
+
151
+ /**
152
+ * Measure on load, then keep watching — these pages animate, and some add a
153
+ * row per beat.
154
+ *
155
+ * Set up HERE rather than in an effect on `loaded`: that effect runs the
156
+ * moment the click flips the state, which is before the frame's document
157
+ * exists, so it observed an empty `about:blank` and never fired again (found
158
+ * live — the frame fitted once and then ignored everything).
159
+ *
160
+ * Watch the CHILDREN, not the body. The body's own box is pinned by the
161
+ * page's `min-height: 100vh` to exactly the frame, so it never reports a
162
+ * change.
163
+ */
164
+ const handleLoad = useCallback((): void => {
165
+ fit();
166
+ let doc: Document | null = null;
167
+ try {
168
+ doc = frame.current?.contentDocument ?? null;
169
+ } catch {
170
+ return;
171
+ }
172
+ containScrolling(doc);
173
+ closeGutter(doc);
174
+ const body = doc?.body;
175
+ if (!body || typeof ResizeObserver === "undefined") return;
176
+ watcher.current?.disconnect();
177
+ const observer = new ResizeObserver(fit);
178
+ for (const child of body.children) observer.observe(child);
179
+ watcher.current = observer;
180
+ }, [fit]);
181
+
182
+ useEffect(() => () => watcher.current?.disconnect(), []);
183
+
184
+ return (
185
+ <figure
186
+ // The measure, like every other figure in the document. Wider was tried
187
+ // twice — 64rem, then 56rem — and both read as a block that had stopped
188
+ // belonging to the text around it (owner, seen live). These pages are
189
+ // responsive and lay themselves out at whatever width they are given, so
190
+ // the measure costs them nothing.
191
+ className="not-prose my-8 w-full"
192
+ >
193
+ <div
194
+ // A ratio rather than a fixed height, so the frame scales with the
195
+ // measure. The floor is there because these pages are usually taller
196
+ // than they are wide, and a narrow window would otherwise letterbox
197
+ // an interactive thing down to a strip.
198
+ className={
199
+ // Before the click this is a card, and it should read as one. After
200
+ // it, the page inside brings its own surface — and the box's does
201
+ // not match it: a sim painted near-black arrived ringed in
202
+ // near-white, on every side the page did not fill.
203
+ //
204
+ // Fitting the WIDTH to the page was tried and cannot work: these
205
+ // pages are responsive, so narrowing the frame made them reflow
206
+ // narrower still, which left a fresh gap and, on one, a scrollbar
207
+ // (measured). A page that does not fill the width now simply sits on
208
+ // the page, which is the honest arrangement — it is the record's own
209
+ // figure, not a screenshot in a mount.
210
+ loaded
211
+ ? "relative w-full overflow-hidden rounded-lg"
212
+ : "relative w-full overflow-hidden rounded-lg border border-fd-border bg-fd-muted"
213
+ }
214
+ // The invitation is a card. Once the page is measured the height goes
215
+ // on the FRAME instead and this box wraps it: put on the box, the
216
+ // border is inside that height, so the frame came out two pixels short
217
+ // and every single embed carried a scrollbar (found live — content 862
218
+ // in a frame of 860). Until it is measured, and always for a frame we
219
+ // may not measure, a ratio with a floor.
220
+ style={
221
+ !loaded
222
+ ? { height: "14rem" }
223
+ : height === null
224
+ ? { aspectRatio: "16 / 10", minHeight: "26rem" }
225
+ : undefined
226
+ }
227
+ >
228
+ {loaded ? (
229
+ <iframe
230
+ ref={frame}
231
+ onLoad={handleLoad}
232
+ src={url}
233
+ title={label}
234
+ allowFullScreen
235
+ // The host learns that its page was opened, not which document of
236
+ // this record opened it.
237
+ referrerPolicy="no-referrer"
238
+ // Scripts, because the point of the page is that it runs. Not
239
+ // top-navigation, and not forms: a framed page may not steer the
240
+ // reader out of the record or collect anything from inside it.
241
+ sandbox="allow-scripts allow-same-origin allow-popups"
242
+ loading="lazy"
243
+ // Absolute only while the box owns the height, which is the ratio
244
+ // case. With a measured height the frame owns it and lays out in
245
+ // flow, so the box is exactly as tall as the page plus its border.
246
+ className={height === null ? "absolute inset-0 size-full" : "block w-full"}
247
+ style={height === null ? undefined : { height }}
248
+ />
249
+ ) : (
250
+ <div className="absolute inset-0 flex flex-col items-center justify-center gap-4 px-6 text-center">
251
+ <Play aria-hidden className="size-8 text-fd-muted-foreground" />
252
+ <Button onClick={() => setLoaded(true)}>{label}</Button>
253
+ <p className="max-w-sm text-xs text-fd-muted-foreground">
254
+ {isOwned
255
+ ? "Part of this record, served from this site. Nothing leaves your browser."
256
+ : `Runs on ${host}, and is not part of this record. Nothing is requested from there until you load it.`}
257
+ </p>
258
+ </div>
259
+ )}
260
+ </div>
261
+
262
+ <figcaption className="mt-2 flex items-center justify-between gap-3 text-xs text-fd-muted-foreground">
263
+ <span>{label}</span>
264
+ <a
265
+ href={url}
266
+ target="_blank"
267
+ rel="noreferrer"
268
+ className="inline-flex items-center gap-1 hover:text-fd-foreground"
269
+ >
270
+ {isOwned ? "Open on its own" : `Open on ${host}`}
271
+ <ExternalLink aria-hidden className="size-3" />
272
+ </a>
273
+ </figcaption>
274
+ </figure>
275
+ );
276
+ }
@@ -1,6 +1,7 @@
1
1
  import Link from "next/link";
2
2
 
3
3
  import { DocumentActions } from "@/components/document-actions";
4
+ import { Clock } from "lucide-react";
4
5
  import type { ReactElement } from "react";
5
6
 
6
7
  import {
@@ -113,17 +114,24 @@ export function GovernanceMeta({
113
114
  governance,
114
115
  replaces = [],
115
116
  markdownUrl,
117
+ minutes,
116
118
  }: {
117
119
  governance: DocumentGovernance;
118
120
  /** Documents this one replaced — derived from the record, never declared. */
119
121
  replaces?: readonly Successor[];
120
122
  /** The document's markdown twin, offered beside its governance. */
121
123
  markdownUrl?: string;
124
+ /**
125
+ * How long the document takes to read, when this row is the only place for
126
+ * it — a document with a summary shows it on that view's own strip instead,
127
+ * because there the number belongs to the view you picked.
128
+ */
129
+ minutes?: number;
122
130
  }): ReactElement | null {
123
131
  const { owner, effective } = governance;
124
132
  const status = caveatStatus(governance.status);
125
133
  const bare = status === null && owner === null && effective === null && replaces.length === 0;
126
- if (bare && markdownUrl === undefined) return null;
134
+ if (bare && markdownUrl === undefined && minutes === undefined) return null;
127
135
 
128
136
  return (
129
137
  <dl className="mb-7 flex flex-wrap items-baseline gap-x-8 gap-y-2.5 border-b border-fd-border pb-4">
@@ -199,6 +207,12 @@ export function GovernanceMeta({
199
207
  <DocumentActions href={markdownUrl} />
200
208
  </span>
201
209
  )}
210
+ {minutes === undefined ? null : (
211
+ <div className="ms-auto flex items-center gap-2 text-sm text-fd-muted-foreground">
212
+ <Clock aria-hidden className="size-3.5 shrink-0" />
213
+ <span>{minutes} min read</span>
214
+ </div>
215
+ )}
202
216
  </dl>
203
217
  );
204
218
  }
@@ -1,4 +1,7 @@
1
1
  import defaultMdxComponents from "fumadocs-ui/mdx";
2
+
3
+ import { WrappableCodeBlock } from "@/components/code-block";
4
+ import { Embed } from "@/components/embed";
2
5
  import { CodeBlockTabsTrigger } from "fumadocs-ui/components/codeblock";
3
6
  import { Tab, Tabs } from "fumadocs-ui/components/tabs";
4
7
  import type { MDXComponents } from "mdx/types";
@@ -32,9 +35,14 @@ export function getMDXComponents(components?: MDXComponents) {
32
35
  // here or a document whose page forgot to pass one serves a 500 rather
33
36
  // than a page without an aid.
34
37
  TeachingAid: () => null,
38
+ // A long line is the reader's to unwrap, per block — see
39
+ // components/code-block.tsx. Replaces fumadocs' own `pre`.
40
+ pre: WrappableCodeBlock,
41
+ // `rehypeEmbeds` (source.config.ts) rewrites a link titled `embed` into
42
+ // this; an unknown component fails the build, so it has to be in the map.
43
+ Embed,
35
44
  // `remarkCodeTab` (source.config.ts) rewrites consecutive fenced blocks
36
- // that declare `tab="…"` into these, so they have to be in the map or the
37
- // build fails on an unknown component rather than at authoring time.
45
+ // that declare `tab="…"` into these, for the same reason.
38
46
  Tabs,
39
47
  Tab,
40
48
  CodeBlockTabsTrigger: BrandedTabsTrigger,
@@ -146,23 +146,12 @@ export function RecordViews({
146
146
  [select, views],
147
147
  );
148
148
 
149
- // No summary, so no tabs — but the strip row still carries the reading time.
150
- // It sits here rather than up with the title because this row is about the
151
- // reading you are ABOUT to do, and a lone line above the facts row read as
152
- // floating between two things it belonged to neither of.
153
- if (only) {
154
- return (
155
- <div className="ksor-views">
156
- {documentMinutes === undefined ? null : (
157
- <p className="mb-6 flex items-center justify-end gap-2 border-b border-fd-border pb-2.5 text-sm text-fd-muted-foreground">
158
- <Clock aria-hidden className="size-3.5 shrink-0" />
159
- <span>{documentMinutes} min read</span>
160
- </p>
161
- )}
162
- {children}
163
- </div>
164
- );
165
- }
149
+ // No summary, so no tabs — and so no strip. It used to keep the row anyway,
150
+ // for the reading time alone: a full-width rule under an empty band with one
151
+ // number at its far end, which is what most documents got, because most have
152
+ // no summary. The number moved to the governance row, where it sits with the
153
+ // document's other facts and needs no furniture of its own.
154
+ if (only) return <div className="ksor-views">{children}</div>;
166
155
 
167
156
  return (
168
157
  <div className="ksor-views">
@@ -0,0 +1,214 @@
1
+ /**
2
+ * What makes a blockquote a CALLOUT: GitHub's alert syntax, unchanged.
3
+ *
4
+ * > [!WARNING]
5
+ * > A withdrawn document is still cited by answers that were given while
6
+ * > it was published.
7
+ *
8
+ * The reason this syntax and not `:::warning`: a blockquote is CommonMark. The
9
+ * record is CommonMark by rule (critical rule 3 keeps `knowledge/` free of any
10
+ * grammar a plain markdown reader has to learn), and this one is already read
11
+ * by the two places a record is looked at OUTSIDE this site — GitHub renders it
12
+ * as a styled alert, and every other viewer renders an ordinary blockquote
13
+ * carrying a visible `[!WARNING]` label. Nobody is misled and nothing is lost.
14
+ * A `:::` directive is a grammar: it renders as the literal characters, and it
15
+ * would reach `/md/`, `llms.txt` and `llms-full.txt`, where an agent would have
16
+ * to know our dialect to read the record.
17
+ *
18
+ * The set is GitHub's five, exactly. Adding a sixth would mean a record that
19
+ * renders here and not there, which is the whole thing this choice buys.
20
+ *
21
+ * A LEAF: no imports, so the remark plugin and the tests share one rule.
22
+ */
23
+
24
+ /**
25
+ * GitHub's marker -> the fumadocs Callout it becomes.
26
+ *
27
+ * `type` is one of fumadocs' `CalloutType` (`info` | `warn` | `error` |
28
+ * `success` | `warning` | `idea`); anything else renders as plain `info` with
29
+ * nothing going red, so these are checked against the shipped page.
30
+ *
31
+ * NOTE and IMPORTANT share `info` because fumadocs has no fifth colour, and
32
+ * inventing one would drift from GitHub. The `title` is what tells them apart,
33
+ * which is also how GitHub distinguishes them.
34
+ */
35
+ export const ALERT_KINDS = [
36
+ { marker: "NOTE", type: "info", title: "Note" },
37
+ { marker: "TIP", type: "idea", title: "Tip" },
38
+ { marker: "IMPORTANT", type: "info", title: "Important" },
39
+ { marker: "WARNING", type: "warn", title: "Warning" },
40
+ { marker: "CAUTION", type: "error", title: "Caution" },
41
+ ] as const;
42
+
43
+ export type AlertKind = (typeof ALERT_KINDS)[number];
44
+
45
+ export interface AlertMatch {
46
+ readonly kind: AlertKind;
47
+ /** What is left of the leading text once the marker's own line is removed. */
48
+ readonly rest: string;
49
+ }
50
+
51
+ /**
52
+ * The alert this blockquote opens with, or null when it is an ordinary quote.
53
+ *
54
+ * `leadingText` is the value of the first text node of the blockquote's first
55
+ * paragraph — the marker has to be the very start of the quote, and has to be
56
+ * the WHOLE of its first line. Both are GitHub's rules, and following them is
57
+ * the point: a quote that renders as a callout here and as a quote there would
58
+ * make the site and the record disagree about the same bytes.
59
+ *
60
+ * Case-insensitive, because GitHub accepts `[!note]` and rendering it plain
61
+ * here would be exactly that disagreement.
62
+ */
63
+ export function matchAlert(leadingText: string): AlertMatch | null {
64
+ if (!leadingText.startsWith("[!")) return null;
65
+
66
+ const close = leadingText.indexOf("]");
67
+ if (close === -1) return null;
68
+
69
+ const marker = leadingText.slice(2, close).toUpperCase();
70
+ const kind = ALERT_KINDS.find((entry) => entry.marker === marker);
71
+ if (!kind) return null;
72
+
73
+ const after = leadingText.slice(close + 1);
74
+ const newline = after.indexOf("\n");
75
+ const restOfLine = newline === -1 ? after : after.slice(0, newline);
76
+ // Anything else on the marker's line means the author wrote a quote that
77
+ // happens to start with a bracket, not an alert.
78
+ if (restOfLine.trim() !== "") return null;
79
+
80
+ return { kind, rest: newline === -1 ? "" : after.slice(newline + 1) };
81
+ }
82
+
83
+ /**
84
+ * The cases the rule is held to.
85
+ *
86
+ * A table rather than prose assertions, because the interesting half is what
87
+ * this must REFUSE: every refusal here is a blockquote an author wrote meaning
88
+ * a blockquote, and turning one into a coloured panel is a change to the
89
+ * record's meaning that nothing else would catch.
90
+ */
91
+ export const ALERT_CASES = [
92
+ // The five, as GitHub documents them.
93
+ { text: "[!NOTE]\nThe record is the source of truth.", type: "info", title: "Note" },
94
+ { text: "[!TIP]\nStart at level 0.", type: "idea", title: "Tip" },
95
+ { text: "[!IMPORTANT]\nCitations pin a generation.", type: "info", title: "Important" },
96
+ { text: "[!WARNING]\nThis document is superseded.", type: "warn", title: "Warning" },
97
+ { text: "[!CAUTION]\nA takedown does not unsay an answer.", type: "error", title: "Caution" },
98
+ // Lowercase renders as an alert on GitHub, so it renders as one here.
99
+ { text: "[!note]\nStill an alert.", type: "info", title: "Note" },
100
+ { text: "[!Warning]\nStill an alert.", type: "warn", title: "Warning" },
101
+ // The marker alone, with the body in later nodes or later blocks.
102
+ { text: "[!NOTE]", type: "info", title: "Note" },
103
+ { text: "[!NOTE]\n", type: "info", title: "Note" },
104
+ // Trailing spaces on the marker's line are invisible; they may not decide.
105
+ { text: "[!NOTE] \nBody.", type: "info", title: "Note" },
106
+ // Ordinary blockquotes, which must stay blockquotes.
107
+ { text: "A quote about something.", type: null, title: null },
108
+ { text: "[!NOTES]\nNot a marker.", type: null, title: null },
109
+ { text: "[!]\nEmpty marker.", type: null, title: null },
110
+ { text: "[NOTE]\nNo bang.", type: null, title: null },
111
+ { text: " [!NOTE]\nLeading space.", type: null, title: null },
112
+ { text: "[!NOTE\nUnclosed.", type: null, title: null },
113
+ // The marker has to own its line. Text beside it means the author quoted it.
114
+ { text: "[!NOTE] see below", type: null, title: null },
115
+ { text: "[!NOTE] see below\nBody.", type: null, title: null },
116
+ ] as const;
117
+
118
+ /**
119
+ * The slice of hast this touches, written structurally rather than imported.
120
+ *
121
+ * `@types/hast` would be a dependency for five field names, and this only ever
122
+ * reads `type`/`tagName`, walks `children`, and edits the `value` of a text
123
+ * node. Typing what is used keeps this file a leaf.
124
+ */
125
+ interface AlertNode {
126
+ type: string;
127
+ tagName?: string;
128
+ children?: AlertNode[];
129
+ value?: string;
130
+ name?: string;
131
+ attributes?: { type: "mdxJsxAttribute"; name: string; value: string }[];
132
+ }
133
+
134
+ /**
135
+ * The `<Callout>` this blockquote becomes, or null when it stays a blockquote.
136
+ *
137
+ * Mutates the quote's own first paragraph to drop the marker line — the marker
138
+ * is syntax, and leaving it in the rendered panel would show the reader the
139
+ * plumbing.
140
+ */
141
+ function calloutFor(node: AlertNode): AlertNode | null {
142
+ if (node.type !== "element" || node.tagName !== "blockquote") return null;
143
+
144
+ // hast keeps the source's whitespace between block children, so the first
145
+ // paragraph is the first ELEMENT rather than the first child.
146
+ const paragraph = node.children?.find((child) => child.type === "element");
147
+ if (!paragraph || paragraph.tagName !== "p") return null;
148
+
149
+ const lead = paragraph.children?.[0];
150
+ if (!lead || lead.type !== "text" || typeof lead.value !== "string") return null;
151
+
152
+ const match = matchAlert(lead.value);
153
+ if (!match) return null;
154
+
155
+ if (match.rest === "") {
156
+ // The marker was the whole text node. Drop it, and drop the paragraph too
157
+ // when the marker was all it held — `> [!NOTE]` on a line of its own.
158
+ paragraph.children?.shift();
159
+ if (paragraph.children?.length === 0) {
160
+ node.children = node.children?.filter((child) => child !== paragraph);
161
+ }
162
+ } else {
163
+ lead.value = match.rest;
164
+ }
165
+
166
+ return {
167
+ type: "mdxJsxFlowElement",
168
+ name: "Callout",
169
+ attributes: [
170
+ { type: "mdxJsxAttribute", name: "type", value: match.kind.type },
171
+ { type: "mdxJsxAttribute", name: "title", value: match.kind.title },
172
+ ],
173
+ children: node.children ?? [],
174
+ };
175
+ }
176
+
177
+ /** Depth-first, so an alert nested inside a list or another quote converts. */
178
+ function convertAlerts(node: AlertNode): void {
179
+ const children = node.children;
180
+ if (!children) return;
181
+
182
+ for (let i = 0; i < children.length; i++) {
183
+ const child = children[i];
184
+ if (!child) continue;
185
+ convertAlerts(child);
186
+ const callout = calloutFor(child);
187
+ if (callout) children[i] = callout;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * REHYPE, deliberately — and this is the load-bearing half of the design.
193
+ *
194
+ * As a remark plugin this works and is wrong: fumadocs serializes the record's
195
+ * markdown from the mdast (`includeProcessedMarkdown` -> `remarkLLMs`), so a
196
+ * blockquote rewritten there reaches `/md/` and `llms-full.txt` as
197
+ * `<Callout type="warn" title="Warning">` — the agent surface served this
198
+ * site's React component in place of the author's blockquote. Measured, not
199
+ * assumed: that is exactly what the first build of this emitted.
200
+ *
201
+ * By the rehype phase the markdown is already captured, so the page gets the
202
+ * callout and every agent-facing surface keeps the record's own shape. This is
203
+ * product principle 2 — one source, two surfaces — and the reason the syntax
204
+ * is GitHub's rather than a directive in the first place.
205
+ *
206
+ * The cost, recorded because it is real: `remarkStructure` also runs in the
207
+ * remark phase, so the search index contains the literal `[!NOTE]` alongside
208
+ * the passage. Noise in one index is the cheaper half of this trade.
209
+ */
210
+ export function rehypeGithubAlerts(): (tree: AlertNode) => void {
211
+ return (tree: AlertNode): void => {
212
+ convertAlerts(tree);
213
+ };
214
+ }