/** * Manages DOM stamping: observation, mutation batching, stega decoding, and attribute application. * Absorbs logic from stamp/index.ts and BrowserController's stamping functionality. */ import { vercelStegaDecode } from '@vercel/stega'; import { splitStega } from '../../stega/decode.js'; import { DecodedInfo, isDecodedInfo } from '../../stega/types.js'; import { createScheduler } from '../../utils/createScheduler.js'; import { resolveDocument } from '../../utils/dom.js'; import type { StampSummary } from '../types.js'; import { AUTOMATIC_STEGA_STAMP_ATTRIBUTE, AUTOMATIC_TARGET_STAMP_ATTRIBUTE, GROUP_ATTRIBUTE, GROUP_BOUNDARY_ATTRIBUTE, SOURCE_STAMP_ATTRIBUTE } from './constants.js'; export class DomStampingManager { private observer: MutationObserver; private readonly pendingElementsToStamp = new Set(); private readonly scheduleStamping = createScheduler(() => this.instantStampPendingElements()); constructor( private readonly root: ParentNode, private readonly onStamp: (summary: StampSummary) => void, private readonly stripStega: boolean = false, private readonly silenceWarnings: boolean = false ) { this.observer = new MutationObserver((mutations) => this.handleMutations(mutations)); this.observer.observe(this.root, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['alt', SOURCE_STAMP_ATTRIBUTE] }); this.instantStampPendingElements(true); } dispose() { this.observer.disconnect(); this.pendingElementsToStamp.clear(); const nodes = this.root.querySelectorAll(`[${AUTOMATIC_TARGET_STAMP_ATTRIBUTE}]`); for (const el of nodes) { el.removeAttribute(AUTOMATIC_TARGET_STAMP_ATTRIBUTE); } } private handleMutations(mutations: MutationRecord[]) { let hasChanges = false; for (const mutation of mutations) { if (mutation.type === 'characterData') { const node = mutation.target as Node; const parent = (node.parentElement ?? node.parentNode ?? this.root) as ParentNode; this.pendingElementsToStamp.add(parent); hasChanges = true; } else if (mutation.type === 'attributes' && mutation.attributeName === 'alt') { const element = mutation.target as Element; this.pendingElementsToStamp.add((element.parentElement ?? this.root) as ParentNode); hasChanges = true; } else if ( mutation.type === 'attributes' && mutation.attributeName === SOURCE_STAMP_ATTRIBUTE ) { const element = mutation.target as Element; this.pendingElementsToStamp.add((element.parentElement ?? this.root) as ParentNode); hasChanges = true; } else if (mutation.type === 'childList') { this.pendingElementsToStamp.add(mutation.target as ParentNode); for (const node of mutation.addedNodes) { if ( node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.DOCUMENT_FRAGMENT_NODE ) { this.pendingElementsToStamp.add(node as ParentNode); } } hasChanges = true; } } if (hasChanges) { this.scheduleStamping(); } } private instantStampPendingElements(firstStamping = false) { const elementsToStamp = this.pendingElementsToStamp.size === 0 ? [this.root] : Array.from(this.pendingElementsToStamp); this.pendingElementsToStamp.clear(); const summaries: StampSummary[] = []; for (const elementToStamp of elementsToStamp) { const summary = this.stampElement(elementToStamp); summaries.push(summary); } const combinedSummary = summaries.length === 1 ? summaries[0] : { appliedStamps: summaries.reduce((acc, summary) => { for (const [key, value] of summary.appliedStamps.entries()) { acc.set(key, value); } return acc; }, new Map()), scope: this.root }; if (!this.silenceWarnings && firstStamping && combinedSummary.appliedStamps.size === 0) { const message = '[@datocms/content-link] No editable elements were detected after initialization. ' + 'Make sure that Content Link headers are enabled in your GraphQL requests! ' + "If you're hydrating/streaming, do not replace the server-rendered nodes that carry stega-encoded data: reuse the same DOM element!"; console.warn(message); } if (summaries.length === 0) { return; } this.onStamp(combinedSummary); } private stampElement(element: ParentNode): StampSummary { const doc = resolveDocument(element); if (!doc) { return { appliedStamps: new Map(), scope: element }; } // Track elements stamped in this pass to detect collisions within the same pass const appliedStamps = new Map(); // First pass: walk text nodes and process stega-encoded content const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); let node: Node | null = walker.nextNode(); while (node) { if (!(node instanceof Text)) { node = walker.nextNode(); continue; } const value = node.nodeValue ?? ''; const parent = node.parentElement; // Skip text nodes inside