Found while documenting the book spine for #10237 (docs PR #10483). Filed, not fixed.
The gap
DocSchema.tags was declared in 17.0.0 (#4509, ADR-0049) as the enforce half of enforce-or-remove: the resolver side already existed, so the key was added to make include: { tag } reachable. matchesInclude in packages/spec/src/system/book.zod.ts compares against doc.tags, and the REST book-tree route forwards the value.
But the collection path cannot produce it. packages/cli/src/utils/collect-docs.ts parses frontmatter with frontmatterScalar, which is documented as reading "single-line scalars" and does exactly that:
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,
};
tags has no case, and DocItem does not declare the field. The string tags appears once in the whole file, in an unrelated comment about JSX component tags.
So for the authoring path the docs actually recommend — flat src/docs/*.md files — a tags: block in frontmatter is silently not collected, every doc reaches resolveBookTree with tags === undefined, and a group declaring include: { tag: 'tutorial' } matches nothing and quietly contributes an empty section.
Why it is not simply "wrong docs"
The tag half does work for a doc declared programmatically in a stack's docs array, so this is a partial reachability gap rather than a dead key. DocSchema's own TSDoc even steers authors away from tags ("prefer a name convention (include: "crm_guide_*") when one exists — tags are for membership that cuts across naming"), which softens the impact but does not close it: the case tags exist for is precisely the one a glob cannot express.
The failure is silent in both directions — no build warning that tags: was dropped, and no resolver diagnostic that an include rule matched nothing.
Options
- Extend the collector — add a
frontmatterList helper reading the two ordinary YAML list spellings (inline [a, b] and the block form) and wire tags through DocItem. Small and mechanical; the parser is already hand-rolled and deliberately minimal, so this stays consistent with it. - Fail loudly instead — if
tags: is present in a doc's frontmatter and cannot be parsed, emit a DocIssue warning rather than dropping it. Cheaper, and it converts a silent no-op into a visible one. Good as a companion to option 1 in any case. - Retire the tag form of
include — the ADR-0049 "remove" direction. Costly: the resolver half is live, tested, and reachable from the programmatic path, so this removes a working capability to fix a collector gap.
Recommendation: 1 plus 2. The consumer already exists (that is what #4509 established), the collector is the only thing missing, and the loud-failure half means the next spelling nobody anticipated is reported rather than dropped.
Interim mitigation already landed
The new book section of content/docs/ui/doc-pages.mdx (PR #10483) carries a warn callout stating that tags is not read from Markdown frontmatter today and pointing authors at a name glob for the flat path, so nobody follows the docs into a section that renders empty.
Found while documenting the
bookspine for #10237 (docs PR #10483). Filed, not fixed.The gap
DocSchema.tagswas declared in 17.0.0 (#4509, ADR-0049) as the enforce half of enforce-or-remove: the resolver side already existed, so the key was added to makeinclude: { tag }reachable.matchesIncludeinpackages/spec/src/system/book.zod.tscompares againstdoc.tags, and the REST book-tree route forwards the value.But the collection path cannot produce it.
packages/cli/src/utils/collect-docs.tsparses frontmatter withfrontmatterScalar, which is documented as reading "single-line scalars" and does exactly that:tagshas no case, andDocItemdoes not declare the field. The stringtagsappears once in the whole file, in an unrelated comment about JSX component tags.So for the authoring path the docs actually recommend — flat
src/docs/*.mdfiles — atags:block in frontmatter is silently not collected, every doc reachesresolveBookTreewithtags === undefined, and a group declaringinclude: { tag: 'tutorial' }matches nothing and quietly contributes an empty section.Why it is not simply "wrong docs"
The tag half does work for a doc declared programmatically in a stack's
docsarray, so this is a partial reachability gap rather than a dead key.DocSchema's own TSDoc even steers authors away from tags ("prefer a name convention (include: "crm_guide_*") when one exists — tags are for membership that cuts across naming"), which softens the impact but does not close it: the case tags exist for is precisely the one a glob cannot express.The failure is silent in both directions — no build warning that
tags:was dropped, and no resolver diagnostic that anincluderule matched nothing.Options
frontmatterListhelper reading the two ordinary YAML list spellings (inline[a, b]and the block form) and wiretagsthroughDocItem. Small and mechanical; the parser is already hand-rolled and deliberately minimal, so this stays consistent with it.tags:is present in a doc's frontmatter and cannot be parsed, emit aDocIssuewarning rather than dropping it. Cheaper, and it converts a silent no-op into a visible one. Good as a companion to option 1 in any case.include— the ADR-0049 "remove" direction. Costly: the resolver half is live, tested, and reachable from the programmatic path, so this removes a working capability to fix a collector gap.Recommendation: 1 plus 2. The consumer already exists (that is what #4509 established), the collector is the only thing missing, and the loud-failure half means the next spelling nobody anticipated is reported rather than dropped.
Interim mitigation already landed
The new
booksection ofcontent/docs/ui/doc-pages.mdx(PR #10483) carries a warn callout stating thattagsis not read from Markdown frontmatter today and pointing authors at a name glob for the flat path, so nobody follows the docs into a section that renders empty.