@panaversity/ksor 0.0.39 → 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.
- package/CHANGELOG.md +96 -0
- package/README.md +18 -2
- package/package.json +1 -1
- package/templates/scaffold/AGENTS.md +59 -6
- package/templates/scaffold/gitignore +3 -0
- package/templates/scaffold/knowledge/governance-ladder.md +5 -0
- package/templates/scaffold/knowledge/surfaces/for-agents.md +6 -0
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +9 -1
- package/templates/scaffold/system/site/app/global.css +185 -1
- package/templates/scaffold/system/site/components/code-block.tsx +87 -0
- package/templates/scaffold/system/site/components/embed.tsx +276 -0
- package/templates/scaffold/system/site/components/governance.tsx +15 -1
- package/templates/scaffold/system/site/components/mdx.tsx +10 -2
- package/templates/scaffold/system/site/components/record-views.tsx +6 -17
- package/templates/scaffold/system/site/lib/alert-rule.ts +214 -0
- package/templates/scaffold/system/site/lib/embed-rule.ts +246 -0
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +100 -3
- package/templates/scaffold/system/site/source.config.ts +32 -1
|
@@ -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,
|
|
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 —
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
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
|
+
}
|