@panaversity/ksor 0.0.40 → 0.0.41
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 +869 -0
- package/README.md +11 -7
- package/dist/checker/check-main.mjs +14049 -0
- package/dist/cli.mjs +11528 -5213
- package/dist/gateway-api-CF4ED9_g-BQusM_dK.mjs +10895 -0
- package/dist/gateway.d.mts +52 -13
- package/dist/gateway.mjs +2 -2
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{src-pl4aOpVs.mjs → src-dqpI-p1a.mjs} +1 -0
- package/docs/authorization.md +8 -6
- package/docs/deploying.md +36 -25
- package/docs/index.md +26 -13
- package/docs/ingesting.md +70 -22
- package/docs/tool-surface.md +69 -16
- package/package.json +4 -3
- package/schema/migrations/2.4-2.5__okf-profile.sql +114 -0
- package/schema/schema.sql +77 -14
- package/templates/scaffold/.agents/skills/add-sources/SKILL.md +63 -18
- package/templates/scaffold/.agents/skills/format-checker/SKILL.md +42 -33
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +13827 -1314
- package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +65 -27
- package/templates/scaffold/.agents/skills/make-slides/SKILL.md +7 -5
- package/templates/scaffold/.agents/skills/make-summary/SKILL.md +13 -6
- package/templates/scaffold/.claude/skills/add-sources/SKILL.md +63 -18
- package/templates/scaffold/.claude/skills/format-checker/SKILL.md +42 -33
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +13827 -1314
- package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +65 -27
- package/templates/scaffold/.claude/skills/make-slides/SKILL.md +7 -5
- package/templates/scaffold/.claude/skills/make-summary/SKILL.md +13 -6
- package/templates/scaffold/.github/workflows/validate.yml +9 -1
- package/templates/scaffold/.ksor/governance.yaml +17 -0
- package/templates/scaffold/AGENTS.md +234 -113
- package/templates/scaffold/Dockerfile +5 -1
- package/templates/scaffold/README.md +160 -42
- package/templates/scaffold/env.example +37 -6
- package/templates/scaffold/gitignore +13 -8
- package/templates/scaffold/instance.md +21 -17
- package/templates/scaffold/knowledge/governance-ladder.md +6 -2
- package/templates/scaffold/knowledge/index.md +9 -0
- package/templates/scaffold/knowledge/surfaces/for-agents.md +7 -6
- package/templates/scaffold/knowledge/surfaces/for-people.md +7 -6
- package/templates/scaffold/knowledge/surfaces/index.md +4 -20
- package/templates/scaffold/knowledge/surfaces/overview.md +25 -0
- package/templates/scaffold/knowledge/what-is-a-ksor.md +6 -5
- package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +4 -0
- package/templates/scaffold/package.json +3 -4
- package/templates/scaffold/pnpm-lock.yaml +3 -0
- package/templates/scaffold/system/gateways/content.ts +13 -0
- package/templates/scaffold/system/site/app/(home)/page.tsx +2 -2
- package/templates/scaffold/system/site/app/.well-known/mcp/server.json/route.ts +10 -0
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +126 -91
- package/templates/scaffold/system/site/app/global.css +13 -5
- package/templates/scaffold/system/site/app/layout.tsx +8 -3
- package/templates/scaffold/system/site/app/llms-full.txt/route.ts +13 -7
- package/templates/scaffold/system/site/app/llms.txt/route.ts +12 -7
- package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +26 -25
- package/templates/scaffold/system/site/components/footer-mark.tsx +3 -2
- package/templates/scaffold/system/site/components/governance.tsx +205 -87
- package/templates/scaffold/system/site/components/record-index.tsx +5 -5
- package/templates/scaffold/system/site/components/record-stack.tsx +10 -9
- package/templates/scaffold/system/site/components/sidebar-status.tsx +19 -18
- package/templates/scaffold/system/site/lib/attachment-rule.ts +6 -1
- package/templates/scaffold/system/site/lib/attachments.ts +0 -28
- package/templates/scaffold/system/site/lib/audience-rule.ts +15 -21
- package/templates/scaffold/system/site/lib/audience.ts +42 -146
- package/templates/scaffold/system/site/lib/embed-rule.ts +9 -0
- package/templates/scaffold/system/site/lib/governance.ts +339 -225
- package/templates/scaffold/system/site/lib/index-routes.ts +125 -0
- package/templates/scaffold/system/site/lib/lifecycle-rule.ts +52 -0
- package/templates/scaffold/system/site/lib/lock.ts +282 -0
- package/templates/scaffold/system/site/lib/order-rule.ts +37 -0
- package/templates/scaffold/system/site/lib/record-href.ts +68 -0
- package/templates/scaffold/system/site/lib/record-link.tsx +26 -0
- package/templates/scaffold/system/site/lib/rules-version.ts +11 -0
- package/templates/scaffold/system/site/lib/shared.ts +67 -104
- package/templates/scaffold/system/site/lib/sim-rule.ts +49 -0
- package/templates/scaffold/system/site/lib/source.ts +256 -186
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +566 -492
- package/templates/scaffold/system/site/lib/stage-manifest.ts +128 -0
- package/templates/scaffold/system/site/package.json +1 -0
- package/templates/scaffold/system/site/record/actor.ts +23 -0
- package/templates/scaffold/system/site/record/check.ts +571 -0
- package/templates/scaffold/system/site/record/citations.ts +312 -0
- package/templates/scaffold/system/site/record/frontmatter.ts +134 -0
- package/templates/scaffold/system/site/record/git-ledger.ts +171 -0
- package/templates/scaffold/system/site/record/hygiene.ts +320 -0
- package/templates/scaffold/system/site/record/index-file.ts +150 -0
- package/templates/scaffold/system/site/record/index.ts +103 -0
- package/templates/scaffold/system/site/record/instance.ts +257 -0
- package/templates/scaffold/system/site/record/instant.ts +43 -0
- package/templates/scaffold/system/site/record/ledger.ts +694 -0
- package/templates/scaffold/system/site/record/load.ts +129 -0
- package/templates/scaffold/system/site/record/lock.ts +306 -0
- package/templates/scaffold/system/site/record/near-miss.ts +37 -0
- package/templates/scaffold/system/site/record/policy.ts +414 -0
- package/templates/scaffold/system/site/record/profile.ts +535 -0
- package/templates/scaffold/system/site/record/refusal.ts +106 -0
- package/templates/scaffold/system/site/record/yaml-file.ts +103 -0
- package/templates/scaffold/system/site/source.config.ts +77 -22
- package/dist/gateway-api-CmIthmJS-IUA9qS-T.mjs +0 -3225
- package/templates/scaffold/system/site/lib/denial-rule.ts +0 -220
- package/templates/scaffold/system/site/lib/page-order.ts +0 -93
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The denial rule, alone, with no imports and no side effects.
|
|
3
|
-
*
|
|
4
|
-
* CANONICAL COPY: `packages/content/src/lib/denial-rule.ts`. The scaffold's
|
|
5
|
-
* site carries a byte-identical copy at `system/site/lib/denial-rule.ts`, and
|
|
6
|
-
* `denial-rule-drift.test.ts` fails if the two ever differ — the same
|
|
7
|
-
* arrangement decision 18 made for the audience rule, and for the same reason:
|
|
8
|
-
* the site cannot import the kernel, whose package carries pg and the embedding
|
|
9
|
-
* providers.
|
|
10
|
-
*
|
|
11
|
-
* Why it is a leaf: these functions decide whether a withdrawn document gets
|
|
12
|
-
* published, and they lived inside a module that reads `instance.md` at import
|
|
13
|
-
* time — so none of them could be tested as rules. A round-8 mutation made
|
|
14
|
-
* `isDenied` return false unconditionally, which publishes every withdrawn
|
|
15
|
-
* document to `/docs` and `llms.txt`, and the entire suite stayed green.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
/** The shape `ksor takedown --export` writes. */
|
|
19
|
-
export interface DenylistManifest {
|
|
20
|
-
format?: number;
|
|
21
|
-
corpus_id?: string;
|
|
22
|
-
source?: string;
|
|
23
|
-
denied?: { stable_id?: string; scope?: string }[];
|
|
24
|
-
denied_subtrees?: string[];
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Is this document denied? Exact ids, plus the directories a `--subtree`
|
|
29
|
-
* takedown governs.
|
|
30
|
-
*
|
|
31
|
-
* `ksor takedown --export` expands a `--subtree` denial to its actual
|
|
32
|
-
* descendants by walking parent_id, where the tree lives. Interpreting SCOPE
|
|
33
|
-
* here meant prefix-matching stable_ids, and a section's stable_id ends in
|
|
34
|
-
* `/index` (or `#section`), so the prefix never matched its children and every
|
|
35
|
-
* descendant kept publishing — the failure decision 14 records as the reason
|
|
36
|
-
* its own walk uses parent_id rather than a prefix.
|
|
37
|
-
*
|
|
38
|
-
* But an expanded list can only name what the ACTIVE GENERATION contains, and
|
|
39
|
-
* the site reads DISK. A document added under a withdrawn section after the
|
|
40
|
-
* last ingest is on disk and not in the database, so subtree denials also
|
|
41
|
-
* arrive as DIRECTORIES. That is not the rejected prefix match: those paths
|
|
42
|
-
* come from `sources.origin_path`, so they are real locations on disk, and a
|
|
43
|
-
* document's location cannot be decoupled from itself by a frontmatter
|
|
44
|
-
* `sor_id:` the way its id can.
|
|
45
|
-
*
|
|
46
|
-
* `recordPath` is in the record's own frame (it starts with the record
|
|
47
|
-
* directory's name), because that is the frame `origin_path` uses.
|
|
48
|
-
*/
|
|
49
|
-
export function isDenied(
|
|
50
|
-
manifest: DenylistManifest,
|
|
51
|
-
stableId: string,
|
|
52
|
-
recordPath: string,
|
|
53
|
-
): boolean {
|
|
54
|
-
if ((manifest.denied ?? []).some((d) => String(d.stable_id) === stableId)) return true;
|
|
55
|
-
return (manifest.denied_subtrees ?? []).some((dir) => {
|
|
56
|
-
const prefix = String(dir).replace(/\\/g, "/");
|
|
57
|
-
if (prefix === "/") return true;
|
|
58
|
-
return recordPath.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`);
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* A plain scalar, read the way the kernel's frontmatter reader reads one.
|
|
64
|
-
*
|
|
65
|
-
* The two diverged on a TRAILING COMMENT: the kernel strips `# …` from an
|
|
66
|
-
* unquoted scalar and the site kept it, so `sor_id: hr/policy # renamed 2026`
|
|
67
|
-
* gave the kernel `hr/policy` and the site `hr/policy # renamed 2026`. A
|
|
68
|
-
* takedown on the id the MCP door reports as `provenance.stable_id` was then
|
|
69
|
-
* denied by the door and silently ignored by the site build, which kept
|
|
70
|
-
* publishing the document.
|
|
71
|
-
*
|
|
72
|
-
* A comment cannot appear inside a QUOTED scalar's value, so quoting is
|
|
73
|
-
* resolved first — exactly the kernel's order.
|
|
74
|
-
*
|
|
75
|
-
* `ok: false` marks a value the kernel's reader REFUSES rather than reads: a
|
|
76
|
-
* flow collection, an anchor, a block scalar, or anything with a `: ` in it.
|
|
77
|
-
* That matters because refusing one line poisons the whole map — see
|
|
78
|
-
* `frontmatterMap`.
|
|
79
|
-
*/
|
|
80
|
-
export function scalarLike(raw: string | undefined): string | undefined {
|
|
81
|
-
if (raw === undefined) return undefined;
|
|
82
|
-
const parsed = readScalar(raw.trim());
|
|
83
|
-
return parsed.kind === "string" ? parsed.value : undefined;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Plain scalars the kernel's reader converts to a bool, null, int or float —
|
|
88
|
-
* never a string, so they can never be an id.
|
|
89
|
-
*/
|
|
90
|
-
const YAML_TYPED =
|
|
91
|
-
/^(?:true|True|TRUE|false|False|FALSE|yes|Yes|YES|no|No|NO|on|On|ON|off|Off|OFF|~|null|Null|NULL|[-+]?[0-9][0-9_]*|[-+]?(?:\.[0-9]+|[0-9][0-9_]*\.[0-9_]*)(?:[eE][-+]?[0-9]+)?)$/;
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Three outcomes, because the kernel's reader has three:
|
|
95
|
-
*
|
|
96
|
-
* string a plain or quoted string — usable as an id.
|
|
97
|
-
* typed a bool, null, int or float. The kernel KEEPS the key with a
|
|
98
|
-
* non-string value, and `stableIdOf` requires a string, so the
|
|
99
|
-
* override is dropped. The key exists; it just is not an id.
|
|
100
|
-
* refused a shape the reader will not read at all. The kernel POISONS the
|
|
101
|
-
* whole map on one of these.
|
|
102
|
-
*
|
|
103
|
-
* Collapsing `typed` into `refused` would empty the map for a document whose
|
|
104
|
-
* `order: 3` is perfectly ordinary — which the kernel does not do.
|
|
105
|
-
*/
|
|
106
|
-
interface ScalarRead {
|
|
107
|
-
readonly kind: "string" | "typed" | "refused";
|
|
108
|
-
readonly value: string;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function readScalar(raw: string): ScalarRead {
|
|
112
|
-
// An EMPTY value is `null` to the kernel — the key exists and is not a
|
|
113
|
-
// string, exactly like a bool or a number.
|
|
114
|
-
if (raw === "") return { kind: "typed", value: "" };
|
|
115
|
-
const dq = /^"(.*)"$/.exec(raw);
|
|
116
|
-
if (dq !== null)
|
|
117
|
-
return { kind: "string", value: (dq[1] ?? "").replace(/\\"/g, '"').replace(/\\\\/g, "\\") };
|
|
118
|
-
const sq = /^'(.*)'$/.exec(raw);
|
|
119
|
-
if (sq !== null) return { kind: "string", value: (sq[1] ?? "").replace(/''/g, "'") };
|
|
120
|
-
const plain = raw.replace(/[ \t]+#.*$/, "").trim();
|
|
121
|
-
// The shapes the kernel's reader does not hand back as a STRING. Two groups,
|
|
122
|
-
// and both matter for the same reason:
|
|
123
|
-
//
|
|
124
|
-
// refused a flow collection, an anchor, a block scalar, a plain value
|
|
125
|
-
// containing ": " — the kernel returns ok:false and poisons the
|
|
126
|
-
// whole map.
|
|
127
|
-
// typed a YAML bool, null, int or float — the kernel returns them as
|
|
128
|
-
// non-strings, and `stableIdOf` requires a string, so it DROPS
|
|
129
|
-
// the override. `sor_id: 4711` therefore resolved to the path on
|
|
130
|
-
// the kernel and to "4711" here: a takedown honoured by the door
|
|
131
|
-
// and ignored by the site build, the same divergence round 9
|
|
132
|
-
// closed for comments and flow lists, in the same function
|
|
133
|
-
// (round-10 review of PR 43).
|
|
134
|
-
//
|
|
135
|
-
// Both are `ok: false` here because both end with the site NOT taking an
|
|
136
|
-
// override — which is what the kernel does. Kept in step with `scalarValue`
|
|
137
|
-
// in ingest/adapters/plain-tree.ts and bound to it by
|
|
138
|
-
// `stable-id-conformance.test.ts`.
|
|
139
|
-
// VALID YAML this reader does not model: a flow sequence or mapping, a block
|
|
140
|
-
// scalar, an anchor/alias/tag. PyYAML parses every one — the DOCUMENT is fine
|
|
141
|
-
// and only this KEY is beyond the reader, so it must not empty the map.
|
|
142
|
-
// Checked BEFORE the ": " test, because a flow mapping legitimately contains
|
|
143
|
-
// one (`meta: {a: 1}`).
|
|
144
|
-
// `typed` rather than `refused`: the key exists but is not a string, so this
|
|
145
|
-
// map (which holds strings) omits it and no override is taken — exactly what
|
|
146
|
-
// the kernel now does with `value: null` (issue #78).
|
|
147
|
-
if (/^[|>&*!{[]/.test(plain)) return { kind: "typed", value: "" };
|
|
148
|
-
if (/:[ \t]/.test(plain) || plain.endsWith(":")) return { kind: "refused", value: "" };
|
|
149
|
-
if (YAML_TYPED.test(plain)) return { kind: "typed", value: "" };
|
|
150
|
-
return { kind: "string", value: plain };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* The frontmatter block as a map, read the way the KERNEL reads it — including
|
|
155
|
-
* the part that looks like a bug and is load-bearing: if ANY top-level line is
|
|
156
|
-
* a shape the reader refuses, the WHOLE map comes back empty.
|
|
157
|
-
*
|
|
158
|
-
* That behaviour is inherited from the oracle's PyYAML path, and mirroring it
|
|
159
|
-
* is not optional. The site read `sor_id:` with a bare regex, so a document
|
|
160
|
-
* carrying an ordinary flow list —
|
|
161
|
-
*
|
|
162
|
-
* title: Policy
|
|
163
|
-
* tags: [hr, payroll]
|
|
164
|
-
* sor_id: hr/policy
|
|
165
|
-
*
|
|
166
|
-
* — got `hr/policy` here and `knowledge/policies/policy` from the kernel, which
|
|
167
|
-
* drops the override with the poisoned map. A takedown then matched on exactly
|
|
168
|
-
* one surface: denied by the MCP door, ignored by the site build, published to
|
|
169
|
-
* /docs and llms.txt. That is the failure decisions 14 and 18 exist to stop,
|
|
170
|
-
* re-entered through the denial rule (round-9 review of PR 43).
|
|
171
|
-
*/
|
|
172
|
-
export function frontmatterMap(block: string): Record<string, string> {
|
|
173
|
-
const map: Record<string, string> = {};
|
|
174
|
-
for (const line of block.split(/\r?\n/)) {
|
|
175
|
-
if (line.trim() === "" || line.trimStart().startsWith("#")) continue;
|
|
176
|
-
if (/^[ \t]/.test(line)) continue; // nested structure — no top-level scalar
|
|
177
|
-
const kv = /^([^\s:]+):(?:[ \t]+(.*))?$/.exec(line);
|
|
178
|
-
const key = kv?.[1];
|
|
179
|
-
if (key === undefined) return {};
|
|
180
|
-
const parsed = readScalar((kv?.[2] ?? "").trim());
|
|
181
|
-
if (parsed.kind === "refused") return {};
|
|
182
|
-
// A typed value is present in the kernel's map and is not a string; this
|
|
183
|
-
// map holds strings, so the key is simply absent — which is what every
|
|
184
|
-
// consumer here needs to know about it.
|
|
185
|
-
if (parsed.kind === "string") map[key] = parsed.value;
|
|
186
|
-
}
|
|
187
|
-
return map;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* The file's path in the frame the RECORD uses — `sources.origin_path`, which
|
|
192
|
-
* is project-root relative and therefore starts with the record directory's
|
|
193
|
-
* own name. `relPath` is the file's path relative to the record directory,
|
|
194
|
-
* with forward slashes.
|
|
195
|
-
*/
|
|
196
|
-
export function recordPathFrom(recordName: string, relPath: string): string {
|
|
197
|
-
return `${recordName}/${relPath}`;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* The record's stable_id for a file, mirroring the kernel's adapter —
|
|
202
|
-
* INCLUDING the `sor_id:` frontmatter override.
|
|
203
|
-
*
|
|
204
|
-
* Deriving it from the path alone meant a takedown of any document carrying an
|
|
205
|
-
* `sor_id:` never matched here and it stayed published, while the MCP door
|
|
206
|
-
* denied it: the same decoupling decision 14 records as the reason the subtree
|
|
207
|
-
* walk uses parent_id rather than a prefix.
|
|
208
|
-
*/
|
|
209
|
-
export function stableIdFrom(
|
|
210
|
-
recordName: string,
|
|
211
|
-
relPath: string,
|
|
212
|
-
frontmatterBlock: string,
|
|
213
|
-
): string {
|
|
214
|
-
// Through the MAP, not a bare regex on the block: the kernel drops the whole
|
|
215
|
-
// map when any line is a shape it refuses, and an id the two surfaces read
|
|
216
|
-
// differently is a takedown that lands on one of them.
|
|
217
|
-
const override = frontmatterMap(frontmatterBlock)["sor_id"];
|
|
218
|
-
if (override !== undefined && override !== "") return override;
|
|
219
|
-
return `${recordName}/${relPath.replace(/\.md$/i, "")}`;
|
|
220
|
-
}
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Turning the reading-order rule into a sorted page tree — the SITE's half of
|
|
3
|
-
* decision 18, and canonical here so it can be tested.
|
|
4
|
-
*
|
|
5
|
-
* It names no framework type — the page-tree node shape is declared
|
|
6
|
-
* structurally below — for two reasons: the file is byte-copied into the
|
|
7
|
-
* scaffold's site, where it runs against Fumadocs's `Node`; and a rule that can
|
|
8
|
-
* only be exercised inside a Next build is a rule nobody exercises. The kernel
|
|
9
|
-
* has no website — it carries this so the SAME ORDER_CASES rows can be run
|
|
10
|
-
* against the tree sort a reader actually sees.
|
|
11
|
-
*
|
|
12
|
-
* Structural, not nominal: any node shaped like this sorts, which is what makes
|
|
13
|
-
* one file serve both the real page tree and the table's fixtures.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { compareSiblings, UNORDERED } from "./order-rule";
|
|
17
|
-
|
|
18
|
-
/** The members of a page-tree node that reading order depends on. */
|
|
19
|
-
export interface OrderNode {
|
|
20
|
-
readonly type: string;
|
|
21
|
-
/** A page's route. Absent on folders. */
|
|
22
|
-
readonly url?: string;
|
|
23
|
-
/** A folder's index document, when it has one. */
|
|
24
|
-
readonly index?: { readonly url: string } | undefined;
|
|
25
|
-
readonly children?: readonly OrderNode[] | undefined;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function nodeOrder(node: OrderNode, orders: ReadonlyMap<string, number>): number {
|
|
29
|
-
if (node.type === "page") return orders.get(node.url ?? "") ?? UNORDERED;
|
|
30
|
-
if (node.type === "folder" && node.index) return orders.get(node.index.url) ?? UNORDERED;
|
|
31
|
-
return UNORDERED;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// A url that identifies this node: its own, its index's, or its first
|
|
35
|
-
// descendant's — a folder with no index document has no url of its own.
|
|
36
|
-
function nodeUrl(node: OrderNode): string {
|
|
37
|
-
if (node.type === "page") return node.url ?? "";
|
|
38
|
-
if (node.type === "folder") {
|
|
39
|
-
if (node.index) return node.index.url;
|
|
40
|
-
for (const child of node.children ?? []) {
|
|
41
|
-
const url = nodeUrl(child);
|
|
42
|
-
if (url !== "") return url;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return "";
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// The index of a top-level document's own segment: "/docs/x" splits to
|
|
49
|
-
// ["", "docs", "x"], so its segment is at 2 — the baseUrl's segment count.
|
|
50
|
-
const BASE_SEGMENTS = "/docs".split("/").length;
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* The tie key: the ONE path segment that distinguishes this node from its
|
|
54
|
-
* siblings — never the whole url.
|
|
55
|
-
*
|
|
56
|
-
* The tree adapter ties on the entry's own name, so a folder with no index
|
|
57
|
-
* document must tie on its folder name too. Its url comes from a descendant
|
|
58
|
-
* (`/docs/guides/first`), and comparing that whole url against a sibling
|
|
59
|
-
* `/docs/guides-x` puts them in the opposite order, because the separator `/`
|
|
60
|
-
* (47) sorts after `-` (45). Taking the segment at this depth is exactly the
|
|
61
|
-
* adapter's key.
|
|
62
|
-
*/
|
|
63
|
-
function tieAt(node: OrderNode, depth: number): string {
|
|
64
|
-
return nodeUrl(node).split("/")[BASE_SEGMENTS + depth] ?? "";
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Children of one parent, in reading order, recursively.
|
|
69
|
-
*
|
|
70
|
-
* Generic in the node so the caller keeps its own richer type: the site passes
|
|
71
|
-
* Fumadocs `Node`s and gets `Node`s back. The one cast is the rebuilt folder —
|
|
72
|
-
* spreading a node and replacing `children` produces a value TypeScript can no
|
|
73
|
-
* longer prove is the same subtype, though it is: every other member is copied
|
|
74
|
-
* verbatim. `depth` starts at 0 for the tree's own children.
|
|
75
|
-
*/
|
|
76
|
-
export function sortNodes<T extends OrderNode>(
|
|
77
|
-
nodes: readonly T[],
|
|
78
|
-
orders: ReadonlyMap<string, number>,
|
|
79
|
-
depth: number,
|
|
80
|
-
): T[] {
|
|
81
|
-
return nodes
|
|
82
|
-
.map((node) =>
|
|
83
|
-
node.type === "folder"
|
|
84
|
-
? ({ ...node, children: sortNodes(node.children ?? [], orders, depth + 1) } as T)
|
|
85
|
-
: node,
|
|
86
|
-
)
|
|
87
|
-
.sort((a, b) =>
|
|
88
|
-
compareSiblings(
|
|
89
|
-
{ order: nodeOrder(a, orders), tie: tieAt(a, depth) },
|
|
90
|
-
{ order: nodeOrder(b, orders), tie: tieAt(b, depth) },
|
|
91
|
-
),
|
|
92
|
-
);
|
|
93
|
-
}
|