From 0e705899ab78c4d0556f633dbbb15568cbdf58b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Tue, 16 Jun 2026 14:08:52 +0800 Subject: [PATCH] fix(build): collect per-doc order/group frontmatter for book sorting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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"). --- .changeset/doc-order-group-frontmatter.md | 19 ++++++++++++++ packages/cli/src/utils/collect-docs.test.ts | 22 ++++++++++++++++ packages/cli/src/utils/collect-docs.ts | 28 ++++++++++++++++++--- packages/spec/src/system/doc.zod.ts | 3 ++- 4 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 .changeset/doc-order-group-frontmatter.md 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)'),