Skip to content

Commit b83cd76

Browse files
os-warrenclaude
andauthored
fix(scripts): collect TypeScript fences opened inside a blockquote (#7100)
`scanFences` anchored a fence opener on leading spaces and tabs only, so a fence opened inside a Markdown blockquote carried a `> ` prefix the anchor never matched. The block was never collected and the gate compiled nothing for it, with no diagnostic: an uncollected block appears in no count and its page still reports as covered. The opener now tolerates a blockquote prefix and carries the opener's quote depth through the rest of the walk — the search for the closing fence reads candidates at that same depth, and body lines are stripped of that many markers before reaching the compiler. Depth 0 takes an identity path that returns the line unchanged byte for byte, so every unquoted fence in the corpus scans exactly as before. Measured over the gate's own 224-document population: 773 -> 774 collected blocks, nothing dropped. The one newly-visible block compiles. Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2c3cd1b commit b83cd76

3 files changed

Lines changed: 137 additions & 3 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
---
3+
4+
Doc-snippet gate tooling only, no published package source changed.
5+
6+
`check-doc-snippet-types` collects blocks with `scanFences`, whose fence-opening
7+
anchor accepted a run of leading spaces and tabs and nothing else. A fence
8+
opened inside a Markdown blockquote carries a `> ` prefix, so the anchor never
9+
matched, the block was never collected, and the gate compiled nothing for it.
10+
There was no diagnostic: an uncollected block appears in no count, and its page
11+
still reports as covered. A callout is a natural home for an import example,
12+
which is exactly the snippet class that rots when an export is renamed — the one
13+
class this gate exists to catch.
14+
15+
The opener now tolerates a blockquote prefix and carries the opener's quote
16+
DEPTH through the rest of the walk: the search for the closing fence reads
17+
candidate lines at that same depth, and every body line is stripped of that many
18+
markers before it reaches the compiler. Depth 0 — every unquoted fence in the
19+
corpus — takes an identity path that returns the line unchanged byte for byte,
20+
so the other 773 collected blocks scan exactly as they did before. Stripping
21+
consumes at most one space after each marker, per CommonMark, so indentation
22+
belonging to the snippet survives.
23+
24+
Carrying the depth to the CLOSING fence is what makes this safe in both
25+
directions. Without it a blockquoted fence would find no close and swallow the
26+
rest of the file; and a plain fence would be closed early by any `> ` + backticks
27+
line quoted inside it as prose. Both directions are pinned.
28+
29+
Measured over the gate's own 224-document population: collected blocks 773 → 774.
30+
The one newly-collected block is the import callout at
31+
`content/docs/api/schema-reference.md` line 12, and it compiles — the gate's
32+
semantic phase judges 272 of 272 with 0 failures. Nothing left the population.

‎scripts/__tests__/check-doc-snippet-types.test.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,73 @@ describe('fence scanning', () => {
116116
expect(blocks).toHaveLength(0);
117117
});
118118

119+
it('collects a fence opened inside a blockquote and strips the quoting from its body', () => {
120+
// objectui#7086: the opening anchor allowed leading spaces and tabs only, so a
121+
// fence inside a callout was never collected and the gate compiled nothing for
122+
// it. Silently — an uncollected block appears in no count, and the page still
123+
// reports as covered.
124+
const { blocks } = scanFences(
125+
[
126+
'> **Import:** All types are available from `@object-ui/types`.',
127+
'>',
128+
`> ${FENCE}typescript`,
129+
"> import type { PageNodeSchema } from '@object-ui/types';",
130+
`> ${FENCE}`,
131+
'',
132+
'Prose after the callout.',
133+
].join('\n'),
134+
);
135+
expect(blocks).toHaveLength(1);
136+
expect(blocks[0].language).toBe('typescript');
137+
expect(blocks[0].body).toBe("import type { PageNodeSchema } from '@object-ui/types';");
138+
});
139+
140+
it('closes a blockquoted fence at its own depth rather than running to end of file', () => {
141+
const { blocks } = scanFences(
142+
[
143+
`> ${FENCE}ts`,
144+
'> export const a = 1;',
145+
`> ${FENCE}`,
146+
'',
147+
'export const notPartOfTheBlock = true;',
148+
].join('\n'),
149+
);
150+
expect(blocks).toHaveLength(1);
151+
expect(blocks[0].body).toBe('export const a = 1;');
152+
});
153+
154+
it('strips the opening depth only, so nesting and the snippet own indentation survive', () => {
155+
const { blocks } = scanFences(
156+
[
157+
`> > ${FENCE}ts`,
158+
'> > export const nested = {',
159+
'> > deep: true,',
160+
'> > };',
161+
`> > ${FENCE}`,
162+
].join('\n'),
163+
);
164+
expect(blocks).toHaveLength(1);
165+
expect(blocks[0].body).toBe(['export const nested = {', ' deep: true,', '};'].join('\n'));
166+
});
167+
168+
it('does not let a quoted backtick line close an unquoted fence', () => {
169+
// Depth 0 takes the identity path. This is what keeps every unquoted fence in
170+
// the corpus collecting exactly as it did before blockquotes were recognised.
171+
const { blocks } = scanFences(
172+
[
173+
`${FENCE}ts`,
174+
'// a quoted fence, as prose inside a snippet:',
175+
`> ${FENCE}`,
176+
'export const a = 1;',
177+
FENCE,
178+
].join('\n'),
179+
);
180+
expect(blocks).toHaveLength(1);
181+
expect(blocks[0].body).toBe(
182+
['// a quoted fence, as prose inside a snippet:', `> ${FENCE}`, 'export const a = 1;'].join('\n'),
183+
);
184+
});
185+
119186
it('attaches a fragment marker only to the fence directly beneath it', () => {
120187
const { blocks } = scanFences(
121188
[

‎scripts/check-doc-snippet-types.mjs‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,31 @@ const UNGATED_DOCS = {
554554

555555
// ── Fence scanning ───────────────────────────────────────────────────────────
556556

557+
/**
558+
* One level of blockquote marker: the `>` a Markdown blockquote puts in front of
559+
* every line it contains — the fences and the code between them alike. At most one
560+
* space after the marker is consumed, per CommonMark, so indentation that belongs
561+
* to the snippet survives the strip.
562+
*/
563+
const QUOTE_MARKER = /^[ \t]*>[ \t]?/;
564+
565+
/**
566+
* `line` with `depth` levels of blockquote marker removed. `depth === 0` returns
567+
* the line unchanged, byte for byte; that identity path is what keeps every
568+
* unquoted fence in the corpus collecting exactly as it did before. A line that
569+
* runs out of markers early is returned as far as it stripped, which bounds a
570+
* lazily-continued blockquote instead of letting its fence run to end of file.
571+
*/
572+
function stripQuotePrefix(line, depth) {
573+
let out = line;
574+
for (let d = 0; d < depth; d++) {
575+
const m = QUOTE_MARKER.exec(out);
576+
if (!m) break;
577+
out = out.slice(m[0].length);
578+
}
579+
return out;
580+
}
581+
557582
/** The declaration a fragment carries; see FRAGMENT_MARKER_EXAMPLES. */
558583
const FRAGMENT_MARKER =
559584
/^[ \t]*(?:\{\/\*|<!--)[ \t]*doc-snippet:[ \t]*fragment[ \t]*(?:—|--|-|:)[ \t]*(.+?)[ \t]*(?:\*\/\}|-->)[ \t]*$/;
@@ -576,6 +601,12 @@ export const FRAGMENT_MARKER_EXAMPLES = [
576601
* Every fenced block in one document, with the ts/tsx ones marked. Fences are
577602
* matched by their own run length so a ```` ```` ```` wrapper containing ``` does
578603
* not confuse the walk, and a block's opening info string is kept verbatim.
604+
*
605+
* A fence opened inside a blockquote is collected too. Its opener's quote depth
606+
* is carried to the search for its closing fence and stripped from every body
607+
* line, so the compiler sees the snippet the reader sees and not the `>` around
608+
* it. Depth 0 — every unquoted fence — takes the identity path and scans exactly
609+
* as it did before blockquotes were recognised.
579610
*/
580611
export function scanFences(source) {
581612
const lines = source.split('\n');
@@ -584,12 +615,13 @@ export function scanFences(source) {
584615
for (let i = 0; i < lines.length; i++) {
585616
const marker = FRAGMENT_MARKER.exec(lines[i]);
586617
if (marker) markers.push({ line: i + 1, reason: marker[1].trim(), consumed: false });
587-
const open = /^([ \t]*)(`{3,})(.*)$/.exec(lines[i]);
618+
const open = /^([ \t]*(?:>[ \t]*)*)(`{3,})(.*)$/.exec(lines[i]);
588619
if (!open) continue;
589620
const ticks = open[2];
621+
const depth = (open[1].match(/>/g) ?? []).length;
590622
let close = lines.length;
591623
for (let j = i + 1; j < lines.length; j++) {
592-
const c = /^[ \t]*(`{3,})[ \t]*$/.exec(lines[j]);
624+
const c = /^[ \t]*(`{3,})[ \t]*$/.exec(stripQuotePrefix(lines[j], depth));
593625
if (c && c[1].length >= ticks.length) {
594626
close = j;
595627
break;
@@ -606,7 +638,10 @@ export function scanFences(source) {
606638
blocks.push({
607639
fenceLine: i + 1,
608640
language,
609-
body: lines.slice(i + 1, close).join('\n'),
641+
body: lines
642+
.slice(i + 1, close)
643+
.map((line) => stripQuotePrefix(line, depth))
644+
.join('\n'),
610645
fragmentReason: above ? above.reason : null,
611646
});
612647
}

0 commit comments

Comments
 (0)