@panaversity/ksor 0.0.2 → 0.0.3

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,178 @@
1
+ import { instanceFrontmatter } from "./shared";
2
+
3
+ /**
4
+ * The audience model, declared in instance.md — the record says who its
5
+ * readers are, and the build enforces it:
6
+ *
7
+ * audiences:
8
+ * - public
9
+ * - internal
10
+ * - restricted
11
+ * default_visibility: public
12
+ *
13
+ * Ordered least- to most-restricted, so "build the internal site" means
14
+ * "public and internal included" with no further configuration. A record
15
+ * that declares no audiences has no model and publishes every document —
16
+ * the behaviour of every instance written before this key existed.
17
+ */
18
+ export interface AudienceModel {
19
+ /** Least- to most-restricted, `public` first. */
20
+ readonly audiences: readonly string[];
21
+ /** The tier of a document that declares no `visibility:`. */
22
+ readonly defaultVisibility: string;
23
+ }
24
+
25
+ function unquote(raw: string): string {
26
+ const trimmed = raw.trim();
27
+ return /^(['"])(.*)\1$/.exec(trimmed)?.[2] ?? trimmed;
28
+ }
29
+
30
+ /** Every refusal this feature makes: a slug a pipeline can match, then the remedy. */
31
+ export function refuse(slug: string, what: string, why: string, fix: string): never {
32
+ // The slug leads, so a pipeline can match on it, and the three lines below
33
+ // it are the whole remedy — an operator never has to read this file.
34
+ throw new Error(`${slug}: ${what}\n why: ${why}\n fix: ${fix}`);
35
+ }
36
+
37
+ function readAudienceModel(): AudienceModel | null {
38
+ const block = instanceFrontmatter();
39
+ // Top-level key only: `^` under /m cannot match an indented child.
40
+ if (!/^audiences:/m.test(block)) return null;
41
+
42
+ // The grammar mirrors the checker's exactly — CRLF-tolerant, list items at
43
+ // ANY indent (YAML allows unindented block sequences), and a ` #` comment
44
+ // ends an unquoted entry (all three found live 2026-08-18: records the
45
+ // checker blessed either failed this build or silently lost a tier).
46
+ const stripComment = (value: string): string =>
47
+ /^["']/.test(value.trim()) ? value : value.replace(/\s+#.*$/, "");
48
+ // A line scanner, not a block regex: a blank line among the items or a
49
+ // comment on the key line broke the block capture and refused every build
50
+ // of a checker-green record (review finding, 2026-08-19).
51
+ const flow = /^audiences:[ \t]*\[(.*)\][ \t]*(?:#.*)?$/m.exec(block)?.[1];
52
+ let items: string[] = [];
53
+ if (flow !== undefined) {
54
+ items = flow.split(",");
55
+ } else {
56
+ const lines = block.split("\n");
57
+ const start = lines.findIndex((line) => /^audiences:[ \t]*(?:#.*)?$/.test(line));
58
+ if (start !== -1) {
59
+ for (const line of lines.slice(start + 1)) {
60
+ if (line.trim() === "") continue;
61
+ const item = /^[ \t]*-[ \t]+(.*)$/.exec(line);
62
+ if (item === null) break;
63
+ items.push(item[1] ?? "");
64
+ }
65
+ }
66
+ }
67
+ const audiences = items
68
+ .map(stripComment)
69
+ .map(unquote)
70
+ .filter((value) => value !== "");
71
+
72
+ // A declared-but-unreadable model must never read as "no model": that is
73
+ // the one parse failure that publishes the whole record.
74
+ if (audiences.length === 0) {
75
+ refuse(
76
+ "ksor-audiences-unreadable",
77
+ "instance.md declares `audiences:` but no audience could be read from it",
78
+ "an unreadable model reads as no model, and no model publishes every document — the one parse failure that leaks",
79
+ "write the audiences as a list, least-restricted first:\n audiences:\n - public\n - internal",
80
+ );
81
+ }
82
+
83
+ // The staging never depends on the checker having run: a
84
+ // most-restrictive-first model would make plain `pnpm build` publish
85
+ // every restricted document with no label (review finding, 2026-08-18).
86
+ if (audiences[0] !== "public") {
87
+ refuse(
88
+ "ksor-audiences-misordered",
89
+ `audiences: must start with public (it starts with "${audiences[0]}")`,
90
+ "the list is ordered least- to most-restricted, and an unset KSOR_AUDIENCE builds the FIRST entry — any other first entry makes the default build the leak",
91
+ "reorder audiences: with public first",
92
+ );
93
+ }
94
+ if (new Set(audiences).size !== audiences.length) {
95
+ refuse(
96
+ "ksor-audiences-duplicate",
97
+ `audiences: declares a tier twice (${audiences.join(", ")})`,
98
+ "a duplicated tier has two positions in the ordering, and which one a build honours is undefined",
99
+ "remove the duplicate entry",
100
+ );
101
+ }
102
+ const defaultVisibility = unquote(
103
+ stripComment(/^default_visibility:[ \t]*(.*)$/m.exec(block)?.[1] ?? ""),
104
+ );
105
+ if (defaultVisibility === "") {
106
+ refuse(
107
+ "ksor-default-visibility-missing",
108
+ "instance.md declares `audiences:` without `default_visibility:`",
109
+ "there is no safe guess: assuming the widest tier leaks on the first document that forgets the key, assuming the narrowest hides the record",
110
+ `add the tier a document without a visibility: key belongs to, e.g. default_visibility: ${audiences[0]}`,
111
+ );
112
+ }
113
+ if (!audiences.includes(defaultVisibility)) {
114
+ refuse(
115
+ "ksor-default-visibility-undeclared",
116
+ `default_visibility: ${defaultVisibility} is not one of the declared audiences (${audiences.join(", ")})`,
117
+ "every document without a visibility: key belongs to this tier — a tier no build understands is a record no build can publish honestly",
118
+ `set default_visibility: to one of ${audiences.join(", ")}, or declare ${defaultVisibility} in audiences:`,
119
+ );
120
+ }
121
+
122
+ return { audiences, defaultVisibility };
123
+ }
124
+
125
+ /** The declared model, or null when this record declares none. */
126
+ export const audienceModel: AudienceModel | null = readAudienceModel();
127
+
128
+ function resolveBuildAudience(model: AudienceModel | null): string {
129
+ const requested = process.env.KSOR_AUDIENCE?.trim() ?? "";
130
+ if (model === null) {
131
+ if (requested !== "") {
132
+ refuse(
133
+ "ksor-audiences-not-declared",
134
+ `KSOR_AUDIENCE="${requested}" was requested, but instance.md declares no audiences`,
135
+ "this build would publish every document — a build that cannot filter must never look like one that did",
136
+ "declare the model in instance.md (audiences: + default_visibility:), or build without KSOR_AUDIENCE",
137
+ );
138
+ }
139
+ return "";
140
+ }
141
+ // Unset means the least-restricted tier: the only default that cannot leak,
142
+ // so `pnpm build` keeps publishing the public site out of the box.
143
+ if (requested === "") return model.audiences[0] as string;
144
+ if (!model.audiences.includes(requested)) {
145
+ refuse(
146
+ "ksor-audience-undeclared",
147
+ `KSOR_AUDIENCE="${requested}" is not an audience this record declares (${model.audiences.join(", ")})`,
148
+ "an unrecognized audience could only be honoured by publishing more than the record names — so it refuses instead of widening",
149
+ `build with one of ${model.audiences.join(", ")}, or add "${requested}" to instance.md's audiences: list`,
150
+ );
151
+ }
152
+ return requested;
153
+ }
154
+
155
+ /** The audience this build publishes for; "" when the record has no model. */
156
+ export const buildAudience: string = resolveBuildAudience(audienceModel);
157
+
158
+ /** Whether a document of this visibility belongs in THIS build. */
159
+ export function visibleInBuild(visibility: string | null): boolean {
160
+ if (audienceModel === null) return true;
161
+ const value =
162
+ visibility === null || visibility === "" ? audienceModel.defaultVisibility : visibility;
163
+ const rank = audienceModel.audiences.indexOf(value);
164
+ // An undeclared visibility is refused, never published: a value no build
165
+ // understands is a typo, and a typo reads as a restriction.
166
+ if (rank === -1) return false;
167
+ return rank <= audienceModel.audiences.indexOf(buildAudience);
168
+ }
169
+
170
+ /**
171
+ * What a non-public build calls itself, in the site chrome — so a leaked
172
+ * screenshot of an internal site says which audience it was built for. The
173
+ * public build (the least-restricted tier) says nothing new.
174
+ */
175
+ export const audienceNotice: string | null =
176
+ audienceModel === null || buildAudience === audienceModel.audiences[0]
177
+ ? null
178
+ : `${buildAudience} build — not for publication`;
@@ -20,12 +20,21 @@ function findInstance(start: string): string {
20
20
  );
21
21
  }
22
22
 
23
- function readInstanceName(): string {
23
+ /**
24
+ * instance.md's frontmatter block — the configuration every surface reads
25
+ * (identity here, the audience model in lib/audience.ts). Only this block:
26
+ * body prose that looks like a key must never become configuration (review
27
+ * finding, 2026-08-18).
28
+ */
29
+ export function instanceFrontmatter(): string {
24
30
  const text = readFileSync(findInstance(process.cwd()), "utf8");
25
- // Only the frontmatter block: body prose mentioning `name:` must never
26
- // become the site's identity (review finding, 2026-08-18).
27
- const block = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? "";
28
- const raw = /^name:[ \t]*(.*)$/m.exec(block)?.[1]?.trim() ?? "";
31
+ // The checker's boundary exactly: BOM stripped, CRLF normalized, lax close.
32
+ const normalized = text.replace(/^\uFEFF/, "").replaceAll("\r\n", "\n");
33
+ return /^---\n([\s\S]*?)\n---/.exec(normalized)?.[1] ?? "";
34
+ }
35
+
36
+ function readInstanceName(): string {
37
+ const raw = /^name:[ \t]*(.*)$/m.exec(instanceFrontmatter())?.[1]?.trim() ?? "";
29
38
  const unquoted = /^(['"])(.*)\1$/.exec(raw);
30
39
  const name = unquoted?.[2] ?? raw;
31
40
  if (name === "") {
@@ -0,0 +1,301 @@
1
+ import {
2
+ copyFileSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ rmSync,
7
+ statSync,
8
+ watch,
9
+ } from "node:fs";
10
+ import path from "node:path";
11
+
12
+ import { audienceModel, buildAudience, refuse, visibleInBuild } from "./audience";
13
+
14
+ // Both relative to the site directory — the directory every build runs from
15
+ // (`pnpm build` is `pnpm -C system/site build`), which is also how fumadocs
16
+ // resolves a collection's `dir`.
17
+ const RECORD_DIR = "../../knowledge";
18
+ const STAGE_DIR = "./.staged-knowledge";
19
+
20
+ // ONE frontmatter boundary, the checker's exactly: BOM stripped, CRLF
21
+ // normalized, lax close (a `----` line closes — review finding 2026-08-19:
22
+ // two boundaries in one file meant a doc one regex saw and the other
23
+ // didn't, and the strict one published a restricted document).
24
+ function frontmatterBlock(text: string): string {
25
+ const normalized = text.replace(/^\uFEFF/, "").replaceAll("\r\n", "\n");
26
+ return /^---\n([\s\S]*?)\n---/.exec(normalized)?.[1] ?? "";
27
+ }
28
+
29
+ /** Exclusion sentinel: present-but-unreadable ranks below every tier. */
30
+ const UNREADABLE = "\u0000ksor-unreadable";
31
+
32
+ /**
33
+ * A document's declared tier: null when the key is absent (default applies),
34
+ * UNREADABLE when the key is present but carries no scalar — a block-list
35
+ * `visibility:` read as absence took the DEFAULT tier and shipped public
36
+ * (review finding, 2026-08-19: the one malformed shape that failed open).
37
+ */
38
+ function visibilityOf(text: string): string | null {
39
+ const block = frontmatterBlock(text);
40
+ const match = /^visibility:[ \t]*(.*)$/m.exec(block);
41
+ if (match === null) return null;
42
+ const raw = (match[1] ?? "").replace(/\s+#.*$/, "").trim();
43
+ const value = /^(['"])(.*)\1$/.exec(raw)?.[2] ?? raw;
44
+ return value === "" ? UNREADABLE : value;
45
+ }
46
+
47
+ function walkFiles(dir: string): string[] {
48
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
49
+ const p = path.join(dir, entry.name);
50
+ return entry.isDirectory() ? walkFiles(p) : [p];
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Code is prose about links, never links — the same rule `pnpm check`
56
+ * applies, so the checker and the stage agree on what a reference is.
57
+ * Strips fenced blocks and inline code spans (per paragraph: CommonMark
58
+ * spans may cross lines, and a document-wide strip lets one stray backtick
59
+ * pair with another pages later).
60
+ */
61
+ function stripCode(text: string): string {
62
+ const kept: string[] = [];
63
+ let fence: { char: string; length: number } | null = null;
64
+ let blank = true;
65
+ let indented = false;
66
+ for (const line of text.replaceAll("\r\n", "\n").split("\n")) {
67
+ if (fence) {
68
+ const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
69
+ if (close && close[1]?.[0] === fence.char && (close[1]?.length ?? 0) >= fence.length) {
70
+ fence = null;
71
+ }
72
+ continue;
73
+ }
74
+ const open = /^ {0,3}(`{3,}|~{3,})/.exec(line);
75
+ if (open?.[1]) {
76
+ fence = { char: open[1][0] as string, length: open[1].length };
77
+ continue;
78
+ }
79
+ // An indented run opened after a blank line is a code block — unless it
80
+ // starts a list item, which sits at exactly this indent and carries real
81
+ // links.
82
+ if (/^(?: {4}|\t)/.test(line) && !/^[ \t]+(?:[-*+]|\d+[.)])\s/.test(line)) {
83
+ if (blank || indented) {
84
+ indented = true;
85
+ continue;
86
+ }
87
+ } else if (line.trim() !== "") {
88
+ indented = false;
89
+ }
90
+ blank = line.trim() === "";
91
+ kept.push(line);
92
+ }
93
+ return kept
94
+ .join("\n")
95
+ .split(/\n{2,}/)
96
+ .map((paragraph) => paragraph.replace(/(`+)[^`]*?\1/g, " "))
97
+ .join("\n\n");
98
+ }
99
+
100
+ // Every shape CommonMark gives a link destination — inline (bare or
101
+ // <angle-bracketed>, with a title) and the reference definitions that
102
+ // `[text][label]` links point at. `![alt](img.png)` is the same shape.
103
+ const INLINE_LINK =
104
+ /\[[^\]]*\]\(\s*(<[^<>\n]*>|[^)\s]+)(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g;
105
+ const REFERENCE_DEFINITION =
106
+ /^[ \t]{0,3}\[[^\]]+\]:[ \t]*(<[^<>\n]*>|\S+)[ \t]*(?:"[^"]*"|'[^']*'|\([^)]*\))?[ \t]*$/gm;
107
+
108
+ function linkTargets(body: string): string[] {
109
+ const raw: string[] = [];
110
+ for (const match of body.matchAll(INLINE_LINK)) if (match[1]) raw.push(match[1]);
111
+ for (const match of body.matchAll(REFERENCE_DEFINITION)) if (match[1]) raw.push(match[1]);
112
+ // <…> exists so a destination may contain spaces; the brackets are syntax.
113
+ return raw.map((t) => (t.startsWith("<") && t.endsWith(">") ? t.slice(1, -1).trim() : t));
114
+ }
115
+
116
+ /**
117
+ * The asset a link points at, or null when it points anywhere else: out of
118
+ * the record, at another document, at a heading, or off the web entirely.
119
+ */
120
+ function assetTarget(recordDir: string, documentPath: string, target: string): string | null {
121
+ if (target === "" || target.startsWith("#") || target.startsWith("//")) return null;
122
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target)) return null;
123
+ const resolved = path.resolve(path.dirname(documentPath), target.split("#")[0] as string);
124
+ if (!resolved.startsWith(recordDir + path.sep)) return null;
125
+ // .md AND .mdx: both render as pages, so neither may ride in as an
126
+ // "asset" — a restricted plan.mdx staged that way published untiered
127
+ // (review finding, 2026-08-18). The record bans .mdx, but staging never
128
+ // depends on the checker having run.
129
+ if (/\.mdx?$/i.test(resolved)) return null;
130
+ try {
131
+ return statSync(resolved).isFile() ? resolved : null;
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Everything this build may publish: the permitted documents, and ONLY the
139
+ * assets those documents reference. An image referenced by nothing published
140
+ * ships its filename and its bytes into every build that copies the record
141
+ * wholesale (research/visibility.md §7) — so the references decide.
142
+ */
143
+ interface StagePlan {
144
+ /** Documents and assets to copy, in that order. */
145
+ readonly files: readonly string[];
146
+ readonly documents: number;
147
+ /** Every document in the record, whatever its tier. */
148
+ readonly total: number;
149
+ }
150
+
151
+ function planStage(recordDir: string): StagePlan {
152
+ const documents: string[] = [];
153
+ const assets = new Set<string>();
154
+ let total = 0;
155
+ for (const file of walkFiles(recordDir)) {
156
+ if (!file.toLowerCase().endsWith(".md")) continue;
157
+ total += 1;
158
+ const text = readFileSync(file, "utf8");
159
+ // An undeclared tier reads as a restriction and the document appears in
160
+ // no build at all — fail closed here, and `pnpm check` (which CI runs) is
161
+ // what names the typo.
162
+ if (!visibleInBuild(visibilityOf(text))) continue;
163
+ documents.push(file);
164
+ // Body only: frontmatter carries no links in the record grammar, and
165
+ // scanning it here while the other shell strips it staged different
166
+ // asset sets from one record (review finding, 2026-08-18).
167
+ const block = frontmatterBlock(text);
168
+ const body = block === "" ? text : text.slice(text.indexOf(block) + block.length);
169
+ for (const target of linkTargets(stripCode(body))) {
170
+ const asset = assetTarget(recordDir, file, target);
171
+ if (asset !== null) assets.add(asset);
172
+ }
173
+ }
174
+ return { files: [...documents, ...assets], documents: documents.length, total };
175
+ }
176
+
177
+ /** Fill a clean stage with exactly the set this build may publish. */
178
+ function fillStage(recordDir: string, stageDir: string): void {
179
+ // The old stage goes first, before any refusal can throw: a refused build
180
+ // that leaves the previous, more permissive stage on disk hands the next
181
+ // careless build a filtered copy nothing governs (review finding,
182
+ // 2026-08-19).
183
+ rmSync(stageDir, { recursive: true, force: true });
184
+ const plan = planStage(recordDir);
185
+ // An empty record is its own problem, reported by the page that renders it;
186
+ // an empty AUDIENCE is a misconfiguration that would otherwise surface as
187
+ // "the record has no documents" against a record full of them.
188
+ if (plan.documents === 0 && plan.total > 0) {
189
+ refuse(
190
+ "ksor-audience-empty",
191
+ `no document in the record is visible to the ${buildAudience} build (${plan.total} document${plan.total === 1 ? "" : "s"}, all above that tier)`,
192
+ "a site with nothing on it is a deploy that looks successful and serves nobody — and the record is not empty, this audience's slice of it is",
193
+ "build a wider audience with KSOR_AUDIENCE, lower default_visibility in instance.md, or give at least one document this tier",
194
+ );
195
+ }
196
+ for (const from of plan.files) {
197
+ const to = path.join(stageDir, path.relative(recordDir, from));
198
+ mkdirSync(path.dirname(to), { recursive: true });
199
+ copyFileSync(from, to);
200
+ }
201
+ }
202
+
203
+ /**
204
+ * With no audience model, `visibility:` is a promise nothing keeps: every
205
+ * document publishes, including one whose author marked it restricted. The
206
+ * checker refuses this record-wide; the build refuses it too, because a
207
+ * deleted or mistyped `audiences:` block would otherwise publish every
208
+ * restricted document on a green build (vis-docusaurus, 2026-08-18).
209
+ */
210
+ function refuseVisibilityWithoutAudiences(recordDir: string): void {
211
+ for (const file of walkFiles(recordDir)) {
212
+ if (!file.toLowerCase().endsWith(".md")) continue;
213
+ const visibility = visibilityOf(readFileSync(file, "utf8"));
214
+ if (visibility === null) continue;
215
+ refuse(
216
+ "ksor-visibility-without-audiences",
217
+ `${path.relative(recordDir, file)} declares visibility: ${visibility}, but instance.md declares no audiences`,
218
+ "without a model every document is published — this build would publish a document its author restricted, and the key saying otherwise would be the only trace",
219
+ "declare the model in instance.md (audiences: least-restricted first, plus default_visibility:), or remove the visibility: key",
220
+ );
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Dev only: carry edits into the documents the stage already holds, so
226
+ * `pnpm dev` shows the record as the owner is writing it rather than as it
227
+ * stood when the server started.
228
+ *
229
+ * Edits only — never adds, never removals. fumadocs' own watcher cannot see
230
+ * a dot-prefixed collection directory (measured 2026-08-18: adding a file to
231
+ * the stage regenerated nothing, and removing one left the generated imports
232
+ * pointing at a file that was gone), so a document that ARRIVES or changes
233
+ * tier needs the restart `pnpm dev` already needs for instance.md. Leaving
234
+ * that to a restart keeps dev honest in the direction that matters: the
235
+ * published build is always staged from scratch.
236
+ */
237
+ function refreshStage(recordDir: string, stageDir: string): void {
238
+ const permitted = new Set(planStage(recordDir).files);
239
+ for (const staged of walkFiles(stageDir)) {
240
+ const from = path.join(recordDir, path.relative(stageDir, staged));
241
+ if (!permitted.has(from)) continue;
242
+ if (readFileSync(from).equals(readFileSync(staged))) continue;
243
+ copyFileSync(from, staged);
244
+ }
245
+ }
246
+
247
+ let watching = false;
248
+
249
+ /**
250
+ * Watch the record in development, never in a build — and unref'd, so this
251
+ * can never be the reason a process refuses to exit.
252
+ */
253
+ function watchRecord(recordDir: string, stageDir: string): void {
254
+ if (process.env.NODE_ENV !== "development" || watching) return;
255
+ watching = true;
256
+ let pending: ReturnType<typeof setTimeout> | null = null;
257
+ const watcher = watch(recordDir, { recursive: true }, () => {
258
+ if (pending !== null) clearTimeout(pending);
259
+ // Debounced: one save is several filesystem events.
260
+ pending = setTimeout(() => {
261
+ try {
262
+ refreshStage(recordDir, stageDir);
263
+ } catch {
264
+ // An editor saving atomically, or a file being moved, is a record
265
+ // that is briefly incomplete — the next event re-runs this, and a
266
+ // dev-only refresh must never take the dev server down with it.
267
+ }
268
+ }, 50);
269
+ pending.unref();
270
+ });
271
+ watcher.unref();
272
+ }
273
+
274
+ /**
275
+ * The directory the docs collection reads: the record itself when this
276
+ * instance declares no audiences (exactly the behaviour of every instance
277
+ * written before the key existed), a staged per-audience copy when it does.
278
+ *
279
+ * Every surface reads the record through that one collection, so filtering
280
+ * the directory behind it filters all of them at once — pages, page tree,
281
+ * search index, llms.txt, llms-full.txt, and any consumer this site grows
282
+ * later. That is why the filter is a directory and not a predicate: a reader
283
+ * nobody remembered still cannot read what is not on disk, where a
284
+ * per-request filter leaked on the fifth and sixth consumer of the record
285
+ * its own author had not enumerated (research/visibility.md §4–§5).
286
+ */
287
+ export function knowledgeSourceDir(): string {
288
+ const stageDir = path.resolve(process.cwd(), STAGE_DIR);
289
+ const recordDir = path.resolve(process.cwd(), RECORD_DIR);
290
+ if (audienceModel === null) {
291
+ // A stage left behind by an earlier model would be a filtered copy of the
292
+ // record nothing governs any more — removed before the refusal below can
293
+ // throw, so a refused build never leaves one behind either.
294
+ rmSync(stageDir, { recursive: true, force: true });
295
+ refuseVisibilityWithoutAudiences(recordDir);
296
+ return RECORD_DIR;
297
+ }
298
+ fillStage(recordDir, stageDir);
299
+ watchRecord(recordDir, stageDir);
300
+ return STAGE_DIR;
301
+ }
@@ -1,13 +1,19 @@
1
1
  import { defineConfig, defineDocs } from "fumadocs-mdx/config";
2
2
  import { metaSchema, pageSchema } from "fumadocs-core/source/schema";
3
3
  import { z } from "zod";
4
+ import { knowledgeSourceDir } from "./lib/stage-knowledge";
4
5
 
5
6
  // The record lives at <repo>/knowledge — two levels up from this site.
6
7
  // Governance frontmatter (status, owner, provenance, superseded_by) is
7
8
  // tolerated on top of the default page schema so a governed document
8
9
  // always renders; `pnpm check` at the repo root is what enforces it.
10
+ //
11
+ // When instance.md declares `audiences:`, the documents this build may
12
+ // publish (and the assets they reference) are staged into a filtered copy
13
+ // FIRST, and this is where that copy is chosen: one directory, one filter,
14
+ // every surface downstream. See lib/stage-knowledge.ts.
9
15
  export const docs = defineDocs({
10
- dir: "../../knowledge",
16
+ dir: knowledgeSourceDir(),
11
17
  docs: {
12
18
  schema: pageSchema
13
19
  .extend({
@@ -0,0 +1,8 @@
1
+ {
2
+ "$schema": "https://openapi.vercel.sh/vercel.json",
3
+ "framework": null,
4
+ "installCommand": "pnpm install --frozen-lockfile",
5
+ "buildCommand": "pnpm build",
6
+ "outputDirectory": "system/site/out",
7
+ "trailingSlash": true
8
+ }