From 92841e3f355a965c2d413061fce77145a57ef6ca Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 18:22:38 -0700 Subject: [PATCH 1/4] feat(builder): send + restore site head code in save/load Extend the save payload with head_code + design so the backend can inject SiteDesign.headCode into published pages, and restore design tokens on load() so the editor reflects the last-saved state. Co-Authored-By: Claude Opus 4.8 (1M context) --- craft/src/hooks/useWhpApi.save.test.ts | 31 ++++++++++++++++++++++++++ craft/src/hooks/useWhpApi.ts | 27 ++++++++++++++++++++-- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/craft/src/hooks/useWhpApi.save.test.ts b/craft/src/hooks/useWhpApi.save.test.ts index 96ef8ef..41c3975 100644 --- a/craft/src/hooks/useWhpApi.save.test.ts +++ b/craft/src/hooks/useWhpApi.save.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'vitest'; import { buildSavePayload } from './useWhpApi'; import { PageData } from '../types'; +import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext'; const pageA: PageData = { id: 'home', name: 'Home', slug: 'index', craftState: 'STORED_HOME' }; const pageB: PageData = { id: 'page_2', name: 'About', slug: 'about', craftState: 'STORED_ABOUT' }; @@ -19,6 +20,8 @@ describe('buildSavePayload', () => { activePageId: '__header__', isEditingHeader: true, isEditingFooter: false, + headCode: DEFAULT_SITE_DESIGN.headCode, + design: DEFAULT_SITE_DESIGN, }); // The fresh live canvas must be reflected in header_craft_state, not the stale stored one. @@ -52,6 +55,8 @@ describe('buildSavePayload', () => { activePageId: '__footer__', isEditingHeader: false, isEditingFooter: true, + headCode: DEFAULT_SITE_DESIGN.headCode, + design: DEFAULT_SITE_DESIGN, }); expect(payload.footer_craft_state).toBe('LIVE_FOOTER'); @@ -75,6 +80,8 @@ describe('buildSavePayload', () => { activePageId: 'home', isEditingHeader: false, isEditingFooter: false, + headCode: DEFAULT_SITE_DESIGN.headCode, + design: DEFAULT_SITE_DESIGN, }); // Live canvas goes to the active page and top-level slots. @@ -105,6 +112,8 @@ describe('buildSavePayload', () => { activePageId: 'home', isEditingHeader: false, isEditingFooter: false, + headCode: DEFAULT_SITE_DESIGN.headCode, + design: DEFAULT_SITE_DESIGN, }); // The live edit must land in pages_craft_state[0] (index.html slot), @@ -116,4 +125,26 @@ describe('buildSavePayload', () => { // The other page is untouched. expect(payload.pages_craft_state.find((p) => p.id === 'p2')?.craftState).toBe('STORED_ABOUT_2'); }); + + test('head code + design tokens are included in the save payload', () => { + const design = { ...DEFAULT_SITE_DESIGN, headCode: '' }; + + const payload = buildSavePayload({ + siteId: 1, + siteName: 'Test Site', + liveCraftState: 'LIVE_PAGE_HOME', + pages: [pageA, pageB], + headerPage, + footerPage, + activePageId: 'home', + isEditingHeader: false, + isEditingFooter: false, + headCode: '', + design, + }); + + expect(payload.head_code).toBe(''); + expect(payload.design).toEqual(design); + expect(payload.design.headCode).toBe(''); + }); }); diff --git a/craft/src/hooks/useWhpApi.ts b/craft/src/hooks/useWhpApi.ts index 2319c4d..464333b 100644 --- a/craft/src/hooks/useWhpApi.ts +++ b/craft/src/hooks/useWhpApi.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import { useEditor } from '@craftjs/core'; import { useEditorConfig } from '../state/EditorConfigContext'; import { usePages } from '../state/PageContext'; +import { useSiteDesign, SiteDesign } from '../state/SiteDesignContext'; import { exportBodyHtml } from '../utils/html-export'; import { PageData } from '../types'; @@ -18,6 +19,10 @@ export interface BuildSavePayloadInput { isEditingHeader: boolean; /** True when the live canvas is showing the footer zone (activePageId === '__footer__'). */ isEditingFooter: boolean; + /** Site-wide custom `` code (also present on `design.headCode`). */ + headCode: string; + /** Full site design tokens object -- lets load() restore colors/fonts/headCode. */ + design: SiteDesign; } /** @@ -45,6 +50,8 @@ export function buildSavePayload(input: BuildSavePayloadInput) { activePageId, isEditingHeader, isEditingFooter, + headCode, + design, } = input; const isPageActive = !isEditingHeader && !isEditingFooter; @@ -173,6 +180,8 @@ export function buildSavePayload(input: BuildSavePayloadInput) { header_craft_state: headerCraftState, footer_craft_state: footerCraftState, pages_craft_state: pagesGrapesjs, + head_code: headCode, + design, }; } @@ -191,6 +200,7 @@ export function useWhpApi() { setPagesCraftState, setActivePageIdDirect, } = usePages(); + const { design, updateDesign } = useSiteDesign(); const save = useCallback(async () => { if (!isWHP || !whpConfig) return null; @@ -210,6 +220,8 @@ export function useWhpApi() { activePageId, isEditingHeader, isEditingFooter, + headCode: design.headCode, + design, }); const resp = await fetch(`${whpConfig.apiUrl}?action=save`, { @@ -221,7 +233,7 @@ export function useWhpApi() { body: JSON.stringify(payload), }); return resp.json(); - }, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter]); + }, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]); const publish = useCallback(async () => { if (!isWHP || !whpConfig) return null; @@ -255,6 +267,17 @@ export function useWhpApi() { if (data.success && data.project) { const proj = data.project; + // Restore site design tokens (colors/fonts/headCode) so the editor + // reflects what was last saved. Prefer the full `design` object when + // present; fall back to just `head_code` for older project.json files + // saved before this field existed (backward-compatible: defaults for + // everything else). + if (proj.design && typeof proj.design === 'object') { + updateDesign(proj.design); + } else if (typeof proj.head_code === 'string') { + updateDesign({ headCode: proj.head_code }); + } + // Restore header craft state if (proj.header_craft_state) { setHeaderCraftState(typeof proj.header_craft_state === 'string' @@ -300,7 +323,7 @@ export function useWhpApi() { } } return data; - }, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter]); + }, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter, updateDesign]); const uploadAsset = useCallback( async (file: File) => { -- 2.52.0 From 0cbc58f8d1ecc87dd904a6e8ce2bb42e918ac167 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 18:30:22 -0700 Subject: [PATCH 2/4] a11y/security: scope Navbar ids + hover styles, Gallery lightbox focus trap M-1: Navbar.toHtml emitted a fixed id="navbar-links" and unscoped .navbar-link/.navbar-cta :hover selectors -- two Navbars on one page collided on the duplicate id and cross-applied each other's hover colors (later // breakout -> arbitrary `; } -- 2.52.0 From 86455413d0cf902fe19ac97ab39bca9b9b5701ce Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 18:31:12 -0700 Subject: [PATCH 3/4] fix: unique addPage ids + collision-free scopeId hashing 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) --- craft/src/state/PageContext.slug.test.tsx | 28 ++++++++++++++++++----- craft/src/state/PageContext.tsx | 18 +++++++++++++-- craft/src/utils/escape.test.ts | 20 ++++++++++++++-- craft/src/utils/escape.ts | 10 ++++++-- 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/craft/src/state/PageContext.slug.test.tsx b/craft/src/state/PageContext.slug.test.tsx index 5f807b8..e8d8ef9 100644 --- a/craft/src/state/PageContext.slug.test.tsx +++ b/craft/src/state/PageContext.slug.test.tsx @@ -2,7 +2,7 @@ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; import React from 'react'; import { createRoot, Root } from 'react-dom/client'; import { act } from 'react-dom/test-utils'; -import { PageProvider, usePages, uniqueSlug } from './PageContext'; +import { PageProvider, usePages, uniqueSlug, nextPageId } from './PageContext'; /* PageContext only needs `useEditor` from @craftjs/core (for query.serialize / actions.deserialize during page switches) — mock just that so PageProvider @@ -15,11 +15,10 @@ vi.mock('@craftjs/core', () => ({ }), })); -/* addPage mints ids from `Date.now()`. Two adds inside the same test can land - in the same millisecond and collide on id, which is an existing, unrelated - bug (id collision, not slug collision) — out of scope here but it makes - these tests flaky since a colliding id defeats the "other pages" slug - lookup. Force distinct ids so the slug-dedupe assertions below are stable. */ +/* addPage mints ids via nextPageId() (timestamp + monotonic counter, M-3), + so same-millisecond calls no longer collide on id. Date.now() is still + pinned/advanced here for determinism across the slug-dedupe assertions + below, independent of wall-clock timing. */ let dateNowSpy: ReturnType; beforeEach(() => { let counter = 1_700_000_000_000; @@ -48,6 +47,23 @@ function unmount() { container.remove(); } +describe('nextPageId (M-3: no same-millisecond id collision)', () => { + test('two calls yield distinct ids even when Date.now() is pinned to a constant', () => { + const spy = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + try { + const id1 = nextPageId(); + const id2 = nextPageId(); + expect(id1).not.toBe(id2); + } finally { + spy.mockRestore(); + } + }); + + test('ids are prefixed with "page_"', () => { + expect(nextPageId()).toMatch(/^page_/); + }); +}); + describe('uniqueSlug', () => { test('returns base unchanged when no collision', () => { expect(uniqueSlug('about', ['index', 'contact'])).toBe('about'); diff --git a/craft/src/state/PageContext.tsx b/craft/src/state/PageContext.tsx index 0edbc6a..946b890 100644 --- a/craft/src/state/PageContext.tsx +++ b/craft/src/state/PageContext.tsx @@ -43,6 +43,20 @@ interface PageContextValue { const HEADER_ID = '__header__'; const FOOTER_ID = '__footer__'; +// M-3: `page_${Date.now()}` alone collides when two pages are minted inside +// the same millisecond (addPage called twice in quick succession, or two AI +// replaceAllPages entries) -- then rename/delete/save operate on both pages +// at once since they share an id. A module-scoped monotonic counter, +// combined with the timestamp, guarantees uniqueness regardless of how many +// ids are minted within the same millisecond. This is state/id-minting code +// (not toHtml/export), so Date.now() here is fine -- see task-minors-brief.md. +let pageIdCounter = 0; + +/** Mints a unique page id: timestamp (base36) + a monotonic per-process counter (base36). */ +export function nextPageId(): string { + return 'page_' + Date.now().toString(36) + '_' + (++pageIdCounter).toString(36); +} + const EMPTY_CANVAS = '{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"100vh","backgroundColor":"#ffffff"},"tag":"div"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}'; @@ -338,7 +352,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => const addPage = useCallback( (name: string, slug: string) => { const requestedSlug = slug || slugify(name); - const id = `page_${Date.now()}`; + const id = nextPageId(); // Save current page first saveCurrentState(); @@ -452,7 +466,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => const slug = i === 0 ? 'index' : uniqueSlug(slugify(p.name), seenSlugs); seenSlugs.push(slug); return { - id: i === 0 ? 'home' : `page_${Date.now()}_${i}`, + id: i === 0 ? 'home' : nextPageId(), name: p.name, slug, craftState: treeToCraftState(p.tree), diff --git a/craft/src/utils/escape.test.ts b/craft/src/utils/escape.test.ts index b30a6e0..27dffbd 100644 --- a/craft/src/utils/escape.test.ts +++ b/craft/src/utils/escape.test.ts @@ -120,8 +120,24 @@ describe('scopeId', () => { expect(id1).not.toBe(id2); }); - test('node id is slugified (non-alphanumeric characters stripped, lowercased)', () => { - expect(scopeId('Node ID! 123', 'seed', 'sb')).toBe('sb_nodeid123'); + test('output is a valid CSS ident: prefix_hash', () => { + expect(scopeId('Node ID! 123', 'seed', 'sb')).toMatch(/^sb_[a-z0-9]+$/); + }); + + test('M-4: case-differing node ids do not collapse to the same scope (no case-fold collision)', () => { + expect(scopeId('AbC', 'seed', 'sb')).not.toBe(scopeId('abc', 'seed', 'sb')); + }); + + test('M-4: punctuation-differing node ids do not collapse to the same scope', () => { + expect(scopeId('a-b', 'seed', 'sb')).not.toBe(scopeId('ab', 'seed', 'sb')); + }); + + test('M-4: same input always yields the identical scope id', () => { + expect(scopeId('some-node-id', 'seed', 'sb')).toBe(scopeId('some-node-id', 'seed', 'sb')); + }); + + test('M-4: output always matches a valid CSS ident pattern', () => { + expect(scopeId('Weird!! Node--ID__123', 'seed', 'sb')).toMatch(/^[a-z]+_[a-z0-9]+$/i); }); test('no nodeId (legacy 2-arg call sites) falls back to a deterministic hash of the seed, not Math.random', () => { diff --git a/craft/src/utils/escape.ts b/craft/src/utils/escape.ts index 335dbae..2e345b8 100644 --- a/craft/src/utils/escape.ts +++ b/craft/src/utils/escape.ts @@ -151,8 +151,14 @@ export function stableHash(seed: string): string { * available. */ export function scopeId(nodeId: string | undefined, fallbackSeed: string, prefix: string): string { - const slug = (nodeId || '').toString().toLowerCase().replace(/[^a-z0-9]+/g, ''); - return `${prefix}_${slug || stableHash(fallbackSeed)}`; + // M-4: hash the raw node id (via the same djb2 `stableHash` used for the + // fallback path below) rather than lowercasing + stripping punctuation + // into a slug. A slug collapses distinct ids that differ only by + // case/punctuation (e.g. "AbC" and "abc", or "a-b" and "ab") onto the same + // scope; hashing the untouched string keeps them distinct while staying + // deterministic and producing a valid CSS ident (prefix + '_' + [a-z0-9]+). + const seed = (nodeId || '').toString(); + return `${prefix}_${stableHash(seed || fallbackSeed)}`; } // Shared enum allowlists for `toHtml` attribute sinks fed by props that are -- 2.52.0 From bf4a9f48eb8328446aa3db398f0aa6d3d035a1e3 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 18:31:25 -0700 Subject: [PATCH 4/4] security: block data:image/svg+xml + sandbox HtmlBlock iframes M-5: safeUrl() blocked javascript:/vbscript:/data:text/html but allowed data:image/svg+xml, which can execute inline '); + expect(out).not.toContain('onload'); + expect(out).not.toContain(' { + purifyHtml(''); + purifyHtml(''); + const out = purifyHtml(''); + const sandboxMatches = out.match(/sandbox="/g) || []; + expect(sandboxMatches.length).toBe(1); + }); + + test('a non-iframe element sanitized alongside an iframe is not touched by the hook', () => { + const out = purifyHtml('

hi

'); + expect(out).toContain('

hi

'); + }); +}); diff --git a/craft/src/components/basic/HtmlBlock.tsx b/craft/src/components/basic/HtmlBlock.tsx index 4fa7f8a..0f41cc4 100644 --- a/craft/src/components/basic/HtmlBlock.tsx +++ b/craft/src/components/basic/HtmlBlock.tsx @@ -24,14 +24,42 @@ const PURIFY_CONFIG = { 'href','src','alt','title','target','rel', 'width','height','class', 'allowfullscreen','allow','frameborder', + 'sandbox','referrerpolicy', ], ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|data:image\/[a-z]+;base64,):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i, FORBID_TAGS: ['script','style','object','embed','link','meta','form','input','button','select','textarea'], FORBID_ATTR: [/^on/i], }; +// M-6: `