diff --git a/.changeset/doc-order-group-frontmatter.md b/.changeset/doc-order-group-frontmatter.md new file mode 100644 index 0000000000..4755297efb --- /dev/null +++ b/.changeset/doc-order-group-frontmatter.md @@ -0,0 +1,19 @@ +--- +"@objectstack/cli": patch +--- + +fix(build): collect per-doc `order:` and `group:` frontmatter so book sorting/placement works + +The doc collector (`collectDocsFromSrc`) parsed only `title:`/`description:` from +each `src/docs/*.md` frontmatter, so the `order` and `group` fields defined on the +`Doc` schema (ADR-0046 §6) were never populated on the compiled `doc` item. The +book resolver (`resolveBookTree`) already sorts group members by `doc.order` then +label and honors explicit `doc.group` placement — but with the collection half +silently dropping both fields, frontmatter-driven sorting/placement never reached +the artifact. + +`parseFrontmatter` now also reads `order:` (parsed to a number; ignored when +non-numeric) and `group:` (string), threading them onto the collected doc when +present. Absent leaves both undefined so the schema/resolver defaults apply. Also +corrects the `order` JSDoc in `doc.zod.ts` to match the resolver, which treats an +absent `order` as `0` (not "after ordered siblings"). diff --git a/packages/cli/src/utils/collect-docs.test.ts b/packages/cli/src/utils/collect-docs.test.ts index 86603d4991..a8dabc1ad4 100644 --- a/packages/cli/src/utils/collect-docs.test.ts +++ b/packages/cli/src/utils/collect-docs.test.ts @@ -50,6 +50,28 @@ describe('collectDocsFromSrc (ADR-0046 §3.2)', () => { expect(plain.description).toBeUndefined(); // absent → omitted, not '' }); + it('reads frontmatter `order:` as a number and ignores non-numeric values', () => { + write('crm_a.md', '---\norder: 3\n---\n\n# A'); + write('crm_b.md', '---\norder: not-a-number\n---\n\n# B'); + write('crm_c.md', '---\norder: 0\n---\n\n# C'); + const { docs, issues } = collectDocsFromSrc(configPath); + expect(issues).toHaveLength(0); + expect(docs.find((d) => d.name === 'crm_a')!.order).toBe(3); // parsed to number + expect(docs.find((d) => d.name === 'crm_b')!.order).toBeUndefined(); // NaN → dropped + expect(docs.find((d) => d.name === 'crm_c')!.order).toBe(0); // zero is a valid order + }); + + it('reads frontmatter `group:` as a string; absent leaves order/group undefined', () => { + write('crm_grouped.md', '---\ngroup: crm_admin\n---\n\n# Grouped'); + write('crm_bare.md', '# Bare\n\nNo frontmatter.'); + const { docs, issues } = collectDocsFromSrc(configPath); + expect(issues).toHaveLength(0); + expect(docs.find((d) => d.name === 'crm_grouped')!.group).toBe('crm_admin'); + const bare = docs.find((d) => d.name === 'crm_bare')!; + expect(bare.order).toBeUndefined(); + expect(bare.group).toBeUndefined(); + }); + it('errors on subdirectories — flatness is the contract', () => { fs.mkdirSync(path.join(docsDir, 'user')); write('crm_index.md', '# x'); diff --git a/packages/cli/src/utils/collect-docs.ts b/packages/cli/src/utils/collect-docs.ts index b80a29a572..15df357800 100644 --- a/packages/cli/src/utils/collect-docs.ts +++ b/packages/cli/src/utils/collect-docs.ts @@ -37,6 +37,13 @@ export interface DocItem { label?: string; description?: string; content: string; + /** + * Sort key + explicit book-group placement (ADR-0046 §6), read from + * frontmatter `order:`/`group:`. The book resolver honors both; absent + * leaves them out so the schema defaults apply. + */ + order?: number; + group?: string; /** * Per-locale variants (ADR-0046 i18n), compiled from sibling * `..md` files. The base file is the default + fallback. @@ -68,19 +75,30 @@ function frontmatterScalar(block: string, key: string): string | undefined { } /** - * Strip a leading `---` frontmatter block; extract `title:` and - * `description:` if present (both optional, single-line scalars). + * Strip a leading `---` frontmatter block; extract `title:`, `description:`, + * `order:`, and `group:` if present (all optional, single-line scalars). + * `order:` is parsed to a number and dropped when non-numeric. */ -function parseFrontmatter(raw: string): { title?: string; description?: string; body: string } { +function parseFrontmatter(raw: string): { + title?: string; + description?: string; + order?: number; + group?: string; + body: string; +} { if (!raw.startsWith('---\n') && !raw.startsWith('---\r\n')) return { body: raw }; const end = raw.indexOf('\n---', 3); if (end === -1) return { body: raw }; const block = raw.slice(raw.indexOf('\n') + 1, end); const bodyStart = raw.indexOf('\n', end + 1); const body = bodyStart === -1 ? '' : raw.slice(bodyStart + 1); + const orderRaw = frontmatterScalar(block, 'order'); + const order = orderRaw !== undefined ? Number(orderRaw) : undefined; return { title: frontmatterScalar(block, 'title'), description: frontmatterScalar(block, 'description'), + ...(order !== undefined && !Number.isNaN(order) ? { order } : {}), + group: frontmatterScalar(block, 'group'), body, }; } @@ -126,7 +144,7 @@ export function collectDocsFromSrc(configPath: string): { docs: DocItem[]; issue const stem = entry.name.slice(0, -3); const raw = fs.readFileSync(path.join(docsDir, entry.name), 'utf-8'); - const { title, description, body } = parseFrontmatter(raw); + const { title, description, order, group, body } = parseFrontmatter(raw); // Locale variant `..md` (ADR-0046 i18n) — checked before the // bare-name rule, since a variant stem legitimately contains a dot. @@ -156,6 +174,8 @@ export function collectDocsFromSrc(configPath: string): { docs: DocItem[]; issue label: title ?? firstHeading(body), ...(description ? { description } : {}), content: body, + ...(order !== undefined ? { order } : {}), + ...(group ? { group } : {}), }); } diff --git a/packages/spec/src/system/doc.zod.ts b/packages/spec/src/system/doc.zod.ts index 6181cf9c96..bd48a566e6 100644 --- a/packages/spec/src/system/doc.zod.ts +++ b/packages/spec/src/system/doc.zod.ts @@ -64,7 +64,8 @@ export const DocSchema = lazySchema(() => z.object({ /** * Optional sort key within a book group (ADR-0046 §6.2.1). A scalar, so it * three-way-merges cleanly under overlay — unlike a central nav array. Absent - * ⇒ sorts after ordered siblings, then alphabetically by label. + * ⇒ treated as 0 by the resolver (sorts among 0-keyed siblings), then + * alphabetically by label. */ order: z.number().optional().describe('Sort key within a book group (ADR-0046 §6)'),