From c1713a28bf2c36ea24b119369caf1115d68447e0 Mon Sep 17 00:00:00 2001 From: collin-thomas Date: Thu, 12 Nov 2026 08:47:05 -0700 Subject: [PATCH] Find end tags in place instead of in a lowercased copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five places lowercased a whole page or text, searched the copy, and used the offsets they found on the original. Unicode lowercasing can lengthen a string ("İ" becomes two characters), so after such a character every offset pointed past the text it named: - preserved-targets (compiler and language server): the end of a , <style>, <script> or <textarea> landed past the next tag, so its classes and ids were left out of the preserved/scoped split. - generated-id-classes: an #id collision in a <style> was reported at the wrong line and column. - docs search: the result snippet could slide past the match. - the skills' link checker and SEO audit: the end of a masked element landed past the next tag, which skipped its links, headings and images. Tag names are now compared in place, in ASCII case, as an HTML parser compares them. The docs search keeps Unicode-aware matching for what a reader types and maps the match back to the original text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../docs-search/search-logic.test.ts | 6 ++++ .../components/docs-search/search-logic.ts | 15 ++++++++ lsp/src/preserved-targets.ts | 25 ++++++++++++-- pkg/src/lib/generated-id-classes.test.ts | 18 ++++++++++ pkg/src/lib/generated-id-classes.ts | 34 ++++++++++++++++--- pkg/src/lib/preserved-targets.test.ts | 21 ++++++++++++ pkg/src/lib/preserved-targets.ts | 25 ++++++++++++-- skills-tests/tests/skill-scripts.test.ts | 20 +++++++++++ skills/bascik-seo/scripts/audit-seo.mjs | 9 ++--- skills/bascik/scripts/check-links.mjs | 9 ++--- 10 files changed, 166 insertions(+), 16 deletions(-) diff --git a/docs/src/components/docs-search/search-logic.test.ts b/docs/src/components/docs-search/search-logic.test.ts index a33a96be0..bbfd89070 100644 --- a/docs/src/components/docs-search/search-logic.test.ts +++ b/docs/src/components/docs-search/search-logic.test.ts @@ -314,6 +314,12 @@ describe("snippet", () => { expect(result.endsWith("\u2026")).toBe(true); }); + it("centers on the match after text whose lowercase is longer, such as a dotted capital I", () => { + // "İ".toLowerCase() is two characters: an offset found in a lowercased copy lands past the match. + const text = "İ".repeat(100) + " match " + "b".repeat(40); + expect(snippet(text, "match", tokens("match"))).toContain("match"); + }); + it("keeps output to roughly 120 characters of text", () => { const text = "word ".repeat(100); const result = snippet(text, "word", tokens("word")); diff --git a/docs/src/components/docs-search/search-logic.ts b/docs/src/components/docs-search/search-logic.ts index 2961bdad8..4e3013c29 100644 --- a/docs/src/components/docs-search/search-logic.ts +++ b/docs/src/components/docs-search/search-logic.ts @@ -171,6 +171,20 @@ export function score(e: SearchEntry, q: string, toks: string[]): number { return 0; } +/** + * Where offset `idx` of `lower` (`text.toLowerCase()`) falls in `text`. Lowercasing can lengthen a string ("İ" + * becomes two characters), so past such a character the two offsets differ. + */ +function indexInOriginal(text: string, lower: string, idx: number): number { + if (idx <= 0 || lower.length === text.length) return idx; + var lowered = 0; + for (var i = 0; i < text.length; i++) { + if (lowered >= idx) return i; + lowered += text[i].toLowerCase().length; + } + return text.length; +} + /** Returns ~120 chars centered on the first query/token match in text. */ export function snippet( text: string | null | undefined, @@ -187,6 +201,7 @@ export function snippet( if (idx >= 0) break; } } + idx = indexInOriginal(text, lo, idx); var start = Math.max(0, idx > 40 ? idx - 40 : 0); return ( (start > 0 ? "\u2026" : "") + diff --git a/lsp/src/preserved-targets.ts b/lsp/src/preserved-targets.ts index c804d374c..bb995e22a 100644 --- a/lsp/src/preserved-targets.ts +++ b/lsp/src/preserved-targets.ts @@ -94,6 +94,28 @@ const directiveBits = (value: string | undefined): number => { }; /** The offset just past the `>` closing a start tag that begins at `from`, honoring quotes; -1 when unclosed. */ +/** + * The index of `</name` at or after `from`, in any ASCII case, or -1. `name` is lowercase letters. Tag names are + * compared in place, as an HTML parser compares them: a lowercased copy of the markup can be longer than it ("İ" + * becomes two characters), so an index found there points past the real end tag. + */ +const findEndTag = (markup: string, name: string, from: number): number => { + for ( + let at = markup.indexOf("</", from); + at !== -1; + at = markup.indexOf("</", at + 2) + ) { + let k = 0; + while ( + k < name.length && + (markup.charCodeAt(at + 2 + k) | 0x20) === name.charCodeAt(k) + ) + k++; + if (k === name.length) return at; + } + return -1; +}; + const startTagEnd = (markup: string, from: number): number => { let quote = ""; for (let i = from; i < markup.length; i++) { @@ -118,7 +140,6 @@ export const collectPreservedTargets = ( preserve: readonly string[], ): PreservedTargets => { const matchers = preserveMatchers(preserve); - const lower = markup.toLowerCase(); const stack: { name: string; bits: number }[] = []; const preserved = { classes: new Set<string>(), @@ -196,7 +217,7 @@ export const collectPreservedTargets = ( classTarget.tags.add(name); if (RAW_TEXT_ELEMENTS.has(name)) { - const rawEnd = lower.indexOf(`</${name}`, end); + const rawEnd = findEndTag(markup, name, end); i = rawEnd === -1 ? markup.length : rawEnd; continue; } diff --git a/pkg/src/lib/generated-id-classes.test.ts b/pkg/src/lib/generated-id-classes.test.ts index c3893813c..b2a2337b1 100644 --- a/pkg/src/lib/generated-id-classes.test.ts +++ b/pkg/src/lib/generated-id-classes.test.ts @@ -157,6 +157,24 @@ describe("generated #id class collisions", () => { expect(error.message.match(/^error: /gm)).toHaveLength(2); }); + it("finds a <style> rule after text whose lowercase is longer, such as a dotted capital I", async () => { + // "İ".toLowerCase() is two characters: an offset found in a lowercased copy of the markup lands past the rule. + write( + "src/components/site-nav_x.html", + '<nav><button id="btn">Menu</button></nav>\n', + ); + write("src/components/site-nav_x.css", "#btn { color: red; }\n"); + write( + "src/components/site-nav.html", + `<nav>\n <a id="x_btn">${"İ".repeat(40)}</a>\n</nav>\n<style>\n #x_btn { color: blue; }\n</style>\n`, + ); + + const error = await collisionOf(); + expect(error.message).toContain( + "src/components/site-nav.html:5:3 #x_btn in <site-nav>", + ); + }); + it("reads CRLF line endings as one line each", async () => { write("src/components/one_two.html", '<p id="three">a</p>'); write( diff --git a/pkg/src/lib/generated-id-classes.ts b/pkg/src/lib/generated-id-classes.ts index 8abb33c7a..233082aaf 100644 --- a/pkg/src/lib/generated-id-classes.ts +++ b/pkg/src/lib/generated-id-classes.ts @@ -133,16 +133,42 @@ const lineAndColumn = ( return { line, column: offset - lineStart + 1 }; }; +/** + * The index of `tag` (`<` and lowercase ASCII, such as `</style`) at or after `from` in `html`, in any ASCII case, + * or -1. Compared in place: a lowercased copy of `html` can be longer than it ("İ" becomes two characters), so an + * index found there points past the text it names. + */ +const indexOfTag = (html: string, tag: string, from: number): number => { + for ( + let at = html.indexOf("<", from); + at !== -1; + at = html.indexOf("<", at + 1) + ) { + let k = 1; + for (; k < tag.length; k++) { + const code = html.charCodeAt(at + k); + const wanted = tag.charCodeAt(k); + // A lowercase letter also matches its uppercase; anything else only itself. + if ( + code !== wanted && + !(wanted >= 97 && wanted <= 122 && code === wanted - 32) + ) + break; + } + if (k === tag.length) return at; + } + return -1; +}; + /** The offset of the first `#id` rule naming `idName` inside a `<style>` element of `html`, or -1. */ const styleElementOffset = (html: string, idName: string): number => { - const lower = html.toLowerCase(); let from = 0; for (;;) { - const open = lower.indexOf("<style", from); + const open = indexOfTag(html, "<style", from); if (open === -1) return -1; - const contentStart = lower.indexOf(">", open); + const contentStart = html.indexOf(">", open); if (contentStart === -1) return -1; - const close = lower.indexOf("</style", contentStart); + const close = indexOfTag(html, "</style", contentStart); const contentEnd = close === -1 ? html.length : close; const offset = cssIdSelectorOffset( html.slice(contentStart + 1, contentEnd), diff --git a/pkg/src/lib/preserved-targets.test.ts b/pkg/src/lib/preserved-targets.test.ts index 5e396ea6e..8e714afc0 100644 --- a/pkg/src/lib/preserved-targets.test.ts +++ b/pkg/src/lib/preserved-targets.test.ts @@ -271,6 +271,27 @@ document.querySelector(".inline");`; }); }); +describe("collectPreservedTargets: raw-text elements", () => { + it("finds the end of a raw-text element in any ASCII case", () => { + const targets = collectPreservedTargets( + '<TITLE>x

