diff --git a/docs/src/components/docs-search/search-logic.test.ts b/docs/src/components/docs-search/search-logic.test.ts index a33a96be..bbfd8907 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 2961bdad..4e3013c2 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 c804d374..bb995e22 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 ` { + 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(error.message.match(/^error: /gm)).toHaveLength(2); }); + it("finds a \n`, + ); + + const error = await collisionOf(); + expect(error.message).toContain( + "src/components/site-nav.html:5:3 #x_btn in ", + ); + }); + it("reads CRLF line endings as one line each", async () => { write("src/components/one_two.html", '

a

'); write( diff --git a/pkg/src/lib/generated-id-classes.ts b/pkg/src/lib/generated-id-classes.ts index 8abb33c7..233082aa 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 ` { + 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 `

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 c804d374..bb995e22 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 5f93fa59..ea0fa2d2 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 4ca4df90..3951f8d9 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