@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.
- package/CHANGELOG.md +24 -0
- package/dist/cli.mjs +1 -0
- package/docs/index.md +6 -2
- package/package.json +1 -1
- package/templates/scaffold/.agents/skills/add-sources/SKILL.md +4 -1
- package/templates/scaffold/.agents/skills/format-checker/SKILL.md +8 -1
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +218 -9
- package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +19 -4
- package/templates/scaffold/.claude/skills/add-sources/SKILL.md +4 -1
- package/templates/scaffold/.claude/skills/format-checker/SKILL.md +8 -1
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +218 -9
- package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +19 -4
- package/templates/scaffold/AGENTS.md +36 -7
- package/templates/scaffold/README.md +27 -0
- package/templates/scaffold/gitignore +3 -0
- package/templates/scaffold/system/site/app/(home)/page.tsx +2 -2
- package/templates/scaffold/system/site/app/docs/layout.tsx +2 -2
- package/templates/scaffold/system/site/components/footer-mark.tsx +22 -0
- package/templates/scaffold/system/site/lib/audience.ts +178 -0
- package/templates/scaffold/system/site/lib/shared.ts +14 -5
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +301 -0
- package/templates/scaffold/system/site/source.config.ts +7 -1
- package/templates/scaffold/vercel.json +8 -0
|
@@ -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
|
-
|
|
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
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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. `` 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:
|
|
16
|
+
dir: knowledgeSourceDir(),
|
|
11
17
|
docs: {
|
|
12
18
|
schema: pageSchema
|
|
13
19
|
.extend({
|