Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/doc-order-group-frontmatter.md
Original file line number Diff line number Diff line change
@@ -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").
22 changes: 22 additions & 0 deletions packages/cli/src/utils/collect-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
28 changes: 24 additions & 4 deletions packages/cli/src/utils/collect-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* `<name>.<locale>.md` files. The base file is the default + fallback.
Expand Down Expand Up @@ -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,
};
}
Expand Down Expand Up @@ -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 `<base>.<locale>.md` (ADR-0046 i18n) — checked before the
// bare-name rule, since a variant stem legitimately contains a dot.
Expand Down Expand Up @@ -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 } : {}),
});
}

Expand Down
3 changes: 2 additions & 1 deletion packages/spec/src/system/doc.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)'),

Expand Down
Loading