diff --git a/apps/web/src/artifacts/html-links.ts b/apps/web/src/artifacts/html-links.ts index 0eff1691d..0ea693097 100644 --- a/apps/web/src/artifacts/html-links.ts +++ b/apps/web/src/artifacts/html-links.ts @@ -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}`; diff --git a/apps/web/tests/artifacts/html-links.test.ts b/apps/web/tests/artifacts/html-links.test.ts index 9b6214516..c7ac2cf32 100644 --- a/apps/web/tests/artifacts/html-links.test.ts +++ b/apps/web/tests/artifacts/html-links.test.ts @@ -89,10 +89,10 @@ describe('rewriteHtmlLinksToCurrentProjectFiles', () => { ''; 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', () => { ''; 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 = + '' + + 'About' + + ''; + + 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( diff --git a/apps/web/tests/components/ProjectView.api-empty-response.test.tsx b/apps/web/tests/components/ProjectView.api-empty-response.test.tsx index 85f276a71..9f650f017 100644 --- a/apps/web/tests/components/ProjectView.api-empty-response.test.tsx +++ b/apps/web/tests/components/ProjectView.api-empty-response.test.tsx @@ -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 =