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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect } from 'vitest';
|
import { describe, test, expect } from 'vitest';
|
||||||
import { escapeHtml, escapeAttr, safeUrl } from './escape';
|
import { escapeHtml, escapeAttr, safeUrl, stableHash, scopeId } from './escape';
|
||||||
|
|
||||||
describe('escapeHtml', () => {
|
describe('escapeHtml', () => {
|
||||||
test('escapes &, <, >, "', () => {
|
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');
|
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_/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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, '');
|
const base = (s || '').toString().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
return base || fallback;
|
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
|
||||||
|
* `<script>` function names.
|
||||||
|
*
|
||||||
|
* Craft.js assigns every node in a serialized page tree a unique id that
|
||||||
|
* is stable across repeated exports of the same saved page. Scoping ids on
|
||||||
|
* that node id -- rather than `Math.random()` (unique but changes on every
|
||||||
|
* export, breaking caching/diffing) or a hash of prop/content data
|
||||||
|
* (deterministic but COLLIDES whenever two instances share default or
|
||||||
|
* identical content) -- yields ids that are simultaneously deterministic
|
||||||
|
* (same node -> same id, every export) and collision-free (different
|
||||||
|
* nodes -> different ids, even with identical content).
|
||||||
|
*
|
||||||
|
* `nodeId` is optional only for defensive/legacy call sites -- existing
|
||||||
|
* `toHtml(props, childrenHtml)` 2-arg callers that haven't been updated to
|
||||||
|
* pass the node id. In that case we fall back to `stableHash(fallbackSeed)`,
|
||||||
|
* which is still deterministic (never Math.random) but, like the old
|
||||||
|
* per-component hash implementations, can collide across instances with
|
||||||
|
* identical content. Callers should always pass the real node id when
|
||||||
|
* 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)}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,10 +74,19 @@ function renderNode(nodes: Record<string, any>, nodeId: string): { html: string
|
|||||||
// Build data attributes for responsive visibility and animations
|
// Build data attributes for responsive visibility and animations
|
||||||
const dataAttrs = buildDataAttrs(props);
|
const dataAttrs = buildDataAttrs(props);
|
||||||
|
|
||||||
// Look up component in resolver and call toHtml
|
// Look up component in resolver and call toHtml. `nodeId` is the Craft.js
|
||||||
|
// node id for this node -- unique per node in the tree and stable across
|
||||||
|
// repeated exports of the same saved page. It's passed as a 3rd argument
|
||||||
|
// so components can derive deterministic AND unique scope ids for their
|
||||||
|
// exported element ids / aria-wiring / inline-script function names
|
||||||
|
// (see `scopeId` in utils/escape.ts) instead of Math.random() (unique but
|
||||||
|
// non-deterministic) or a content hash (deterministic but collides when
|
||||||
|
// two instances share default/identical content). Components that don't
|
||||||
|
// need a scope id simply ignore the extra argument -- backward compatible
|
||||||
|
// with existing 2-arg `toHtml(props, childrenHtml)` implementations/tests.
|
||||||
const component = resolver[typeName];
|
const component = resolver[typeName];
|
||||||
if (component && typeof component.toHtml === 'function') {
|
if (component && typeof component.toHtml === 'function') {
|
||||||
const result = component.toHtml(props, allChildrenHtml);
|
const result = component.toHtml(props, allChildrenHtml, nodeId);
|
||||||
const html = result.html || '';
|
const html = result.html || '';
|
||||||
return { html: injectAttrs(html, dataAttrs) };
|
return { html: injectAttrs(html, dataAttrs) };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user