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
37 changes: 37 additions & 0 deletions .changeset/doc-tags-frontmatter-list.md
Original file line numberDiff line numberDiff line change
@@ -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
(`<name>.<locale>.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.
88 changes: 88 additions & 0 deletions packages/cli/src/utils/collect-docs.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) ──────
Expand DownExpand Up@@ -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');
Expand All@@ -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 });

Expand Down
129 changes: 125 additions & 4 deletions packages/cli/src/utils/collect-docs.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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](./<name>.md)`; resolution is a basename
* lookup with zero path arithmetic).
Expand DownExpand Up@@ -44,6 +46,18 @@ export interface DocItem {
*/
order?: number;
group?: string;
/**
* Membership tags — the operand of a book group's `include: { tag: '<t>' }`
* 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
* `<name>.<locale>.md` files. The base file is the default + fallback.
Expand DownExpand Up@@ -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 };
Expand All@@ -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,
};
}
Expand DownExpand Up@@ -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 `<base>.<locale>.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],
Expand All@@ -176,6 +296,7 @@ export function collectDocsFromSrc(configPath: string): { docs: DocItem[]; issue
content: body,
...(order !== undefined ? { order } : {}),
...(group ? { group } : {}),
...(tags ? { tags } : {}),
});
}

Expand Down
Loading