|
| 1 | +function trimIdUnderscores(value: string): string { |
| 2 | + let start = 0; |
| 3 | + let end = value.length; |
| 4 | + while (value[start] === "_") start += 1; |
| 5 | + while (end > start && value[end - 1] === "_") end -= 1; |
| 6 | + return value.slice(start, end); |
| 7 | +} |
| 8 | + |
| 9 | +export function buildTimelineAssetId(assetPath: string, existingIds: Iterable<string>): string { |
| 10 | + const baseName = assetPath.slice(assetPath.lastIndexOf("/") + 1); |
| 11 | + const normalized = baseName |
| 12 | + .replace(/\.[^.]+$/, "") |
| 13 | + .replace(/[^a-zA-Z0-9_-]+/g, "_") |
| 14 | + .toLowerCase(); |
| 15 | + const baseId = trimIdUnderscores(normalized) || "asset"; |
| 16 | + const ids = new Set(existingIds); |
| 17 | + if (!ids.has(baseId)) return baseId; |
| 18 | + let suffix = 2; |
| 19 | + while (ids.has(`${baseId}_${suffix}`)) suffix += 1; |
| 20 | + return `${baseId}_${suffix}`; |
| 21 | +} |
| 22 | + |
| 23 | +function referenceChildren(element: Element): Iterable<Element> { |
| 24 | + if (element.children.length > 0) return element.children; |
| 25 | + if (element.tagName.toLowerCase() !== "template") return []; |
| 26 | + const content = (element as HTMLTemplateElement).content; |
| 27 | + return content ? content.children : []; |
| 28 | +} |
| 29 | + |
| 30 | +function documentElements(document: Document): Element[] { |
| 31 | + const elements: Element[] = []; |
| 32 | + const visit = (element: Element): void => { |
| 33 | + elements.push(element); |
| 34 | + for (const child of referenceChildren(element)) visit(child); |
| 35 | + }; |
| 36 | + if (document.documentElement) visit(document.documentElement); |
| 37 | + return elements; |
| 38 | +} |
| 39 | + |
| 40 | +function decodeIdEscape(original: string, hex: string): string { |
| 41 | + const codePoint = Number.parseInt(hex, 16); |
| 42 | + return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : original; |
| 43 | +} |
| 44 | + |
| 45 | +function referencesId(value: string, id: string): boolean { |
| 46 | + const decoded = value |
| 47 | + .replace(/\\x([0-9a-f]{2})/gi, (original, hex) => decodeIdEscape(original, hex)) |
| 48 | + .replace(/\\u\{([0-9a-f]+)\}|\\u([0-9a-f]{4})/gi, (original, braced, fixed) => |
| 49 | + decodeIdEscape(original, braced ?? fixed), |
| 50 | + ) |
| 51 | + .replace(/\\([0-9a-f]{1,6})\s?/gi, (original, hex) => decodeIdEscape(original, hex)) |
| 52 | + .replace(/\\([^\r\n])/g, "$1"); |
| 53 | + return decoded.includes(id); |
| 54 | +} |
| 55 | + |
| 56 | +function decodeFragment(value: string): string { |
| 57 | + const fragment = value.split("#").slice(1).join("#"); |
| 58 | + try { |
| 59 | + return decodeURIComponent(fragment); |
| 60 | + } catch { |
| 61 | + return fragment; |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +function attributeReferencesId(attribute: Attr, id: string): boolean { |
| 66 | + if (["id", "data-hf-id"].includes(attribute.name)) return false; |
| 67 | + if (attribute.name === "src") return referencesId(decodeFragment(attribute.value), id); |
| 68 | + return referencesId(attribute.value, id); |
| 69 | +} |
| 70 | + |
| 71 | +function elementReferencesId(element: Element, id: string): boolean { |
| 72 | + const code = ["script", "style"].includes(element.tagName.toLowerCase()); |
| 73 | + if (code && referencesId(element.textContent ?? "", id)) return true; |
| 74 | + return Array.from(element.attributes).some((attribute) => attributeReferencesId(attribute, id)); |
| 75 | +} |
| 76 | + |
| 77 | +type MediaIdentity = { id: string; src: string }; |
| 78 | + |
| 79 | +function readMediaIdentity(element: Element): MediaIdentity | null { |
| 80 | + const media = ["video", "audio", "img"].includes(element.tagName.toLowerCase()); |
| 81 | + const src = element.getAttribute("src"); |
| 82 | + const id = element.getAttribute("id"); |
| 83 | + if (!media || !src || !id) return null; |
| 84 | + return { id, src }; |
| 85 | +} |
| 86 | + |
| 87 | +function matchesGeneratedId({ id, src }: MediaIdentity): boolean { |
| 88 | + const base = buildTimelineAssetId(src, []); |
| 89 | + const suffix = id.slice(base.length); |
| 90 | + return id === base || (id.startsWith(base) && /^_(?:[2-9]\d*|1\d+)$/.test(suffix)); |
| 91 | +} |
| 92 | + |
| 93 | +function canRenameMedia(element: Element, identity: MediaIdentity, newSrc: string): boolean { |
| 94 | + const authored = ["data-timeline-label", "data-label", "aria-label"].some((name) => |
| 95 | + element.getAttribute(name)?.trim(), |
| 96 | + ); |
| 97 | + return !authored && identity.src !== newSrc && matchesGeneratedId(identity); |
| 98 | +} |
| 99 | + |
| 100 | +export function replacementTimelineAssetId( |
| 101 | + document: Document, |
| 102 | + element: Element, |
| 103 | + newSrc: string, |
| 104 | +): string | null { |
| 105 | + const identity = readMediaIdentity(element); |
| 106 | + if (!identity || !canRenameMedia(element, identity, newSrc)) return null; |
| 107 | + const elements = documentElements(document); |
| 108 | + if (elements.some((other) => elementReferencesId(other, identity.id))) return null; |
| 109 | + const ids = elements |
| 110 | + .filter((other) => other !== element) |
| 111 | + .map((other) => other.getAttribute("id")) |
| 112 | + .filter((value): value is string => value !== null); |
| 113 | + return buildTimelineAssetId(newSrc, ids); |
| 114 | +} |
0 commit comments