fix(builder): dedupe page slugs on add/rename/replaceAll (D3)
Two pages that slugify to the same string (e.g. both named "About") previously both published to about.html, silently overwriting each other on publish. Add a pure uniqueSlug(base, existingSlugs) helper that appends -2, -3, ... on collision, and apply it in addPage, renamePage, and replaceAllPages. The landing page's slug stays locked to 'index' regardless of collisions, matching existing behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -148,6 +148,19 @@ function slugify(name: string): string {
|
||||
.replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Append `-2`, `-3`, … to `base` until it no longer collides with
|
||||
* `existingSlugs`. Two pages that slugify to the same string (e.g. both
|
||||
* named "About") must not both publish to `about.html` — the second write
|
||||
* would silently overwrite the first on publish.
|
||||
*/
|
||||
export function uniqueSlug(base: string, existingSlugs: string[]): string {
|
||||
if (!existingSlugs.includes(base)) return base;
|
||||
let i = 2;
|
||||
while (existingSlugs.includes(`${base}-${i}`)) i++;
|
||||
return `${base}-${i}`;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE: PageData = {
|
||||
id: 'home',
|
||||
name: 'Home',
|
||||
@@ -278,7 +291,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
|
||||
const addPage = useCallback(
|
||||
(name: string, slug: string) => {
|
||||
const finalSlug = slug || slugify(name);
|
||||
const requestedSlug = slug || slugify(name);
|
||||
const id = `page_${Date.now()}`;
|
||||
|
||||
// Save current page first
|
||||
@@ -289,7 +302,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
{
|
||||
id,
|
||||
name,
|
||||
slug: finalSlug,
|
||||
slug: uniqueSlug(requestedSlug, prev.map((p) => p.slug)),
|
||||
craftState: null,
|
||||
headCode: '',
|
||||
},
|
||||
@@ -335,8 +348,10 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
// First page is the landing page — its slug is locked to 'index' so
|
||||
// the file always publishes to index.html regardless of the user-set
|
||||
// name. The display name can change freely.
|
||||
const nextSlug = i === 0 ? 'index' : (slug || slugify(name));
|
||||
return { ...p, name, slug: nextSlug };
|
||||
if (i === 0) return { ...p, name, slug: 'index' };
|
||||
const requestedSlug = slug || slugify(name);
|
||||
const otherSlugs = prev.filter((pp) => pp.id !== pageId).map((pp) => pp.slug);
|
||||
return { ...p, name, slug: uniqueSlug(requestedSlug, otherSlugs) };
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
@@ -467,16 +482,23 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
*/
|
||||
const replaceAllPages = useCallback((newPages: { name: string; tree: SerializedTreeNode }[]) => {
|
||||
if (newPages.length === 0) return;
|
||||
const built = newPages.map((p, i) => ({
|
||||
id: i === 0 ? 'home' : `page_${Date.now()}_${i}`,
|
||||
name: p.name,
|
||||
// Track slugs as they're assigned so later pages dedupe against earlier
|
||||
// ones in the same batch (e.g. the AI generating two "About" pages).
|
||||
const seenSlugs: string[] = [];
|
||||
const built = newPages.map((p, i) => {
|
||||
// First page must publish to index.html so it serves at the site root.
|
||||
// Apache resolves '/' to index.html, not home.html — without this, the
|
||||
// AI's "Home" page lands at /home.html and visitors hit a blank root.
|
||||
slug: i === 0 ? 'index' : slugify(p.name),
|
||||
craftState: treeToState(p.tree),
|
||||
headCode: '',
|
||||
}));
|
||||
const slug = i === 0 ? 'index' : uniqueSlug(slugify(p.name), seenSlugs);
|
||||
seenSlugs.push(slug);
|
||||
return {
|
||||
id: i === 0 ? 'home' : `page_${Date.now()}_${i}`,
|
||||
name: p.name,
|
||||
slug,
|
||||
craftState: treeToState(p.tree),
|
||||
headCode: '',
|
||||
};
|
||||
});
|
||||
setPages(built);
|
||||
// Load the first page into the live canvas
|
||||
const firstState = built[0].craftState;
|
||||
|
||||
Reference in New Issue
Block a user