Skip to content

Commit 5543020

Browse files
os-zhuangclaude
andauthored
fix(spec): docs-gen 把嵌套花括号整对包进行内代码,不再切成半对 (#5452) (#5550)
* fix(spec): docs-gen wraps a nested `{…}` / `<…>` whole instead of cutting it (#5452) The reference-docs escaper located a fragment's closing delimiter with `indexOf` — the first closer, not the matching one — so a nested pair was wrapped only up to its inner closer and the outer one leaked outside the inline-code span. `{{var}}` published as `` `{{var}` `` plus a stray `}`, on exactly the rows documenting template-variable syntax. The matcher now counts nesting depth. Escaping moves to `scripts/lib/escape-mdx.ts` (same reason `format-type.ts` was extracted in #4912: the generator is a side-effecting top-level script, so its output could only be asserted on by grepping emitted `.mdx`), pinned by `scripts/escape-mdx.test.ts` — unit cases plus a corpus gate asserting inline-code brace balance across all 224 generated pages. Regenerating fixes 5 rows on 4 pages: model-registry.mdx ×2, flow.mdx, solution-blueprint.mdx, analytics.mdx. The issue counted 3; the last two start with a single brace, so the `` `{{ `` grep could not see them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D * docs(spec): correct the measured counts in the escape-mdx pin headers (#5452) The reverse verification turns FIVE unit cases red, not four, and the defect spanned five rows of four reference pages, not three pages — both numbers were written before the corpus was measured. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c113690 commit 5543020

8 files changed

Lines changed: 293 additions & 46 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): docs-gen no longer cuts a nested `{…}` / `<…>` in half (#5452)
6+
7+
The reference-docs generator wraps a delimited fragment of `.describe()` prose
8+
in an inline-code span so MDX renders it literally instead of parsing it as a
9+
JS expression or a JSX tag. It located the fragment's closing delimiter with
10+
`indexOf` — the **first** closer, not the **matching** one — so any nested pair
11+
was wrapped only up to its inner closer and the outer one fell outside the
12+
span.
13+
14+
The published symptom: `{{var}}` in a description was emitted as
15+
`` `{{var}` `` followed by a stray `}`. Readers saw `{{var}` plus an orphan
16+
brace on precisely the rows that teach template-variable syntax, where the
17+
paired double brace *is* the thing being documented. Nesting is not an exotic
18+
input in this corpus — template interpolation and filter-map examples both
19+
produce it.
20+
21+
The matcher now counts nesting depth, so the whole pair lands inside one code
22+
span. Five rows across four regenerated reference pages change:
23+
24+
- `references/ai/model-registry.mdx``PromptTemplate.system` / `.user`,
25+
both `{{var}}`
26+
- `references/automation/flow.mdx``flow.nodes[].outputSchema`,
27+
`{{nodeId.field}}`
28+
- `references/ai/solution-blueprint.mdx` — the roll-up `filter` example,
29+
`{ status: { $in: [...] } }`
30+
- `references/api/analytics.mdx` — the retired `query` envelope,
31+
`{ cube, query: {...} }`
32+
33+
The issue reported three; the last two were cut in the same place but start
34+
with a single brace, so the `` `{{ `` grep that found the others could never
35+
have seen them.
36+
37+
Unchanged: a single `{…}` pair, a `{<id>}` nest, and a lone unmatched `<` / `{`
38+
(entity-escaped, e.g. a SemVer range `>=4.0 <5`) all escape exactly as before.
39+
No package export or runtime behaviour changes — the fix is in
40+
`scripts/build-docs.ts`, whose escaping moved to `scripts/lib/escape-mdx.ts` so
41+
it can be pinned directly rather than by grepping emitted `.mdx`.

content/docs/references/ai/model-registry.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,8 @@ const result = ModelCapabilitySchema.parse(data);
168168
| **id** | `string` || Unique template identifier |
169169
| **name** | `string` || Template name (snake_case) |
170170
| **label** | `string` || Display name |
171-
| **system** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | System prompt — supports `{{var}`} interpolation |
172-
| **user** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` || User prompt template — supports `{{var}`} interpolation |
171+
| **system** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | System prompt — supports `{{var}}` interpolation |
172+
| **user** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` || User prompt template — supports `{{var}}` interpolation |
173173
| **assistant** | `string` | optional | Assistant message prefix |
174174
| **variables** | `{ name: string; type?: Enum<'string' \| 'number' \| 'boolean' \| 'object' \| 'array'>; required?: boolean; defaultValue?: any; … }[]` | optional | Template variables |
175175
| **modelId** | `string` | optional | Recommended model ID |

content/docs/references/ai/solution-blueprint.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ const result = BlueprintAppSchema.parse(data);
153153
| **field** | `string` | optional | Numeric field on the CHILD object to aggregate. Ignored for "count" (pass "id" or omit it). |
154154
| **relationshipField** | `string` | optional | The child FK field pointing back at this parent. Auto-detected from the child's lookup / master_detail; set it only when the child has more than one reference to this parent. |
155155
| **conditions** | `{ field: string; op: Enum<'lt' \| 'lte' \| 'gt' \| 'gte' \| 'eq' \| 'ne'>; value: number \| string \| boolean }[]` | optional | CONDITIONAL roll-up: aggregate only the child rows matching these comparisons (ANDed). REQUIRED whenever the field name carries a qualifier — "已完成任务数 / 已收货金额 / 待处理工单数", any 已X / 未X / `<某状态>`的 count-or-sum → e.g. [`{ field: "status", op: "eq", value: "completed" }`]. WITHOUT it the roll-up silently counts EVERY child and reports a plausible-looking WRONG number, which is worse than a visible 0. |
156-
| **filter** | `any` | optional | The same predicate as a canonical query filter map (e.g. `{ status: "completed" }`, `{ status: { $in: ["received", "partial"] }` }). Use it when hand-authoring a blueprint; the structured design path uses `conditions` instead. Wins over `conditions` when both are given. |
156+
| **filter** | `any` | optional | The same predicate as a canonical query filter map (e.g. `{ status: "completed" }`, `{ status: { $in: ["received", "partial"] } }`). Use it when hand-authoring a blueprint; the structured design path uses `conditions` instead. Wins over `conditions` when both are given. |
157157

158158

159159
---

content/docs/references/api/analytics.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ const result = AnalyticsEndpoint.parse(data);
6767
| **limit** | `number` | optional | |
6868
| **offset** | `number` | optional | |
6969
| **timezone** | `string` | optional | |
70-
| **query** | `any` | optional | [REMOVED] `query` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0 (#3878). The `{ cube, query: {...}` } envelope was the dialect of the retired degraded analytics shim (#3891) — the real engine never understood it. Move the query.* fields to the body top level: `{ cube, measures, dimensions?, where?, timeDimensions?, order?, limit?, offset?, timezone? }`. |
70+
| **query** | `any` | optional | [REMOVED] `query` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0 (#3878). The `{ cube, query: {...} }` envelope was the dialect of the retired degraded analytics shim (#3891) — the real engine never understood it. Move the query.* fields to the body top level: `{ cube, measures, dimensions?, where?, timeDimensions?, order?, limit?, offset?, timezone? }`. |
7171
| **format** | `any` | optional | [REMOVED] `format` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0 (#3878). It was never implemented — every response is the JSON envelope. Delete the key; for CSV/XLSX use the export surface instead. |
7272

7373

content/docs/references/automation/flow.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ const result = FlowSchema.parse(data);
103103
| **position** | `{ x: number; y: number }` | optional | |
104104
| **timeoutMs** | `integer` | optional | Maximum execution time for this node in milliseconds |
105105
| **inputSchema** | `Record<string, { type: Enum<'string' \| 'number' \| 'boolean' \| 'object' \| 'array'>; required: boolean; description?: string }>` | optional | Input parameter schema for this node |
106-
| **outputSchema** | `any` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}`}) regardless of any declaration. |
106+
| **outputSchema** | `any` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. |
107107
| **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string; timeoutMs?: any; … }` | optional | Configuration for wait node event resumption |
108108
| **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes |
109109

packages/spec/scripts/build-docs.ts

Lines changed: 3 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
resolveImports,
2727
type CategorySurface,
2828
} from './lib/docs-import-surface';
29+
import { escapeMdxDescription } from './lib/escape-mdx';
2930
import { anchorFor, formatType, type TypeContext } from './lib/format-type';
3031
import { createSink } from './lib/generated-output';
3132
import { schemaNameFromExportKey } from './lib/schema-name';
@@ -257,47 +258,8 @@ function generateMarkdown(schemaName: string, schema: any, category: string, zod
257258
// Add schema heading
258259
md += `## ${schemaName}\n\n`;
259260

260-
// Escape MDX-unsafe characters in description text. MDX parses `{` as a JS
261-
// expression and `<` as JSX, so any raw `{token}` / `<title>` inside a Zod
262-
// `.describe()` string breaks the docs build. Wrap such fragments in inline
263-
// code so they render literally.
264-
//
265-
// Single pass with backtick tracking: fragments already inside an inline-code
266-
// span are left untouched. A naive two-pass replace double-wraps nested cases
267-
// like `{<id>}` into `` `{`<id>`}` `` — the inner backticks close the span
268-
// early and leak `<id>` as raw JSX (MDX: "Expected a closing tag for `<id>`").
269-
//
270-
// A matched `{…}` / `<…>` pair is wrapped in an inline-code span so it renders
271-
// literally. A *lone* `<` or `{` with no closing partner (e.g. a SemVer range
272-
// `">=4.0 <5"`, or prose like `count < 5`) can't be wrapped, so it is replaced
273-
// with its HTML entity — otherwise MDX reads the `<` as the start of a JSX tag
274-
// and the build dies ("Unexpected character `5` before name").
275-
const escapeMdxDescription = (raw: string): string => {
276-
let out = '';
277-
let inCode = false;
278-
for (let i = 0; i < raw.length; i++) {
279-
const ch = raw[i];
280-
if (ch === '`') {
281-
inCode = !inCode;
282-
out += ch;
283-
continue;
284-
}
285-
if (!inCode && (ch === '{' || ch === '<')) {
286-
const close = ch === '{' ? '}' : '>';
287-
const end = raw.indexOf(close, i + 1);
288-
if (end !== -1) {
289-
out += '`' + raw.slice(i, end + 1) + '`';
290-
i = end;
291-
continue;
292-
}
293-
// Unmatched: escape so MDX doesn't treat it as a JSX/expression opener.
294-
out += ch === '<' ? '&lt;' : '&#123;';
295-
continue;
296-
}
297-
out += ch;
298-
}
299-
return out;
300-
};
261+
// Description text is made MDX-safe by `lib/escape-mdx.ts` — extracted so the
262+
// escaping can be pinned directly instead of by grepping emitted `.mdx`.
301263

302264
// Add description with better formatting
303265
if (mainDef.description) {
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Pin for how the reference-docs generator escapes `{…}` / `<…>` inside
5+
* `.describe()` prose — #5452.
6+
*
7+
* The escaper wrapped a delimited fragment in an inline-code span, but found
8+
* its closing partner with `indexOf`, i.e. the FIRST closer rather than the
9+
* MATCHING one. Nested delimiters are not an exotic input here — `{{var}}` is
10+
* the template-interpolation syntax the spec's own prose *teaches*, and a
11+
* filter map (`{ status: { $in: [...] } }`) is an ordinary example — so the
12+
* wrap ended at the inner `}` and the outer one fell outside the span:
13+
* `{{var}}` was published as `` `{{var}` `` plus a stray `}`.
14+
*
15+
* The issue counted three sites (`grep -rn '`{{' content/docs/references/`).
16+
* The corpus gate below counts FIVE, because that grep only sees the fragments
17+
* whose nesting starts at character one; `ai/solution-blueprint.mdx` and
18+
* `api/analytics.mdx` were cut in exactly the same place with a single leading
19+
* brace, and no grep for `` `{{ `` could have found them.
20+
*
21+
* MEASURED (reverse verification), both directions run:
22+
* - restoring the old matcher (`raw.indexOf(close, i + 1)` in place of
23+
* `findMatchingClose`) turns all five nested-delimiter unit cases red
24+
* (`5 failed | 7 passed`), each reporting the split shape;
25+
* - and, after re-running `gen:docs` over the restored escaper, the corpus
26+
* gate goes red with 5 offenders — the same 5 that were on `main`.
27+
* The direction is the ordinary one (restore the defect → the new pins go red)
28+
* because these assert a POSITIVE output shape the fix produces, not the
29+
* absence of a finding. The single-delimiter and unmatched cases stay green
30+
* either way, which is exactly why the bug survived: the escaper was correct
31+
* on every shape anyone had thought to look at.
32+
*/
33+
34+
import fs from 'fs';
35+
import path from 'path';
36+
import url from 'url';
37+
38+
import { describe, expect, it } from 'vitest';
39+
40+
import { escapeMdxDescription } from './lib/escape-mdx';
41+
42+
const HERE = path.dirname(url.fileURLToPath(import.meta.url));
43+
const REPO = path.resolve(HERE, '../../..');
44+
const REFERENCES = path.join(REPO, 'content/docs/references');
45+
46+
describe('escapeMdxDescription — nested delimiters (#5452)', () => {
47+
it('wraps `{{var}}` whole, leaving no stray closer outside the span', () => {
48+
expect(escapeMdxDescription('System prompt — supports {{var}} interpolation')).toBe(
49+
'System prompt — supports `{{var}}` interpolation',
50+
);
51+
});
52+
53+
it('wraps a doubled brace inside parentheses (the automation/flow.mdx specimen)', () => {
54+
expect(
55+
escapeMdxDescription(
56+
'Downstream nodes read prior outputs via expressions ({{nodeId.field}}) regardless.',
57+
),
58+
).toBe('Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless.');
59+
});
60+
61+
it('wraps a singly-nested filter map whole (the ai/solution-blueprint.mdx specimen)', () => {
62+
// The variant `grep '`{{'` could not see: nesting that starts one char in.
63+
expect(escapeMdxDescription('e.g. { status: { $in: ["received"] } }.')).toBe(
64+
'e.g. `{ status: { $in: ["received"] } }`.',
65+
);
66+
});
67+
68+
it('wraps nested angle delimiters whole', () => {
69+
// The span opens at the delimiter, not at the identifier in front of it —
70+
// pre-existing behaviour, and MDX-safe either way. What #5452 changes is
71+
// that BOTH closing `>` land inside the span instead of one leaking out.
72+
expect(escapeMdxDescription('Shaped as Array<Record<string, any>> at rest')).toBe(
73+
'Shaped as Array`<Record<string, any>>` at rest',
74+
);
75+
});
76+
77+
it('wraps each of two independent doubled pairs on one line', () => {
78+
expect(escapeMdxDescription('render {{a.b}} then {{c}} here')).toBe(
79+
'render `{{a.b}}` then `{{c}}` here',
80+
);
81+
});
82+
});
83+
84+
describe('escapeMdxDescription — shapes the fix must not disturb', () => {
85+
it('still wraps a single `{…}` pair', () => {
86+
expect(escapeMdxDescription('a {token} b')).toBe('a `{token}` b');
87+
});
88+
89+
it('still wraps a `{<id>}` nest in ONE span (no inner backticks)', () => {
90+
expect(escapeMdxDescription('path {<id>} here')).toBe('path `{<id>}` here');
91+
});
92+
93+
it('still entity-escapes a lone `<` with no partner (SemVer range)', () => {
94+
expect(escapeMdxDescription('supports >=4.0 <5 only')).toBe('supports >=4.0 &lt;5 only');
95+
});
96+
97+
it('still entity-escapes a lone `{` with no partner', () => {
98+
expect(escapeMdxDescription('an unclosed { here')).toBe('an unclosed &#123; here');
99+
});
100+
101+
it('still leaves fragments already inside an inline-code span untouched', () => {
102+
expect(escapeMdxDescription('see `{{var}}` above')).toBe('see `{{var}}` above');
103+
});
104+
});
105+
106+
/**
107+
* Corpus gate over the generator's committed OUTPUT.
108+
*
109+
* The invariant is brace BALANCE inside an inline-code span, not the literal
110+
* `` `{{ ``-plus-stray-`}` string the issue grepped for. Balance is what the
111+
* defect actually violates — the wrap cut a pair in half — so it catches the
112+
* two sites whose nesting did not start at character one, and it keeps
113+
* catching them when the offending prose is reworded.
114+
*
115+
* Angle delimiters deliberately get NO corpus gate: `<`/`>` are also the
116+
* comparison operators, and validation-rule / SemVer examples legitimately
117+
* carry an unbalanced one inside a code span (`record.amount < 0`,
118+
* `>=1.2.3`) — 11 such spans, all correct. Nesting for angles is pinned by
119+
* the positive unit case above instead. Backslash-escaped delimiters are not
120+
* delimiters: the module-JSDoc path escapes braces as `\{`, so they are
121+
* dropped before counting.
122+
*/
123+
describe('published reference pages keep inline-code braces balanced (#5452)', () => {
124+
const pages: string[] = [];
125+
const walk = (dir: string) => {
126+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
127+
const full = path.join(dir, entry.name);
128+
if (entry.isDirectory()) walk(full);
129+
else if (entry.name.endsWith('.mdx')) pages.push(full);
130+
}
131+
};
132+
walk(REFERENCES);
133+
134+
it('finds the generated reference corpus', () => {
135+
expect(pages.length).toBeGreaterThan(50);
136+
});
137+
138+
it('has no inline-code span with an unbalanced brace', () => {
139+
const offenders: string[] = [];
140+
for (const file of pages) {
141+
const rel = path.relative(REPO, file);
142+
fs.readFileSync(file, 'utf-8')
143+
.split('\n')
144+
.forEach((line, index) => {
145+
// Odd segments of a backtick split are the inline-code spans.
146+
const segments = line.split('`');
147+
for (let i = 1; i < segments.length; i += 2) {
148+
const span = segments[i].replace(/\\[{}]/g, '');
149+
const opens = span.split('{').length - 1;
150+
const closes = span.split('}').length - 1;
151+
if (opens !== closes) offenders.push(`${rel}:${index + 1} \`${segments[i]}\``);
152+
}
153+
});
154+
}
155+
expect(offenders).toEqual([]);
156+
});
157+
});

0 commit comments

Comments
 (0)