a

b

', + ["p"], + ); + expect([...targets.classes]).toEqual(["kept", "also"]); + }); + + it("reads past a raw-text element whose lowercase is longer, such as one holding a dotted capital I", () => { + // "İ".toLowerCase() is two characters: an end found in a lowercased copy of the markup lands past the next tag. + for (const element of ["title", "style", "textarea", "script"]) { + const targets = collectPreservedTargets( + `<${element}>${"İ".repeat(12)}

a

`, + ["p"], + ); + expect([...targets.classes], element).toEqual(["kept"]); + } + }); +}); + describe("preserved-targets copies", () => { it("keeps the language server's copy byte-identical to the compiler's", () => { const pkgCopy = readFileSync( diff --git a/pkg/src/lib/preserved-targets.ts b/pkg/src/lib/preserved-targets.ts index c804d374c..bb995e22a 100644 --- a/pkg/src/lib/preserved-targets.ts +++ b/pkg/src/lib/preserved-targets.ts @@ -94,6 +94,28 @@ const directiveBits = (value: string | undefined): number => { }; /** The offset just past the `>` closing a start tag that begins at `from`, honoring quotes; -1 when unclosed. */ +/** + * The index of ` { + for ( + let at = markup.indexOf(" { let quote = ""; for (let i = from; i < markup.length; i++) { @@ -118,7 +140,6 @@ export const collectPreservedTargets = ( preserve: readonly string[], ): PreservedTargets => { const matchers = preserveMatchers(preserve); - const lower = markup.toLowerCase(); const stack: { name: string; bits: number }[] = []; const preserved = { classes: new Set(), @@ -196,7 +217,7 @@ export const collectPreservedTargets = ( classTarget.tags.add(name); if (RAW_TEXT_ELEMENTS.has(name)) { - const rawEnd = lower.indexOf(` { expect(facts.imagesWithoutAlt).toBe(0); }); + it("reads past a script or style whose lowercase is longer, such as one holding a dotted capital I", () => { + // "İ".toLowerCase() is two characters: an end found in a lowercased copy of the page lands past the next tag. + const facts = inspectPage( + `

real

`, + ); + expect(facts.h1Count).toBe(1); + expect(facts.imagesWithoutAlt).toBe(1); + }); + it("does not form new HTML from either side of a script or style", () => { const facts = inspectPage( 'inert1>fakeb`, + ); + expect(links.map((link: { url: string }) => link.url)).toEqual([ + "/after-pre", + "/after-script", + ]); + }); + it("decodes entities once and ignores links in masked elements", () => { const links = extractLinks( '
x
real', diff --git a/skills/bascik-seo/scripts/audit-seo.mjs b/skills/bascik-seo/scripts/audit-seo.mjs index 5f93fa593..ea0fa2d22 100644 --- a/skills/bascik-seo/scripts/audit-seo.mjs +++ b/skills/bascik-seo/scripts/audit-seo.mjs @@ -88,7 +88,6 @@ export function inspectPage(html) { metas.find((m) => (m[key] ?? "").toLowerCase() === value)?.content; // Preserve boundaries between visible regions, rather than joining text on // either side of a removed element into a new tag on another pass. - const lower = html.toLowerCase(); const visible = []; const opening = /<(script|style)\b[^>]*>/gi; let from = 0; @@ -97,9 +96,11 @@ export function inspectPage(html) { if (match.index < from) continue; visible.push(html.slice(from, match.index)); const name = match[1].toLowerCase(); - let close = lower.indexOf(`= 0 && !/[\s/>]/.test(lower[close + name.length + 2] ?? "")) - close = lower.indexOf(`])`, "gi"); + endTag.lastIndex = opening.lastIndex; + const close = endTag.exec(html)?.index ?? -1; const end = close < 0 ? -1 : html.indexOf(">", close + name.length + 2); from = end < 0 ? html.length : end + 1; opening.lastIndex = from; diff --git a/skills/bascik/scripts/check-links.mjs b/skills/bascik/scripts/check-links.mjs index 4ca4df900..3951f8d9e 100644 --- a/skills/bascik/scripts/check-links.mjs +++ b/skills/bascik/scripts/check-links.mjs @@ -62,7 +62,6 @@ export function extractLinks(html) { }; // Scan disjoint visible regions. Removing markup and joining the leftovers can // turn ipt> into a new