Skip to content

Commit fcd3471

Browse files
authored
fix(build): collect per-doc order/group frontmatter for book sorting (#1944)
1 parent ea9e0e6 commit fcd3471

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)