@@ -51,12 +51,341 @@ function isPlaceholderOnlyValue(raw) {
5151 return PLACEHOLDER_ONLY_RE . test ( value ) ;
5252}
5353
54+ /**
55+ * Strip image/media-only content from a markdown or HTML fragment so that a
56+ * section whose only content is a screenshot or media embed is treated as
57+ * empty by the validators.
58+ *
59+ * Handles:
60+ * - Markdown images: ``, ``
61+ * - HTML <img ...> and <picture> ... </picture> blocks
62+ * - Common media embeds (video/audio) when they are the only content
63+ *
64+ * Text mixed with media (for example a caption or repro steps around an
65+ * image) is preserved; only the media tokens themselves are removed.
66+ */
67+ function stripMediaTokens ( text ) {
68+ if ( typeof text !== "string" ) return "" ;
69+ // Indented code lines render as literal code in GitHub Markdown. Protect
70+ // them first so neither the HTML nor the Markdown media stripper can
71+ // remove example syntax; restore the lines afterwards.
72+ const protectedText = protectIndentedCodeLines ( text ) ;
73+ const markdownStripped = stripMarkdownImages ( stripHtmlMedia ( protectedText . text ) ) ;
74+ const referenceStripped = stripReferenceImages ( markdownStripped ) ;
75+ return restoreIndentedCodeLines ( referenceStripped , protectedText . lines ) ;
76+ }
77+
78+ /**
79+ * Replace every indented code line (4+ leading spaces or a tab) with a
80+ * placeholder of equal length so media stripping cannot touch it. Returns the
81+ * masked text plus the original lines for restoration.
82+ */
83+ function protectIndentedCodeLines ( text ) {
84+ const lines = [ ] ;
85+ const masked = text . split ( "\n" ) . map ( ( line ) => {
86+ if ( / ^ (?: { 4 , } | \t ) / . test ( line ) ) {
87+ lines . push ( line ) ;
88+ return "\u0000" + line . replace ( / [ ^ \n ] / g, " " ) . slice ( 1 ) ;
89+ }
90+ lines . push ( null ) ;
91+ return line ;
92+ } ) ;
93+ return { text : masked . join ( "\n" ) , lines } ;
94+ }
95+
96+ /**
97+ * Restore masked indented-code lines from their original content. Placeholder
98+ * lines are identified by the leading \u0000 marker and matched positionally.
99+ */
100+ function restoreIndentedCodeLines ( text , lines ) {
101+ const out = text . split ( "\n" ) . map ( ( line , i ) => {
102+ if ( lines [ i ] !== null && line . startsWith ( "\u0000" ) ) {
103+ return lines [ i ] ;
104+ }
105+ return line ;
106+ } ) ;
107+ return out . join ( "\n" ) ;
108+ }
109+
110+ /**
111+ * Strip HTML media blocks whose entire inner content is media markup (no
112+ * substantive text). A block that contains fallback/caption prose — for
113+ * example `<video controls>Route requests through the fallback provider.</video>`
114+ * — is left untouched so the prose survives the empty-section check.
115+ *
116+ * Handles <img ...>, <picture>...</picture>, <video>...</video>, and
117+ * <audio>...</audio>.
118+ */
119+ function stripHtmlMedia ( text ) {
120+ if ( typeof text !== "string" ) return "" ;
121+ let s = text
122+ . replace ( / < i m g \b [ ^ > ] * > / gi, " " )
123+ . replace ( / < ! - - [ \s \S ] * ?- - > / g, " " ) ;
124+
125+ // Whole media blocks: replace only when the inner content is not
126+ // substantive text (no word characters outside tags).
127+ s = s . replace (
128+ / < ( p i c t u r e | v i d e o | a u d i o ) \b [ ^ > ] * > ( [ \s \S ] * ?) < \/ \1> / gi,
129+ ( match , tag , inner ) => {
130+ const innerStripped = inner
131+ . replace ( / < [ ^ > ] + > / g, " " )
132+ . replace ( / [ \s _ * ~ ` ] + / g, " " )
133+ . trim ( ) ;
134+ return innerStripped . length === 0 ? " " : match ;
135+ } ,
136+ ) ;
137+ return s ;
138+ }
139+
140+ /**
141+ * Remove Markdown image tokens `` using a small
142+ * balanced scanner instead of a regex, because destinations may contain
143+ * balanced parentheses (for example `image_(final).png`) and alt text may
144+ * contain balanced brackets (`![Image [screenshot]](url)`).
145+ *
146+ * A token is matched only when:
147+ * - it starts with `![` (not escaped);
148+ * - the alt text is balanced with respect to `[` / `]`;
149+ * - the destination is balanced with respect to `(`, `)` and `"` (an
150+ * optional title may follow); and
151+ * - the token closes with a `)`.
152+ *
153+ * Malformed tokens (unbalanced destination, e.g. `a)b.png)`) are left in
154+ * place — they are not valid Markdown images and must not be silently
155+ * dropped.
156+ */
157+ function stripMarkdownImages ( text ) {
158+ if ( typeof text !== "string" ) return "" ;
159+ const out = [ ] ;
160+ let i = 0 ;
161+ while ( i < text . length ) {
162+ // Inside an indented code block (4+ leading spaces or a tab), image
163+ // syntax is literal code, not a rendered image. Leave it untouched so a
164+ // section that documents example syntax is not emptied.
165+ if ( isInsideIndentedCode ( text , i ) ) {
166+ out . push ( text [ i ] ) ;
167+ i += 1 ;
168+ continue ;
169+ }
170+ // A backslash-escaped or code-fenced `![` is not an image token. We only
171+ // guard the common `\!` escape here; fenced blocks are handled by the
172+ // section extractor upstream, which does not include them in sections.
173+ if ( text [ i ] === "!" && text [ i + 1 ] === "[" ) {
174+ const end = scanMarkdownImage ( text , i ) ;
175+ if ( end !== - 1 ) {
176+ out . push ( " " ) ;
177+ i = end ;
178+ continue ;
179+ }
180+ }
181+ out . push ( text [ i ] ) ;
182+ i += 1 ;
183+ }
184+ return out . join ( "" ) ;
185+ }
186+
187+ /**
188+ * True when `index` sits inside an indented code block, i.e. on a line that
189+ * starts with four or more spaces or a tab. Such lines render as literal
190+ * code in GitHub Markdown.
191+ */
192+ function isInsideIndentedCode ( text , index ) {
193+ const lineStart = text . lastIndexOf ( "\n" , index - 1 ) + 1 ;
194+ const prefix = text . slice ( lineStart , index ) ;
195+ return / ^ (?: { 4 , } | \t ) / . test ( prefix ) ;
196+ }
197+
198+ /**
199+ * Strip reference-style Markdown images: inline references `![alt][ref]`
200+ * and the reference definitions `[ref]: https://...` they point at. These
201+ * are valid image syntax that a media-only section may use to embed a
202+ * screenshot.
203+ */
204+ function stripReferenceImages ( text ) {
205+ if ( typeof text !== "string" ) return "" ;
206+ // Inline reference: ![alt][ref] or ![alt][] (implicit). Alt may contain
207+ // balanced brackets, so a balanced scan is used for the label part.
208+ let s = stripInlineReferences ( text ) ;
209+ // Reference definitions: [ref]: url "title" — only when the reference is
210+ // actually used by an image in the same text. A definition alone (or one
211+ // used by a text link) is not media and must stay.
212+ const refs = new Set ( ) ;
213+ for ( const ref of collectInlineReferenceLabels ( text ) ) {
214+ refs . add ( ref . toLowerCase ( ) ) ;
215+ }
216+ if ( refs . size > 0 ) {
217+ s = s . replace (
218+ / ^ \s * \[ ( [ ^ \] ] + ) \] : \s * \S + (?: \s + [ " ' ( ] [ ^ " ' ) ] * [ " ' ) ] ) ? \s * $ / gm,
219+ ( line , ref ) => ( refs . has ( ref . toLowerCase ( ) ) ? " " : line ) ,
220+ ) ;
221+ }
222+ return s ;
223+ }
224+
225+ /**
226+ * Strip inline reference-style image tokens `![alt][ref]` / `![alt][]`
227+ * using a balanced scan for the alt text (which may contain nested brackets).
228+ */
229+ function stripInlineReferences ( text ) {
230+ const out = [ ] ;
231+ let i = 0 ;
232+ while ( i < text . length ) {
233+ if ( text [ i ] === "!" && text [ i + 1 ] === "[" ) {
234+ const end = scanReferenceImage ( text , i ) ;
235+ if ( end !== - 1 ) {
236+ out . push ( " " ) ;
237+ i = end ;
238+ continue ;
239+ }
240+ }
241+ out . push ( text [ i ] ) ;
242+ i += 1 ;
243+ }
244+ return out . join ( "" ) ;
245+ }
246+
247+ /**
248+ * Scan an inline reference-style image `![alt][ref]` or `![alt][]` starting
249+ * at `start`. Returns the index just past the closing `]` on success, or -1.
250+ */
251+ function scanReferenceImage ( text , start ) {
252+ const altEnd = scanBalancedBrackets ( text , start + 2 ) ;
253+ if ( altEnd === - 1 || text [ altEnd ] !== "]" ) return - 1 ;
254+ if ( text [ altEnd + 1 ] !== "[" ) return - 1 ;
255+ const refEnd = scanBalancedBrackets ( text , altEnd + 2 ) ;
256+ if ( refEnd === - 1 || text [ refEnd ] !== "]" ) return - 1 ;
257+ return refEnd + 1 ;
258+ }
259+
260+ /**
261+ * Scan balanced bracket content starting at `start` (inside the opening `[`).
262+ * Returns the index of the matching closing `]`, or -1 when unbalanced.
263+ */
264+ function scanBalancedBrackets ( text , start ) {
265+ let depth = 0 ;
266+ for ( let i = start ; i < text . length ; i += 1 ) {
267+ const ch = text [ i ] ;
268+ if ( ch === "\\" ) {
269+ i += 1 ;
270+ continue ;
271+ }
272+ if ( ch === "[" ) {
273+ depth += 1 ;
274+ } else if ( ch === "]" ) {
275+ if ( depth === 0 ) return i ;
276+ depth -= 1 ;
277+ }
278+ }
279+ return - 1 ;
280+ }
281+
282+ /**
283+ * Collect the reference labels used by inline reference-style images. For an
284+ * explicit `![alt][ref]` the label is `ref`; for an implicit `![alt][]` the
285+ * label is the alt text.
286+ */
287+ function collectInlineReferenceLabels ( text ) {
288+ const labels = [ ] ;
289+ let i = 0 ;
290+ while ( i < text . length ) {
291+ if ( text [ i ] === "!" && text [ i + 1 ] === "[" ) {
292+ const altStart = i + 2 ;
293+ const altEnd = scanBalancedBrackets ( text , altStart ) ;
294+ if ( altEnd !== - 1 && text [ altEnd ] === "]" ) {
295+ const alt = text . slice ( altStart , altEnd ) ;
296+ if ( text [ altEnd + 1 ] === "[" ) {
297+ const refStart = altEnd + 2 ;
298+ const refEnd = scanBalancedBrackets ( text , refStart ) ;
299+ if ( refEnd !== - 1 && text [ refEnd ] === "]" ) {
300+ const ref = text . slice ( refStart , refEnd ) ;
301+ labels . push ( ref ? ref : alt ) ;
302+ i = refEnd + 1 ;
303+ continue ;
304+ }
305+ }
306+ }
307+ }
308+ i += 1 ;
309+ }
310+ return labels ;
311+ }
312+
313+ /**
314+ * Scan a Markdown image token starting at `start` (which points at `!`).
315+ * Returns the index just past the closing `)` on success, or -1 when the
316+ * token is malformed.
317+ */
318+ function scanMarkdownImage ( text , start ) {
319+ // Alt text: `![` ... `]` with balanced nested brackets.
320+ let i = start + 2 ;
321+ let bracketDepth = 0 ;
322+ for ( ; i < text . length ; i += 1 ) {
323+ const ch = text [ i ] ;
324+ if ( ch === "\\" ) {
325+ i += 1 ; // skip escaped character
326+ continue ;
327+ }
328+ if ( ch === "[" ) {
329+ bracketDepth += 1 ;
330+ } else if ( ch === "]" ) {
331+ if ( bracketDepth === 0 ) break ;
332+ bracketDepth -= 1 ;
333+ }
334+ }
335+ if ( i >= text . length || text [ i ] !== "]" ) return - 1 ;
336+
337+ // Destination: `(` ... `)` with balanced parentheses. An optional
338+ // whitespace-separated `"title"` may follow the destination.
339+ if ( text [ i + 1 ] !== "(" ) return - 1 ;
340+ i += 2 ;
341+ let parenDepth = 1 ;
342+ let inQuotes = false ;
343+ for ( ; i < text . length ; i += 1 ) {
344+ const ch = text [ i ] ;
345+ if ( ch === "\\" ) {
346+ i += 1 ; // skip escaped character
347+ continue ;
348+ }
349+ if ( ch === '"' ) {
350+ inQuotes = ! inQuotes ;
351+ continue ;
352+ }
353+ if ( inQuotes ) continue ;
354+ if ( ch === "(" ) {
355+ parenDepth += 1 ;
356+ } else if ( ch === ")" ) {
357+ parenDepth -= 1 ;
358+ if ( parenDepth === 0 ) return i + 1 ;
359+ }
360+ }
361+ return - 1 ;
362+ }
363+
364+ /**
365+ * True when a section contains no substantive text after removing media
366+ * tokens and whitespace. Used to decide whether a media-only section should
367+ * count as empty for quality validation.
368+ */
369+ function isMediaOnly ( text ) {
370+ if ( typeof text !== "string" ) return false ;
371+ const stripped = stripMediaTokens ( text ) ;
372+ return stripped . replace ( / \s + / g, "" ) . length === 0 ;
373+ }
374+
54375/**
55376 * Strip HTML comments, placeholder-only values, and trim whitespace.
56377 */
57378function clean ( raw ) {
58379 if ( typeof raw !== "string" ) return "" ;
59380 let s = raw . replace ( / < ! - - [ \s \S ] * ?- - > / g, "" ) ;
381+ // Media-only sections (a lone screenshot or embed) carry no reportable
382+ // text. Strip the media tokens so the section participates in emptiness and
383+ // duplicate detection like any other blank section. This closes the
384+ // image-only-section bypass (see #1098: an `<img>`-only goal hid repeated
385+ // prose in the other sections from duplicate detection).
386+ if ( isMediaOnly ( s ) ) {
387+ s = stripMediaTokens ( s ) . replace ( / \s + / g, " " ) . trim ( ) ;
388+ }
60389 // Whole-value placeholders first (including a single enclosing fence), so
61390 // line-by-line stripping cannot leave bare fence markers behind.
62391 if ( isPlaceholderOnlyValue ( s ) ) return "" ;
@@ -1290,6 +1619,8 @@ module.exports = {
12901619 clean,
12911620 normalise,
12921621 canonicalise,
1622+ stripMediaTokens,
1623+ isMediaOnly,
12931624 extractSection,
12941625 resolveSection,
12951626 detectIssueKind,
0 commit comments