From 51c5e2af873ecd25b4cad6c0a5de3f4c807f02fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 01:25:06 +0000 Subject: [PATCH] fix(cli): read doc `tags` from src/docs frontmatter, and report what it cannot read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DocSchema.tags` was declared in 17.0.0 (#4509, ADR-0049) as the enforce half of enforce-or-remove: the resolver already compared against it (`matchesInclude` in `book.zod.ts`) and the REST book-tree route already forwarded it. But `collect-docs.ts` read frontmatter with `frontmatterScalar` alone — single-line scalars — and had no case for `tags`. On the flat `src/docs/*.md` path the docs recommend, a `tags:` block was dropped without a word: every doc reached `resolveBookTree` with `tags === undefined`, so a group declaring `include: { tag: 'tutorial' }` matched nothing and rendered empty. Two halves, both needed: - `frontmatterList` reads the two ordinary YAML sequence spellings — inline `tags: [a, b]` and the block form of `- item` lines — wired through `DocItem.tags`. The block sequence ends at the next frontmatter key, so a `group:` following a `tags:` block still parses. An authored `tags: []` parses and means what it says. - A `docs/frontmatter-tags` warning fires whenever `tags:` is present in a spelling the reader cannot parse (bare scalar, unterminated inline sequence, key with nothing under it). The reader stays deliberately minimal and is not becoming a YAML engine; the warning is what keeps that minimalism honest, by turning the next unanticipated spelling from a silent drop into a visible report. The same warning fires when a locale variant declares `tags:`, since tags belong to the doc rather than to one translation and `DocTranslationItem` carries no such field. No schema change: `DocSchema.tags` already declared the key, and only the collector could not produce it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r --- .changeset/doc-tags-frontmatter-list.md | 37 ++++++ packages/cli/src/utils/collect-docs.test.ts | 88 +++++++++++++ packages/cli/src/utils/collect-docs.ts | 129 +++++++++++++++++++- 3 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 .changeset/doc-tags-frontmatter-list.md diff --git a/.changeset/doc-tags-frontmatter-list.md b/.changeset/doc-tags-frontmatter-list.md new file mode 100644 index 0000000000..ccd344b706 --- /dev/null +++ b/.changeset/doc-tags-frontmatter-list.md @@ -0,0 +1,37 @@ +--- +"@objectstack/cli": patch +--- + +Read `doc.tags` from `src/docs/*.md` frontmatter, so a book group's +`include: { tag }` can match on the documented authoring path (#10486). + +`DocSchema.tags` was declared in 17.0.0 (#4509, ADR-0049) as the *enforce* half +of enforce-or-remove: the resolver side already compared against it +(`matchesInclude` in `book.zod.ts`) and the REST book-tree route already +forwarded it. But `collect-docs.ts` parsed frontmatter with `frontmatterScalar` +alone — single-line scalars — and had no case for `tags` at all. On the flat +`src/docs/*.md` path the docs actually recommend, a `tags:` block was therefore +dropped without a word: every doc reached `resolveBookTree` with +`tags === undefined`, and a group declaring `include: { tag: 'tutorial' }` +matched nothing and rendered as an empty section. + +Two halves: + +- **A minimal `frontmatterList`** reading the two ordinary YAML sequence + spellings — inline `tags: [tutorial, beginner]` and the block form of `- item` + lines — wired through `DocItem.tags`. The block sequence ends at the next + frontmatter key, so `group:` after a `tags:` block still parses. An authored + `tags: []` parses and means what it says: no tags. + +- **A loud `docs/frontmatter-tags` warning** whenever `tags:` is present in a + spelling the reader cannot parse — a bare scalar, an unterminated inline + sequence, a key with nothing under it. The reader is deliberately minimal and + is **not** growing into a YAML engine; this is what keeps that minimalism + honest, by converting the next unanticipated spelling from a silent drop into + a visible report. The same warning fires when a locale variant + (`..md`) declares `tags:`, since tags belong to the doc rather + than to one translation and a `DocTranslationItem` carries no such field. + +Warnings surface through the paths that already print `DocIssue`s: `os lint`, +`os validate`, and `os compile`. No schema change — `DocSchema.tags` already +declared the key; only the collector could not produce it. diff --git a/packages/cli/src/utils/collect-docs.test.ts b/packages/cli/src/utils/collect-docs.test.ts index 5de613cf8a..bc42761e28 100644 --- a/packages/cli/src/utils/collect-docs.test.ts +++ b/packages/cli/src/utils/collect-docs.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; +import { resolveBookTree } from '@objectstack/spec/system'; import { collectDocsFromSrc, lintDocs, collectAndLintDocs, lintMetadataEmbeds, type DocItem } from './collect-docs.js'; // ── ADR-0051: ```metadata embed lint (body shape + reference liveness) ────── @@ -134,6 +135,68 @@ describe('collectDocsFromSrc (ADR-0046 §3.2)', () => { expect(bare.group).toBeUndefined(); }); + it('reads frontmatter `tags:` in the inline list form', () => { + write('crm_inline.md', '---\ntitle: Inline\ntags: [tutorial, beginner]\n---\n\n# Inline'); + const { docs, issues } = collectDocsFromSrc(configPath); + expect(issues).toHaveLength(0); + const doc = docs.find((d) => d.name === 'crm_inline')!; + expect(doc.tags).toEqual(['tutorial', 'beginner']); + expect(doc.content).not.toContain('tags:'); // frontmatter stripped + }); + + it('reads frontmatter `tags:` in the block list form without swallowing the next key', () => { + write('crm_block.md', '---\ntitle: Block\ntags:\n - tutorial\n - advanced\ngroup: crm_admin\n---\n\n# Block'); + const { docs, issues } = collectDocsFromSrc(configPath); + expect(issues).toHaveLength(0); + const doc = docs.find((d) => d.name === 'crm_block')!; + expect(doc.tags).toEqual(['tutorial', 'advanced']); + expect(doc.group).toBe('crm_admin'); // the sequence ends at the next key + }); + + it('unquotes list items; an authored empty list is no tags and no complaint', () => { + write('crm_quoted.md', '---\ntags: [\'tutorial\', "deep-dive"]\n---\n\n# Q'); + write('crm_none.md', '---\ntags: []\n---\n\n# N'); + const { docs, issues } = collectDocsFromSrc(configPath); + expect(issues).toHaveLength(0); // `[]` parses — nothing was dropped + expect(docs.find((d) => d.name === 'crm_quoted')!.tags).toEqual(['tutorial', 'deep-dive']); + expect(docs.find((d) => d.name === 'crm_none')!.tags).toBeUndefined(); + }); + + // The loud half. A `tags:` spelling this deliberately minimal reader cannot + // parse must be REPORTED, never dropped — a silent drop is the defect the + // key exists to fix, and a warning nothing asserts is a warning that can + // quietly stop firing. + it.each([ + ['a bare scalar', 'crm_scalar.md', '---\ntags: tutorial\n---\n\n# S', 'tags: tutorial'], + ['an unterminated inline list', 'crm_open.md', '---\ntags: [tutorial, beginner\n---\n\n# O', 'tags: [tutorial, beginner'], + ['a key with nothing under it', 'crm_naked.md', '---\ntags:\ndescription: d\n---\n\n# B', 'no list items follow'], + ])('warns when `tags:` is present but unreadable — %s', (_label, file, content, found) => { + write(file, content); + const { docs, issues } = collectDocsFromSrc(configPath); + expect(issues).toHaveLength(1); + expect(issues[0].severity).toBe('warning'); + expect(issues[0].rule).toBe('docs/frontmatter-tags'); + expect(issues[0].path).toBe(`src/docs/${file}`); + expect(issues[0].message).toContain('include: { tag }'); // names the consequence + expect(issues[0].message).toContain(found); // quotes what it actually found + expect(docs).toHaveLength(1); // the doc is still collected; only its tags are missing + expect(docs[0].tags).toBeUndefined(); + }); + + it('warns when a locale variant declares `tags:` — tags are doc-level, not per translation', () => { + write('crm_guide.md', '---\ntags: [tutorial]\n---\n\n# Guide'); + write('crm_guide.zh.md', '---\ntags: [tutorial]\n---\n\n# Zh'); + const { docs, issues } = collectDocsFromSrc(configPath); + expect(issues).toHaveLength(1); + expect(issues[0].severity).toBe('warning'); + expect(issues[0].rule).toBe('docs/frontmatter-tags'); + expect(issues[0].path).toBe('src/docs/crm_guide.zh.md'); + expect(issues[0].message).toContain('crm_guide.md'); // points at the base file + const base = docs.find((d) => d.name === 'crm_guide')!; + expect(base.tags).toEqual(['tutorial']); // the base doc still carries them + expect(Object.keys(base.translations ?? {})).toEqual(['zh']); // folding unaffected + }); + it('errors on subdirectories — flatness is the contract', () => { fs.mkdirSync(path.join(docsDir, 'user')); write('crm_index.md', '# x'); @@ -157,6 +220,31 @@ describe('collectDocsFromSrc (ADR-0046 §3.2)', () => { }); }); +// The reason the key exists (ADR-0046 §5/§6, #4509): a book group's +// `include: { tag }` must be able to match docs authored on the flat +// `src/docs/*.md` path. Before the reader had a list case, every such group +// resolved to zero entries and the docs fell into *Uncategorized* — with no +// diagnostic on either side saying so. +describe('frontmatter tags reach the book resolver', () => { + it("lets a group's include: { tag } match docs authored in either list form", () => { + write('crm_inline.md', '---\ntitle: Inline\ntags: [tutorial, beginner]\n---\n\n# Inline'); + write('crm_block.md', '---\ntitle: Block\ntags:\n - tutorial\n---\n\n# Block'); + write('crm_reference.md', '---\ntitle: Reference\ntags: [reference]\n---\n\n# Reference'); + const { docs, issues } = collectDocsFromSrc(configPath); + expect(issues).toHaveLength(0); + + const tree = resolveBookTree( + { name: 'crm_book', groups: [{ key: 'tutorials', label: 'Tutorials', include: { tag: 'tutorial' } }] }, + docs, + ); + const tutorials = tree.groups.find((g) => g.key === 'tutorials')!; + expect(tutorials.entries.map((e) => e.doc).sort()).toEqual(['crm_block', 'crm_inline']); + // The untagged-by-this-rule doc is not dropped — it lands in Uncategorized. + const uncategorized = tree.groups.find((g) => g.key === 'uncategorized')!; + expect(uncategorized.entries.map((e) => e.doc)).toEqual(['crm_reference']); + }); +}); + describe('lintDocs (ADR-0046 §3.2–§3.4)', () => { const doc = (name: string, content: string): DocItem => ({ name, content }); diff --git a/packages/cli/src/utils/collect-docs.ts b/packages/cli/src/utils/collect-docs.ts index de919fd4e6..8d646597eb 100644 --- a/packages/cli/src/utils/collect-docs.ts +++ b/packages/cli/src/utils/collect-docs.ts @@ -8,7 +8,9 @@ * (`docs: DocSchema[]`). This module owns both halves of that contract: * * - **Collection**: filename stem → `name`, frontmatter `title:` or the - * first `#` heading → `label`, body → `content`. Subdirectories are a + * first `#` heading → `label`, body → `content`; the optional + * `description:`/`order:`/`group:` scalars and the `tags:` list are read + * through to the `doc` item. Subdirectories are a * build error — flatness is the contract that keeps cross-references * stable (a link is `[text](./.md)`; resolution is a basename * lookup with zero path arithmetic). @@ -44,6 +46,18 @@ export interface DocItem { */ order?: number; group?: string; + /** + * Membership tags — the operand of a book group's `include: { tag: '' }` + * rule (ADR-0046 §5), read from frontmatter `tags:`. + * + * `DocSchema.tags` was declared to make `include: { tag }` reachable (the + * enforce half of ADR-0049), and the resolver's `matchesInclude` has always + * compared against it — but this collector had no list case, so on the flat + * `src/docs/*.md` path every doc reached the resolver with `tags === + * undefined` and a tag rule matched nothing. Absent leaves the key out so + * the schema default applies. + */ + tags?: string[]; /** * Per-locale variants (ADR-0046 i18n), compiled from sibling * `..md` files. The base file is the default + fallback. @@ -74,16 +88,94 @@ function frontmatterScalar(block: string, key: string): string | undefined { return value || undefined; } +/** Strip one leading and one trailing quote, matching `frontmatterScalar`. */ +function unquote(value: string): string { + return value.replace(/^['"]|['"]$/g, '').trim(); +} + +/** + * The result of reading a list-valued frontmatter key: absent, parsed, or + * present in a spelling this reader does not handle. + * + * The third state is the point. This parser is deliberately minimal — it is + * not a YAML engine — and a minimal reader that returns `undefined` for + * everything it cannot read is indistinguishable from one the author never + * wrote to. Silently dropping authored input is exactly the defect this key + * was added to fix, so an unreadable spelling is reported instead. + */ +type FrontmatterList = + | { kind: 'absent' } + | { kind: 'list'; values: string[] } + | { kind: 'unreadable'; spelling: string }; + +/** + * Extract a list-valued `key:` from a frontmatter block, in the two ordinary + * YAML sequence spellings and no others: + * + * ```yaml + * tags: [tutorial, beginner] # inline + * tags: # block + * - tutorial + * - beginner + * ``` + * + * Anything else present under `key:` — a bare scalar, an unterminated inline + * sequence, a key with nothing under it — comes back `unreadable` for the + * caller to report as a `DocIssue`. Deliberately NOT handled (and therefore + * reported rather than half-read): nested/mapping items, block scalars, and + * quoted items containing commas. + */ +function frontmatterList(block: string, key: string): FrontmatterList { + const re = new RegExp(`^${key}\\s*:`, 'i'); + const lines = block.split(/\r?\n/); + const at = lines.findIndex((l) => re.test(l)); + if (at === -1) return { kind: 'absent' }; + const here = lines[at].trim(); + + // ── inline form: `key: [a, b]` ── + const inline = lines[at].replace(re, '').trim(); + if (inline) { + if (!inline.startsWith('[') || !inline.endsWith(']')) { + return { kind: 'unreadable', spelling: here }; + } + const values = inline + .slice(1, -1) + .split(',') + .map((item) => unquote(item.trim())) + .filter((item) => item.length > 0); + return { kind: 'list', values }; + } + + // ── block form: `- item` lines under the key, at any indent. Blank lines + // are skipped; the first line that is neither blank nor an item ends the + // sequence (normally the next frontmatter key). + const values: string[] = []; + for (let i = at + 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + const item = line.match(/^-\s*(.*)$/); + if (!item) break; + const value = unquote(item[1].trim()); + if (value) values.push(value); + } + if (values.length === 0) return { kind: 'unreadable', spelling: `${here} (no list items follow)` }; + return { kind: 'list', values }; +} + /** * 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. + * `order:`, and `group:` if present (all optional, single-line scalars), plus + * the list-valued `tags:` (see `frontmatterList`). `order:` is parsed to a + * number and dropped when non-numeric. A `tags:` this reader cannot parse is + * returned as `unreadableTags` so the caller can report it — never dropped. */ function parseFrontmatter(raw: string): { title?: string; description?: string; order?: number; group?: string; + tags?: string[]; + unreadableTags?: string; body: string; } { if (!raw.startsWith('---\n') && !raw.startsWith('---\r\n')) return { body: raw }; @@ -94,11 +186,16 @@ function parseFrontmatter(raw: string): { const body = bodyStart === -1 ? '' : raw.slice(bodyStart + 1); const orderRaw = frontmatterScalar(block, 'order'); const order = orderRaw !== undefined ? Number(orderRaw) : undefined; + const tags = frontmatterList(block, 'tags'); return { title: frontmatterScalar(block, 'title'), description: frontmatterScalar(block, 'description'), ...(order !== undefined && !Number.isNaN(order) ? { order } : {}), group: frontmatterScalar(block, 'group'), + // An authored-but-empty `tags: []` is left out: it parses fine and means + // the same as absent to `matchesInclude`, so it is not a dropped value. + ...(tags.kind === 'list' && tags.values.length > 0 ? { tags: tags.values } : {}), + ...(tags.kind === 'unreadable' ? { unreadableTags: tags.spelling } : {}), body, }; } @@ -144,12 +241,35 @@ 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, order, group, body } = parseFrontmatter(raw); + const { title, description, order, group, tags, unreadableTags, body } = parseFrontmatter(raw); + + // A `tags:` the reader could not parse is REPORTED, never dropped: the + // symptom of a drop is a book group that renders empty with nothing + // anywhere saying why, which is the failure this key exists to prevent. + if (unreadableTags !== undefined) { + issues.push({ + severity: 'warning', + rule: 'docs/frontmatter-tags', + message: `Frontmatter \`tags:\` in "${entry.name}" is not a list this reader understands, so no tags were collected and a book group's \`include: { tag }\` cannot match this doc. Write either \`tags: [tutorial, beginner]\` or a block of \`- item\` lines under \`tags:\`. Found: ${unreadableTags}`, + path: rel, + }); + } // Locale variant `..md` (ADR-0046 i18n) — checked before the // bare-name rule, since a variant stem legitimately contains a dot. const variantMatch = stem.match(DOC_VARIANT_RE); if (variantMatch) { + // Tags are a property of the DOC, not of a translation — a + // `DocTranslationItem` carries only label/description/content. Parsed + // tags here would otherwise vanish exactly as unparsed ones used to. + if (tags) { + issues.push({ + severity: 'warning', + rule: 'docs/frontmatter-tags', + message: `Locale variant "${entry.name}" declares frontmatter \`tags:\`, but tags belong to the doc rather than to one translation and are read from the base file "${variantMatch[1]}.md" only — these tags were not collected. Move them to the base file.`, + path: rel, + }); + } variants.push({ base: variantMatch[1], locale: variantMatch[2], @@ -176,6 +296,7 @@ export function collectDocsFromSrc(configPath: string): { docs: DocItem[]; issue content: body, ...(order !== undefined ? { order } : {}), ...(group ? { group } : {}), + ...(tags ? { tags } : {}), }); }