@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,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An interactive page the document points at, as a CLICK-TO-LOAD frame.
|
|
3
|
+
*
|
|
4
|
+
* A record often wants to show something running — a simulation, a dashboard,
|
|
5
|
+
* a player — at one exact point in the prose. A deck is one per document and
|
|
6
|
+
* lives in an attachment (`<doc>.slides.yaml`); this is the other shape: many
|
|
7
|
+
* per document, each where the sentence before it puts it.
|
|
8
|
+
*
|
|
9
|
+
* The form is an ordinary CommonMark link whose TITLE is the word `embed`:
|
|
10
|
+
*
|
|
11
|
+
* [Play run-until-done](https://example.org/sims/goal-loop "embed")
|
|
12
|
+
*
|
|
13
|
+
* Chosen the way the alert syntax was chosen — for what it does everywhere the
|
|
14
|
+
* record is read that is NOT this site. GitHub renders a link with a tooltip.
|
|
15
|
+
* `/md/` and `llms-full.txt` carry the author's link. A plain editor shows a
|
|
16
|
+
* link. No grammar is added to `knowledge/`, so critical rule 2 holds.
|
|
17
|
+
*
|
|
18
|
+
* The TITLE, rather than a lone link on its own line, because the opt-in has
|
|
19
|
+
* to be something an author WROTE. A rule that reframes any link standing
|
|
20
|
+
* alone would silently pull a third party's page into the record the first
|
|
21
|
+
* time someone put a citation on its own line.
|
|
22
|
+
*
|
|
23
|
+
* REHYPE, not remark, for the reason `alert-rule.ts` records: this record's
|
|
24
|
+
* markdown is serialized from the mdast, so rewriting there would publish this
|
|
25
|
+
* site's React component to the agent surface in place of the author's link.
|
|
26
|
+
*
|
|
27
|
+
* A LEAF: no imports, so the rule can be tested on its own.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** The link title that opts a link in. Anything else is an ordinary link. */
|
|
31
|
+
export const EMBED_TITLE = "embed";
|
|
32
|
+
|
|
33
|
+
/** What the panel names when the page is the record's own. */
|
|
34
|
+
export const SELF_HOST = "this record";
|
|
35
|
+
|
|
36
|
+
export interface EmbedMatch {
|
|
37
|
+
readonly url: string;
|
|
38
|
+
readonly host: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The host a frame would reach, or null when the value is not a url.
|
|
43
|
+
*
|
|
44
|
+
* Surfaced rather than hidden: the placeholder names it, so a reader who
|
|
45
|
+
* clicks has already been told whose page is about to load. That naming is
|
|
46
|
+
* half of why there is no host allowlist here — the other half is that an
|
|
47
|
+
* allowlist written into a scaffold would be one adopter's hosts shipped to
|
|
48
|
+
* every other adopter.
|
|
49
|
+
*/
|
|
50
|
+
export function hostOf(value: string): string | null {
|
|
51
|
+
try {
|
|
52
|
+
return new URL(value).hostname;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The frame this link asks for, or null when it is an ordinary link.
|
|
60
|
+
*
|
|
61
|
+
* https only. A browser blocks an http frame inside a secure page as mixed
|
|
62
|
+
* content, so an http embed publishes a panel that silently never loads —
|
|
63
|
+
* worse than refusing it, because nothing goes red.
|
|
64
|
+
*/
|
|
65
|
+
/**
|
|
66
|
+
* The suffix that marks an asset as a page to be SERVED rather than bundled.
|
|
67
|
+
*
|
|
68
|
+
* A sim is an asset of its document, exactly like the figures beside it: many
|
|
69
|
+
* per document, named freely, staged only when a published document links to
|
|
70
|
+
* it. It is deliberately NOT an attachment — an attachment is named after its
|
|
71
|
+
* parent (`<doc>.quiz.yaml`), and seven sims cannot all be `index.sim.html`.
|
|
72
|
+
*/
|
|
73
|
+
export const SIM_SUFFIX = ".sim.html";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Where a sim is served, derived from where it sits in the record.
|
|
77
|
+
*
|
|
78
|
+
* The record path is the identity (product principle 3), so two documents
|
|
79
|
+
* may each own a `goal-loop.sim.html` without colliding.
|
|
80
|
+
*/
|
|
81
|
+
export function publicSimPath(recordRelative: string): string {
|
|
82
|
+
return recordRelative.slice(0, -SIM_SUFFIX.length).replaceAll("\\", "/") + ".html";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function matchEmbed(url: string, title: string | undefined): EmbedMatch | null {
|
|
86
|
+
if (title !== EMBED_TITLE) return null;
|
|
87
|
+
|
|
88
|
+
// A sim carried IN the record. Same origin, so no third party learns
|
|
89
|
+
// anything, it works offline, and no host can refuse to be framed — which
|
|
90
|
+
// is not hypothetical: every sim this was first built for answers
|
|
91
|
+
// `x-frame-options: SAMEORIGIN`, so a cross-origin frame to them can never
|
|
92
|
+
// render (measured 2026-08-24, all seven).
|
|
93
|
+
if (url.endsWith(SIM_SUFFIX) && !url.includes(":")) {
|
|
94
|
+
return { url, host: SELF_HOST };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let parsed: URL;
|
|
98
|
+
try {
|
|
99
|
+
parsed = new URL(url);
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
if (parsed.protocol !== "https:") return null;
|
|
104
|
+
|
|
105
|
+
return { url, host: parsed.hostname };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface EmbedCase {
|
|
109
|
+
readonly url: string;
|
|
110
|
+
/** `undefined` is the ordinary link — no title at all. */
|
|
111
|
+
readonly title: string | undefined;
|
|
112
|
+
readonly embeds: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The rule, as a table — what embeds, and what deliberately does not. */
|
|
116
|
+
export const EMBED_CASES: readonly EmbedCase[] = [
|
|
117
|
+
{ url: "https://example.org/sims/goal-loop", title: EMBED_TITLE, embeds: true },
|
|
118
|
+
// Carried in the record, beside its document.
|
|
119
|
+
{ url: "goal-loop.sim.html", title: EMBED_TITLE, embeds: true },
|
|
120
|
+
{ url: "sims/goal-loop.sim.html", title: EMBED_TITLE, embeds: true },
|
|
121
|
+
// Still opt-in: an ordinary link to a sim is an ordinary link.
|
|
122
|
+
{ url: "goal-loop.sim.html", title: undefined, embeds: false },
|
|
123
|
+
{ url: "https://example.org/sims/goal-loop?v=3", title: EMBED_TITLE, embeds: true },
|
|
124
|
+
// No title is the ordinary case, and by far the commonest link in a record.
|
|
125
|
+
{ url: "https://example.org/sims/goal-loop", title: undefined, embeds: false },
|
|
126
|
+
// A title an author wrote for a reader is a tooltip, not an instruction.
|
|
127
|
+
{ url: "https://example.org/sims/goal-loop", title: "The run-until-done sim", embeds: false },
|
|
128
|
+
// Near misses on the marker, which must stay links.
|
|
129
|
+
{ url: "https://example.org/sim", title: "Embed", embeds: false },
|
|
130
|
+
{ url: "https://example.org/sim", title: "embedded", embeds: false },
|
|
131
|
+
{ url: "https://example.org/sim", title: " embed", embeds: false },
|
|
132
|
+
// No height token: the frame measures the page it holds, so a record never
|
|
133
|
+
// carries a number that a different measure would make wrong.
|
|
134
|
+
{ url: "goal-loop.sim.html", title: "embed 660", embeds: false },
|
|
135
|
+
// http would be blocked as mixed content, so it stays a link that works.
|
|
136
|
+
{ url: "http://example.org/sim", title: EMBED_TITLE, embeds: false },
|
|
137
|
+
// Neither of these is a url a frame could reach.
|
|
138
|
+
{ url: "/sims/goal-loop", title: EMBED_TITLE, embeds: false },
|
|
139
|
+
{ url: "mailto:records@example.org", title: EMBED_TITLE, embeds: false },
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
interface EmbedNode {
|
|
143
|
+
type?: string;
|
|
144
|
+
tagName?: string;
|
|
145
|
+
name?: string;
|
|
146
|
+
value?: string;
|
|
147
|
+
properties?: { href?: unknown; title?: unknown };
|
|
148
|
+
attributes?: readonly { type: string; name: string; value: string }[];
|
|
149
|
+
children?: EmbedNode[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The link's own text, which becomes the frame's title and the link out. */
|
|
153
|
+
function labelOf(node: EmbedNode): string {
|
|
154
|
+
return (node.children ?? [])
|
|
155
|
+
.map((child) => (child.type === "text" ? (child.value ?? "") : labelOf(child)))
|
|
156
|
+
.join("")
|
|
157
|
+
.trim();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The record-relative directory a staged document sits in, or "". */
|
|
161
|
+
/**
|
|
162
|
+
* BOTH roots, because there are two. A record that declares `audiences:` or
|
|
163
|
+
* carries a takedown is read from `.staged-knowledge/`; every other record is
|
|
164
|
+
* read from `knowledge/` directly, which is the level-0 fast path and the
|
|
165
|
+
* common case. Keying on the staged one alone dropped the directory from
|
|
166
|
+
* every url on exactly the records most people have (found live 2026-08-24:
|
|
167
|
+
* `/sims/goal-loop.html` for a sim that lives in `loop-engineering/`).
|
|
168
|
+
*/
|
|
169
|
+
const RECORD_ROOTS = [".staged-knowledge/", "knowledge/"] as const;
|
|
170
|
+
|
|
171
|
+
export function recordDirOf(filePath: string | undefined): string {
|
|
172
|
+
if (!filePath) return "";
|
|
173
|
+
const normalized = filePath.replaceAll("\\", "/");
|
|
174
|
+
for (const marker of RECORD_ROOTS) {
|
|
175
|
+
const at = normalized.lastIndexOf(marker);
|
|
176
|
+
if (at === -1) continue;
|
|
177
|
+
const rel = normalized.slice(at + marker.length);
|
|
178
|
+
const cut = rel.lastIndexOf("/");
|
|
179
|
+
return cut === -1 ? "" : rel.slice(0, cut);
|
|
180
|
+
}
|
|
181
|
+
return "";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The served url for a sim linked from a document in `dir`. */
|
|
185
|
+
export function servedSimUrl(dir: string, target: string): string {
|
|
186
|
+
const joined = dir === "" ? target : `${dir}/${target}`;
|
|
187
|
+
return "/sims/" + publicSimPath(joined);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function embedFor(node: EmbedNode, dir: string): EmbedNode | null {
|
|
191
|
+
if (node.type !== "element" || node.tagName !== "p") return null;
|
|
192
|
+
|
|
193
|
+
// ALONE in its paragraph. hast keeps the source's whitespace between inline
|
|
194
|
+
// children, so a link that shares a sentence still has text nodes beside it
|
|
195
|
+
// — and reframing that would swallow the sentence around the link.
|
|
196
|
+
const meaningful = (node.children ?? []).filter(
|
|
197
|
+
(child) => child.type !== "text" || (child.value ?? "").trim() !== "",
|
|
198
|
+
);
|
|
199
|
+
const [link] = meaningful;
|
|
200
|
+
if (meaningful.length !== 1 || !link || link.type !== "element" || link.tagName !== "a") {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const { href, title } = link.properties ?? {};
|
|
205
|
+
if (typeof href !== "string") return null;
|
|
206
|
+
const match = matchEmbed(href, typeof title === "string" ? title : undefined);
|
|
207
|
+
if (match === null) return null;
|
|
208
|
+
|
|
209
|
+
// A sim is written as a link to the file BESIDE the document, exactly like
|
|
210
|
+
// a figure. The url it is served at is derived here rather than authored,
|
|
211
|
+
// so the record never contains a path into the site's own build output.
|
|
212
|
+
const url = href.endsWith(SIM_SUFFIX) ? servedSimUrl(dir, href) : match.url;
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
type: "mdxJsxFlowElement",
|
|
216
|
+
name: "Embed",
|
|
217
|
+
attributes: [
|
|
218
|
+
{ type: "mdxJsxAttribute", name: "url", value: url },
|
|
219
|
+
{ type: "mdxJsxAttribute", name: "host", value: match.host },
|
|
220
|
+
// Carried in the record, or someone else's page. The two say very
|
|
221
|
+
// different things to a reader, so the panel is told which.
|
|
222
|
+
{ type: "mdxJsxAttribute", name: "owned", value: String(match.host === SELF_HOST) },
|
|
223
|
+
{ type: "mdxJsxAttribute", name: "label", value: labelOf(link) },
|
|
224
|
+
],
|
|
225
|
+
children: [],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function convertEmbeds(node: EmbedNode, dir: string): void {
|
|
230
|
+
const children = node.children;
|
|
231
|
+
if (!children) return;
|
|
232
|
+
|
|
233
|
+
for (let i = 0; i < children.length; i++) {
|
|
234
|
+
const child = children[i];
|
|
235
|
+
if (!child) continue;
|
|
236
|
+
convertEmbeds(child, dir);
|
|
237
|
+
const embed = embedFor(child, dir);
|
|
238
|
+
if (embed) children[i] = embed;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function rehypeEmbeds(): (tree: EmbedNode, file?: { path?: string }) => void {
|
|
243
|
+
return (tree: EmbedNode, file?: { path?: string }): void => {
|
|
244
|
+
convertEmbeds(tree, recordDirOf(file?.path));
|
|
245
|
+
};
|
|
246
|
+
}
|
|
@@ -14,6 +14,7 @@ import path from "node:path";
|
|
|
14
14
|
import { ATTACHMENT_SUFFIXES, isAttachment, parentDocumentOf } from "./attachment-rule";
|
|
15
15
|
import { audienceModel, buildAudience, refuse, visibleInBuild } from "./audience";
|
|
16
16
|
import { isDenied, recordPathFrom, stableIdFrom, type DenylistManifest } from "./denial-rule";
|
|
17
|
+
import { publicSimPath, SIM_SUFFIX } from "./embed-rule";
|
|
17
18
|
import { appName, instanceFrontmatter } from "./shared";
|
|
18
19
|
|
|
19
20
|
// Both relative to the site directory — the directory every build runs from
|
|
@@ -21,6 +22,8 @@ import { appName, instanceFrontmatter } from "./shared";
|
|
|
21
22
|
// resolves a collection's `dir`.
|
|
22
23
|
const RECORD_DIR = "../../knowledge";
|
|
23
24
|
const STAGE_DIR = "./.staged-knowledge";
|
|
25
|
+
// Served, not bundled. Next copies public/ into the export as-is.
|
|
26
|
+
const PUBLIC_SIM_DIR = "./public/sims";
|
|
24
27
|
|
|
25
28
|
// ONE frontmatter boundary, the checker's exactly: BOM stripped, CRLF
|
|
26
29
|
// normalized, lax close (a `----` line closes — review finding 2026-08-19:
|
|
@@ -444,7 +447,20 @@ function withStageLock<T>(stageDir: string, work: () => T): T {
|
|
|
444
447
|
writeFileSync(lockFile, String(process.pid), { flag: "wx" });
|
|
445
448
|
break;
|
|
446
449
|
} catch (error) {
|
|
447
|
-
|
|
450
|
+
// EEXIST is "someone holds it". EPERM is the SAME THING on Windows: a
|
|
451
|
+
// create against a path whose file is in the pending-delete state — the
|
|
452
|
+
// window between another process calling `rmSync` and the filesystem
|
|
453
|
+
// actually releasing the name — raises EPERM, not EEXIST. Rethrowing it
|
|
454
|
+
// failed the build for the ordinary contended case, and only on Windows,
|
|
455
|
+
// and only sometimes: green on five CI runs of this same code and red on
|
|
456
|
+
// the next two, because it depends on landing inside a window a few
|
|
457
|
+
// milliseconds wide (2026-08-25, `Init acceptance (Windows)`).
|
|
458
|
+
//
|
|
459
|
+
// Waiting is safe for both: a holder that has died leaves a lock
|
|
460
|
+
// `lockIsAbandoned` breaks, so neither code can wait forever on a
|
|
461
|
+
// process that is gone.
|
|
462
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
463
|
+
if (code !== "EEXIST" && code !== "EPERM") throw error;
|
|
448
464
|
if (lockIsAbandoned(lockFile)) {
|
|
449
465
|
rmSync(lockFile, { force: true });
|
|
450
466
|
continue;
|
|
@@ -541,13 +557,17 @@ function fillStage(recordDir: string, stageDir: string, denied: DenylistManifest
|
|
|
541
557
|
removeStage(stageDir);
|
|
542
558
|
throw error;
|
|
543
559
|
}
|
|
544
|
-
if (stageHolds(recordDir, stageDir, plan))
|
|
560
|
+
if (stageHolds(recordDir, stageDir, plan)) {
|
|
561
|
+
publishSims(stageDir);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
545
564
|
removeStage(stageDir);
|
|
546
565
|
for (const from of plan.files) {
|
|
547
566
|
const to = path.join(stageDir, path.relative(recordDir, from));
|
|
548
567
|
mkdirSync(path.dirname(to), { recursive: true });
|
|
549
568
|
copyFileSync(from, to);
|
|
550
569
|
}
|
|
570
|
+
publishSims(stageDir);
|
|
551
571
|
});
|
|
552
572
|
}
|
|
553
573
|
|
|
@@ -686,6 +706,73 @@ function watchRecord(recordDir: string, stageDir: string): void {
|
|
|
686
706
|
* per-request filter leaked on the fifth and sixth consumer of the record
|
|
687
707
|
* its own author had not enumerated (research/visibility.md §4–§5).
|
|
688
708
|
*/
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* A sim is the one asset that has to be SERVED rather than bundled: it is a
|
|
712
|
+
* page, and a page needs a url before anything can frame it. Next copies
|
|
713
|
+
* `public/` into the export as-is, so that is where it goes.
|
|
714
|
+
*
|
|
715
|
+
* A PASS OF ITS OWN, over the directory the collection actually reads — not a
|
|
716
|
+
* rider on staging. Staging runs only for a record that declares `audiences:`
|
|
717
|
+
* or carries a takedown, and most records declare neither, so a sim hung off
|
|
718
|
+
* it published for the rare record and silently vanished for the common one
|
|
719
|
+
* (found live 2026-08-24: nothing reached `public/` on the level-0 path).
|
|
720
|
+
* Reading the SOURCE dir inherits the filtering when there is any, and works
|
|
721
|
+
* when there is none. What it does NOT inherit at level 0 is the staging
|
|
722
|
+
* plan's rule that only a REFERENCED asset ships: a record with no audiences
|
|
723
|
+
* and no takedowns publishes every document anyway, so an unreferenced sim
|
|
724
|
+
* ships its bytes there. Said rather than fixed, because the moment either
|
|
725
|
+
* governance exists the staged dir is what this walks and the rule applies.
|
|
726
|
+
*/
|
|
727
|
+
/** Whether the record carries a sim at all — a lock nobody needs is churn. */
|
|
728
|
+
function hasSims(dir: string): boolean {
|
|
729
|
+
return walkFiles(dir).some((file) => file.endsWith(SIM_SUFFIX));
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function publishSims(sourceDir: string): void {
|
|
733
|
+
const target = path.resolve(process.cwd(), PUBLIC_SIM_DIR);
|
|
734
|
+
|
|
735
|
+
const walk = (dir: string, rel: string): void => {
|
|
736
|
+
let entries;
|
|
737
|
+
try {
|
|
738
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
739
|
+
} catch {
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
for (const entry of entries) {
|
|
743
|
+
const from = path.join(dir, entry.name);
|
|
744
|
+
const next = rel === "" ? entry.name : `${rel}/${entry.name}`;
|
|
745
|
+
if (entry.isDirectory()) {
|
|
746
|
+
walk(from, next);
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
if (!entry.name.endsWith(SIM_SUFFIX)) continue;
|
|
750
|
+
const to = path.join(target, publicSimPath(next));
|
|
751
|
+
// Same size AND same mtime is this file's own definition of unchanged
|
|
752
|
+
// (see `stageHolds`). Skipping the write is what keeps the common
|
|
753
|
+
// build from touching the tree at all.
|
|
754
|
+
try {
|
|
755
|
+
const source = statSync(from);
|
|
756
|
+
const published = statSync(to);
|
|
757
|
+
if (published.size === source.size && published.mtimeMs >= source.mtimeMs) continue;
|
|
758
|
+
} catch {
|
|
759
|
+
// Not published yet, which is the ordinary first-build case.
|
|
760
|
+
}
|
|
761
|
+
mkdirSync(path.dirname(to), { recursive: true });
|
|
762
|
+
copyFileSync(from, to);
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
// The CALLER holds the stage lock, and this takes none of its own — for the
|
|
767
|
+
// reason `withStageLock` records at length, plus one this change learned on
|
|
768
|
+
// Windows: taking it a SECOND time per evaluation doubles the create/delete
|
|
769
|
+
// churn on one lock file, and `wx` create against a file in Windows'
|
|
770
|
+
// pending-delete state fails as `EPERM`, which is not `EEXIST` and so is
|
|
771
|
+
// rethrown. Green on macOS and Linux, red on Windows CI, from a pass that
|
|
772
|
+
// was correct about needing the lock and wrong about taking it again.
|
|
773
|
+
walk(sourceDir, "");
|
|
774
|
+
}
|
|
775
|
+
|
|
689
776
|
export function knowledgeSourceDir(): string {
|
|
690
777
|
const stageDir = path.resolve(process.cwd(), STAGE_DIR);
|
|
691
778
|
const recordDir = path.resolve(process.cwd(), RECORD_DIR);
|
|
@@ -704,9 +791,19 @@ export function knowledgeSourceDir(): string {
|
|
|
704
791
|
// because two evaluations removing one tree is the `ENOTEMPTY` shape of the
|
|
705
792
|
// same race; the existence check keeps a record that never stages from
|
|
706
793
|
// taking a lock on every build.
|
|
707
|
-
if (existsSync(stageDir)) withStageLock(stageDir, () => removeStage(stageDir));
|
|
708
794
|
refuseVisibilityWithoutAudiences(recordDir);
|
|
709
795
|
assertAttachmentsWellFormed(recordDir);
|
|
796
|
+
// ONE acquisition on this path too, doing both jobs — and keyed on the
|
|
797
|
+
// stage path rather than the record's, because `${recordDir}.lock` would
|
|
798
|
+
// drop a lock file beside `knowledge/`, in the adopter's repo, for a build
|
|
799
|
+
// that never stages anything.
|
|
800
|
+
const stale = existsSync(stageDir);
|
|
801
|
+
if (stale || hasSims(recordDir)) {
|
|
802
|
+
withStageLock(stageDir, () => {
|
|
803
|
+
if (stale) removeStage(stageDir);
|
|
804
|
+
publishSims(recordDir);
|
|
805
|
+
});
|
|
806
|
+
}
|
|
710
807
|
return RECORD_DIR;
|
|
711
808
|
}
|
|
712
809
|
if (audienceModel === null) refuseVisibilityWithoutAudiences(recordDir);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { defineCollections, defineConfig, defineDocs } from "fumadocs-mdx/config";
|
|
2
2
|
import { remarkCodeTab } from "fumadocs-core/mdx-plugins/remark-code-tab";
|
|
3
|
+
import { rehypeGithubAlerts } from "./lib/alert-rule";
|
|
4
|
+
import { rehypeEmbeds } from "./lib/embed-rule";
|
|
3
5
|
import { metaSchema, pageSchema } from "fumadocs-core/source/schema";
|
|
4
6
|
import { z } from "zod";
|
|
5
7
|
import { DeckSchema } from "./lib/deck";
|
|
@@ -123,7 +125,36 @@ export default defineConfig({
|
|
|
123
125
|
* place; the page decides whether there is a deck to put there. See
|
|
124
126
|
* lib/teaching-aid-rule.ts.
|
|
125
127
|
*/
|
|
126
|
-
rehypePlugins: [
|
|
128
|
+
rehypePlugins: [
|
|
129
|
+
[rehypeTeachingAid, { isAttachment }],
|
|
130
|
+
/**
|
|
131
|
+
* A passage the reader must not miss, as a CALLOUT.
|
|
132
|
+
*
|
|
133
|
+
* GitHub's alert syntax: a blockquote whose first line is `[!WARNING]`.
|
|
134
|
+
* GitHub renders it as a styled alert, every other viewer renders an
|
|
135
|
+
* ordinary blockquote carrying a visible label, and it reaches `/md/`
|
|
136
|
+
* and `llms-full.txt` as the author's blockquote rather than as markup.
|
|
137
|
+
*
|
|
138
|
+
* Not `:::warning`. fumadocs ships `remarkAdmonition` for that form and
|
|
139
|
+
* deprecates it in favour of a `remark-directive` setup — but the deeper
|
|
140
|
+
* objection is that `:::` is a dialect, and a record written in one
|
|
141
|
+
* renders as literal punctuation everywhere it is read outside this site.
|
|
142
|
+
*
|
|
143
|
+
* REHYPE rather than remark, and lib/alert-rule.ts records why: the
|
|
144
|
+
* record's markdown is serialized from the mdast, so converting there
|
|
145
|
+
* publishes this site's React component to the agent surface.
|
|
146
|
+
*/
|
|
147
|
+
rehypeGithubAlerts,
|
|
148
|
+
/**
|
|
149
|
+
* An interactive page the document points at, as a click-to-load
|
|
150
|
+
* frame. Authored as an ordinary link titled `embed`, so the record
|
|
151
|
+
* stays CommonMark and every other reader of it sees a link.
|
|
152
|
+
*
|
|
153
|
+
* REHYPE, so `/md/` and `llms-full.txt` keep the author's link
|
|
154
|
+
* rather than this site's component. See lib/embed-rule.ts.
|
|
155
|
+
*/
|
|
156
|
+
rehypeEmbeds,
|
|
157
|
+
],
|
|
127
158
|
/**
|
|
128
159
|
* Alternative versions of the same instruction, as TABS.
|
|
129
160
|
*
|