fix(web): scope html artifact link rewrites

This commit is contained in:
116405 2026-05-30 20:26:27 +08:00
parent ac0adced65
commit 9d29ef35d3
3 changed files with 49 additions and 10 deletions

View file

@ -81,12 +81,21 @@ function buildLatestHtmlFileIndex(projectFiles: readonly HtmlLinkProjectFile[]):
if (!isHtmlProjectFile(file)) continue;
const key = htmlFileFamilyKey(file.name);
if (!key) continue;
if (!htmlFileManifestMatchesFamily(file, 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 htmlFileManifestMatchesFamily(file: HtmlLinkProjectFile, familyKey: string): 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);
}
function isHtmlProjectFile(file: HtmlLinkProjectFile): boolean {
const name = file.name.toLowerCase();
return (
@ -151,6 +160,21 @@ function htmlFileFamilyKey(pathname: string): string | null {
return `${directory}${familyStem}${ext}`.replace(/^\.\//, '');
}
function htmlFileFamilyIdentifier(familyKey: string): string {
const slash = familyKey.lastIndexOf('/');
const fileName = slash >= 0 ? familyKey.slice(slash + 1) : familyKey;
const dot = fileName.lastIndexOf('.');
const stem = dot > 0 ? fileName.slice(0, dot) : fileName;
return normalizeArtifactIdentifier(stem);
}
function normalizeArtifactIdentifier(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function preserveRelativePrefix(originalPathname: string, latestName: string): string {
if (originalPathname.startsWith('./') && !latestName.startsWith('./')) {
return `./${latestName}`;

View file

@ -89,10 +89,10 @@ describe('rewriteHtmlLinksToCurrentProjectFiles', () => {
'</body></html>';
const out = rewriteHtmlLinksToCurrentProjectFiles(html, [
htmlFile('about.html', 10),
htmlFile('about-2.html', 30),
htmlFile('contact.html', 20),
htmlFile('contact-2.html', 40),
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' }),
]);
expect(out).toContain('href="about-2.html"');
@ -109,13 +109,28 @@ describe('rewriteHtmlLinksToCurrentProjectFiles', () => {
'</body></html>';
const out = rewriteHtmlLinksToCurrentProjectFiles(html, [
htmlFile('index.html', 10),
htmlFile('index-2.html', 40),
htmlFile('index.html', 10, { artifactIdentifier: 'index' }),
htmlFile('index-2.html', 40, { artifactIdentifier: 'index' }),
]);
expect(out).toContain('href="index-2.html"');
expect(out).toContain('href="./index-2.html#top"');
});
it('does not rewrite to an unrelated newer numbered html file', () => {
const html =
'<!doctype html><html><body>' +
'<a href="about.html">About</a>' +
'</body></html>';
const out = rewriteHtmlLinksToCurrentProjectFiles(html, [
htmlFile('about.html', 10, { artifactIdentifier: 'about' }),
htmlFile('about-2.html', 40, { artifactIdentifier: 'other-about' }),
]);
expect(out).toContain('href="about.html"');
expect(out).not.toContain('href="about-2.html"');
});
});
function htmlFile(

View file

@ -561,8 +561,8 @@ 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),
htmlProjectFile('about-2.html', 40),
htmlProjectFile('about.html', 20, { artifactIdentifier: 'about' }),
htmlProjectFile('about-2.html', 40, { artifactIdentifier: 'about' }),
] as never);
mockedWriteProjectTextFile.mockResolvedValue(htmlProjectFile('index.html', 50) as never);
const artifact =
@ -596,8 +596,8 @@ 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),
htmlProjectFile('about-2.html', 40),
htmlProjectFile('about.html', 20, { artifactIdentifier: 'about' }),
htmlProjectFile('about-2.html', 40, { artifactIdentifier: 'about' }),
] as never);
mockedWriteProjectTextFile.mockResolvedValue(htmlProjectFile('index-2.html', 50) as never);
const artifact =