import { decodeHTML } from "entities";
export interface DesignExportFile {
filename: string;
fileType: string | null;
content: string | null;
}
export interface DesignExportSaveResult {
filePath?: string;
saveWarning?: string;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
// Layers toggled hidden in the editor are only visually suppressed by the
// live editor bridge (which paints `display:none` on
// `[data-agent-native-hidden="true"]` inside the canvas iframe). Exports never
// go through that bridge, so without this rule hidden layers would leak back
// into every exported artifact. Inject the same rule at export time so
// hidden-in-editor stays hidden-in-export.
export const HIDDEN_LAYER_EXPORT_STYLE_MARKER =
"data-agent-native-export-hidden";
export const HIDDEN_LAYER_EXPORT_CSS = `[data-agent-native-hidden="true"]{display:none!important}`;
/**
* Wraps the hidden-layer suppression rule in a marked `;
}
/**
* Injects the hidden-layer suppression rule into a standalone HTML document,
* before `` when present, otherwise prepended to the document. Safe to
* call more than once — re-injection is skipped if the marked style tag is
* already present.
*/
export function injectHiddenLayerExportStyle(html: string): string {
if (
new RegExp(``;
const closeHead = html.lastIndexOf("");
if (closeHead !== -1) {
html = `${html.slice(0, closeHead)}${styleBlock}\n${html.slice(closeHead)}`;
} else {
html = `${styleBlock}\n${html}`;
}
}
return injectHiddenLayerExportStyle(html);
}
const combinedBody = [...htmlFiles, ...jsxFiles]
.map((f) => extractRenderableHtml(f.content ?? ""))
.join("\n\n");
return `
${escapeHtml(title)}
${hiddenLayerExportStyleTag()}
${combinedBody}
`;
}
function escapeXmlAttribute(value: string): string {
return value
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(//g, ">");
}
function isStaticXmlAttributeName(name: string): boolean {
if (
name.startsWith("@") ||
name.startsWith(":") ||
/^x-(?:data|init|show|cloak|if|for|transition|on|bind|model|text|html|ref|teleport|id|effect|ignore)(?:$|[.:])/i.test(
name,
)
) {
return false;
}
if (/^on/i.test(name)) return false;
// Preserve the standard namespaces used by inline SVG icons. Other colon
// prefixes are unbound in raw HTML and would make the enclosing XML invalid.
return (
/^(?:xmlns:xlink|xlink:href|xml:lang|xml:space)$/i.test(name) ||
/^[A-Za-z_][A-Za-z0-9._-]*$/.test(name)
);
}
function unquotedAttributeValue(valueSuffix: string): string {
const equals = valueSuffix.indexOf("=");
if (equals === -1) return "";
const raw = valueSuffix.slice(equals + 1).trim();
if (
raw.length >= 2 &&
((raw.startsWith('"') && raw.endsWith('"')) ||
(raw.startsWith("'") && raw.endsWith("'")))
) {
return raw.slice(1, -1);
}
return raw;
}
function isStaticXmlAttributeValue(name: string, valueSuffix: string): boolean {
const value = unquotedAttributeValue(valueSuffix);
if (
/^(?:href|src|action|formaction|poster|xlink:href)$/i.test(name) &&
(/^(?:javascript|vbscript):/i.test(value.trim()) ||
(/^data:/i.test(value.trim()) &&
!/^data:image\/(?:png|jpeg|webp|gif|avif);base64,/i.test(value.trim())))
) {
return false;
}
if (
name.toLowerCase() === "style" &&
/(?:javascript|vbscript|data\s*:\s*text\/html)/i.test(value)
) {
return false;
}
return true;
}
/**
* Convert one HTML start tag into XML-safe XHTML without touching quoted
* values. This is a small stateful tokenizer rather than a directive-specific
* regex: it handles arbitrary whitespace, quoted `>` characters, boolean
* attributes, and any future framework shorthand whose name is not an XML
* QName. Runtime directives are deliberately omitted because an exported SVG
* is a static snapshot and cannot run the source framework scripts.
*/
function normalizeStartTagForXml(tag: string): string {
let cursor = 1;
while (cursor < tag.length && !/[\s/>]/.test(tag[cursor]!)) cursor += 1;
let output = tag.slice(0, cursor);
while (cursor < tag.length) {
const whitespaceStart = cursor;
while (cursor < tag.length && /\s/.test(tag[cursor]!)) cursor += 1;
const whitespace = tag.slice(whitespaceStart, cursor);
if (tag.startsWith("/>", cursor) || tag[cursor] === ">") {
return output + whitespace + tag.slice(cursor);
}
const nameStart = cursor;
while (cursor < tag.length && !/[\s=/>]/.test(tag[cursor]!)) cursor += 1;
const name = tag.slice(nameStart, cursor);
if (!name) return output + whitespace + tag.slice(cursor);
const afterNameStart = cursor;
while (cursor < tag.length && /\s/.test(tag[cursor]!)) cursor += 1;
let valueSuffix = tag.slice(afterNameStart, cursor);
let hasValue = false;
if (tag[cursor] === "=") {
hasValue = true;
const equalsStart = cursor;
cursor += 1;
while (cursor < tag.length && /\s/.test(tag[cursor]!)) cursor += 1;
if (tag[cursor] === '"' || tag[cursor] === "'") {
const quote = tag[cursor]!;
cursor += 1;
while (cursor < tag.length && tag[cursor] !== quote) cursor += 1;
if (cursor < tag.length) cursor += 1;
} else {
while (cursor < tag.length && !/[\s>]/.test(tag[cursor]!)) cursor += 1;
}
valueSuffix += tag.slice(equalsStart, cursor);
}
if (
isStaticXmlAttributeName(name) &&
isStaticXmlAttributeValue(name, valueSuffix)
) {
output += `${whitespace}${name}${hasValue ? valueSuffix : '=""'}`;
}
}
return output;
}
function findMatchingElementEnd(
html: string,
openEnd: number,
tagName: string,
): number {
const tagPattern = new RegExp(`<\\s*(/?)\\s*${tagName}\\b`, "gi");
tagPattern.lastIndex = openEnd + 1;
let depth = 1;
let match: RegExpExecArray | null;
while ((match = tagPattern.exec(html))) {
let end = match.index + match[0].length;
let quote = "";
while (end < html.length) {
const character = html[end]!;
if (quote) {
if (character === quote) quote = "";
} else if (character === '"' || character === "'") {
quote = character;
} else if (character === ">") {
break;
}
end += 1;
}
if (end >= html.length) return html.length;
if (match[1] === "/") {
depth -= 1;
if (depth === 0) return end + 1;
} else if (!/\/\s*>$/.test(html.slice(match.index, end + 1))) {
depth += 1;
}
tagPattern.lastIndex = end + 1;
}
return html.length;
}
function normalizeStartTagsForXml(html: string): string {
let output = "";
let cursor = 0;
while (cursor < html.length) {
const start = html.indexOf("<", cursor);
if (start === -1) return output + html.slice(cursor);
output += html.slice(cursor, start);
if (html.startsWith("" : "]]>";
const end = html.indexOf(marker, start + 4);
if (end === -1) return output + html.slice(start);
output += html.slice(start, end + marker.length);
cursor = end + marker.length;
continue;
}
if (/^<\s*[!/?]/.test(html.slice(start))) {
const end = html.indexOf(">", start + 1);
if (end === -1) return output + html.slice(start);
output += html.slice(start, end + 1);
cursor = end + 1;
continue;
}
let end = start + 1;
let quote = "";
while (end < html.length) {
const character = html[end]!;
if (quote) {
if (character === quote) quote = "";
} else if (character === '"' || character === "'") {
quote = character;
} else if (character === ">") {
break;
}
end += 1;
}
if (end >= html.length) return output + html.slice(start);
const tag = html.slice(start, end + 1);
const openingTagName = /^<\s*([A-Za-z][A-Za-z0-9:-]*)\b/.exec(tag)?.[1];
const editorChromeElement =
/\bdata-agent-native-(?:editor-chrome|edit-overlay|edit-handle|edge-handle|rotate-handle|transform-badge|spacing-badge|spacing-overlay|spacing-line|spacing-region|insertion-guide|measurement-overlay)\b/i.test(
tag,
);
if (
openingTagName &&
(editorChromeElement ||
/^(?:script|iframe|object|embed|base|foreignObject|animate|set)$/i.test(
openingTagName,
))
) {
const isVoid =
/^(?:embed|base)$/i.test(openingTagName) || /\/\s*>$/.test(tag);
if (isVoid) {
cursor = end + 1;
continue;
}
cursor = findMatchingElementEnd(html, end, openingTagName);
continue;
}
output += normalizeStartTagForXml(tag);
cursor = end + 1;
const rawTag = /^<\s*(script|style)\b/i.exec(tag)?.[1];
if (rawTag) {
const closePattern = new RegExp(`<\\/\\s*${rawTag}\\s*>`, "i");
const rest = html.slice(cursor);
const close = closePattern.exec(rest);
if (!close?.index) {
if (!close) return output + rest;
}
if (close) {
const closeEnd = cursor + close.index + close[0].length;
output += html.slice(cursor, closeEnd);
cursor = closeEnd;
}
}
}
return output;
}
function normalizeHtmlForSvg(html: string): string {
const withoutDoctype = html.replace(/]*>/i, "").trim();
const withXmlSafeTags = normalizeStartTagsForXml(withoutDoctype);
const withClosedVoidElements = withXmlSafeTags.replace(
/<(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)\b([^>]*)>/gi,
(match, tag: string, attrs: string) => {
if (/\/\s*>$/.test(match)) return match;
return `<${tag}${attrs} />`;
},
);
const withXmlEntities = withClosedVoidElements.replace(
/&([A-Za-z][A-Za-z0-9]+);/g,
(entity, name: string) => {
if (/^(?:amp|lt|gt|quot|apos)$/i.test(name)) return entity;
const decoded = decodeHTML(entity);
if (decoded === entity) return `&${name};`;
return Array.from(decoded)
.map((character) => `${character.codePointAt(0)};`)
.join("");
},
);
const withEscapedBareAmpersands = withXmlEntities.replace(
/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)/g,
"&",
);
// Wrap