2026-07-12 11:41:11 -07:00
|
|
|
/**
|
|
|
|
|
* Shared HTML-escaping and URL-safety utilities.
|
|
|
|
|
*
|
|
|
|
|
* `escapeHtml` / `escapeAttr` are the single source of truth for escaping
|
|
|
|
|
* user-supplied strings before they are interpolated into exported/rendered
|
|
|
|
|
* HTML. `safeUrl` blocks dangerous URL schemes (javascript:, vbscript:,
|
|
|
|
|
* data:text/html) including whitespace/case/entity-obfuscated variants.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/** Escapes &, <, >, " for safe use in text content and double-quoted attributes. */
|
|
|
|
|
export function escapeHtml(s: string): string {
|
|
|
|
|
if (typeof s !== 'string') {
|
|
|
|
|
if (s === null || s === undefined) return '';
|
|
|
|
|
s = String(s);
|
|
|
|
|
}
|
|
|
|
|
// Ampersand MUST be escaped first, otherwise the entities produced by the
|
|
|
|
|
// other replacements would themselves be re-escaped (double-escaping).
|
|
|
|
|
return s
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
.replace(/"/g, '"');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Escapes everything escapeHtml does, plus ' and ` for use in any attribute context. */
|
|
|
|
|
export function escapeAttr(s: string): string {
|
|
|
|
|
if (typeof s !== 'string') {
|
|
|
|
|
if (s === null || s === undefined) return '';
|
|
|
|
|
s = String(s);
|
|
|
|
|
}
|
|
|
|
|
return escapeHtml(s)
|
|
|
|
|
.replace(/'/g, ''')
|
|
|
|
|
.replace(/`/g, '`');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Dangerous scheme prefixes, checked against a normalized copy with all
|
|
|
|
|
// colons removed (see safeUrl) so that colon-splicing obfuscation like
|
|
|
|
|
// "java:script:alert(1)" can't slip past a literal "javascript:" check.
|
|
|
|
|
const DANGEROUS_SCHEME_PREFIXES = ['javascript', 'vbscript', 'datatext/html'];
|
|
|
|
|
|
|
|
|
|
/** Decodes &#NN; and &#xNN; numeric HTML entities (used to obfuscate scheme names). */
|
|
|
|
|
function decodeNumericEntities(s: string): string {
|
|
|
|
|
return s.replace(/&#x([0-9a-f]+);?/gi, (_m, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
|
|
|
.replace(/�*([0-9]+);?/g, (_m, dec) => String.fromCharCode(parseInt(dec, 10)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Returns the trimmed input if it is a safe URL, otherwise returns ''.
|
|
|
|
|
* Blocks javascript:, vbscript:, and data:text/html schemes, including
|
|
|
|
|
* whitespace / control-char / case / HTML-entity obfuscated variants.
|
|
|
|
|
* Allowed URLs are returned UNCHANGED (trimmed only) -- query strings etc.
|
|
|
|
|
* are never mangled.
|
|
|
|
|
*/
|
|
|
|
|
export function safeUrl(s: string): string {
|
|
|
|
|
if (typeof s !== 'string') return '';
|
|
|
|
|
|
|
|
|
|
const trimmed = s.trim();
|
|
|
|
|
if (trimmed === '') return '';
|
|
|
|
|
|
|
|
|
|
// Build a normalized copy for scheme detection only: decode numeric HTML
|
|
|
|
|
// entities (catches e.g. j -> 'j'), strip whitespace/control chars,
|
|
|
|
|
// and lowercase.
|
|
|
|
|
const normalized = decodeNumericEntities(trimmed)
|
|
|
|
|
.replace(/[\x00-\x20]+/g, '')
|
|
|
|
|
.toLowerCase();
|
|
|
|
|
|
|
|
|
|
// Strip any colons before matching the scheme prefix. This defeats
|
|
|
|
|
// obfuscation that splices extra colons into the scheme name itself
|
|
|
|
|
// (e.g. decoding : mid-word produces "java:script:alert(1)", which
|
|
|
|
|
// would otherwise dodge a literal "^javascript:" check) while still
|
|
|
|
|
// reliably catching the real "javascript:"/"vbscript:"/"data:text/html"
|
|
|
|
|
// prefixes once their own colon is removed.
|
|
|
|
|
const collapsed = normalized.replace(/:/g, '');
|
|
|
|
|
|
|
|
|
|
if (DANGEROUS_SCHEME_PREFIXES.some((prefix) => collapsed.startsWith(prefix))) {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return trimmed;
|
|
|
|
|
}
|
2026-07-12 14:19:01 -07:00
|
|
|
|
2026-07-12 15:56:25 -07:00
|
|
|
/**
|
|
|
|
|
* Neutralizes CSS-context breakout for a single design-token value (color,
|
|
|
|
|
* length, gradient, etc.) so it is safe to interpolate RAW into either CSS
|
|
|
|
|
* context found in exported HTML:
|
|
|
|
|
* - ATTRIBUTE context: `style="color:${v}"` -- a `"` or `'` closes the
|
|
|
|
|
* attribute early; a `;` injects an extra declaration.
|
|
|
|
|
* - ELEMENT context: `<style>.x{color:${v}}</style>` -- `<`, `>`, `{`, `}`
|
|
|
|
|
* let a value close the rule / the `<style>` element itself and open a
|
|
|
|
|
* `<script>`, which is the worst case (arbitrary script execution).
|
|
|
|
|
* Strips the characters that can terminate a value, declaration, rule, or
|
|
|
|
|
* the `<style>` element itself, and neutralizes `url(` so a background
|
|
|
|
|
* shorthand can't smuggle a `javascript:` URL. None of hex colors,
|
|
|
|
|
* `rgb()`/`rgba()`, `var(...)`, gradients, `calc(...)`, or `px`/`%`/`em`/
|
|
|
|
|
* `rem` sizes contain any stripped character, so legitimate values pass
|
|
|
|
|
* through unchanged. If a sink legitimately needs a background image URL,
|
|
|
|
|
* route that value through `safeUrl` instead of `cssValue`.
|
|
|
|
|
*/
|
|
|
|
|
export function cssValue(v: unknown): string {
|
|
|
|
|
if (typeof v !== 'string') return '';
|
|
|
|
|
return v
|
|
|
|
|
.replace(/[<>{};"'\\]/g, '')
|
|
|
|
|
.replace(/url\s*\(/gi, '');
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 14:19:01 -07:00
|
|
|
/**
|
|
|
|
|
* Derives a deterministic, HTML-id-safe slug from a field's `name`/`label`
|
|
|
|
|
* for wiring `<label for>` to a matching `<input id>` in exported markup.
|
|
|
|
|
* Pure function of the input string -- no Math.random/Date.now -- so the
|
|
|
|
|
* same field always gets the same id across export runs.
|
|
|
|
|
*/
|
|
|
|
|
export function slugId(s: string | undefined, fallback = 'field'): string {
|
|
|
|
|
const base = (s || '').toString().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
|
|
|
return base || fallback;
|
|
|
|
|
}
|
2026-07-12 14:34:53 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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)}`;
|
|
|
|
|
}
|