mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
Merge 4ecdb4a15c into def2e9fd2e
This commit is contained in:
commit
a93c7fe912
4 changed files with 726 additions and 12 deletions
234
apps/web/src/artifacts/html-links.ts
Normal file
234
apps/web/src/artifacts/html-links.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
export function resolveHtmlArtifactFileName(input: {
|
||||
baseName: string;
|
||||
ext: '.html' | '.jsx' | '.tsx';
|
||||
existingFileNames: ReadonlySet<string>;
|
||||
savedArtifactName?: string | null;
|
||||
canOverwriteExistingEntry?: boolean;
|
||||
}): string {
|
||||
const preferredFileName = `${input.baseName}${input.ext}`;
|
||||
if (
|
||||
input.ext === '.html' &&
|
||||
input.baseName.toLowerCase() === 'index' &&
|
||||
(!input.existingFileNames.has(preferredFileName) ||
|
||||
input.savedArtifactName === preferredFileName ||
|
||||
input.canOverwriteExistingEntry === true)
|
||||
) {
|
||||
return 'index.html';
|
||||
}
|
||||
|
||||
let fileName = preferredFileName;
|
||||
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;
|
||||
artifactManifest?: {
|
||||
entry: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
interface HtmlLinkRewriteOptions {
|
||||
artifactGroupIdentifier?: string;
|
||||
}
|
||||
|
||||
export function canOverwriteHtmlArtifactEntry(input: {
|
||||
baseName: string;
|
||||
ext: '.html' | '.jsx' | '.tsx';
|
||||
projectFiles: readonly HtmlLinkProjectFile[];
|
||||
savedArtifactName?: string | null;
|
||||
artifactIdentifier?: string;
|
||||
}): boolean {
|
||||
if (input.ext !== '.html' || input.baseName.toLowerCase() !== 'index') return false;
|
||||
if (input.savedArtifactName === 'index.html') return true;
|
||||
const existingIndex = input.projectFiles.find((file) => {
|
||||
return file.name === 'index.html' || file.path === 'index.html';
|
||||
});
|
||||
if (!existingIndex) return true;
|
||||
const manifest = existingIndex.artifactManifest;
|
||||
const artifactIdentifier = input.artifactIdentifier ?? '';
|
||||
return (
|
||||
artifactIdentifier.length > 0 &&
|
||||
manifest?.entry === 'index.html' &&
|
||||
manifest.metadata?.identifier === artifactIdentifier
|
||||
);
|
||||
}
|
||||
|
||||
export function rewriteHtmlLinksToCurrentProjectFiles(
|
||||
html: string,
|
||||
projectFiles: readonly HtmlLinkProjectFile[],
|
||||
options: HtmlLinkRewriteOptions = {},
|
||||
): string {
|
||||
const latestByTarget = buildLatestHtmlFileIndex(projectFiles, options);
|
||||
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[],
|
||||
options: HtmlLinkRewriteOptions,
|
||||
): Map<string, string> {
|
||||
const groupedLatest = new Map<string, HtmlLinkProjectFile>();
|
||||
const legacyLatest = new Map<string, HtmlLinkProjectFile>();
|
||||
const groupedFamilies = new Set<string>();
|
||||
const artifactGroupIdentifier = normalizeOptionalIdentifier(options.artifactGroupIdentifier);
|
||||
for (const file of projectFiles) {
|
||||
if (!isHtmlProjectFile(file)) continue;
|
||||
const key = htmlFileFamilyKey(file.name);
|
||||
if (!key) continue;
|
||||
const manifestMatch = htmlFileManifestFamilyMatch(file, key);
|
||||
if (!manifestMatch) continue;
|
||||
|
||||
if (manifestMatch.artifactGroupIdentifier !== null) {
|
||||
groupedFamilies.add(key);
|
||||
}
|
||||
|
||||
if (
|
||||
artifactGroupIdentifier !== null &&
|
||||
manifestMatch.artifactGroupIdentifier === artifactGroupIdentifier
|
||||
) {
|
||||
const current = groupedLatest.get(key);
|
||||
if (!current || file.mtime > current.mtime) groupedLatest.set(key, file);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (manifestMatch.artifactGroupIdentifier === null) {
|
||||
const current = legacyLatest.get(key);
|
||||
if (!current || file.mtime > current.mtime) legacyLatest.set(key, file);
|
||||
}
|
||||
}
|
||||
|
||||
const latest = new Map<string, HtmlLinkProjectFile>();
|
||||
for (const [key, file] of legacyLatest) {
|
||||
if (!groupedFamilies.has(key)) latest.set(key, file);
|
||||
}
|
||||
for (const [key, file] of groupedLatest) {
|
||||
latest.set(key, file);
|
||||
}
|
||||
return new Map(Array.from(latest, ([key, file]) => [key, file.name]));
|
||||
}
|
||||
|
||||
function htmlFileManifestFamilyMatch(
|
||||
file: HtmlLinkProjectFile,
|
||||
familyKey: string,
|
||||
): { artifactGroupIdentifier: string | null } | null {
|
||||
const identifier = file.artifactManifest?.metadata?.identifier;
|
||||
if (typeof identifier !== 'string') return null;
|
||||
const normalizedIdentifier = normalizeArtifactIdentifier(identifier);
|
||||
if (!normalizedIdentifier) return null;
|
||||
if (normalizedIdentifier !== htmlFileFamilyIdentifier(familyKey)) return null;
|
||||
|
||||
return {
|
||||
artifactGroupIdentifier: normalizeOptionalIdentifier(
|
||||
file.artifactManifest?.metadata?.artifactGroupIdentifier,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
const familyStem = stem.replace(/-\d+$/, '');
|
||||
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 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}`;
|
||||
}
|
||||
return latestName;
|
||||
}
|
||||
|
|
@ -11,6 +11,11 @@ import {
|
|||
} from 'react';
|
||||
import { createHtmlArtifactManifest, inferLegacyManifest } from '../artifacts/manifest';
|
||||
import { resolveHtmlPointerArtifactTarget } from '../artifacts/pointer';
|
||||
import {
|
||||
canOverwriteHtmlArtifactEntry,
|
||||
resolveHtmlArtifactFileName,
|
||||
rewriteHtmlLinksToCurrentProjectFiles,
|
||||
} from '../artifacts/html-links';
|
||||
import { validateHtmlArtifact } from '../artifacts/validate';
|
||||
import { createArtifactParser } from '../artifacts/parser';
|
||||
import { useI18n } from '../i18n';
|
||||
|
|
@ -1135,20 +1140,40 @@ 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 canOverwriteExistingEntry = canOverwriteHtmlArtifactEntry({
|
||||
baseName,
|
||||
ext,
|
||||
projectFiles: currentProjectFiles,
|
||||
savedArtifactName: savedArtifactRef.current,
|
||||
artifactIdentifier: art.identifier,
|
||||
});
|
||||
const fileName = resolveHtmlArtifactFileName({
|
||||
baseName,
|
||||
ext,
|
||||
existingFileNames: existing,
|
||||
savedArtifactName: savedArtifactRef.current,
|
||||
canOverwriteExistingEntry,
|
||||
});
|
||||
const artifactGroupIdentifier =
|
||||
ext === '.html'
|
||||
? htmlArtifactGroupIdentifierFor(art, currentProjectFiles, fileName, {
|
||||
canReuseExistingGroup:
|
||||
canOverwriteExistingEntry || savedArtifactRef.current === fileName,
|
||||
})
|
||||
: undefined;
|
||||
const html =
|
||||
ext === '.html'
|
||||
? rewriteHtmlLinksToCurrentProjectFiles(art.html, currentProjectFiles, {
|
||||
artifactGroupIdentifier,
|
||||
})
|
||||
: art.html;
|
||||
if (ext === '.html') {
|
||||
const pointerTarget = resolveHtmlPointerArtifactTarget({
|
||||
content: art.html,
|
||||
content: html,
|
||||
candidateFileName: fileName,
|
||||
projectFiles: currentProjectFiles,
|
||||
});
|
||||
|
|
@ -1165,7 +1190,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;
|
||||
|
|
@ -1178,6 +1203,7 @@ export function ProjectView({
|
|||
identifier: art.identifier,
|
||||
artifactType: art.artifactType,
|
||||
inferred: false,
|
||||
...(artifactGroupIdentifier ? { artifactGroupIdentifier } : {}),
|
||||
};
|
||||
const manifest =
|
||||
ext === '.html'
|
||||
|
|
@ -1197,7 +1223,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) {
|
||||
|
|
@ -4618,6 +4644,52 @@ function artifactBaseNameFor(art: Artifact): string {
|
|||
);
|
||||
}
|
||||
|
||||
function htmlArtifactGroupIdentifierFor(
|
||||
art: Artifact,
|
||||
projectFiles: ProjectFile[],
|
||||
fileName: string,
|
||||
options: { canReuseExistingGroup: boolean },
|
||||
): string {
|
||||
const existingFile = projectFiles.find((file) => file.name === fileName || file.path === fileName);
|
||||
const existingFileGroup = existingArtifactGroupIdentifier(
|
||||
existingFile?.artifactManifest?.metadata?.artifactGroupIdentifier,
|
||||
);
|
||||
if (options.canReuseExistingGroup && existingFileGroup) {
|
||||
return existingFileGroup;
|
||||
}
|
||||
|
||||
if (options.canReuseExistingGroup) {
|
||||
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 existingArtifactGroupIdentifier(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
return normalizeArtifactGroupSegment(value).length > 0 ? value : null;
|
||||
}
|
||||
|
||||
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[],
|
||||
|
|
|
|||
206
apps/web/tests/artifacts/html-links.test.ts
Normal file
206
apps/web/tests/artifacts/html-links.test.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
canOverwriteHtmlArtifactEntry,
|
||||
resolveHtmlArtifactFileName,
|
||||
rewriteHtmlLinksToCurrentProjectFiles,
|
||||
} from '../../src/artifacts/html-links';
|
||||
|
||||
describe('resolveHtmlArtifactFileName', () => {
|
||||
it('keeps index.html as the stable entry point when the saved artifact already owns it', () => {
|
||||
expect(
|
||||
resolveHtmlArtifactFileName({
|
||||
baseName: 'index',
|
||||
ext: '.html',
|
||||
existingFileNames: new Set(['index.html']),
|
||||
savedArtifactName: 'index.html',
|
||||
}),
|
||||
).toBe('index.html');
|
||||
});
|
||||
|
||||
it('uses a suffix for index.html when the existing file is unrelated', () => {
|
||||
expect(
|
||||
resolveHtmlArtifactFileName({
|
||||
baseName: 'index',
|
||||
ext: '.html',
|
||||
existingFileNames: new Set(['index.html']),
|
||||
}),
|
||||
).toBe('index-2.html');
|
||||
});
|
||||
|
||||
it('keeps index.html when overwrite ownership is proven by the caller', () => {
|
||||
expect(
|
||||
resolveHtmlArtifactFileName({
|
||||
baseName: 'index',
|
||||
ext: '.html',
|
||||
existingFileNames: new Set(['index.html']),
|
||||
canOverwriteExistingEntry: true,
|
||||
}),
|
||||
).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('canOverwriteHtmlArtifactEntry', () => {
|
||||
it('allows index.html overwrite when the existing manifest identifier matches', () => {
|
||||
expect(
|
||||
canOverwriteHtmlArtifactEntry({
|
||||
baseName: 'index',
|
||||
ext: '.html',
|
||||
projectFiles: [
|
||||
htmlFile('index.html', 10, { artifactIdentifier: 'index' }),
|
||||
],
|
||||
artifactIdentifier: 'index',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects index.html overwrite when the existing manifest identifier is empty', () => {
|
||||
expect(
|
||||
canOverwriteHtmlArtifactEntry({
|
||||
baseName: 'index',
|
||||
ext: '.html',
|
||||
projectFiles: [
|
||||
htmlFile('index.html', 10, { artifactIdentifier: '' }),
|
||||
],
|
||||
artifactIdentifier: '',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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, { 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"');
|
||||
expect(out).toContain('href="#local"');
|
||||
expect(out).toContain('href="https://example.com/about.html"');
|
||||
});
|
||||
|
||||
it('rewrites home links when the artifact entry had to move off index.html', () => {
|
||||
const html =
|
||||
'<!doctype html><html><body>' +
|
||||
'<a href="index.html">Home</a>' +
|
||||
'<a href="./index.html#top">Top</a>' +
|
||||
'</body></html>';
|
||||
|
||||
const out = rewriteHtmlLinksToCurrentProjectFiles(html, [
|
||||
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"');
|
||||
});
|
||||
|
||||
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', 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"');
|
||||
});
|
||||
|
||||
it('falls back to legacy same-identifier files when no group-tagged candidates exist', () => {
|
||||
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: 'about' }),
|
||||
], { artifactGroupIdentifier: 'site-a' });
|
||||
|
||||
expect(out).toContain('href="about-2.html"');
|
||||
});
|
||||
|
||||
it('does not fall back to legacy files when a group-tagged candidate family exists', () => {
|
||||
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', 20, { artifactIdentifier: 'about' }),
|
||||
htmlFile('about-3.html', 40, {
|
||||
artifactIdentifier: 'about',
|
||||
artifactGroupIdentifier: 'site-b',
|
||||
}),
|
||||
], { artifactGroupIdentifier: 'site-a' });
|
||||
|
||||
expect(out).toContain('href="about.html"');
|
||||
expect(out).not.toContain('href="about-2.html"');
|
||||
expect(out).not.toContain('href="about-3.html"');
|
||||
});
|
||||
});
|
||||
|
||||
function htmlFile(
|
||||
name: string,
|
||||
mtime: number,
|
||||
options: { artifactIdentifier?: string; artifactGroupIdentifier?: string } = {},
|
||||
) {
|
||||
return {
|
||||
name,
|
||||
kind: 'html',
|
||||
mime: 'text/html',
|
||||
size: 1,
|
||||
mtime,
|
||||
artifactManifest: options.artifactIdentifier !== undefined
|
||||
? {
|
||||
entry: name,
|
||||
metadata: {
|
||||
identifier: options.artifactIdentifier,
|
||||
artifactGroupIdentifier: options.artifactGroupIdentifier,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
|
@ -558,6 +558,177 @@ 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, {
|
||||
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 =
|
||||
'<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('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',
|
||||
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 =
|
||||
'<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-2.html');
|
||||
expect(content).toContain('href="about-2.html"');
|
||||
});
|
||||
|
||||
it('starts a fresh group when a new index artifact is relocated away from an existing owner', async () => {
|
||||
mockedFetchProjectFiles.mockResolvedValue([
|
||||
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-2.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, options] = mockedWriteProjectTextFile.mock.calls.at(-1) ?? [];
|
||||
expect(fileName).toBe('index-2.html');
|
||||
expect(content).toContain('href="about.html"');
|
||||
expect(content).not.toContain('href="about-2.html"');
|
||||
expect(options?.artifactManifest?.metadata?.artifactGroupIdentifier).toBe(
|
||||
'html-artifact:index-2.html',
|
||||
);
|
||||
});
|
||||
|
||||
it('rewrites child links for legacy multipage artifacts without group metadata', async () => {
|
||||
mockedFetchProjectFiles.mockResolvedValue([
|
||||
htmlProjectFile('index.html', 10, { artifactIdentifier: 'index' }),
|
||||
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 =
|
||||
'<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, options] = mockedWriteProjectTextFile.mock.calls.at(-1) ?? [];
|
||||
expect(fileName).toBe('index.html');
|
||||
expect(content).toContain('href="about-2.html"');
|
||||
expect(options?.artifactManifest?.metadata?.artifactGroupIdentifier).toBe(
|
||||
'html-artifact:index.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 +864,34 @@ function hasSavedAssistantMessage(predicate: (message: ChatMessage) => boolean):
|
|||
return message.role === 'assistant' && predicate(message);
|
||||
});
|
||||
}
|
||||
|
||||
function htmlProjectFile(
|
||||
name: string,
|
||||
mtime: number,
|
||||
options: { artifactIdentifier?: string; artifactGroupIdentifier?: string } = {},
|
||||
) {
|
||||
return {
|
||||
name,
|
||||
path: name,
|
||||
kind: 'html',
|
||||
mime: 'text/html',
|
||||
size: 1,
|
||||
mtime,
|
||||
artifactManifest: options.artifactIdentifier
|
||||
? {
|
||||
version: 1,
|
||||
kind: 'html',
|
||||
title: name,
|
||||
entry: name,
|
||||
renderer: 'html',
|
||||
status: 'complete',
|
||||
exports: ['html'],
|
||||
primary: true,
|
||||
metadata: {
|
||||
identifier: options.artifactIdentifier,
|
||||
artifactGroupIdentifier: options.artifactGroupIdentifier,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue