fix(web): isolate multipage html link groups

This commit is contained in:
116405 2026-05-30 22:42:15 +08:00
parent 9d29ef35d3
commit c48aee0aa6
4 changed files with 126 additions and 26 deletions

View file

@ -37,6 +37,10 @@ interface HtmlLinkProjectFile {
};
}
interface HtmlLinkRewriteOptions {
artifactGroupIdentifier?: string;
}
export function canOverwriteHtmlArtifactEntry(input: {
baseName: string;
ext: '.html' | '.jsx' | '.tsx';
@ -62,8 +66,9 @@ export function canOverwriteHtmlArtifactEntry(input: {
export function rewriteHtmlLinksToCurrentProjectFiles(
html: string,
projectFiles: readonly HtmlLinkProjectFile[],
options: HtmlLinkRewriteOptions = {},
): string {
const latestByTarget = buildLatestHtmlFileIndex(projectFiles);
const latestByTarget = buildLatestHtmlFileIndex(projectFiles, options);
if (latestByTarget.size === 0) return html;
return html.replace(
@ -75,25 +80,42 @@ export function rewriteHtmlLinksToCurrentProjectFiles(
);
}
function buildLatestHtmlFileIndex(projectFiles: readonly HtmlLinkProjectFile[]): Map<string, string> {
function buildLatestHtmlFileIndex(
projectFiles: readonly HtmlLinkProjectFile[],
options: HtmlLinkRewriteOptions,
): Map<string, string> {
const latest = new Map<string, HtmlLinkProjectFile>();
const artifactGroupIdentifier = normalizeOptionalIdentifier(options.artifactGroupIdentifier);
for (const file of projectFiles) {
if (!isHtmlProjectFile(file)) continue;
const key = htmlFileFamilyKey(file.name);
if (!key) continue;
if (!htmlFileManifestMatchesFamily(file, key)) continue;
if (!htmlFileManifestMatchesFamily(file, key, artifactGroupIdentifier)) 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 htmlFileManifestMatchesFamily(file: HtmlLinkProjectFile, familyKey: string): boolean {
function htmlFileManifestMatchesFamily(
file: HtmlLinkProjectFile,
familyKey: string,
artifactGroupIdentifier: string | null,
): boolean {
const identifier = file.artifactManifest?.metadata?.identifier;
if (typeof identifier !== 'string') return false;
const normalizedIdentifier = normalizeArtifactIdentifier(identifier);
if (!normalizedIdentifier) return false;
return normalizedIdentifier === htmlFileFamilyIdentifier(familyKey);
if (normalizedIdentifier !== htmlFileFamilyIdentifier(familyKey)) return false;
const fileGroupIdentifier = normalizeOptionalIdentifier(
file.artifactManifest?.metadata?.artifactGroupIdentifier,
);
return (
artifactGroupIdentifier !== null &&
fileGroupIdentifier !== null &&
artifactGroupIdentifier === fileGroupIdentifier
);
}
function isHtmlProjectFile(file: HtmlLinkProjectFile): boolean {
@ -175,6 +197,12 @@ function normalizeArtifactIdentifier(value: string): string {
.replace(/^-+|-+$/g, '');
}
function normalizeOptionalIdentifier(value: unknown): string | null {
if (typeof value !== 'string') return null;
const normalized = normalizeArtifactIdentifier(value);
return normalized || null;
}
function preserveRelativePrefix(originalPathname: string, latestName: string): string {
if (originalPathname.startsWith('./') && !latestName.startsWith('./')) {
return `./${latestName}`;

View file

@ -1157,9 +1157,15 @@ export function ProjectView({
artifactIdentifier: art.identifier,
}),
});
const artifactGroupIdentifier =
ext === '.html'
? htmlArtifactGroupIdentifierFor(art, currentProjectFiles, fileName)
: undefined;
const html =
ext === '.html'
? rewriteHtmlLinksToCurrentProjectFiles(art.html, currentProjectFiles)
? rewriteHtmlLinksToCurrentProjectFiles(art.html, currentProjectFiles, {
artifactGroupIdentifier,
})
: art.html;
if (ext === '.html') {
const pointerTarget = resolveHtmlPointerArtifactTarget({
@ -1193,6 +1199,7 @@ export function ProjectView({
identifier: art.identifier,
artifactType: art.artifactType,
inferred: false,
...(artifactGroupIdentifier ? { artifactGroupIdentifier } : {}),
};
const manifest =
ext === '.html'
@ -4629,6 +4636,35 @@ function artifactBaseNameFor(art: Artifact): string {
);
}
function htmlArtifactGroupIdentifierFor(
art: Artifact,
projectFiles: ProjectFile[],
fileName: string,
): string {
const identifier = normalizeArtifactGroupSegment(art.identifier);
if (identifier) {
const existingGroup = projectFiles
.filter((file) => {
return normalizeArtifactGroupSegment(file.artifactManifest?.metadata?.identifier) === identifier;
})
.sort((a, b) => b.mtime - a.mtime)
.map((file) => file.artifactManifest?.metadata?.artifactGroupIdentifier)
.find((value): value is string => {
return typeof value === 'string' && normalizeArtifactGroupSegment(value).length > 0;
});
if (existingGroup) return existingGroup;
}
return `html-artifact:${fileName}`;
}
function normalizeArtifactGroupSegment(value: unknown): string {
if (typeof value !== 'string') return '';
return value
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export function findExistingArtifactProjectFile(
art: Artifact,
projectFiles: ProjectFile[],

View file

@ -89,11 +89,11 @@ describe('rewriteHtmlLinksToCurrentProjectFiles', () => {
'</body></html>';
const out = rewriteHtmlLinksToCurrentProjectFiles(html, [
htmlFile('about.html', 10, { artifactIdentifier: 'about' }),
htmlFile('about-2.html', 30, { artifactIdentifier: 'about' }),
htmlFile('contact.html', 20, { artifactIdentifier: 'contact' }),
htmlFile('contact-2.html', 40, { artifactIdentifier: 'contact' }),
]);
htmlFile('about.html', 10, { artifactIdentifier: 'about', artifactGroupIdentifier: 'site-a' }),
htmlFile('about-2.html', 30, { artifactIdentifier: 'about', artifactGroupIdentifier: 'site-a' }),
htmlFile('contact.html', 20, { artifactIdentifier: 'contact', artifactGroupIdentifier: 'site-a' }),
htmlFile('contact-2.html', 40, { artifactIdentifier: 'contact', artifactGroupIdentifier: 'site-a' }),
], { artifactGroupIdentifier: 'site-a' });
expect(out).toContain('href="about-2.html"');
expect(out).toContain('href="contact-2.html?tab=team#lead"');
@ -109,9 +109,9 @@ describe('rewriteHtmlLinksToCurrentProjectFiles', () => {
'</body></html>';
const out = rewriteHtmlLinksToCurrentProjectFiles(html, [
htmlFile('index.html', 10, { artifactIdentifier: 'index' }),
htmlFile('index-2.html', 40, { artifactIdentifier: 'index' }),
]);
htmlFile('index.html', 10, { artifactIdentifier: 'index', artifactGroupIdentifier: 'site-a' }),
htmlFile('index-2.html', 40, { artifactIdentifier: 'index', artifactGroupIdentifier: 'site-a' }),
], { artifactGroupIdentifier: 'site-a' });
expect(out).toContain('href="index-2.html"');
expect(out).toContain('href="./index-2.html#top"');
@ -124,9 +124,24 @@ describe('rewriteHtmlLinksToCurrentProjectFiles', () => {
'</body></html>';
const out = rewriteHtmlLinksToCurrentProjectFiles(html, [
htmlFile('about.html', 10, { artifactIdentifier: 'about' }),
htmlFile('about-2.html', 40, { artifactIdentifier: 'other-about' }),
]);
htmlFile('about.html', 10, { artifactIdentifier: 'about', artifactGroupIdentifier: 'site-a' }),
htmlFile('about-2.html', 40, { artifactIdentifier: 'other-about', artifactGroupIdentifier: 'site-b' }),
], { artifactGroupIdentifier: 'site-a' });
expect(out).toContain('href="about.html"');
expect(out).not.toContain('href="about-2.html"');
});
it('does not rewrite to another artifact group with the same page identifier', () => {
const html =
'<!doctype html><html><body>' +
'<a href="about.html">About</a>' +
'</body></html>';
const out = rewriteHtmlLinksToCurrentProjectFiles(html, [
htmlFile('about.html', 10, { artifactIdentifier: 'about', artifactGroupIdentifier: 'site-a' }),
htmlFile('about-2.html', 40, { artifactIdentifier: 'about', artifactGroupIdentifier: 'site-b' }),
], { artifactGroupIdentifier: 'site-a' });
expect(out).toContain('href="about.html"');
expect(out).not.toContain('href="about-2.html"');
@ -136,7 +151,7 @@ describe('rewriteHtmlLinksToCurrentProjectFiles', () => {
function htmlFile(
name: string,
mtime: number,
options: { artifactIdentifier?: string } = {},
options: { artifactIdentifier?: string; artifactGroupIdentifier?: string } = {},
) {
return {
name,
@ -147,7 +162,10 @@ function htmlFile(
artifactManifest: options.artifactIdentifier !== undefined
? {
entry: name,
metadata: { identifier: options.artifactIdentifier },
metadata: {
identifier: options.artifactIdentifier,
artifactGroupIdentifier: options.artifactGroupIdentifier,
},
}
: undefined,
};

View file

@ -560,9 +560,18 @@ describe('ProjectView API empty response handling', () => {
it('keeps regenerated multipage artifact entry at index.html and rewrites child links', async () => {
mockedFetchProjectFiles.mockResolvedValue([
htmlProjectFile('index.html', 10, { artifactIdentifier: 'index' }),
htmlProjectFile('about.html', 20, { artifactIdentifier: 'about' }),
htmlProjectFile('about-2.html', 40, { artifactIdentifier: 'about' }),
htmlProjectFile('index.html', 10, {
artifactIdentifier: 'index',
artifactGroupIdentifier: 'site-a',
}),
htmlProjectFile('about.html', 20, {
artifactIdentifier: 'about',
artifactGroupIdentifier: 'site-a',
}),
htmlProjectFile('about-2.html', 40, {
artifactIdentifier: 'about',
artifactGroupIdentifier: 'site-a',
}),
] as never);
mockedWriteProjectTextFile.mockResolvedValue(htmlProjectFile('index.html', 50) as never);
const artifact =
@ -596,8 +605,14 @@ describe('ProjectView API empty response handling', () => {
it('does not overwrite an unrelated existing index.html when saving a multipage artifact', async () => {
mockedFetchProjectFiles.mockResolvedValue([
htmlProjectFile('index.html', 10),
htmlProjectFile('about.html', 20, { artifactIdentifier: 'about' }),
htmlProjectFile('about-2.html', 40, { artifactIdentifier: 'about' }),
htmlProjectFile('about.html', 20, {
artifactIdentifier: 'about',
artifactGroupIdentifier: 'html-artifact:index-2.html',
}),
htmlProjectFile('about-2.html', 40, {
artifactIdentifier: 'about',
artifactGroupIdentifier: 'html-artifact:index-2.html',
}),
] as never);
mockedWriteProjectTextFile.mockResolvedValue(htmlProjectFile('index-2.html', 50) as never);
const artifact =
@ -767,7 +782,7 @@ function hasSavedAssistantMessage(predicate: (message: ChatMessage) => boolean):
function htmlProjectFile(
name: string,
mtime: number,
options: { artifactIdentifier?: string } = {},
options: { artifactIdentifier?: string; artifactGroupIdentifier?: string } = {},
) {
return {
name,
@ -786,7 +801,10 @@ function htmlProjectFile(
status: 'complete',
exports: ['html'],
primary: true,
metadata: { identifier: options.artifactIdentifier },
metadata: {
identifier: options.artifactIdentifier,
artifactGroupIdentifier: options.artifactGroupIdentifier,
},
}
: undefined,
};