smartssr/ts/smartssr.function.serialize.ts

151 lines
5.1 KiB
TypeScript

/**
* Represents a style object on a custom element.
*/
export interface IStyleObject {
cssText: string;
}
/**
* Represents a constructor for a custom element that may have a static `styles` property.
*/
export interface ICustomElementConstructor extends Function {
styles?: IStyleObject[];
}
declare var document: Document;
/**
* Serializes shadow-DOM-based elements into light DOM by:
* 1. Moving slotted children out of their <slot> elements.
* 2. Converting shadow-root CSS to scoping-based CSS.
* 3. Appending styles and elements back onto the node.
*
* @param rootNode The root node under which all child nodes will be serialized.
*/
export function serializeFunction(rootNode: Node): void {
/**
* Instead of a UUID, we use a globally incrementing counter for stable,
* predictable IDs across multiple serializations of the same tree.
*/
let globalIdCounter = 0;
const generateStableId = (): string => {
globalIdCounter++;
// Feel free to adjust the prefix/suffix as you like
return `unix-${globalIdCounter}`;
};
/**
* Prepend the generated ID class to CSS in order to emulate :host scoping.
*/
const prependCss = (uuidID: string, styleTemplate: string): string => {
// Ensure there's at least one :host, so the user code typically expects scoping
if (!styleTemplate.includes(':host')) {
styleTemplate = `:host {}\n\n${styleTemplate}`;
}
// Replace patterns that should be placed under the .uuidID scope
styleTemplate = styleTemplate.replace(/}[ \t\n]+\./g, `}\n\n.${uuidID} .`);
styleTemplate = styleTemplate.replace(/}[ \t\n]+\*/g, `}\n\n.${uuidID} *`);
styleTemplate = styleTemplate.replace(/\(\[/g, `[`);
styleTemplate = styleTemplate.replace(/\]\)/g, `]`);
// Replace :host with .uuidID
styleTemplate = styleTemplate.replace(/:host/g, `.${uuidID}`);
styleTemplate = styleTemplate.replace(/{[ \t\n]+\./g, `{\n\n.${uuidID} .`);
styleTemplate = styleTemplate.replace(/}[ \t\n]+img/g, `}\n\n.${uuidID} img`);
styleTemplate = styleTemplate.replace(/}[ \t\n]+div/g, `}\n\n.${uuidID} div`);
return styleTemplate;
};
// Keep track of visited nodes to avoid loops
const visitedNodes = new WeakSet<Node>();
/**
* Recursively serializes a node, moving shadow DOM content into light DOM.
*
* @param nodeArg The node to serialize.
*/
function serializeNode(nodeArg: HTMLElement | Node): void {
// Prevent re-serializing the same node
if (visitedNodes.has(nodeArg)) {
return;
}
visitedNodes.add(nodeArg);
// If the node has a shadowRoot, move everything into light DOM
if (
nodeArg instanceof HTMLElement &&
nodeArg.shadowRoot &&
nodeArg.shadowRoot instanceof ShadowRoot
) {
// Mark node for SSR
nodeArg.setAttribute('smartssr', 'yes');
// Generate a stable ID for CSS scoping
const nodeId = generateStableId();
nodeArg.classList.add(nodeId);
// Move slotted nodes from <slot> to the light DOM
const slots = nodeArg.shadowRoot.querySelectorAll('slot');
const slotsForMove: HTMLSlotElement[] = Array.from(slots);
for (const slot of slotsForMove) {
const slottedLightNodesForMove = slot.assignedNodes();
slottedLightNodesForMove.forEach((lightNode) => {
if (slot.parentNode) {
slot.parentNode.insertBefore(lightNode, slot);
}
});
}
// Prepare nodes to append after transformations
const nodesToAppend: Node[] = [];
// Some frameworks store static styles in a static `styles` property on the constructor
const elementConstructor = nodeArg.constructor as ICustomElementConstructor;
if (Array.isArray(elementConstructor.styles)) {
for (const styleObj of elementConstructor.styles) {
const styleTag = document.createElement('style');
styleTag.textContent = prependCss(nodeId, styleObj.cssText);
nodesToAppend.push(styleTag);
}
}
// Convert existing shadow DOM childNodes
nodeArg.shadowRoot.childNodes.forEach((childNode: ChildNode) => {
if (childNode instanceof HTMLElement) {
// If it's a <style>, prepend the scoping class
if (childNode.tagName === 'STYLE') {
childNode.textContent = prependCss(nodeId, childNode.textContent ?? '');
} else {
// Recursively serialize sub-elements
serializeNode(childNode);
}
nodesToAppend.push(childNode);
} else {
// For non-HTMLElement child nodes, just append
nodesToAppend.push(childNode);
}
});
// Clear the element and re-append the now converted nodes
while (nodeArg.firstChild) {
nodeArg.removeChild(nodeArg.firstChild);
}
for (const childNode of nodesToAppend) {
nodeArg.appendChild(childNode);
}
} else {
// If it's a normal (light DOM) node, just recurse into its childNodes
nodeArg.childNodes.forEach((child) => {
serializeNode(child);
});
}
}
// Start serialization from the children of the provided root
rootNode.childNodes.forEach((nodeArg) => {
serializeNode(nodeArg);
});
}