Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/inspector/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
34 changes: 31 additions & 3 deletions packages/inspector/src/mapInspection.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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,
),
];
}
99 changes: 92 additions & 7 deletions packages/inspector/src/sources.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<number, number | undefined>();
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]);
Expand All @@ -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),
};
});
}
Expand Down Expand Up @@ -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
Expand All @@ -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<string, HtmlTag>; hostsOf: Map<string, HtmlTag[]> };
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<string, HtmlTag[]>();
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<string, string>;
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<HtmlTag>([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);
Expand Down Expand Up @@ -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[] = [];
Expand Down
Loading
Loading