Skip to content

Commit 41b0f68

Browse files
committed
docs(spec): offer the property-reference title rung only to a page with a Properties table
An enum-only module (data/feed) rendered no Properties table yet took the 'property reference' rung. The rung now reads rendersPropertiesTable(), the section renderer's own condition, shared through declaresProperties(); a page without a table starts at the next rung. Pinned in page-title.test.ts, including agreement with renderSchemaSection per schema shape. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude <noreply@anthropic.com>
1 parent a61335f commit 41b0f68

4 files changed

Lines changed: 183 additions & 40 deletions

File tree

‎packages/spec/scripts/build-docs.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ import {
6161
} from './lib/schema-index';
6262
import { schemaNameFromExportKey } from './lib/schema-name';
6363
import { formatSplitEntryCoverage, splitEntryCoverage } from './lib/split-entries';
64-
import { renderSchemaSection } from './lib/schema-section';
64+
import { renderSchemaSection, rendersPropertiesTable } from './lib/schema-section';
6565
import {
6666
categoryIndexDescription,
6767
modulePageDescription,
@@ -517,10 +517,17 @@ function generateZodFileMarkdown(zodFile: string, schemas: Array<{name: string,
517517
descriptionSources[description.from]++;
518518

519519
// The search-facing title follows the docs title rule; `zodTitle`, the title
520-
// this page carried before it, stays the sidebar label as `navTitle`.
520+
// this page carried before it, stays the sidebar label as `navTitle`. A page
521+
// is called a `property reference` only when one of its sections renders a
522+
// `### Properties` table — the renderer's own condition, asked of the same
523+
// schemas the loop below renders.
521524
const titles = pageTitleOrExit(() =>
522525
modulePageTitle(
523-
{ name: zodTitle, categoryTitle: CATEGORIES[category] },
526+
{
527+
name: zodTitle,
528+
categoryTitle: CATEGORIES[category],
529+
documentsProperties: schemas.some(s => rendersPropertiesTable(s.name, s.content)),
530+
},
524531
path.relative(REPO_ROOT, path.join(DOCS_ROOT, category, `${zodFile}.mdx`)),
525532
),
526533
);

‎packages/spec/scripts/lib/page-title.ts‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
* the first candidate inside the band:
3636
*
3737
* module page `<Name> schema — <Category> property reference` (name + category + 29)
38+
* (offered only when the page renders a `### Properties` table)
3839
* `<Name> schema — <Category> reference` (name + category + 20)
3940
* `<Name> — <Category> reference` (name + category + 13)
4041
* `<Name> — <Category>` (name + category + 3)
@@ -49,6 +50,18 @@
4950
* is 25. The category keeps two same-named modules apart (`Plugin` is both a
5051
* `kernel` and a `studio` page), so no two generated titles collide.
5152
*
53+
* The first module rung is CONDITIONAL. `property reference` is a claim about
54+
* the page, and a page that renders no `### Properties` table — an enum-only
55+
* module such as `data/feed`, whose two schemas render `### Allowed Values`
56+
* only — would be misdescribed by it. So that rung is offered only when at
57+
* least one of the page's schemas renders a property table
58+
* (`rendersPropertiesTable` in `lib/schema-section.ts`, the renderer's own
59+
* condition); otherwise the page starts at the second rung. Without the first
60+
* rung the ladder covers name + category lengths from 16 to 43 rather than 7:
61+
* the shortest property-less pair on the tree is 17 (`Feed` in `Data
62+
* Protocol`), and a shorter one would be refused by name — the remedy is a rung
63+
* here, the same as for any page no rung fits.
64+
*
5265
* A page no rung fits is REFUSED, never truncated: a cut title is an invented
5366
* one, and a title outside the band is the defect this module exists to end.
5467
* The refusal names the page and the candidates, and the remedy is a rung
@@ -119,12 +132,18 @@ export interface ModuleTitleInput {
119132
name: string;
120133
/** The category's declared title — `AI Protocol`. */
121134
categoryTitle: string;
135+
/**
136+
* Whether the page renders at least one `### Properties` table — required,
137+
* never defaulted: the `property reference` rung is a claim about the page,
138+
* and a caller that does not know must not get it by omission.
139+
*/
140+
documentsProperties: boolean;
122141
}
123142

124-
/** The module-page ladder, longest first. */
125-
export function modulePageTitleCandidates({ name, categoryTitle }: ModuleTitleInput): string[] {
143+
/** The module-page ladder, longest first; the first rung only for a page with a property table. */
144+
export function modulePageTitleCandidates({ name, categoryTitle, documentsProperties }: ModuleTitleInput): string[] {
126145
return [
127-
`${name} schema${TITLE_SEPARATOR}${categoryTitle} property reference`,
146+
...(documentsProperties ? [`${name} schema${TITLE_SEPARATOR}${categoryTitle} property reference`] : []),
128147
`${name} schema${TITLE_SEPARATOR}${categoryTitle} reference`,
129148
`${name}${TITLE_SEPARATOR}${categoryTitle} reference`,
130149
`${name}${TITLE_SEPARATOR}${categoryTitle}`,

‎packages/spec/scripts/lib/schema-section.ts‎

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,39 @@ export function selectRootDef(schemaName: string, schema: any): any {
9090
return mainDef;
9191
}
9292

93+
/**
94+
* Whether a node renders as a `### Properties` table: an object that declares
95+
* its properties. The ONE spelling of that condition — {@link renderSchemaSection}
96+
* branches on it for the schema root and for each union arm, and
97+
* {@link rendersPropertiesTable} asks it for the page title.
98+
*/
99+
export function declaresProperties(node: any): boolean {
100+
return node?.type === 'object' && !!node.properties;
101+
}
102+
103+
/**
104+
* Whether {@link renderSchemaSection} gives this schema at least one
105+
* `### Properties` table — at its root, or in an arm of its `### Union Options`.
106+
*
107+
* Read by the page title (`lib/page-title.ts`, #15403): a module page is titled
108+
* a `property reference` only when one of its schemas really renders a property
109+
* table. An enum-only module (`data/feed`: two string enums, `### Allowed Values`
110+
* only) documents no property, and a title saying it does misdescribes the page.
111+
*
112+
* Same branch order as the renderer: an object root with properties renders its
113+
* table; a string enum renders `### Allowed Values` and nothing else, even when
114+
* it also carries a union; a union renders a table for each arm that declares
115+
* properties; every other root renders one type line. `page-title.test.ts` holds
116+
* this answer equal to what the renderer emits, shape by shape.
117+
*/
118+
export function rendersPropertiesTable(schemaName: string, schema: any): boolean {
119+
const mainDef = selectRootDef(schemaName, schema);
120+
if (declaresProperties(mainDef)) return true;
121+
if (mainDef.type === 'string' && mainDef.enum) return false;
122+
const variants = mainDef.anyOf || mainDef.oneOf;
123+
return Array.isArray(variants) && variants.some(declaresProperties);
124+
}
125+
93126
/**
94127
* Character budget for a default value spelled inside the Required cell.
95128
*
@@ -555,7 +588,7 @@ export function renderSchemaSection(schemaName: string, schema: any, ctx: Sectio
555588
return t;
556589
};
557590

558-
if (mainDef.type === 'object' && mainDef.properties) {
591+
if (declaresProperties(mainDef)) {
559592
md += renderProperties(mainDef.properties, new Set(mainDef.required || []));
560593

561594
} else if (mainDef.type === 'string' && mainDef.enum) {
@@ -571,9 +604,7 @@ export function renderSchemaSection(schemaName: string, schema: any, ctx: Sectio
571604
// branch below calls `renderProperties`, and only `renderProperties`
572605
// emits `### Nested Shape:` / `### Allowed Values:` headings. An `enum`,
573606
// `$ref` or scalar arm prints one line and can collide with nothing.
574-
const emitsHeadings: boolean[] = variants.map(
575-
(variant: any) => variant?.type === 'object' && !!variant.properties,
576-
);
607+
const emitsHeadings: boolean[] = variants.map(declaresProperties);
577608
const emitters = emitsHeadings.filter(Boolean).length;
578609
// Fewer than two and there is nothing to tell apart: a lone object arm's
579610
// headings are already unique on the page, so it keeps the exact bytes it
@@ -588,7 +619,7 @@ export function renderSchemaSection(schemaName: string, schema: any, ctx: Sectio
588619
md += `#### ${variantTitle}\n\n`;
589620
if (variant.description) md += `${escapeMdxDescription(variant.description)}\n\n`;
590621

591-
if (variant.type === 'object' && variant.properties) {
622+
if (declaresProperties(variant)) {
592623
if (variant.properties.type && variant.properties.type.const) {
593624
md += `**Type:** \`${variant.properties.type.const}\`\n\n`;
594625
}

‎packages/spec/scripts/page-title.test.ts‎

Lines changed: 115 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
/**
44
* Pin for the frontmatter `title` / `navTitle` of generated reference pages
5-
* (#15403).
5+
* (#15403), and for the one page fact the title reads besides names: whether
6+
* the page renders a `### Properties` table.
67
*
78
* `check:docs` compares the regenerated tree with the committed one, so it
89
* holds the OUTPUT still but says nothing about the rule: a regression that
@@ -15,6 +16,7 @@
1516
import { describe, expect, it } from 'vitest';
1617

1718
import { CATEGORY_TITLES } from './lib/category-title';
19+
import { declaresProperties, renderSchemaSection, rendersPropertiesTable } from './lib/schema-section';
1820
import {
1921
RENDERED_TITLE_MAX,
2022
ROOT_INDEX_NAV_TITLE,
@@ -57,69 +59,153 @@ describe('the band', () => {
5759
});
5860

5961
/**
60-
* Real `(module name, category title)` pairs from `content/docs/references/**`
61-
* at `862b6ce8`, the name being the page's title before this rule — two per
62-
* rung of the module ladder, including the tree's shortest name (`Mcp`, 3) and
63-
* its two longest pairs (`Expression Bindable Text Keys` in UI, name 29;
64-
* `Schemaless Node Config` in Automation, name + category 41).
62+
* Real `(module name, category title, renders a Properties table)` triples from
63+
* `content/docs/references/**`, the name being the page's title before this
64+
* rule — two per rung of the module ladder, including the tree's shortest name
65+
* (`Mcp`, 3), its two longest pairs (`Expression Bindable Text Keys` in UI, name
66+
* 29; `Schemaless Node Config` in Automation, name + category 41), and
67+
* `data/feed`, the one page the first rung reached without a property table.
6568
*/
66-
const MODULE_PAGES: Array<[name: string, categoryTitle: string, title: string]> = [
67-
['Mcp', 'AI Protocol', 'Mcp schema — AI Protocol property reference'],
68-
['Agent', 'AI Protocol', 'Agent schema — AI Protocol property reference'],
69-
['Object', 'Data Protocol', 'Object schema — Data Protocol reference'],
70-
['Flow', 'Automation Protocol', 'Flow schema — Automation Protocol reference'],
71-
['Plugin Registry', 'Kernel Protocol', 'Plugin Registry — Kernel Protocol reference'],
72-
['Package Api Assembled', 'API Protocol', 'Package Api Assembled — API Protocol reference'],
73-
['Metadata Protection', 'Kernel Protocol', 'Metadata Protection — Kernel Protocol'],
74-
['Expression Bindable Text Keys', 'UI Protocol', 'Expression Bindable Text Keys — UI Protocol'],
75-
['Schemaless Node Config', 'Automation Protocol', 'Schemaless Node Config — Automation Protocol'],
69+
const MODULE_PAGES: Array<[name: string, categoryTitle: string, documentsProperties: boolean, title: string]> = [
70+
['Mcp', 'AI Protocol', true, 'Mcp schema — AI Protocol property reference'],
71+
['Agent', 'AI Protocol', true, 'Agent schema — AI Protocol property reference'],
72+
['Feed', 'Data Protocol', false, 'Feed schema — Data Protocol reference'],
73+
['Object', 'Data Protocol', true, 'Object schema — Data Protocol reference'],
74+
['Flow', 'Automation Protocol', true, 'Flow schema — Automation Protocol reference'],
75+
['Plugin Registry', 'Kernel Protocol', true, 'Plugin Registry — Kernel Protocol reference'],
76+
['Package Api Assembled', 'API Protocol', true, 'Package Api Assembled — API Protocol reference'],
77+
['Metadata Protection', 'Kernel Protocol', false, 'Metadata Protection — Kernel Protocol'],
78+
['Expression Bindable Text Keys', 'UI Protocol', false, 'Expression Bindable Text Keys — UI Protocol'],
79+
['Schemaless Node Config', 'Automation Protocol', true, 'Schemaless Node Config — Automation Protocol'],
7680
];
7781

7882
describe('modulePageTitle', () => {
79-
it.each(MODULE_PAGES)('%s (%s) → %s', (name, categoryTitle, title) => {
80-
const out = modulePageTitle({ name, categoryTitle }, `references/x/${name}.mdx`);
83+
it.each(MODULE_PAGES)('%s (%s, properties: %s) → %s', (name, categoryTitle, documentsProperties, title) => {
84+
const out = modulePageTitle({ name, categoryTitle, documentsProperties }, `references/x/${name}.mdx`);
8185
expect(out).toEqual({ title, navTitle: name });
8286
expectRuleShaped(out.title);
8387
});
8488

8589
it('takes the LONGEST rung inside the band', () => {
8690
// `Agent` fits the first rung (45) and the second (36): the first wins.
87-
const candidates = modulePageTitleCandidates({ name: 'Agent', categoryTitle: 'AI Protocol' });
91+
const agent = { name: 'Agent', categoryTitle: 'AI Protocol', documentsProperties: true };
92+
const candidates = modulePageTitleCandidates(agent);
8893
expect(candidates.filter(titleInBand)).toHaveLength(2);
89-
expect(modulePageTitle({ name: 'Agent', categoryTitle: 'AI Protocol' }, 'p').title).toBe(candidates[0]);
94+
expect(modulePageTitle(agent, 'p').title).toBe(candidates[0]);
95+
});
96+
97+
it('offers `property reference` only to a page that renders a Properties table', () => {
98+
// The same name and category, the one fact flipped: `data/feed` renders two
99+
// enums (`### Allowed Values`) and no property, so it starts at the second rung.
100+
const feed = { name: 'Feed', categoryTitle: 'Data Protocol' };
101+
const withTable = modulePageTitleCandidates({ ...feed, documentsProperties: true });
102+
const withoutTable = modulePageTitleCandidates({ ...feed, documentsProperties: false });
103+
expect(withTable[0]).toBe('Feed schema — Data Protocol property reference');
104+
expect(withoutTable.some(c => c.includes('property'))).toBe(false);
105+
expect(withoutTable).toEqual(withTable.slice(1));
106+
expect(modulePageTitle({ ...feed, documentsProperties: false }, 'p').title).toBe('Feed schema — Data Protocol reference');
90107
});
91108

92109
it('keeps the rungs longest first, so the first fit is the longest fit', () => {
93-
const lengths = modulePageTitleCandidates({ name: 'N', categoryTitle: 'C' }).map(c => c.length);
94-
expect([...lengths].sort((a, b) => b - a)).toEqual(lengths);
110+
for (const documentsProperties of [true, false]) {
111+
const lengths = modulePageTitleCandidates({ name: 'N', categoryTitle: 'C', documentsProperties }).map(c => c.length);
112+
expect([...lengths].sort((a, b) => b - a)).toEqual(lengths);
113+
}
95114
});
96115

97116
it('is total for every declared category and every name that can fit beside it', () => {
98-
// The ladder's rungs overlap end to end: every name + category length from
99-
// 7 to 43 lands in the band. Swept over the real category titles, with
100-
// every name length up to the limit that category leaves.
117+
// With a Properties table the rungs overlap end to end from name + category
118+
// 7 to 43; without one, from 16 to 43. Swept over the real category titles,
119+
// with every name length up to the limit that category leaves.
101120
for (const categoryTitle of Object.values(CATEGORY_TITLES)) {
102121
for (let n = 1; categoryTitle.length + n <= 43; n++) {
103-
expectRuleShaped(modulePageTitle({ name: 'N'.repeat(n), categoryTitle }, 'p').title);
122+
expectRuleShaped(modulePageTitle({ name: 'N'.repeat(n), categoryTitle, documentsProperties: true }, 'p').title);
123+
if (categoryTitle.length + n >= 16) {
124+
expectRuleShaped(modulePageTitle({ name: 'N'.repeat(n), categoryTitle, documentsProperties: false }, 'p').title);
125+
}
104126
}
105127
}
106128
});
107129

130+
it('refuses a property-less page too short for the second rung, naming it', () => {
131+
// name + category 15: the second rung is 35, one under the band.
132+
expect(() =>
133+
modulePageTitle({ name: 'Abcd', categoryTitle: 'AI Protocol', documentsProperties: false }, 'content/docs/references/ai/abcd.mdx'),
134+
).toThrow(/content\/docs\/references\/ai\/abcd\.mdx/);
135+
});
136+
108137
it('keeps two same-named modules in different categories apart', () => {
109-
const kernel = modulePageTitle({ name: 'Plugin', categoryTitle: 'Kernel Protocol' }, 'p').title;
110-
const studio = modulePageTitle({ name: 'Plugin', categoryTitle: 'Studio Protocol' }, 'p').title;
138+
const kernel = modulePageTitle({ name: 'Plugin', categoryTitle: 'Kernel Protocol', documentsProperties: true }, 'p').title;
139+
const studio = modulePageTitle({ name: 'Plugin', categoryTitle: 'Studio Protocol', documentsProperties: true }, 'p').title;
111140
expect(kernel).not.toBe(studio);
112141
});
113142

114143
it('refuses, naming the page, when no rung fits — never a truncated title', () => {
115-
const tooLong = { name: 'An Exceedingly Long Module Display Name', categoryTitle: 'Integration Protocol' };
144+
const tooLong = { name: 'An Exceedingly Long Module Display Name', categoryTitle: 'Integration Protocol', documentsProperties: true };
116145
expect(modulePageTitleCandidates(tooLong).some(titleInBand)).toBe(false);
117146
expect(() => modulePageTitle(tooLong, 'content/docs/references/integration/long.mdx')).toThrow(
118147
/content\/docs\/references\/integration\/long\.mdx/,
119148
);
120149
});
121150
});
122151

152+
/**
153+
* The fact the first module rung reads — whether a schema renders a
154+
* `### Properties` table — held equal to what the renderer really emits, one
155+
* JSON Schema shape per branch of `renderSchemaSection`. If a branch changes
156+
* what it renders and the predicate does not follow, this goes red instead of
157+
* a title quietly claiming a table its page lacks.
158+
*/
159+
const SECTION_SHAPES: Array<[shape: string, name: string, schema: any, rendersTable: boolean]> = [
160+
['object root with properties', 'Agent', { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, true],
161+
['string enum (data/feed FeedFilterMode)', 'FeedFilterMode', { type: 'string', enum: ['all', 'comments_only', 'changes_only', 'tasks_only'] }, false],
162+
['string enum (data/feed FeedItemType)', 'FeedItemType', { type: 'string', enum: ['comment', 'field_change', 'task'] }, false],
163+
[
164+
'union with an object arm',
165+
'Trigger',
166+
{ anyOf: [{ type: 'object', properties: { type: { type: 'string', const: 'cron' }, expr: { type: 'string' } } }, { type: 'string' }] },
167+
true,
168+
],
169+
['oneOf with an object arm', 'Source', { oneOf: [{ type: 'object', properties: { url: { type: 'string' } } }, { type: 'number' }] }, true],
170+
['union of an enum and a scalar', 'Mode', { anyOf: [{ type: 'string', enum: ['a', 'b'] }, { type: 'number' }] }, false],
171+
[
172+
'string enum that also carries a union (the enum branch wins)',
173+
'Kind',
174+
{ type: 'string', enum: ['a'], anyOf: [{ type: 'object', properties: { x: { type: 'string' } } }] },
175+
false,
176+
],
177+
['bare scalar', 'ObjectName', { type: 'string', description: 'Machine name' }, false],
178+
['record map (additionalProperties, no properties)', 'Labels', { type: 'object', additionalProperties: { type: 'string' } }, false],
179+
['array of objects', 'Rows', { type: 'array', items: { type: 'object', properties: { id: { type: 'string' } } } }, false],
180+
[
181+
'definitions entry under its own name',
182+
'Wrapped',
183+
{ $ref: '#/definitions/Wrapped', definitions: { Wrapped: { type: 'object', properties: { a: { type: 'number' } } } } },
184+
true,
185+
],
186+
];
187+
188+
describe('rendersPropertiesTable', () => {
189+
it.each(SECTION_SHAPES)('%s → %s', (_shape, name, schema, rendersTable) => {
190+
expect(rendersPropertiesTable(name, schema)).toBe(rendersTable);
191+
// The renderer's own output agrees, whatever the expectation above says.
192+
expect(/^### Properties$/m.test(renderSchemaSection(name, schema))).toBe(rendersTable);
193+
});
194+
195+
it('answers the data/feed page as having no property table', () => {
196+
const feed = SECTION_SHAPES.filter(([shape]) => shape.includes('data/feed'));
197+
expect(feed).toHaveLength(2);
198+
expect(feed.some(([, name, schema]) => rendersPropertiesTable(name, schema))).toBe(false);
199+
});
200+
201+
it('declaresProperties is the object-with-properties test and nothing wider', () => {
202+
expect(declaresProperties({ type: 'object', properties: { a: { type: 'string' } } })).toBe(true);
203+
expect(declaresProperties({ type: 'object' })).toBe(false);
204+
expect(declaresProperties({ properties: { a: {} } })).toBe(false);
205+
expect(declaresProperties(undefined)).toBe(false);
206+
});
207+
});
208+
123209
describe('categoryIndexTitle', () => {
124210
it.each([
125211
['AI Protocol', 'AI Protocol — complete schema reference'],

0 commit comments

Comments
 (0)