mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
fix(web): isolate multipage html link groups
This commit is contained in:
parent
9d29ef35d3
commit
c48aee0aa6
4 changed files with 126 additions and 26 deletions
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue