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:
@@ -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
|
||||
* `<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)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user