Skip to content

Commit 2c37ba8

Browse files
sunbryeCopilot
andauthored
Strip docs-validate: hidden blocks when syncing SDK docs (#62825)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d1301965-d56d-41f8-ae13-7bcb967da5db
1 parent 860de23 commit 2c37ba8

3 files changed

Lines changed: 568 additions & 2 deletions

File tree

src/workflows/sync-sdk-docs/normalize-sdk-docs.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
* landing page for each docs directory; docs-internal requires index.md)
99
* - Adds YAML frontmatter (title, intro, shortTitle, versions, contentType)
1010
* - Adds `children` arrays to index.md files
11+
* - Removes `docs-validate: hidden` ranges (validation-only code samples that
12+
* must not reach readers)
1113
* - Converts consecutive <details> language blocks to {% codetabs %} syntax
1214
* - Rewrites internal relative .md links to [AUTOTITLE](/path) format
1315
* - Rewrites absolute docs.github.com links to [AUTOTITLE](/path) format
@@ -26,6 +28,8 @@ import path from 'node:path'
2628
import { parseArgs } from 'node:util'
2729
import matter from '@gr2m/gray-matter'
2830

31+
import { stripHiddenBlocks } from './strip-hidden-blocks'
32+
2933
// Parse CLI arguments
3034
const { values: args } = parseArgs({
3135
options: {
@@ -590,6 +594,61 @@ function fixBlanksAroundFences(filePath: string): void {
590594
}
591595
}
592596

597+
/**
598+
* Step 1b: Remove `docs-validate: hidden` ranges.
599+
* These wrap validation-only code samples that the SDK's docs-validate workflow
600+
* compiles in place of the reader-facing fragment that follows them. The markers
601+
* are HTML comments with no rendering semantics, so without this step the
602+
* validation sample publishes alongside the real one and readers see the same
603+
* example twice. Runs before the codetabs conversion so the ranges are gone
604+
* before any <details> group is rewritten.
605+
*
606+
* An unbalanced marker is left in place rather than swallowing the rest of the
607+
* file. Because this workflow opens its PR automatically, those warnings are
608+
* also written to the job summary so they survive outside the run log.
609+
*/
610+
const unbalancedMarkerWarnings: string[] = []
611+
612+
function stripHiddenValidationBlocks(filePath: string): void {
613+
const raw = fs.readFileSync(filePath, 'utf8')
614+
const { content, removed, unbalanced } = stripHiddenBlocks(raw)
615+
const relativePath = path.relative(SDK_DOCS_DIR, filePath)
616+
617+
if (unbalanced > 0) {
618+
const message = `${relativePath}: ${unbalanced} unclosed "docs-validate: hidden" marker(s), left in place`
619+
unbalancedMarkerWarnings.push(message)
620+
console.log(` WARN (${message})`)
621+
}
622+
623+
if (removed > 0) {
624+
fs.writeFileSync(filePath, content, 'utf8')
625+
console.log(` HIDDEN (removed ${removed}): ${relativePath}`)
626+
}
627+
}
628+
629+
/**
630+
* Write unbalanced-marker warnings to the Actions job summary, which is linked
631+
* from the generated PR. Without this the only record is the run log, which a
632+
* PR reviewer will not see.
633+
*/
634+
function reportUnbalancedMarkers(): void {
635+
const summaryPath = process.env.GITHUB_STEP_SUMMARY
636+
if (unbalancedMarkerWarnings.length === 0 || !summaryPath) return
637+
638+
const lines = [
639+
'### ⚠️ Unclosed `docs-validate: hidden` markers',
640+
'',
641+
'These markers have no matching `<!-- /docs-validate: hidden -->`, so the validation-only',
642+
'code sample they open was published instead of being removed. Fix the pair in',
643+
'[copilot-sdk docs](https://github.com/github/copilot-sdk/tree/main/docs).',
644+
'',
645+
...unbalancedMarkerWarnings.map((warning) => `* \`${warning}\``),
646+
'',
647+
]
648+
649+
fs.appendFileSync(summaryPath, lines.join('\n'), 'utf8')
650+
}
651+
593652
/**
594653
* Step 2: Convert consecutive <details> language blocks to codetabs.
595654
* SDK source docs use <details><summary><strong>Language</strong></summary>
@@ -759,8 +818,9 @@ function parseDetailsBlock(lines: string[], start: number): DetailsBlock | null
759818

760819
const endLine = i // The </details> line
761820

762-
// Clean up inner lines: strip docs-validate comments, trim leading/trailing blanks
763-
const cleaned = innerLines.filter((l) => !/^\s*<!--\s*\/?docs-validate:\s*hidden\s*-->/.test(l))
821+
// Hidden ranges are already gone (Step 1b), including any unbalanced marker
822+
// left deliberately in place, so only blank-line trimming is needed here.
823+
const cleaned = [...innerLines]
764824

765825
// Trim leading and trailing blank lines
766826
while (cleaned.length > 0 && cleaned[0].trim() === '') cleaned.shift()
@@ -849,6 +909,14 @@ for (const file of files) {
849909
addFrontmatter(file)
850910
}
851911

912+
// Step 1b: Remove docs-validate: hidden ranges before anything rewrites the
913+
// blocks that contain them.
914+
console.log('\n--- Removing docs-validate: hidden blocks ---\n')
915+
for (const file of files) {
916+
stripHiddenValidationBlocks(file)
917+
}
918+
reportUnbalancedMarkers()
919+
852920
// Step 2: Convert <details> language blocks to codetabs
853921
console.log('\n--- Converting details blocks to codetabs ---\n')
854922
for (const file of files) {
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/**
2+
* Removes `docs-validate: hidden` ranges from Copilot SDK docs.
3+
*
4+
* The copilot-sdk repo wraps validation-only code samples in a marker pair:
5+
*
6+
* <!-- docs-validate: hidden -->
7+
* ```go
8+
* package main
9+
*
10+
* func main() { ... }
11+
* ```
12+
* <!-- /docs-validate: hidden -->
13+
*
14+
* ```go
15+
* client := copilot.NewClient(nil)
16+
* ```
17+
*
18+
* The first sample is a complete, compilable program that exists so the SDK's
19+
* `docs-validate` workflow has something a compiler can accept. The second is
20+
* the trimmed fragment intended for readers. The SDK's extractor treats the
21+
* closing marker as "validate the hidden block instead of the next one", so the
22+
* contract is: compile the hidden sample, publish the visible one.
23+
*
24+
* Nothing enforced the publishing half of that contract. The markers are plain
25+
* HTML comments, and a Markdown parser treats each as a self-contained
26+
* single-line HTML block — the fence between them is a sibling node, not a
27+
* child, so it renders like any other code block. Without this step both
28+
* samples ship and readers see the same example twice.
29+
*/
30+
31+
// Markers are our own directive syntax, so match them permissively: a marker we
32+
// fail to recognize silently reintroduces the duplicate-sample bug. Trailing
33+
// content after `-->` is tolerated for the same reason.
34+
const HIDDEN_OPEN = /^\s*<!--\s*docs-validate:\s*hidden\s*-->/i
35+
const HIDDEN_CLOSE = /^\s*<!--\s*\/\s*docs-validate:\s*hidden\s*-->/i
36+
37+
// Fences are CommonMark structure, so match them exactly: an opener may be
38+
// indented at most 3 spaces, and the run of backticks or tildes may exceed 3.
39+
const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/
40+
41+
interface OpenFence {
42+
char: string
43+
length: number
44+
}
45+
46+
/**
47+
* Apply a line to the fence state machine and return the new state.
48+
*
49+
* A closing fence must use the same character as its opener, be at least as
50+
* long, and carry no info string. Tracking the length matters because a
51+
* four-backtick fence can legally contain a three-backtick line as content.
52+
*/
53+
function nextFenceState(line: string, open: OpenFence | null): OpenFence | null {
54+
const match = FENCE.exec(line)
55+
if (!match) return open
56+
57+
const [, marker, info] = match
58+
const char = marker[0]
59+
const length = marker.length
60+
61+
if (open === null) {
62+
// An info string on a backtick fence may not itself contain a backtick.
63+
if (char === '`' && info.includes('`')) return null
64+
return { char, length }
65+
}
66+
67+
if (char === open.char && length >= open.length && info.trim() === '') return null
68+
return open
69+
}
70+
71+
export interface StripHiddenBlocksResult {
72+
content: string
73+
/** Number of complete marker ranges removed. */
74+
removed: number
75+
/** Number of opening markers with no matching close. */
76+
unbalanced: number
77+
}
78+
79+
/**
80+
* Find the closing marker for an opener, ignoring markers inside code fences.
81+
* Returns -1 when the range is malformed, which includes a second opener
82+
* appearing before any close.
83+
*/
84+
function findClosingMarker(lines: string[], start: number): number {
85+
// The opener is only matched outside a fence, so the inner scan starts closed.
86+
let fence: OpenFence | null = null
87+
88+
for (let i = start; i < lines.length; i++) {
89+
const line = lines[i]
90+
const next = nextFenceState(line, fence)
91+
92+
if (next !== fence) {
93+
fence = next
94+
continue
95+
}
96+
if (fence !== null) continue
97+
98+
if (HIDDEN_CLOSE.test(line)) return i
99+
if (HIDDEN_OPEN.test(line)) return -1
100+
}
101+
102+
return -1
103+
}
104+
105+
/**
106+
* Strip every `docs-validate: hidden` range, markers included.
107+
*
108+
* Markers inside a fenced code block are sample text rather than directives and
109+
* are left alone. An opener with no matching close is also left alone: dropping
110+
* to the end of the file would silently destroy content, so the caller is
111+
* warned instead.
112+
*/
113+
export function stripHiddenBlocks(content: string): StripHiddenBlocksResult {
114+
const lines = content.split('\n')
115+
const result: string[] = []
116+
let removed = 0
117+
let unbalanced = 0
118+
let fence: OpenFence | null = null
119+
let i = 0
120+
121+
while (i < lines.length) {
122+
const line = lines[i]
123+
124+
if (fence === null && HIDDEN_OPEN.test(line)) {
125+
const closeIndex = findClosingMarker(lines, i + 1)
126+
127+
if (closeIndex === -1) {
128+
unbalanced++
129+
result.push(line)
130+
i++
131+
continue
132+
}
133+
134+
removed++
135+
i = closeIndex + 1
136+
137+
const previous = result[result.length - 1]
138+
const next = lines[i]
139+
// Treat the start and end of the file as blank so the range never leaves
140+
// a stray blank line at either edge.
141+
const previousIsBlank = previous === undefined || previous.trim() === ''
142+
const nextIsBlank = next === undefined || next.trim() === ''
143+
144+
if (previousIsBlank && nextIsBlank) {
145+
// Both sides were blank and are now adjacent — keep only one.
146+
i++
147+
} else if (!previousIsBlank && !nextIsBlank) {
148+
// The range was the only thing separating two blocks. Without a blank
149+
// line between them they would merge into a single paragraph.
150+
result.push('')
151+
}
152+
continue
153+
}
154+
155+
fence = nextFenceState(line, fence)
156+
result.push(line)
157+
i++
158+
}
159+
160+
return { content: result.join('\n'), removed, unbalanced }
161+
}

0 commit comments

Comments
 (0)