M-3: PageContext.addPage minted ids from bare `page_${Date.now()}` --
two adds inside the same millisecond collided on id, so a subsequent
rename/delete/save silently acted on both pages at once. Added a
module-scoped monotonic counter combined with the timestamp
(nextPageId(), exported for direct unit testing) and used it
everywhere an addPage-style id is minted (addPage, replaceAllPages).
M-4: scopeId() lowercased + stripped non-alphanumeric characters from
the node id into a slug, so two node ids differing only by
case/punctuation (e.g. "AbC" vs "abc", or "a-b" vs "ab") collapsed
onto the same scope -- defeating the whole point of scoping ids per
node (M-1/Menu/Tabs/ColumnLayout/Gallery/etc. all rely on it). Now
hashes the raw node id via the existing djb2 stableHash() instead of
slugifying it: still deterministic (same id -> same scope) and a valid
CSS ident, but collision-resistant across case/punctuation. This
changes the exact scope strings Menu/Tabs/ColumnLayout/Gallery/etc.
emit -- expected and fine, since none of their tests pinned an exact
scope value (all already asserted structure/uniqueness).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
slugify('!!!') stripped down to '' since punctuation-only names have no
a-z0-9 characters left; buildSavePayload then wrote filename = '' +
'.html'. slugify now falls back to 'page' when the computed slug is
empty, so uniqueSlug's existing dedupe (page, page-2, ...) applies same
as any other collision. Landing page slug stays forced to 'index'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>