3333#
3434# Code handling (applies to checks 2-6; check 1 greps the fence-stripped line
3535# WITHOUT inline-span stripping): fenced code blocks in BOTH backtick
36- # (```) and tilde (~~~) styles are skipped, following the pinned MDX parser's
37- # fence behavior. Unlike CommonMark, MDX has no indented code blocks, so a
38- # fence may have any leading spaces or tabs. It opens on a run of >= 3 of either
39- # character and closes only on a later line whose leading run is the SAME
40- # character, at least as long, and followed by nothing but whitespace (so an
41- # info string such as ```yaml opens a block and never closes one). Inline
42- # code spans are stripped before scanning; a span opens on a run of N backticks
43- # and closes at the next run of exactly N backticks, so spans of any
44- # backtick-run length are honored (not just single-backtick spans) .
36+ # (```) and tilde (~~~) styles are skipped by a lexical fence tracker. An
37+ # explicit fence opens on a run of >= 3 of either character after 0-3 leading
38+ # spaces, and closes only on an explicit later line whose leading run is the
39+ # SAME character, at least as long, and followed by nothing but whitespace.
40+ # Thus ```yaml starts a fence when outside one and is content, not a closer,
41+ # when inside one. For an immediate direct top-level bullet or 1. item whose
42+ # content indent is at most three, a nonblank outdent implicitly ends its fence.
43+ # Inline code spans are stripped before scanning; a span opens on a run of N
44+ # backticks and closes at the next run of exactly N backticks .
4545#
4646# NOTE: this checker is a fast, dependency-free APPROXIMATION of the MDX
4747# parser, not a reimplementation of it. tools/check-docs-mdx-parse runs the
4848# real parser (@mdx-js/mdx, locked in tools/mdx/) and is the authoritative
4949# gate — it is what the merge-gate 'docs-mdx' job blocks on. A green run HERE
5050# is not parser-level validation; it means no known hazard pattern matched.
5151#
52- # The rules above are deliberately a strict SUBSET of what the parser rejects.
53- # Erring toward under-reporting is the correct direction: a miss is caught by
54- # the blocking parse gate, whereas a false positive would force contributors to
55- # mangle prose the publish step would have accepted. Known misses left to the
56- # parser: a stray closing tag ('</div>'), an unclosed fragment ('<>'), a
57- # placeholder sharing a line with well-formed JSX, unbalanced expression braces
58- # spanning lines, and acorn-level syntax errors.
52+ # The hazard rules deliberately cover common failures rather than every parser
53+ # rejection. Outside the lexical code-handling subset documented above, this
54+ # line-based approximation can disagree with the parser in either direction.
55+ # Known misses left to the parser include a stray closing tag ('</div>'), an
56+ # unclosed fragment ('<>'), a placeholder sharing a line with well-formed JSX,
57+ # unbalanced expression braces spanning lines, and acorn-level syntax errors.
5958#
60- # Known code-handling limit: a code span is matched within one line, even
61- # though CommonMark lets a span cross a line break (an opening backtick run on
62- # one line closing on a later one). Content between such a break is scanned as
63- # prose and can surface as a false positive here. This is a deliberate
64- # limitation of a line-based scanner, not an oversight: the parse gate
65- # (check-docs-mdx-parse) is the arbiter and will accept the file.
59+ # Known code-handling limits: later list items, lazy continuations, nested lists,
60+ # and mixed containers are parser-owned. Code spans are matched within one line;
61+ # the parser handles multi-line spans.
6662
6763set -euo pipefail
6864
@@ -136,169 +132,48 @@ readonly FENCE_AWK='
136132 }
137133 return width
138134 }
139- function column_width(s, i, ch, width) {
140- width = 0
141- for (i = 1; i <= length(s); i++) {
142- ch = substr(s, i, 1)
143- if (ch == "\t") width += 4 - (width % 4)
144- else width++
145- }
146- return width
147- }
148- function thematic_break(s, text) {
149- text = s
150- sub(/\r$/, "", text)
151- return text ~ /^[ \t]*(-[ \t]*){3,}$/ ||
152- text ~ /^[ \t]*(\*[ \t]*){3,}$/ ||
153- text ~ /^[ \t]*(_[ \t]*){3,}$/
154- }
155- function setext_underline(s, text) {
156- text = s
157- sub(/^[ \t]+/, "", text); sub(/\r$/, "", text); sub(/[ \t]+$/, "", text)
158- return text ~ /^(=+|-+)$/
159- }
160- function table_delimiter(s, text, outer) {
161- text = s
162- sub(/^[ \t]+/, "", text); sub(/\r$/, "", text); sub(/[ \t]+$/, "", text)
163- outer = (text ~ /^\|.*\|$/)
164- gsub(/[ \t]/, "", text)
165- if (text !~ /\|/ && !outer) return 0
166- sub(/^\|/, "", text); sub(/\|$/, "", text)
167- return text ~ /^:?-+:?(\|:?-+:?)*$/
168- }
169- function lazy_text(s, text, fence_rest) {
170- text = s
171- sub(/^[ \t]+/, "", text)
172- sub(/\r$/, "", text)
173- if (text !~ /[^ \t]/) return 0
174- if (thematic_break(text)) return 0
175- if (text ~ /^(>|#{1,6}([ \t]|$)|~{3,})/) return 0
176- if (match(text, /^`{3,}/)) {
177- fence_rest = substr(text, RSTART + RLENGTH)
178- if (index(fence_rest, "`") == 0) return 0
179- }
180- if (text ~ /^\{/ || text ~ /^<[^ \t]/) return 0
181- if (text ~ /^([-+*]|[0-9]{1,9}[.)])[ \t]+/) return 0
182- return 1
183- }
184135 NR <= fm_end { next }
185- # Fence rules shared by both checker passes. Getting any of these wrong
186- # leaves the tracker open and silently skips every later hazard.
187- # indent: any leading spaces or tabs, matching the MDX parser
136+ # Lexical fence rules shared by both checker passes:
137+ # indent: 0-3 leading spaces; tabs are not fence indentation
188138 # open: a backtick in the info string makes the line prose, not a
189139 # fence (ambiguous with an inline code span); tildes are exempt
190- # close: same char, at least as long, nothing after but whitespace, so
191- # ```yaml only ever opens; the \r admits a CRLF closer
192- # scope: a fence opened inside a list item ends implicitly when a
193- # nonblank line leaves that item. list_content/fscope carry
194- # container indents; list_lazy/top_lazy preserve CommonMark
195- # paragraph continuation lines, and thematic breaks never
196- # create lists.
140+ # close: same char, at least as long, nothing after but whitespace;
141+ # the optional \r admits a CRLF closer
142+ # scope: an immediate direct top-level bullet or 1. item may own an
143+ # indented fence; any nonblank outdent ends that scope
197144 {
198145 blank = ($0 ~ /^[ \t]*\r?$/)
199146 ind = indent_width($0)
200- fence_in_marker = 0
201- is_fence = match($0, /^[ \t]*(`{3,}|~{3,})/)
147+ is_fence = match($0, /^ {0,3}(`{3,}|~{3,})/)
202148 if (is_fence) {
203- m = substr($0, RSTART, RLENGTH); sub(/^[ \t]* /, "", m)
149+ m = substr($0, RSTART, RLENGTH); sub(/^ + /, "", m)
204150 ch = substr(m, 1, 1); len = length(m)
205151 rest = substr($0, RSTART + RLENGTH)
206152 }
207153
208- # A nonblank outdent ends both the list item and any unclosed fence it
209- # contains. Re-process the boundary line below: it may be prose, a new
210- # list item, or the opener for a following top-level fence.
154+ # Re-process a direct list boundary as prose or as a new outer fence.
211155 if (in_fence && fscope > 0 && !blank && ind < fscope) {
212- in_fence = 0; fscope = 0
156+ in_fence = 0; fscope = 0; list_indent = 0
213157 }
214158
215159 if (in_fence) {
216- if (is_fence && ch == fch && len >= flen && rest ~ /^[ \t]*\r?$/) { in_fence = 0; next }
160+ if (is_fence && ch == fch && len >= flen && rest ~ /^[ \t]*\r?$/) { in_fence = 0; fscope = 0; next }
217161 next
218162 }
219163
220- # Maintain just enough list-container state to distinguish an
221- # outdented list-fence boundary from a valid less-indented top-level
222- # closer. CommonMark lazy paragraph lines keep the item open. A blank
223- # ends laziness and also ends an item whose marker had no content.
224- table_continuation = top_table && $0 ~ /\|/
225- if (top_table && !table_continuation) top_table = 0
226- setext_line = top_lazy && setext_underline($0)
227- rejected_marker = 0
228- list_candidate = !setext_line && !table_continuation && !thematic_break($0) && match($0, /^[ \t]*([-+*]|[0-9]{1,9}[.)])([ \t]+|[ \t]*\r?$)/)
229- if (list_candidate) {
230- marker_prefix_len = RLENGTH
231- marker_indent = ind
232- content = substr($0, RSTART + marker_prefix_len)
233- marker = substr($0, 1, marker_prefix_len)
234- sub(/^[ \t]*/, "", marker)
235- match(marker, /^([-+*]|[0-9]{1,9}[.)])/)
236- marker = substr(marker, RSTART, RLENGTH)
237- content_empty = (content ~ /^\r?$/)
238- if (content_empty) {
239- content_indent = marker_indent + length(marker) + 1
240- } else {
241- content_indent = column_width(substr($0, 1, marker_prefix_len))
242- }
243- can_interrupt = !content_empty && (marker !~ /^[0-9]/ || marker + 0 == 1)
244- target_depth = list_depth
245- while (target_depth > 0 && marker_indent < list_content[target_depth] && marker_indent != list_marker[target_depth]) target_depth--
246- continues_list = target_depth > 0 && marker_indent == list_marker[target_depth]
247- paragraph_open = target_depth > 0 ? list_lazy[target_depth] : top_lazy
248- if (!continues_list && paragraph_open && !can_interrupt) {
249- list_candidate = 0; rejected_marker = 1; list_depth = target_depth
250- }
251- }
252- if (list_candidate) {
253- if (match(content, /^(`{3,}|~{3,})/)) {
254- fence_in_marker = 1; is_fence = 1
255- m = substr(content, RSTART, RLENGTH)
256- ch = substr(m, 1, 1); len = length(m)
257- rest = substr(content, RSTART + RLENGTH)
258- }
259- list_depth = target_depth
260- if (list_depth == 0 || marker_indent != list_marker[list_depth]) {
261- if (list_depth > 0) { list_lazy[list_depth] = 0; list_empty[list_depth] = 0 }
262- list_depth++
263- }
264- list_marker[list_depth] = marker_indent
265- list_content[list_depth] = content_indent
266- list_lazy[list_depth] = lazy_text(content)
267- list_empty[list_depth] = content_empty
268- top_lazy = 0; top_pipe = 0; top_table = 0
269- } else if (blank) {
270- while (list_depth > 0 && list_empty[list_depth]) list_depth--
271- for (i = 1; i <= list_depth; i++) list_lazy[i] = 0
272- top_lazy = 0; top_pipe = 0; top_table = 0
273- } else if (rejected_marker) {
274- if (list_depth > 0) {
275- list_empty[list_depth] = 0; list_lazy[list_depth] = 1
276- top_lazy = 0
277- } else {
278- top_lazy = 1; top_pipe = ($0 ~ /\|/)
279- }
280- } else {
281- while (list_depth > 0 && ind < list_content[list_depth] && !(list_lazy[list_depth] && lazy_text($0))) list_depth--
282- if (list_depth > 0) {
283- list_empty[list_depth] = 0
284- if (is_fence && (ch != "`" || index(rest, "`") == 0)) list_lazy[list_depth] = 0
285- else list_lazy[list_depth] = lazy_text($0)
286- top_lazy = 0
287- } else if (table_continuation) {
288- top_lazy = 0; top_pipe = 0
289- } else if (setext_line) {
290- top_lazy = 0; top_pipe = 0
291- } else if (top_lazy && top_pipe && table_delimiter($0)) {
292- top_lazy = 0; top_pipe = 0; top_table = 1
293- } else {
294- top_lazy = lazy_text($0)
295- top_pipe = top_lazy && $0 ~ /\|/
296- }
164+ # Deliberately narrow list recognition: direct top-level bullets and
165+ # ordered items starting at 1, with a normal content indent <= 3.
166+ # Exclude thematic breaks, which otherwise resemble bullet items.
167+ marker = !($0 ~ /^(-[ \t]*){3,}\r?$/ || $0 ~ /^(\*[ \t]*){3,}\r?$/) && match($0, /^([-+*]|1[.)]) +/)
168+ if (marker && RLENGTH <= 3 && substr($0, RLENGTH + 1, 1) ~ /[^ \t\r]/) {
169+ list_indent = RLENGTH
170+ } else if (!blank && list_indent > 0 && ind < list_indent) {
171+ list_indent = 0
297172 }
298173
299174 if (is_fence && (ch != "`" || index(rest, "`") == 0)) {
300175 in_fence = 1; fch = ch; flen = len
301- fscope = fence_in_marker ? content_indent : ((list_depth > 0 && ind >= list_content[list_depth] ) ? list_content[list_depth] : 0)
176+ fscope = (list_indent > 0 && ind >= list_indent ) ? list_indent : 0
302177 next
303178 }
304179 }
@@ -424,8 +299,7 @@ for file in "${FILES[@]}"; do
424299 # valid content. Deciding whether tags actually balance needs parser
425300 # state, which this script does not have; the trade is that a placeholder
426301 # sharing a line with real JSX is caught by tools/check-docs-mdx-parse
427- # instead of here. That is the safe direction — see the strict-subset
428- # note in the header.
302+ # instead of here, as described by the parser-ownership note above.
429303 line ~ /<[a-zA-Z]/ && line !~ /\/>/ && line !~ /<\// && line !~ /<(img|br|hr|input|source|meta|link|area|base|col|embed|param|track|wbr)([ \t>\/])/ && line !~ /<https?:\/\// {
430304 printf "MDX: bare <word> tag outside code fence: %s:%d: %s\n", FILENAME, NR, $0
431305 errors++
@@ -439,8 +313,7 @@ for file in "${FILES[@]}"; do
439313 # parse cleanly through @mdx-js/mdx — micromark only enters JSX-tag mode
440314 # when a name-ish character follows immediately, so `<` + space stays
441315 # literal text. Flagging them forced contributors to backtick prose the
442- # publish step accepts, which is the false-positive direction this script
443- # must avoid (see the strict-subset note in the header).
316+ # publish step accepts, which this approximation must avoid.
444317 #
445318 # `</div>` and `<>` are NOT covered here: both are real MDX errors, but
446319 # deciding whether a tag is balanced needs parser state. They are caught
0 commit comments