@panaversity/ksor 0.0.9 → 0.0.10
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 +155 -0
- package/dist/cli.mjs +452 -66
- package/package.json +1 -1
- package/templates/scaffold/AGENTS.md +18 -17
- package/templates/scaffold/README.md +1 -1
- package/templates/scaffold/env.example +6 -5
- package/templates/scaffold/instance.md +4 -1
- package/templates/scaffold/system/site/lib/order-rule.ts +107 -0
- package/templates/scaffold/system/site/lib/page-order.ts +93 -0
- package/templates/scaffold/system/site/lib/source.ts +9 -48
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panaversity/ksor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"description": "Knowledge System of Record — compile governed markdown into a static site for people and an MCP server for AI agents, with citations and measured abstention.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"abstention",
|
|
@@ -147,19 +147,20 @@ Stand it up in this order (each step's errors explain how to fix themselves):
|
|
|
147
147
|
`knowledge/`**: an applied schema reports "already applied", an existing
|
|
148
148
|
grant reports "already granted", and ingest builds a fresh generation.
|
|
149
149
|
|
|
150
|
-
**`pnpm
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
150
|
+
**`pnpm refresh` after editing `knowledge/`; `pnpm serve` to bring the
|
|
151
|
+
server up.** Two commands, and the split is the point: serving must not
|
|
152
|
+
publish, or a restart, a crash-loop or an autoscaling event each republishes
|
|
153
|
+
your record. Everything is re-runnable and reports the state it found rather
|
|
154
|
+
than failing — an applied schema says "already applied", an existing grant
|
|
155
|
+
says "already granted".
|
|
156
|
+
|
|
157
|
+
A refresh on an unchanged record costs **nothing at all**: ingest compares
|
|
158
|
+
the corpus it just read against the generation already serving and, when
|
|
159
|
+
they are identical at the same commit, consumes no generation and writes no
|
|
160
|
+
rows ("unchanged — generation N already serves this corpus"). Edit a
|
|
161
|
+
document and the next refresh builds a generation for it, re-embedding only
|
|
162
|
+
what changed and carrying the rest forward by content hash — so an ordinary
|
|
163
|
+
edit makes a handful of provider calls, not a corpus-worth.
|
|
163
164
|
|
|
164
165
|
Generations do accumulate as you edit. Reap the superseded ones when you
|
|
165
166
|
think of it, or on a schedule:
|
|
@@ -169,10 +170,10 @@ Stand it up in this order (each step's errors explain how to fix themselves):
|
|
|
169
170
|
```
|
|
170
171
|
|
|
171
172
|
The individual verbs (`pnpm schema`, `pnpm grant`, `pnpm ingest`,
|
|
172
|
-
`pnpm
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
173
|
+
`pnpm gc`) are what `provision` and `refresh` are made of. Reach for them
|
|
174
|
+
when duties are split — a deploy step that ingests while a different process
|
|
175
|
+
serves, or a DBA who holds the credentials that authorize ingest — not as a
|
|
176
|
+
daily choice.
|
|
176
177
|
|
|
177
178
|
4. **Turn the abstention gate on — deliberately, once it serves.** This is the
|
|
178
179
|
step that makes "not in this corpus" a real answer, and it is measured, never
|
|
@@ -97,7 +97,7 @@ different coding agent's way of finding the same working contract.
|
|
|
97
97
|
| `.gitattributes` | markdown is checked out byte-stable on every platform, so the same commit hashes the same everywhere. |
|
|
98
98
|
| `.env.example` | the variables the served rung needs; copy to `.env` (gitignored) and fill in. |
|
|
99
99
|
| `.gitignore` | keeps build output, `node_modules/`, and `.env` out of the record's history. |
|
|
100
|
-
| `package.json` | the
|
|
100
|
+
| `package.json` | the surface commands — `pnpm dev` (the site) and `pnpm provision` / `pnpm refresh` / `pnpm serve` (the agent surface: set up once, publish, then serve) — plus `pnpm build` / `pnpm check`, the pinned `@panaversity/ksor` tool, and the pnpm version this project pins. |
|
|
101
101
|
| `pnpm-workspace.yaml` | where the workspace looks for code (`system/site`, plus reserved `system/gateways/*` and `system/packages/*`), and the supply-chain policy for installs. |
|
|
102
102
|
| `pnpm-lock.yaml` | the exact dependency versions — the reason two machines build the same site. |
|
|
103
103
|
|
|
@@ -20,11 +20,12 @@ GEMINI_API_KEY=
|
|
|
20
20
|
# KSOR_SSO_URL=https://your-sso.example.com
|
|
21
21
|
# KSOR_MCP_RESOURCE_URL=https://your-host.example.com/mcp
|
|
22
22
|
# KSOR_JWT_ALLOWED_AUDIENCES=https://your-host.example.com/mcp
|
|
23
|
-
# The
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
23
|
+
# The signing keys are DISCOVERED from your SSO's own metadata document —
|
|
24
|
+
# RFC 8414 (/.well-known/oauth-authorization-server), then OpenID Discovery
|
|
25
|
+
# (/.well-known/openid-configuration) — so Auth0, Okta, Entra, Keycloak,
|
|
26
|
+
# Cognito and Google all work unmodified. The boot line says which document
|
|
27
|
+
# answered and where the keys came from.
|
|
28
|
+
# Set this only to override discovery, or when your SSO publishes no metadata:
|
|
28
29
|
# KSOR_JWKS_URL=https://your-sso.example.com/.well-known/jwks.json
|
|
29
30
|
# Serving a public bind with auth off additionally requires
|
|
30
31
|
# KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1, which serves your whole record to anyone
|
|
@@ -6,7 +6,10 @@ ksor:
|
|
|
6
6
|
scaffolded: "KSOR-STAMP-VERSION"
|
|
7
7
|
# The served MCP rung needs ONE required block: the NAME of the environment
|
|
8
8
|
# variable holding your Postgres DSN — never the DSN itself. Uncomment it, copy
|
|
9
|
-
# .env.example to .env, then
|
|
9
|
+
# .env.example to .env, then: `pnpm provision` once (schema + grant), then
|
|
10
|
+
# `pnpm refresh` to PUBLISH the record, then `pnpm serve`. Serving does not
|
|
11
|
+
# publish — that is deliberate, and skipping refresh serves nothing.
|
|
12
|
+
# Nothing else here is required:
|
|
10
13
|
# `embedding:` already defaults to Gemini at 1536 dimensions, and leaving
|
|
11
14
|
# `retrieval:` out starts you with the abstention gate off and honest about it
|
|
12
15
|
# (turn it on afterwards with `ksor calibrate`, once the record is serving).
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading order — ONE rule, for the website and the MCP door alike.
|
|
3
|
+
*
|
|
4
|
+
* `order:` is the only ordering key an author may write: it is in the governed
|
|
5
|
+
* frontmatter set the format checker closes, and the checker's own remedy for a
|
|
6
|
+
* stray `meta.json` says so ("sidebar order is the `order` frontmatter key").
|
|
7
|
+
*
|
|
8
|
+
* The MCP door did not read it. The kernel's tree adapter was converted from
|
|
9
|
+
* the predecessor, where the ordering keys were Docusaurus's `position` /
|
|
10
|
+
* `sidebar_position` — neither of which a compliant record may declare, because
|
|
11
|
+
* the checker refuses them as unknown keys. So the two surfaces disagreed about
|
|
12
|
+
* the record's reading order for every corpus that ordered itself at all: the
|
|
13
|
+
* site honoured `order:` and the door fell back to filename order and called it
|
|
14
|
+
* the record's structure. On a curriculum, where reading order IS the content,
|
|
15
|
+
* an agent asking `outline` for "what do I read first" got the wrong answer
|
|
16
|
+
* (found live 2026-08-21, by an agent probing a real ingested record).
|
|
17
|
+
*
|
|
18
|
+
* That is decision 18's shape — one guarantee, two surfaces, two heads — so it
|
|
19
|
+
* gets decision 18's treatment: this file is the rule, `ORDER_CASES` is the
|
|
20
|
+
* decision table, and both surfaces are asserted against the same rows. The
|
|
21
|
+
* site cannot import the kernel, so this file is COPIED into the scaffold and
|
|
22
|
+
* the copy is asserted byte-identical rather than trusted.
|
|
23
|
+
*
|
|
24
|
+
* Four things the two surfaces disagreed about beyond the key name, each of
|
|
25
|
+
* which is a row in the table:
|
|
26
|
+
*
|
|
27
|
+
* - the unordered sentinel. The kernel used 10_000, a real number, so
|
|
28
|
+
* `order: 20000` sorted AFTER an unordered document in the door and BEFORE
|
|
29
|
+
* it on the site. Unordered is not a large order; it is the absence of one.
|
|
30
|
+
* - truncation. The kernel applied `Math.trunc`, collapsing 3.2 and 3.7 into
|
|
31
|
+
* one position and re-sorting them by name; the site kept both.
|
|
32
|
+
* - the tie key's extension. The kernel compared `example.md` against
|
|
33
|
+
* `example-two.md` — where `-` (45) sorts before `.` (46) — while the site
|
|
34
|
+
* compared the extensionless urls, where the shorter is a prefix and wins.
|
|
35
|
+
* Two ordinary filenames, two different orders.
|
|
36
|
+
* - case. The kernel lowercased the tie key and the site did not, so
|
|
37
|
+
* `apple.md` and `Banana.md` came out in opposite orders.
|
|
38
|
+
*
|
|
39
|
+
* No imports: a leaf, so it is testable in isolation and safe to copy.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A document that declares no usable `order:` sorts after every document that
|
|
44
|
+
* does. Infinity, not a large number — see above.
|
|
45
|
+
*/
|
|
46
|
+
export const UNORDERED: number = Number.POSITIVE_INFINITY;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The `order:` frontmatter value as a sort key.
|
|
50
|
+
*
|
|
51
|
+
* A numeric string is accepted because YAML frontmatter is read by scanners
|
|
52
|
+
* here, not by a YAML library: `order: 3` and `order: "3"` both reach this as
|
|
53
|
+
* text on one surface and as a number on the other, and an author cannot be
|
|
54
|
+
* expected to know which. Anything that is not a finite number — a word, a
|
|
55
|
+
* boolean, an empty value — is NOT an order, and the document sorts unordered.
|
|
56
|
+
*/
|
|
57
|
+
export function orderValue(raw: unknown): number {
|
|
58
|
+
if (typeof raw === "number") return Number.isFinite(raw) ? raw : UNORDERED;
|
|
59
|
+
if (typeof raw === "string") {
|
|
60
|
+
const trimmed = raw.trim();
|
|
61
|
+
if (trimmed === "") return UNORDERED;
|
|
62
|
+
const parsed = Number(trimmed);
|
|
63
|
+
return Number.isFinite(parsed) ? parsed : UNORDERED;
|
|
64
|
+
}
|
|
65
|
+
return UNORDERED;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The tie key for one sibling: its name with a MARKDOWN extension removed,
|
|
70
|
+
* case PRESERVED. The extension comes off because the site compares routes,
|
|
71
|
+
* which never carry one, and `.` sorting after `-` silently reversed ordinary
|
|
72
|
+
* pairs. Only `.md`/`.mdx` come off — a directory named `v1.2` keeps its dot,
|
|
73
|
+
* because the site's route keeps it too. Case is preserved because the site
|
|
74
|
+
* compares urls, and the url is what a reader sees.
|
|
75
|
+
*/
|
|
76
|
+
export function tieKey(name: string): string {
|
|
77
|
+
return name.replace(/\.mdx?$/, "");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One sibling's place in its parent: what it declared, and how ties break. */
|
|
81
|
+
export interface Sibling {
|
|
82
|
+
readonly order: number;
|
|
83
|
+
readonly tie: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Compare by code point, not by locale or UTF-16 unit: reading order must be
|
|
88
|
+
* one bytewise truth on every machine, and `<` on strings compares UTF-16 units
|
|
89
|
+
* — which differ from code points on astral names.
|
|
90
|
+
*/
|
|
91
|
+
export function codePointCompare(a: string, b: string): number {
|
|
92
|
+
const as = [...a];
|
|
93
|
+
const bs = [...b];
|
|
94
|
+
const n = Math.min(as.length, bs.length);
|
|
95
|
+
for (let i = 0; i < n; i += 1) {
|
|
96
|
+
const x = as[i]?.codePointAt(0) ?? 0;
|
|
97
|
+
const y = bs[i]?.codePointAt(0) ?? 0;
|
|
98
|
+
if (x !== y) return x < y ? -1 : 1;
|
|
99
|
+
}
|
|
100
|
+
return as.length === bs.length ? 0 : as.length < bs.length ? -1 : 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Declared order first; ties break on the tie key. Total, and stable-safe. */
|
|
104
|
+
export function compareSiblings(a: Sibling, b: Sibling): number {
|
|
105
|
+
if (a.order !== b.order) return a.order < b.order ? -1 : 1;
|
|
106
|
+
return codePointCompare(a.tie, b.tie);
|
|
107
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
}
|
|
@@ -3,6 +3,9 @@ import { loader } from "fumadocs-core/source";
|
|
|
3
3
|
import { lucideIconsPlugin } from "fumadocs-core/source/lucide-icons";
|
|
4
4
|
import type { Node, Root } from "fumadocs-core/page-tree";
|
|
5
5
|
|
|
6
|
+
import { orderValue } from "./order-rule";
|
|
7
|
+
import { sortNodes } from "./page-order";
|
|
8
|
+
|
|
6
9
|
// See https://fumadocs.dev/docs/headless/source-api for more info
|
|
7
10
|
export const source = loader({
|
|
8
11
|
baseUrl: "/docs",
|
|
@@ -18,54 +21,12 @@ export type KnowledgePage = (typeof source)["$inferPage"];
|
|
|
18
21
|
// every rendered link.
|
|
19
22
|
export const basePath: string = process.env.KSOR_BASE_PATH ?? "";
|
|
20
23
|
|
|
21
|
-
//
|
|
22
|
-
//
|
|
24
|
+
// Reading order is ONE rule, shared with the MCP door byte-for-byte — see
|
|
25
|
+
// ./order-rule.ts. The site cannot import the kernel, so the rule is copied and
|
|
26
|
+
// the copy is asserted; every case both surfaces must agree on is a row in the
|
|
27
|
+
// kernel's ORDER_CASES table, and this half is asserted against the same rows.
|
|
23
28
|
function orderOf(page: KnowledgePage): number {
|
|
24
|
-
|
|
25
|
-
const value = typeof raw === "string" ? Number(raw) : raw;
|
|
26
|
-
return typeof value === "number" && Number.isFinite(value) ? value : Number.POSITIVE_INFINITY;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function nodeOrder(node: Node, orders: ReadonlyMap<string, number>): number {
|
|
30
|
-
if (node.type === "page") return orders.get(node.url) ?? Number.POSITIVE_INFINITY;
|
|
31
|
-
if (node.type === "folder" && node.index) {
|
|
32
|
-
return orders.get(node.index.url) ?? Number.POSITIVE_INFINITY;
|
|
33
|
-
}
|
|
34
|
-
return Number.POSITIVE_INFINITY;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// The tie-break key: a page's url, a folder's index url or first page's url.
|
|
38
|
-
// Ties break on it so unordered documents read in plain name order, folders
|
|
39
|
-
// interleaved — the canonical reading order both shells implement (found
|
|
40
|
-
// live 2026-08-18: the loader's own tie order grouped folders after loose
|
|
41
|
-
// files, silently diverging from the Docusaurus shell on the same record).
|
|
42
|
-
function nodeName(node: Node): string {
|
|
43
|
-
if (node.type === "page") return node.url;
|
|
44
|
-
if (node.type === "folder") {
|
|
45
|
-
if (node.index) return node.index.url;
|
|
46
|
-
for (const child of node.children) {
|
|
47
|
-
const name = nodeName(child);
|
|
48
|
-
if (name !== "") return name;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
return "";
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function sortNodes(nodes: readonly Node[], orders: ReadonlyMap<string, number>): Node[] {
|
|
55
|
-
return nodes
|
|
56
|
-
.map((node) =>
|
|
57
|
-
node.type === "folder" ? { ...node, children: sortNodes(node.children, orders) } : node,
|
|
58
|
-
)
|
|
59
|
-
.sort((a, b) => {
|
|
60
|
-
const left = nodeOrder(a, orders);
|
|
61
|
-
const right = nodeOrder(b, orders);
|
|
62
|
-
if (left !== right) return left < right ? -1 : 1;
|
|
63
|
-
const leftName = nodeName(a);
|
|
64
|
-
const rightName = nodeName(b);
|
|
65
|
-
// Codepoint comparison, not locale: reading order must be one bytewise
|
|
66
|
-
// truth on every machine.
|
|
67
|
-
return leftName < rightName ? -1 : leftName > rightName ? 1 : 0;
|
|
68
|
-
});
|
|
29
|
+
return orderValue(page.data.order);
|
|
69
30
|
}
|
|
70
31
|
|
|
71
32
|
/**
|
|
@@ -76,7 +37,7 @@ function sortNodes(nodes: readonly Node[], orders: ReadonlyMap<string, number>):
|
|
|
76
37
|
export function getSortedPageTree(): Root {
|
|
77
38
|
const orders = new Map(source.getPages().map((page) => [page.url, orderOf(page)] as const));
|
|
78
39
|
const tree = source.getPageTree();
|
|
79
|
-
return { ...tree, children: sortNodes(tree.children, orders) };
|
|
40
|
+
return { ...tree, children: sortNodes(tree.children, orders, 0) };
|
|
80
41
|
}
|
|
81
42
|
|
|
82
43
|
function collectUrls(nodes: readonly Node[], urls: string[]): void {
|