Skip to content

Commit 0e70589

Browse files
committed
fix(build): collect per-doc order/group frontmatter for book sorting
collectDocsFromSrc parsed only title:/description: from src/docs/*.md frontmatter, so the order/group fields on the Doc schema (ADR-0046 §6) were never populated on the compiled doc item. resolveBookTree already sorts group members by doc.order then label and honors explicit doc.group placement, but the collection half silently dropped both, so frontmatter-driven sorting/placement never reached the artifact. parseFrontmatter now also reads order: (parsed to a number; ignored when non-numeric) and group: (string), threaded onto the collected doc when present; absent leaves both undefined so schema/resolver defaults apply. Also corrects the order JSDoc in doc.zod.ts to match the resolver, which treats absent order as 0 (not "after ordered siblings").
1 parent 6149749 commit 0e70589

4 files changed

Lines changed: 67 additions & 5 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(build): collect per-doc `order:` and `group:` frontmatter so book sorting/placement works
6+
7+
The doc collector (`collectDocsFromSrc`) parsed only `title:`/`description:` from
8+
each `src/docs/*.md` frontmatter, so the `order` and `group` fields defined on the
9+
`Doc` schema (ADR-0046 §6) were never populated on the compiled `doc` item. The
10+
book resolver (`resolveBookTree`) already sorts group members by `doc.order` then
11+
label and honors explicit `doc.group` placement — but with the collection half
12+
silently dropping both fields, frontmatter-driven sorting/placement never reached
13+
the artifact.
14+
15+
`parseFrontmatter` now also reads `order:` (parsed to a number; ignored when
16+
non-numeric) and `group:` (string), threading them onto the collected doc when
17+
present. Absent leaves both undefined so the schema/resolver defaults apply. Also
18+
corrects the `order` JSDoc in `doc.zod.ts` to match the resolver, which treats an
19+
absent `order` as `0` (not "after ordered siblings").

packages/cli/src/utils/collect-docs.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,28 @@ describe('collectDocsFromSrc (ADR-0046 §3.2)', () => {
5050
expect(plain.description).toBeUndefined(); // absent → omitted, not ''
5151
});
5252

53+
it('reads frontmatter `order:` as a number and ignores non-numeric values', () => {
54+
write('crm_a.md', '---\norder: 3\n---\n\n# A');
55+
write('crm_b.md', '---\norder: not-a-number\n---\n\n# B');
56+
write('crm_c.md', '---\norder: 0\n---\n\n# C');
57+
const { docs, issues } = collectDocsFromSrc(configPath);
58+
expect(issues).toHaveLength(0);
59+
expect(docs.find((d) => d.name === 'crm_a')!.order).toBe(3); // parsed to number
60+
expect(docs.find((d) => d.name === 'crm_b')!.order).toBeUndefined(); // NaN → dropped
61+
expect(docs.find((d) => d.name === 'crm_c')!.order).toBe(0); // zero is a valid order
62+
});
63+
64+
it('reads frontmatter `group:` as a string; absent leaves order/group undefined', () => {
65+
write('crm_grouped.md', '---\ngroup: crm_admin\n---\n\n# Grouped');
66+
write('crm_bare.md', '# Bare\n\nNo frontmatter.');
67+
const { docs, issues } = collectDocsFromSrc(configPath);
68+
expect(issues).toHaveLength(0);
69+
expect(docs.find((d) => d.name === 'crm_grouped')!.group).toBe('crm_admin');
70+
const bare = docs.find((d) => d.name === 'crm_bare')!;
71+
expect(bare.order).toBeUndefined();
72+
expect(bare.group).toBeUndefined();
73+
});
74+
5375
it('errors on subdirectories — flatness is the contract', () => {
5476
fs.mkdirSync(path.join(docsDir, 'user'));
5577
write('crm_index.md', '# x');

packages/cli/src/utils/collect-docs.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ export interface DocItem {
3737
label?: string;
3838
description?: string;
3939
content: string;
40+
/**
41+
* Sort key + explicit book-group placement (ADR-0046 §6), read from
42+
* frontmatter `order:`/`group:`. The book resolver honors both; absent
43+
* leaves them out so the schema defaults apply.
44+
*/
45+
order?: number;
46+
group?: string;
4047
/**
4148
* Per-locale variants (ADR-0046 i18n), compiled from sibling
4249
* `<name>.<locale>.md` files. The base file is the default + fallback.
@@ -68,19 +75,30 @@ function frontmatterScalar(block: string, key: string): string | undefined {
6875
}
6976

7077
/**
71-
* Strip a leading `---` frontmatter block; extract `title:` and
72-
* `description:` if present (both optional, single-line scalars).
78+
* Strip a leading `---` frontmatter block; extract `title:`, `description:`,
79+
* `order:`, and `group:` if present (all optional, single-line scalars).
80+
* `order:` is parsed to a number and dropped when non-numeric.
7381
*/
74-
function parseFrontmatter(raw: string): { title?: string; description?: string; body: string } {
82+
function parseFrontmatter(raw: string): {
83+
title?: string;
84+
description?: string;
85+
order?: number;
86+
group?: string;
87+
body: string;
88+
} {
7589
if (!raw.startsWith('---\n') && !raw.startsWith('---\r\n')) return { body: raw };
7690
const end = raw.indexOf('\n---', 3);
7791
if (end === -1) return { body: raw };
7892
const block = raw.slice(raw.indexOf('\n') + 1, end);
7993
const bodyStart = raw.indexOf('\n', end + 1);
8094
const body = bodyStart === -1 ? '' : raw.slice(bodyStart + 1);
95+
const orderRaw = frontmatterScalar(block, 'order');
96+
const order = orderRaw !== undefined ? Number(orderRaw) : undefined;
8197
return {
8298
title: frontmatterScalar(block, 'title'),
8399
description: frontmatterScalar(block, 'description'),
100+
...(order !== undefined && !Number.isNaN(order) ? { order } : {}),
101+
group: frontmatterScalar(block, 'group'),
84102
body,
85103
};
86104
}
@@ -126,7 +144,7 @@ export function collectDocsFromSrc(configPath: string): { docs: DocItem[]; issue
126144

127145
const stem = entry.name.slice(0, -3);
128146
const raw = fs.readFileSync(path.join(docsDir, entry.name), 'utf-8');
129-
const { title, description, body } = parseFrontmatter(raw);
147+
const { title, description, order, group, body } = parseFrontmatter(raw);
130148

131149
// Locale variant `<base>.<locale>.md` (ADR-0046 i18n) — checked before the
132150
// bare-name rule, since a variant stem legitimately contains a dot.
@@ -156,6 +174,8 @@ export function collectDocsFromSrc(configPath: string): { docs: DocItem[]; issue
156174
label: title ?? firstHeading(body),
157175
...(description ? { description } : {}),
158176
content: body,
177+
...(order !== undefined ? { order } : {}),
178+
...(group ? { group } : {}),
159179
});
160180
}
161181

packages/spec/src/system/doc.zod.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ export const DocSchema = lazySchema(() => z.object({
6464
/**
6565
* Optional sort key within a book group (ADR-0046 §6.2.1). A scalar, so it
6666
* three-way-merges cleanly under overlay — unlike a central nav array. Absent
67-
* ⇒ sorts after ordered siblings, then alphabetically by label.
67+
* ⇒ treated as 0 by the resolver (sorts among 0-keyed siblings), then
68+
* alphabetically by label.
6869
*/
6970
order: z.number().optional().describe('Sort key within a book group (ADR-0046 §6)'),
7071

0 commit comments

Comments
 (0)