fix(web): keep multipage artifact links current

This commit is contained in:
116405 2026-05-30 17:00:03 +08:00
parent cfde84b038
commit 66cbb5b2c2
4 changed files with 253 additions and 12 deletions

View file

@ -0,0 +1,126 @@
export function resolveHtmlArtifactFileName(input: {
baseName: string;
ext: '.html' | '.jsx' | '.tsx';
existingFileNames: ReadonlySet<string>;
savedArtifactName?: string | null;
}): string {
if (input.ext === '.html' && input.baseName.toLowerCase() === 'index') {
return 'index.html';
}
let fileName = `${input.baseName}${input.ext}`;
let n = 2;
while (input.existingFileNames.has(fileName) && input.savedArtifactName !== fileName) {
fileName = `${input.baseName}-${n}${input.ext}`;
n += 1;
}
return fileName;
}
interface HtmlLinkProjectFile {
name: string;
path?: string;
kind?: string;
mime?: string;
mtime: number;
}
export function rewriteHtmlLinksToCurrentProjectFiles(
html: string,
projectFiles: readonly HtmlLinkProjectFile[],
): string {
const latestByTarget = buildLatestHtmlFileIndex(projectFiles);
if (latestByTarget.size === 0) return html;
return html.replace(
/\b(href)\s*=\s*(["'])([^"']+)\2/gi,
(match, attr: string, quote: string, rawValue: string) => {
const rewritten = rewriteHtmlLinkTarget(rawValue, latestByTarget);
return rewritten === rawValue ? match : `${attr}=${quote}${rewritten}${quote}`;
},
);
}
function buildLatestHtmlFileIndex(projectFiles: readonly HtmlLinkProjectFile[]): Map<string, string> {
const latest = new Map<string, HtmlLinkProjectFile>();
for (const file of projectFiles) {
if (!isHtmlProjectFile(file)) continue;
const key = htmlFileFamilyKey(file.name);
if (!key) continue;
const current = latest.get(key);
if (!current || file.mtime > current.mtime) latest.set(key, file);
}
return new Map(Array.from(latest, ([key, file]) => [key, file.name]));
}
function isHtmlProjectFile(file: HtmlLinkProjectFile): boolean {
const name = file.name.toLowerCase();
return (
(file.kind === undefined || file.kind === 'html') &&
(file.mime === undefined || file.mime === 'text/html') &&
(name.endsWith('.html') || name.endsWith('.htm'))
);
}
function rewriteHtmlLinkTarget(
value: string,
latestByTarget: ReadonlyMap<string, string>,
): string {
if (!value || value.startsWith('#') || isExternalOrOpaqueUrl(value)) return value;
const parsed = splitRelativeReference(value);
if (!isHtmlPath(parsed.pathname)) return value;
const key = htmlFileFamilyKey(parsed.pathname);
if (!key) return value;
const latest = latestByTarget.get(key);
if (!latest || latest === parsed.pathname) return value;
const rewrittenPath = preserveRelativePrefix(parsed.pathname, latest);
return `${rewrittenPath}${parsed.search}${parsed.hash}`;
}
function isExternalOrOpaqueUrl(value: string): boolean {
return /^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith('//');
}
function splitRelativeReference(value: string): {
pathname: string;
search: string;
hash: string;
} {
const hashIndex = value.indexOf('#');
const beforeHash = hashIndex >= 0 ? value.slice(0, hashIndex) : value;
const hash = hashIndex >= 0 ? value.slice(hashIndex) : '';
const queryIndex = beforeHash.indexOf('?');
return {
pathname: queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash,
search: queryIndex >= 0 ? beforeHash.slice(queryIndex) : '',
hash,
};
}
function isHtmlPath(pathname: string): boolean {
const lower = pathname.toLowerCase();
return lower.endsWith('.html') || lower.endsWith('.htm');
}
function htmlFileFamilyKey(pathname: string): string | null {
const slash = pathname.lastIndexOf('/');
const directory = slash >= 0 ? pathname.slice(0, slash + 1) : '';
const fileName = slash >= 0 ? pathname.slice(slash + 1) : pathname;
const dot = fileName.lastIndexOf('.');
if (dot <= 0) return null;
const stem = fileName.slice(0, dot);
const ext = fileName.slice(dot).toLowerCase();
if (stem.toLowerCase() === 'index') return null;
const familyStem = stem.replace(/-\d+$/, '');
return `${directory}${familyStem}${ext}`.replace(/^\.\//, '');
}
function preserveRelativePrefix(originalPathname: string, latestName: string): string {
if (originalPathname.startsWith('./') && !latestName.startsWith('./')) {
return `./${latestName}`;
}
return latestName;
}

View file

@ -11,6 +11,10 @@ import {
} from 'react';
import { createHtmlArtifactManifest, inferLegacyManifest } from '../artifacts/manifest';
import { resolveHtmlPointerArtifactTarget } from '../artifacts/pointer';
import {
resolveHtmlArtifactFileName,
rewriteHtmlLinksToCurrentProjectFiles,
} from '../artifacts/html-links';
import { validateHtmlArtifact } from '../artifacts/validate';
import { createArtifactParser } from '../artifacts/parser';
import { useI18n } from '../i18n';
@ -1135,20 +1139,23 @@ export function ProjectView({
async (art: Artifact, projectFilesSnapshot?: ProjectFile[]) => {
const baseName = artifactBaseNameFor(art);
const ext = artifactExtensionFor(art);
// Pick a name that doesn't collide with an existing project file.
// The first run uses `<base>.<ext>`; subsequent runs append `-2`, `-3`…
// so prior artifacts aren't silently overwritten.
// Pick a name that doesn't collide with an existing project file while
// keeping index.html stable for multi-page HTML artifacts.
const currentProjectFiles = projectFilesSnapshot ?? projectFilesRef.current;
const existing = new Set(currentProjectFiles.map((f) => f.name));
let fileName = `${baseName}${ext}`;
let n = 2;
while (existing.has(fileName) && savedArtifactRef.current !== fileName) {
fileName = `${baseName}-${n}${ext}`;
n += 1;
}
const fileName = resolveHtmlArtifactFileName({
baseName,
ext,
existingFileNames: existing,
savedArtifactName: savedArtifactRef.current,
});
const html =
ext === '.html'
? rewriteHtmlLinksToCurrentProjectFiles(art.html, currentProjectFiles)
: art.html;
if (ext === '.html') {
const pointerTarget = resolveHtmlPointerArtifactTarget({
content: art.html,
content: html,
candidateFileName: fileName,
projectFiles: currentProjectFiles,
});
@ -1165,7 +1172,7 @@ export function ProjectView({
// when only Edit-tool changes happened this turn. Without this guard,
// such content lands as a phantom HTML file in the project panel.
if (ext === '.html') {
const validation = validateHtmlArtifact(art.html);
const validation = validateHtmlArtifact(html);
if (!validation.ok) {
setError(`Refused to save artifact "${art.identifier || art.title || 'untitled'}": ${validation.reason}`);
return;
@ -1197,7 +1204,7 @@ export function ProjectView({
designSystemId: project.designSystemId,
},
});
const file = await writeProjectTextFile(project.id, fileName, art.html, {
const file = await writeProjectTextFile(project.id, fileName, html, {
artifactManifest: manifest ?? undefined,
});
if (file) {

View file

@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import {
resolveHtmlArtifactFileName,
rewriteHtmlLinksToCurrentProjectFiles,
} from '../../src/artifacts/html-links';
describe('resolveHtmlArtifactFileName', () => {
it('keeps index.html as the stable entry point when it already exists', () => {
expect(
resolveHtmlArtifactFileName({
baseName: 'index',
ext: '.html',
existingFileNames: new Set(['index.html']),
}),
).toBe('index.html');
});
it('keeps numbered collision names for non-entry html artifacts', () => {
expect(
resolveHtmlArtifactFileName({
baseName: 'about',
ext: '.html',
existingFileNames: new Set(['about.html', 'about-2.html']),
}),
).toBe('about-3.html');
});
});
describe('rewriteHtmlLinksToCurrentProjectFiles', () => {
it('rewrites relative html links to the newest matching project file', () => {
const html =
'<!doctype html><html><body>' +
'<a href="about.html">About</a>' +
'<a href="contact.html?tab=team#lead">Contact</a>' +
'<a href="#local">Local</a>' +
'<a href="https://example.com/about.html">External</a>' +
'</body></html>';
const out = rewriteHtmlLinksToCurrentProjectFiles(html, [
htmlFile('about.html', 10),
htmlFile('about-2.html', 30),
htmlFile('contact.html', 20),
htmlFile('contact-2.html', 40),
]);
expect(out).toContain('href="about-2.html"');
expect(out).toContain('href="contact-2.html?tab=team#lead"');
expect(out).toContain('href="#local"');
expect(out).toContain('href="https://example.com/about.html"');
});
});
function htmlFile(name: string, mtime: number) {
return {
name,
kind: 'html',
mime: 'text/html',
size: 1,
mtime,
};
}

View file

@ -558,6 +558,41 @@ describe('ProjectView API empty response handling', () => {
expect(screen.queryByText(/Refused to save artifact/i)).toBeNull();
});
it('keeps regenerated multipage artifact entry at index.html and rewrites child links', async () => {
mockedFetchProjectFiles.mockResolvedValue([
htmlProjectFile('index.html', 10),
htmlProjectFile('about.html', 20),
htmlProjectFile('about-2.html', 40),
] as never);
mockedWriteProjectTextFile.mockResolvedValue(htmlProjectFile('index.html', 50) as never);
const artifact =
'<artifact identifier="index" type="text/html" title="Multipage Site">' +
'<!doctype html><html><head><title>Home</title></head><body>' +
'<main><h1>Home</h1><a href="about.html">About</a></main>' +
'</body></html>' +
'</artifact>';
mockedStreamMessage.mockImplementation(async (
_cfg: AppConfig,
_system: string,
_history: ChatMessage[],
_signal: AbortSignal,
handlers: StreamHandlers,
) => {
handlers.onDelta(artifact);
handlers.onDone('');
});
renderProjectView();
await sendTestPrompt();
await waitFor(() => {
expect(mockedWriteProjectTextFile).toHaveBeenCalled();
});
const [, fileName, content] = mockedWriteProjectTextFile.mock.calls.at(-1) ?? [];
expect(fileName).toBe('index.html');
expect(content).toContain('href="about-2.html"');
});
it('injects ElevenLabs voice options into API-mode audio project prompts', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
@ -693,3 +728,14 @@ function hasSavedAssistantMessage(predicate: (message: ChatMessage) => boolean):
return message.role === 'assistant' && predicate(message);
});
}
function htmlProjectFile(name: string, mtime: number) {
return {
name,
path: name,
kind: 'html',
mime: 'text/html',
size: 1,
mtime,
};
}