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 { 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_/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user