From c2aac870e7211fbd27eeea3b55d2ec47f646b5fc Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 14:34:53 -0700 Subject: [PATCH] thread node id through toHtml for deterministic+unique export ids renderNode now passes the Craft node id as toHtml's 3rd argument (props, childrenHtml, nodeId). Backward compatible: the resolver map is untyped (any), so existing 2-arg toHtml implementations/tests are unaffected. Adds scopeId()/stableHash() helpers in utils/escape.ts: scopeId derives a scope string from the node id (deterministic AND unique, since Craft node ids are unique per node and stable across repeated exports of the same page) with a stableHash(seed) fallback for legacy call sites without a node id. Co-Authored-By: Claude Opus 4.8 (1M context) --- craft/src/utils/escape.test.ts | 49 +++++++++++++++++++++++++++++++++- craft/src/utils/escape.ts | 41 ++++++++++++++++++++++++++++ craft/src/utils/html-export.ts | 13 +++++++-- 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/craft/src/utils/escape.test.ts b/craft/src/utils/escape.test.ts index ffe47b3..2b51dbd 100644 --- a/craft/src/utils/escape.test.ts +++ b/craft/src/utils/escape.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'vitest'; -import { escapeHtml, escapeAttr, safeUrl } from './escape'; +import { escapeHtml, escapeAttr, safeUrl, stableHash, scopeId } from './escape'; describe('escapeHtml', () => { test('escapes &, <, >, "', () => { @@ -93,3 +93,50 @@ describe('safeUrl', () => { expect(safeUrl('https://x.com/a?b=1&c=2')).toBe('https://x.com/a?b=1&c=2'); }); }); + +describe('stableHash', () => { + test('same input always produces the same output', () => { + expect(stableHash('hello')).toBe(stableHash('hello')); + }); + + test('different inputs produce different output (no collision for these cases)', () => { + expect(stableHash('hello')).not.toBe(stableHash('world')); + }); + + test('returns a non-empty string', () => { + expect(stableHash('')).toEqual(expect.any(String)); + expect(stableHash('x').length).toBeGreaterThan(0); + }); +}); + +describe('scopeId', () => { + test('same node id -> identical scope id across calls (deterministic)', () => { + expect(scopeId('node-abc', 'seed', 'sb')).toBe(scopeId('node-abc', 'seed', 'sb')); + }); + + test('different node ids -> different, non-colliding scope ids even with the same fallback seed', () => { + const id1 = scopeId('node-a', 'same-seed', 'sb'); + const id2 = scopeId('node-b', 'same-seed', 'sb'); + 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('no nodeId (legacy 2-arg call sites) falls back to a deterministic hash of the seed, not Math.random', () => { + const id1 = scopeId(undefined, 'same-seed', 'sb'); + const id2 = scopeId(undefined, 'same-seed', 'sb'); + expect(id1).toBe(id2); + }); + + test('no nodeId: different seeds fall back to different hashes', () => { + const id1 = scopeId(undefined, 'seed-one', 'sb'); + const id2 = scopeId(undefined, 'seed-two', 'sb'); + expect(id1).not.toBe(id2); + }); + + test('prefix is applied', () => { + expect(scopeId('node-abc', 'seed', 'tabs')).toMatch(/^tabs_/); + }); +}); diff --git a/craft/src/utils/escape.ts b/craft/src/utils/escape.ts index bf3470f..98fc91d 100644 --- a/craft/src/utils/escape.ts +++ b/craft/src/utils/escape.ts @@ -89,3 +89,44 @@ export function slugId(s: string | undefined, fallback = 'field'): string { const base = (s || '').toString().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); return base || fallback; } + +/** + * Deterministic (non-cryptographic) string hash -- djb2. Same input always + * produces the same output across export runs (pure function of the + * string, no Math.random/Date.now). Used as a fallback seed source when a + * Craft node id isn't available -- see `scopeId` below. + */ +export function stableHash(seed: string): string { + let h = 5381; + for (let i = 0; i < seed.length; i++) { + h = ((h << 5) + h + seed.charCodeAt(i)) | 0; + } + return (h >>> 0).toString(36); +} + +/** + * Derives a deterministic AND unique per-node scope id for a component's + * exported element ids / aria-controls / aria-labelledby / for / inline + * `