fix(studio-server): HTML takes a folder-relative path only where its file exists, as the runtime reads it

This commit is contained in:
Miguel Angel Simon Sierra
2026-10-03 15:45:58 -07:00
parent 3b169043cc
commit fb74fefe90
2 changed files with 76 additions and 19 deletions
@@ -338,12 +338,61 @@ describe("renaming a folder over the route", () => {
join(project, "a", "style.css"),
'url(b/../logo.png) url(b/sub/../../logo.png) url(b\\.\\..\\logo.png) url("b/../in/logo.png") url("b/sub dir/../../logo.png") url("b/s,(;)/../../logo.png") url(b/image.png)',
);
writeFileSync(join(project, "index.html"), '<img src="a/b/../logo.png">');
expect((await renameIn(project, "a/b", "b/c")).status).toBe(200);
expect(readFileSync(join(project, "a", "style.css"), "utf8")).toBe(
'url(b/../logo.png) url(b/sub/../../logo.png) url(b\\.\\..\\logo.png) url("b/../in/logo.png") url("b/sub dir/../../logo.png") url("b/s,(;)/../../logo.png") url(../b/c/image.png)',
);
expect(readFileSync(join(project, "index.html"), "utf8")).toBe('<img src="a/b/../logo.png">');
});
it("in HTML takes the folder reading only where its file exists, else leaves the root path to the root pass", async () => {
const project = mkdtempSync(join(tmpdir(), "hf-rename-refs-"));
dirs.push(project);
mkdirSync(join(project, "assets"));
mkdirSync(join(project, "compositions", "assets"), { recursive: true });
writeFileSync(join(project, "assets", "logo.png"), "x");
writeFileSync(join(project, "compositions", "assets", "bg.png"), "x");
writeFileSync(
join(project, "compositions", "hero.html"),
'<img src="assets/logo.png"><img src="assets/bg.png">',
);
expect((await renameIn(project, "compositions/assets", "compositions/media")).status).toBe(200);
expect(readFileSync(join(project, "compositions", "hero.html"), "utf8")).toBe(
'<img src="assets/logo.png"><img src="media/bg.png">',
);
});
it("in HTML gives a path the runtime reads from the root to the root pass, where both readings match", async () => {
const project = mkdtempSync(join(tmpdir(), "hf-rename-refs-"));
dirs.push(project);
mkdirSync(join(project, "scenes", "scenes"), { recursive: true });
writeFileSync(join(project, "scenes", "scenes", "clip.mp4"), "x");
writeFileSync(join(project, "scenes", "intro.html"), '<video src="scenes/scenes/clip.mp4">');
expect((await renameIn(project, "scenes/scenes", "scenes/shots")).status).toBe(200);
expect(readFileSync(join(project, "scenes", "intro.html"), "utf8")).toBe(
'<video src="scenes/shots/clip.mp4">',
);
});
it("edits each reference once when the new path ends in the old one, and keeps a longer folder's path", async () => {
const project = mkdtempSync(join(tmpdir(), "hf-rename-refs-"));
dirs.push(project);
mkdirSync(join(project, "a", "b"), { recursive: true });
mkdirSync(join(project, "assets", "clips"), { recursive: true });
mkdirSync(join(project, "assets", "old clips"), { recursive: true });
writeFileSync(join(project, "a", "b", "x.png"), "x");
writeFileSync(join(project, "assets", "clips", "a.png"), "x");
writeFileSync(join(project, "assets", "old clips", "a.png"), "x");
writeFileSync(join(project, "a", "s.css"), "url(b/x.png)");
writeFileSync(join(project, "assets", "s.css"), 'url("old clips/a.png") url(clips/a.png)');
expect((await renameIn(project, "a/b", "a/a/b")).status).toBe(200);
expect(readFileSync(join(project, "a", "s.css"), "utf8")).toBe("url(a/b/x.png)");
expect((await renameIn(project, "assets/clips", "assets/takes")).status).toBe(200);
expect(readFileSync(join(project, "assets", "s.css"), "utf8")).toBe(
'url("old clips/a.png") url(takes/a.png)',
);
});
});
+25 -17
View File
@@ -703,9 +703,9 @@ function referenceEdits(
newPath: string,
isDirectory: boolean,
existing: readonly string[],
bareOrDotLead = false,
beside: { targets: ReadonlySet<string> | null } | null = null,
): (text: string) => Edit[] {
const pattern = referencePattern(oldPath, isDirectory, bareOrDotLead);
const pattern = referencePattern(oldPath, isDirectory, beside !== null);
const around = new Map<string, Map<number, Set<string>>>();
const afterLengths = new Set<number>();
let window = 0;
@@ -738,9 +738,13 @@ function referenceEdits(
);
});
const end = offset + match.length;
const rest = /^[^"'`<>\r\n]*/.exec(text.slice(end, end + MAX_REST))![0];
const climbsOut = isDirectory && leavesFolder(normalized(rest));
return inLonger || climbsOut ? [] : [{ at, end, text: newPath }];
if (inLonger) return [];
if (beside) {
const rest = normalized(/^[^"'`<>\r\n]*/.exec(text.slice(end, end + MAX_REST))![0]);
if (isDirectory && leavesFolder(rest)) return [];
if (beside.targets && !reaches(beside.targets, `${newPath}${rest}`)) return [];
}
return [{ at, end, text: newPath }];
});
}
@@ -754,6 +758,14 @@ export function referenceRewriter(
return (text) => applyEdits(text, edits(text));
}
// HTML reads a bare path beside itself only where that file exists, else from the project root (rewriteAssetPath).
function reaches(targets: ReadonlySet<string>, path: string): boolean {
for (let i = 0; i <= path.length; i++)
if ((i === path.length || /[\s?#),;]/.test(path[i]!)) && targets.has(path.slice(0, i)))
return true;
return false;
}
function leavesFolder(rest: string): boolean {
let depth = 0;
for (const segment of rest.split("/")) {
@@ -810,22 +822,17 @@ function updateReferences(
const existing = projectPaths(projectDir);
const rootEdits = referenceEdits(oldPath, newPath, isDirectory, existing);
const parentFolderEdits = new Map<string, (text: string) => Edit[]>();
const parentFolderEdits = new Map<string, Record<"css" | "other", (text: string) => Edit[]>>();
for (let at = oldPath.indexOf("/"); at > 0; at = oldPath.indexOf("/", at + 1)) {
const dir = oldPath.slice(0, at);
const inside = existing
.filter((path) => path.startsWith(`${dir}/`))
.map((path) => path.slice(at + 1));
parentFolderEdits.set(
dir,
referenceEdits(
oldPath.slice(at + 1),
posix.relative(dir, newPath),
isDirectory,
inside,
true,
),
);
const [from, to] = [oldPath.slice(at + 1), posix.relative(dir, newPath)];
parentFolderEdits.set(dir, {
css: referenceEdits(from, to, isDirectory, inside, { targets: null }),
other: referenceEdits(from, to, isDirectory, inside, { targets: new Set(inside) }),
});
}
let updatedCount = 0;
for (const file of textFiles) {
@@ -833,7 +840,8 @@ function updateReferences(
const content = readableText(file);
if (content === null) continue;
const beside = parentFolderEdits.get(relative(projectDir, dirname(file)).split(sep).join("/"));
const parent = parentFolderEdits.get(relative(projectDir, dirname(file)).split(sep).join("/"));
const beside = parent?.[/\.css$/i.test(file) ? "css" : "other"];
// A bare path in a folder's file is relative to that folder, so its edit wins where both start.
const updated = applyEdits(content, [...(beside?.(content) ?? []), ...rootEdits(content)]);
if (updated !== content) {