diff --git a/bun.lock b/bun.lock index d04c3aca454..76e4334a788 100644 --- a/bun.lock +++ b/bun.lock @@ -209,6 +209,7 @@ "acorn": "^8.17.0", "esbuild": "^0.25.12", "eslint-scope": "^9.1.2", + "htmlparser2": "^10.1.0", "postcss": "^8.5.8", "puppeteer-core": "^25.8.0", }, diff --git a/packages/inspector/package.json b/packages/inspector/package.json index 302c815ae2c..510a5d8bbc9 100644 --- a/packages/inspector/package.json +++ b/packages/inspector/package.json @@ -71,6 +71,7 @@ "acorn": "^8.17.0", "esbuild": "^0.25.12", "eslint-scope": "^9.1.2", + "htmlparser2": "^10.1.0", "postcss": "^8.5.8", "puppeteer-core": "^25.8.0" }, diff --git a/packages/inspector/src/mapInspection.ts b/packages/inspector/src/mapInspection.ts index b57cb7128f8..4f517e64549 100644 --- a/packages/inspector/src/mapInspection.ts +++ b/packages/inspector/src/mapInspection.ts @@ -1,4 +1,4 @@ -import { Sources, markupSnippet, mergeSnippets } from "./sources"; +import { Sources, mergeSnippets } from "./sources"; import type { InspectRequest, RuntimeResponse, Snippet } from "./types"; const KEPT_FIRST_UNDER_BUDGET = [ "animation creation", @@ -41,6 +41,7 @@ export function mapInspection( const { sources, map, catalog, generation, sourceVersion, reused, start, visualState } = context; const maxBytes = request.maxBytes ?? 12000; const collected: Snippet[] = []; + const compositions: Snippet[] = []; const queriedRefs = new Set(raw.results.flatMap((result) => result.elements.map((el) => el.ref))); const results = raw.results.map((result, index) => { let unmappedAnimations = 0; @@ -54,8 +55,11 @@ export function mapInspection( ); } for (const el of result.elements) { - const snippet = markupSnippet(sources, el, index); + const tag = sources.markupTag(el); + if (!tag) continue; + const snippet = sources.slice(tag.file, tag.start, tag.end, "html opening tag", index); if (snippet) collected.push(snippet); + compositions.push(...sources.compositions(tag, index)); } for (const id of result.css) { const rule = sources.cssRules.find((rule) => rule.id === id); @@ -92,7 +96,7 @@ export function mapInspection( scopeTruncated: result.truncated, }; }); - const merged = mergeSnippets(collected, sources); + const merged = withCompositionsLast(mergeSnippets(collected, sources), compositions, sources); for (const snippet of merged) catalog.set(snippet.id, snippet); let selected = [...merged].sort((a, b) => priority(a) - priority(b)); if (request.snippets) @@ -101,6 +105,7 @@ export function mapInspection( if (!snippet) throw new Error(`unknown_snippet: ${id}`); return snippet; }); + const candidateContext: { coverage?: string[] } = {}; const response = { generation: generation, visualState, @@ -111,6 +116,7 @@ export function mapInspection( omitted: [] as { id: string; bytes: number }[], omittedCount: 0, nextOffset: null as number | null, + ...candidateContext, recordLimitReached: raw.recordLimitReached, hooked: raw.hooked, debugAvailable: true, @@ -135,8 +141,19 @@ export function mapInspection( const kept: Snippet[] = []; for (let i = offset; i < selected.length; i++) { response.snippets.push(shown(selected[i], targetCount)); + const priorCoverage = response.coverage; + if ( + response.coverage === undefined && + selected[i].kind.some( + (kind) => kind.startsWith("composition ") && kind.endsWith(" candidate"), + ) + ) + response.coverage = [ + "Composition candidates are alternative source mounts and their enclosing roots, not a verified runtime instance chain; all alternatives remain available within the snippet budget and pagination.", + ]; if (size() > maxBytes - 700) { response.snippets.pop(); + response.coverage = priorCoverage; response.omittedCount = selected.length - i; response.omitted = selected .slice(i, i + 8) @@ -155,3 +172,14 @@ export function mapInspection( if (request.debug) return { ...response, debug: raw }; return response; } +function withCompositionsLast(merged: Snippet[], compositions: Snippet[], sources: Sources) { + const covered = (s: Snippet) => + merged.some((m) => m.file === s.file && m.startLine <= s.startLine && m.endLine >= s.endLine); + return [ + ...merged, + ...mergeSnippets( + compositions.filter((s) => !covered(s)), + sources, + ), + ]; +} diff --git a/packages/inspector/src/sources.ts b/packages/inspector/src/sources.ts index 36d45505d72..74f3526a417 100644 --- a/packages/inspector/src/sources.ts +++ b/packages/inspector/src/sources.ts @@ -1,7 +1,8 @@ import { closeSync, fstatSync, openSync, readFileSync, readdirSync, realpathSync } from "node:fs"; -import { extname, join, relative, resolve } from "node:path"; +import { extname, join, posix, relative, resolve } from "node:path"; import { createHash } from "node:crypto"; import { parse, type Node } from "acorn"; +import { Parser } from "htmlparser2"; import postcss from "postcss"; import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping"; import type { BundleScriptProvenance } from "../../core/src/compiler/htmlBundler"; @@ -162,8 +163,29 @@ export interface HtmlTag { hfId?: string; classes: string[]; tag: string; + compositionId?: string; + compositionSrc?: string; + inMarkup: boolean; + parent?: number; +} +function nesting(text: string) { + const parents = new Map(); + const open: number[] = []; + const parser = new Parser({ + onopentag() { + parents.set(parser.startIndex, open.at(-1)); + open.push(parser.startIndex); + }, + onclosetag() { + open.pop(); + }, + }); + parser.write(text); + parser.end(); + return parents; } function htmlTagsOf(file: string, text: string): HtmlTag[] { + const parents = nesting(text); return [...text.matchAll(/<([a-z][\w-]*)\b[^>]*>/gi)].map((m) => { const attr = (name: string) => { const quoted = new RegExp(`\\s${name}\\s*=\\s*["']([^"']*)["']`, "i").exec(m[0]); @@ -178,6 +200,10 @@ function htmlTagsOf(file: string, text: string): HtmlTag[] { id: attr("id"), hfId: attr("data-hf-id"), classes: (attr("class") ?? "").split(/\s+/).filter(Boolean), + compositionId: attr("data-composition-id"), + compositionSrc: attr("data-composition-src"), + inMarkup: parents.has(m.index), + parent: parents.get(m.index), }; }); } @@ -292,6 +318,10 @@ export class Sources { }; } markup(element: ElementRef, target: number) { + const tag = this.markupTag(element); + return tag ? this.slice(tag.file, tag.start, tag.end, "html opening tag", target) : null; + } + markupTag(element: ElementRef): HtmlTag | null { const sameTag = this.htmlTags.filter((tag) => tag.tag === element.tag); const byHfId = element.hfId ? sameTag.filter((tag) => tag.hfId === element.hfId) : []; const tags = byHfId.length @@ -301,9 +331,67 @@ export class Sources { ? tag.id === element.id : element.classes.length > 0 && element.classes.every((c) => tag.classes.includes(c)), ); - return tags.length === 1 - ? this.slice(tags[0].file, tags[0].start, tags[0].end, "html opening tag", target) - : null; + return tags.length === 1 ? tags[0] : null; + } + private nestingIndex?: { tagAt: Map; hostsOf: Map }; + private get nested() { + if (this.nestingIndex) return this.nestingIndex; + const tagAt = new Map(this.htmlTags.map((t) => [`${t.file}:${t.start}`, t])); + const hostsOf = new Map(); + for (const host of this.htmlTags.filter((t) => t.inMarkup && t.compositionSrc)) { + const file = this.mountedFile(host); + if (file) hostsOf.set(file, [...(hostsOf.get(file) ?? []), host]); + } + return (this.nestingIndex = { tagAt, hostsOf }); + } + private fileByPath?: Map; + private mountedFile(host: HtmlTag) { + const files = (this.fileByPath ??= new Map( + [...this.files.keys()].map((file) => [file.replace(/\\/g, "/"), file]), + )); + const src = (host.compositionSrc ?? "").split(/[?#]/)[0].replace(/\\/g, "/"); + const path = [ + posix.normalize(src.replace(/^\.?\//, "")), + posix.join(posix.dirname(host.file.replace(/\\/g, "/")), src), + ].find((file) => files.has(file)); + return path === undefined ? undefined : files.get(path); + } + compositions(tag: HtmlTag, target: number): Snippet[] { + const { tagAt, hostsOf } = this.nested; + const shown = new Set([tag]); + const result: Snippet[] = []; + const show = (t: HtmlTag, kind: string) => { + if (shown.has(t)) return false; + shown.add(t); + const snippet = this.slice(t.file, t.start, t.end, kind, target); + if (snippet) result.push(snippet); + return true; + }; + const mounted = tag.compositionSrc && this.mountedFile(tag); + const root = + mounted && + this.htmlTags.find( + (t) => + t.file === mounted && + t.inMarkup && + t.compositionId && + (!tag.compositionId || t.compositionId === tag.compositionId), + ); + if (root) show(root, "composition root"); + const parentOf = (t: HtmlTag) => + t.parent === undefined ? undefined : tagAt.get(`${t.file}:${t.parent}`); + const climb = [{ tag, candidate: false }]; + for (let from = climb.shift(); from; from = climb.shift()) { + for (let at = parentOf(from.tag); at; at = parentOf(at)) + if (at.compositionId) + show(at, from.candidate ? "composition root candidate" : "composition root"); + const hosts = hostsOf.get(from.tag.file) ?? []; + const candidate = from.candidate || hosts.length > 1; + for (const host of hosts) + if (show(host, candidate ? "composition host candidate" : "composition host")) + climb.push({ tag: host, candidate }); + } + return result; } mapper(html: string, provenance: BundleScriptProvenance[]) { const htmlLines = starts(html); @@ -400,9 +488,6 @@ export class Sources { } } -export function markupSnippet(sources: Sources, element: ElementRef, target: number) { - return sources.markup(element, target); -} export function mergeSnippets(snippets: Snippet[], sources: Sources): Snippet[] { const sorted = snippets.sort((a, b) => a.file.localeCompare(b.file) || a.startLine - b.startLine); const output: Snippet[] = []; diff --git a/packages/inspector/tests/mapCompositions.test.ts b/packages/inspector/tests/mapCompositions.test.ts new file mode 100644 index 00000000000..e386785300e --- /dev/null +++ b/packages/inspector/tests/mapCompositions.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect } from "vitest"; +import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { mapInspection } from "../src/mapInspection"; +import { Sources } from "../src/sources"; +import type { ElementRef, RuntimeResponse, Snippet } from "../src/types"; + +describe("components report compositions", () => { + const files = { + "index.html": [ + ``, + `
`, + `
`, + `
`, + ``, + ], + "films/a/index.html": [ + ``, + `
`, + ``, + `
`, + `
`, + ``, + ], + "films/a/scene.html": [ + ``, + ], + }; + const ref = (id: string, tag: string): ElementRef => ({ + ref: `hfi:g:${id}`, + tag, + id, + hfId: null, + classes: [], + composition: null, + }); + + function inspectIds( + ids: [string, string][], + request: Partial[1]>, + prepare: (sources: Sources) => void = () => {}, + projectFiles: Record = files, + ) { + const dir = mkdtempSync(join(tmpdir(), "inspect-compositions-")); + mkdirSync(join(dir, "films/a"), { recursive: true }); + for (const [file, lines] of Object.entries(projectFiles)) + writeFileSync(join(dir, file), lines.join("\n")); + try { + const sources = new Sources(dir); + prepare(sources); + const raw: RuntimeResponse = { + results: ids.map(([id, tag], index) => ({ + target: request.targets?.[index] ?? { selector: `#${id}` }, + status: "found", + candidates: [ref(id, tag)], + animations: [], + elements: [ref(id, tag)], + css: sources.cssRules.filter((rule) => rule.selector === `#${id}`).map((rule) => rule.id), + truncated: false, + })), + recorded: 0, + recordLimitReached: false, + hooked: true, + }; + return mapInspection( + raw, + { targets: ids.map(([id]) => ({ selector: `#${id}` })), ...request }, + { + sources, + map: () => [], + catalog: new Map(), + generation: "g", + sourceVersion: "v", + reused: false, + start: performance.now(), + }, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + const shown = (report: ReturnType) => + report.snippets.map((s) => [s.id, s.kind.join(",")]).sort(); + + it("shows every composition and host that times an element inside a sub-composition", () => { + const report = inspectIds([["word", "span"]], { maxBytes: 256000 }); + expect(shown(report)).toEqual( + [ + ["films/a/index.html:2-2", "matched CSS candidate"], + ["films/a/scene.html:3-3", "html opening tag"], + ["films/a/index.html:4-4", "composition root"], + ["films/a/index.html:6-7", "composition host"], + ["films/a/scene.html:2-2", "composition root"], + ["index.html:2-4", "composition root,composition host"], + ].sort(), + ); + }); + + it("shows the root a targeted host mounts and the compositions around the host", () => { + const report = inspectIds([["sec", "div"]], { maxBytes: 256000 }); + expect(shown(report)).toEqual( + [ + ["films/a/index.html:6-7", "html opening tag"], + ["films/a/index.html:4-4", "composition root"], + ["films/a/scene.html:2-2", "composition root"], + ["index.html:2-4", "composition root,composition host"], + ].sort(), + ); + }); + + it.each(["scene", "missing", undefined])("selects the mounted root for host ID %s", (id) => { + const report = inspectIds( + [["sec", "div"]], + { maxBytes: 256000 }, + (sources) => { + const host = sources.htmlTags.find((tag) => tag.id === "sec"); + if (!host) throw new Error("missing fixture host"); + host.compositionId = id; + }, + { + ...files, + "films/a/scene.html": [ + '
', + '
', + ], + }, + ); + const roots = report.snippets.filter((snippet) => snippet.id.startsWith("films/a/scene.html:")); + expect(roots.map((snippet) => Number(snippet.id.split(":").at(-1)?.split("-")[0]))).toEqual( + id === "scene" ? [2] : id === "missing" ? [] : [1], + ); + }); + + it("resolves root-relative and host-relative mounts with Windows source keys", () => { + const report = inspectIds([["word", "span"]], { maxBytes: 256000 }, (sources) => { + for (const [file, source] of [...sources.files]) { + sources.files.delete(file); + sources.files.set(file.replaceAll("/", "\\"), source); + } + for (const tag of sources.htmlTags) { + tag.file = tag.file.replaceAll("/", "\\"); + if (tag.id === "sec") tag.compositionSrc = "scene.html"; + } + for (const rule of sources.cssRules) rule.file = rule.file.replaceAll("/", "\\"); + }); + expect( + report.snippets.some( + (snippet) => snippet.id.startsWith("index.html:") && snippet.code.includes('id="film"'), + ), + ).toBe(true); + expect( + report.snippets.some( + (snippet) => + snippet.id.startsWith("films\\a\\index.html:") && snippet.code.includes('id="sec"'), + ), + ).toBe(true); + }); + + it.each([{ selector: "#word", within: "#left" }, { ref: "hfi:g:word" }])( + "retains repeated mounts as source candidates for scoped target %j", + (target) => { + const report = inspectIds( + [["word", "span"]], + { targets: [target], maxBytes: 256000 }, + () => {}, + { + "index.html": [ + '
', + '
', + "", + '', + "
", + ], + "films/a/scene.html": files["films/a/scene.html"], + }, + ); + const candidates = report.snippets.filter((snippet) => + snippet.kind.includes("composition host candidate"), + ); + expect(candidates).toHaveLength(2); + expect(candidates.map((snippet) => snippet.code)).toEqual([ + expect.stringContaining('id="left"'), + expect.stringContaining('id="right"'), + ]); + expect(report.results.map((result) => result.target)).toEqual([0]); + expect(report.snippets.some((snippet) => snippet.kind.includes("composition host"))).toBe( + false, + ); + expect(report.coverage).toContain( + "Composition candidates are alternative source mounts and their enclosing roots, not a verified runtime instance chain; all alternatives remain available within the snippet budget and pagination.", + ); + }, + ); + + it("keeps primary snippets when candidate context does not fit", () => { + const report = inspectIds([["word", "span"]], { maxBytes: 2048 }, () => {}, { + "index.html": [ + '
', + "", + '', + ], + "films/a/scene.html": [`Hi`], + }); + expect(report.snippets.map((snippet) => snippet.kind)).toEqual([["html opening tag"]]); + expect(report.omittedCount).toBe(2); + expect( + report.coverage?.some((line) => line.startsWith("Composition candidates")) ?? false, + ).toBe(false); + }); + + it("leaves out a composition tag another snippet already shows", () => { + const report = inspectIds( + [ + ["word", "span"], + ["film", "div"], + ], + { maxBytes: 256000 }, + ); + const lines = report.snippets.map((s) => s.id.split("-")[0]); + expect(new Set(lines).size).toBe(lines.length); + expect(report.snippets.find((s) => s.id === "index.html:3-4")?.kind).toEqual([ + "html opening tag", + ]); + expect(report.snippets.find((s) => s.id === "index.html:2-2")?.kind).toEqual([ + "composition root", + ]); + }); + + it("omits composition tags before the snippets about the element itself", () => { + const report = inspectIds([["word", "span"]], { maxBytes: 1800 }); + expect(report.snippets.map((s) => s.kind[0])).toEqual([ + "matched CSS candidate", + "html opening tag", + ]); + expect(report.omitted.map((s) => s.id)).toEqual([ + "films/a/index.html:4-4", + "films/a/index.html:6-7", + "films/a/scene.html:2-2", + "index.html:2-4", + ]); + expect(report.nextOffset).toBe(2); + }); +});