This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
import path from 'path';
|
||||
import traverseBabel from '@babel/traverse';
|
||||
import * as t from '@babel/types';
|
||||
import {
|
||||
validateFilePath,
|
||||
findJSXElementAtPosition,
|
||||
generateSourceWithMap,
|
||||
VITE_PROJECT_ROOT,
|
||||
} from '../../utils/ast-utils.js';
|
||||
import { decodeHtmlEntities, cssPropToCamel, toJsxAttributeName } from '../utils/html-utils.js';
|
||||
|
||||
const WRITABLE_ATTRIBUTE_TARGETS = new Set(['src', 'href', 'to']);
|
||||
const JSX_STYLE_PROPERTY_PATTERN = /^[A-Za-z_$][\w$]*$/;
|
||||
|
||||
/**
|
||||
* Parses `filePath:line:column` edit IDs produced by the transform plugin.
|
||||
*
|
||||
* @param {string} editId
|
||||
* @returns {{ filePath: string, line: number, column: number }|null}
|
||||
*/
|
||||
function parseEditId(editId) {
|
||||
const segments = editId.split(':');
|
||||
if (segments.length < 3) return null;
|
||||
const column = parseInt(segments.at(-1), 10);
|
||||
const line = parseInt(segments.at(-2), 10);
|
||||
const filePath = segments.slice(0, -2).join(':');
|
||||
if (!filePath || isNaN(line) || isNaN(column)) return null;
|
||||
return { filePath, line, column };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an HTML `style="..."` string into a Babel JSX style object expression.
|
||||
*
|
||||
* @param {string} styleString
|
||||
* @returns {import('@babel/types').JSXExpressionContainer}
|
||||
*/
|
||||
function parseStyleString(styleString) {
|
||||
const decoded = decodeHtmlEntities(styleString);
|
||||
const properties = [];
|
||||
for (const declaration of decoded.split(';')) {
|
||||
const colonIndex = declaration.indexOf(':');
|
||||
if (colonIndex < 0) continue;
|
||||
const key = declaration.slice(0, colonIndex).trim();
|
||||
const value = declaration.slice(colonIndex + 1).trim();
|
||||
if (!key || !value) continue;
|
||||
properties.push(
|
||||
t.objectProperty(t.identifier(cssPropToCamel(key)), t.stringLiteral(value))
|
||||
);
|
||||
}
|
||||
return t.jsxExpressionContainer(t.objectExpression(properties));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses HTML attribute string from saved innerHTML into JSX attribute nodes.
|
||||
* Skips `data-edit-*` attributes; maps names via {@link toJsxAttributeName}.
|
||||
*
|
||||
* @param {string} rawAttributes
|
||||
* @returns {import('@babel/types').JSXAttribute[]}
|
||||
*/
|
||||
function parseHtmlAttributesToJsx(rawAttributes) {
|
||||
if (!rawAttributes || !rawAttributes.trim()) return [];
|
||||
const attributes = [];
|
||||
const ATTRIBUTE_REGEX = /([\w-]+)\s*=\s*"([^"]*)"/g;
|
||||
let match;
|
||||
while ((match = ATTRIBUTE_REGEX.exec(rawAttributes)) !== null) {
|
||||
const [, rawName, value] = match;
|
||||
// data-edit-* are injected by the visual-editor transform; never write them to source
|
||||
if (rawName.startsWith('data-edit-')) continue;
|
||||
const name = toJsxAttributeName(rawName);
|
||||
if (name === 'style') {
|
||||
attributes.push(t.jsxAttribute(t.jsxIdentifier(name), parseStyleString(value)));
|
||||
} else {
|
||||
attributes.push(t.jsxAttribute(t.jsxIdentifier(name), t.stringLiteral(decodeHtmlEntities(value))));
|
||||
}
|
||||
}
|
||||
return attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds JSX children from saved innerHTML, reconstructing every tag structurally.
|
||||
* Rendered `<svg>` blocks are matched back, in order, to `iconQueue` (original icon nodes).
|
||||
*
|
||||
* @param {string} html
|
||||
* @param {import('@babel/types').JSXElement[]} iconQueue - consumed as rendered `<svg>` blocks are matched.
|
||||
* @returns {import('@babel/types').JSXChild[]}
|
||||
*/
|
||||
function buildChildrenFromText(html, iconQueue) {
|
||||
if (!html || html.trim() === '') return [];
|
||||
|
||||
// Decode </> back to literal tags (not ", which TAG_REGEX relies on as a delimiter).
|
||||
html = html.replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
const TAG_REGEX = /<(\/?)(\w+)((?:\s+[\w-]+(?:\s*=\s*"[^"]*")?)*)\s*(\/?)>/g;
|
||||
|
||||
const result = [];
|
||||
const stack = [{ children: result }];
|
||||
let lastIndex = 0;
|
||||
/** Depth inside a rendered `<svg>` block being skipped because it was matched to a preserved icon. */
|
||||
let skippedSvgDepth = 0;
|
||||
let match;
|
||||
|
||||
while ((match = TAG_REGEX.exec(html)) !== null) {
|
||||
const [full, closing, tagName, rawAttributes, selfClose] = match;
|
||||
const lower = tagName.toLowerCase();
|
||||
|
||||
if (skippedSvgDepth > 0) {
|
||||
if (lower === 'svg' && selfClose !== '/') {
|
||||
skippedSvgDepth += closing === '/' ? -1 : 1;
|
||||
}
|
||||
lastIndex = match.index + full.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (match.index > lastIndex) {
|
||||
const text = html.slice(lastIndex, match.index);
|
||||
if (text) stack[stack.length - 1].children.push(t.jsxText(text));
|
||||
}
|
||||
lastIndex = match.index + full.length;
|
||||
|
||||
if (closing === '/') {
|
||||
if (stack.length > 1 && stack[stack.length - 1].tag === lower) {
|
||||
const frame = stack.pop();
|
||||
const attributes = parseHtmlAttributesToJsx(frame.rawAttrs);
|
||||
stack[stack.length - 1].children.push(
|
||||
t.jsxElement(
|
||||
t.jsxOpeningElement(t.jsxIdentifier(frame.tag), attributes),
|
||||
t.jsxClosingElement(t.jsxIdentifier(frame.tag)),
|
||||
frame.children
|
||||
)
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lower === 'svg' && selfClose !== '/' && iconQueue.length > 0) {
|
||||
stack[stack.length - 1].children.push(iconQueue.shift());
|
||||
skippedSvgDepth = 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Void elements (img, br, ...) never get a closing tag in serialized HTML; others always do.
|
||||
const hasClosingTag = new RegExp(`</${lower}\\s*>`, 'i').test(html.slice(lastIndex));
|
||||
if (selfClose === '/' || !hasClosingTag) {
|
||||
const attributes = parseHtmlAttributesToJsx(rawAttributes);
|
||||
stack[stack.length - 1].children.push(
|
||||
t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier(lower), attributes, true), null, [], true)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
stack.push({ tag: lower, children: [], rawAttrs: rawAttributes || '' });
|
||||
}
|
||||
|
||||
if (lastIndex < html.length) {
|
||||
const text = html.slice(lastIndex);
|
||||
if (text) stack[stack.length - 1].children.push(t.jsxText(text));
|
||||
}
|
||||
|
||||
// Auto-close any unclosed tags (defensive: malformed/truncated captured HTML).
|
||||
while (stack.length > 1) {
|
||||
const frame = stack.pop();
|
||||
const attributes = parseHtmlAttributesToJsx(frame.rawAttrs);
|
||||
stack[stack.length - 1].children.push(
|
||||
t.jsxElement(
|
||||
t.jsxOpeningElement(t.jsxIdentifier(frame.tag), attributes),
|
||||
t.jsxClosingElement(t.jsxIdentifier(frame.tag)),
|
||||
frame.children
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges or creates a JSX `style={{ ... }}` object on the opening element.
|
||||
* An empty-string value means the editor removed the inline property, so the
|
||||
* matching JSX style entry is dropped instead of written as `prop: ""`.
|
||||
*
|
||||
* @param {import('@babel/types').JSXOpeningElement} openingElement
|
||||
* @param {Record<string, string>} styleObject
|
||||
* @returns {boolean} Whether the style shape was safe to update.
|
||||
*/
|
||||
function setJsxStyleProperty(openingElement, styleObject) {
|
||||
const existing = openingElement.attributes.find(attribute =>
|
||||
t.isJSXAttribute(attribute) && attribute.name?.name === 'style'
|
||||
);
|
||||
if (existing && t.isJSXExpressionContainer(existing.value) && t.isObjectExpression(existing.value.expression)) {
|
||||
const properties = existing.value.expression.properties;
|
||||
for (const [key, value] of Object.entries(styleObject)) {
|
||||
const propertyIndex = properties.findIndex(property =>
|
||||
t.isObjectProperty(property) && t.isIdentifier(property.key) && property.key.name === key
|
||||
);
|
||||
if (!value) {
|
||||
if (propertyIndex !== -1) properties.splice(propertyIndex, 1);
|
||||
} else if (propertyIndex !== -1) {
|
||||
properties[propertyIndex].value = t.stringLiteral(value);
|
||||
} else {
|
||||
properties.push(t.objectProperty(t.identifier(key), t.stringLiteral(value)));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} else if (!existing) {
|
||||
const properties = Object.entries(styleObject)
|
||||
.filter(([, value]) => !!value)
|
||||
.map(([key, value]) => t.objectProperty(t.identifier(key), t.stringLiteral(value)));
|
||||
if (properties.length === 0) return true;
|
||||
openingElement.attributes.push(
|
||||
t.jsxAttribute(t.jsxIdentifier('style'), t.jsxExpressionContainer(t.objectExpression(properties)))
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or removes a static JSX attribute.
|
||||
*
|
||||
* @param {import('@babel/types').JSXOpeningElement} openingElement
|
||||
* @param {string} attributeName
|
||||
* @param {string|null} value - `null` removes the attribute.
|
||||
* @returns {{ modified: true }|{ modified: false, error: string }}
|
||||
*/
|
||||
function setJsxAttribute(openingElement, attributeName, value) {
|
||||
const attributeIndex = openingElement.attributes.findIndex(attribute =>
|
||||
t.isJSXAttribute(attribute) && attribute.name?.name === attributeName
|
||||
);
|
||||
const existingAttribute = openingElement.attributes[attributeIndex];
|
||||
|
||||
if (value === null) {
|
||||
if (attributeIndex !== -1) openingElement.attributes.splice(attributeIndex, 1);
|
||||
return { modified: true };
|
||||
}
|
||||
|
||||
const newLiteral = t.stringLiteral(value);
|
||||
if (!existingAttribute) {
|
||||
openingElement.attributes.push(t.jsxAttribute(t.jsxIdentifier(attributeName), newLiteral));
|
||||
return { modified: true };
|
||||
}
|
||||
if (t.isStringLiteral(existingAttribute.value)) {
|
||||
existingAttribute.value = newLiteral;
|
||||
return { modified: true };
|
||||
}
|
||||
if (t.isJSXExpressionContainer(existingAttribute.value) && t.isStringLiteral(existingAttribute.value.expression)) {
|
||||
existingAttribute.value = newLiteral;
|
||||
return { modified: true };
|
||||
}
|
||||
return { modified: false, error: `Cannot write '${attributeName}' — source value is not a static string literal` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse + validate every draftKey; group valid edits by absolute file path;
|
||||
* push error entries for invalid ones.
|
||||
* @param {Record<string, object>} draft
|
||||
* @returns {{ editsByFile: Map<string, object[]>, results: object[] }}
|
||||
*/
|
||||
export function groupEditsByFile(draft) {
|
||||
const results = [];
|
||||
const editsByFile = new Map();
|
||||
|
||||
for (const [draftKey, edit] of Object.entries(draft)) {
|
||||
let editId = draftKey;
|
||||
const atIndex = editId.indexOf('@');
|
||||
if (atIndex >= 0) editId = editId.slice(0, atIndex);
|
||||
|
||||
const parsedId = parseEditId(editId);
|
||||
if (!parsedId) {
|
||||
results.push({ draftKey, success: false, error: 'Invalid editId format' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const validation = validateFilePath(parsedId.filePath);
|
||||
if (!validation.isValid) {
|
||||
results.push({ draftKey, success: false, error: validation.error });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!editsByFile.has(validation.absolutePath)) {
|
||||
editsByFile.set(validation.absolutePath, []);
|
||||
}
|
||||
editsByFile.get(validation.absolutePath).push({ draftKey, editId, edit, parsedId });
|
||||
}
|
||||
|
||||
return { editsByFile, results };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the JSX opening element and apply its atomic content, attribute, and style changes.
|
||||
* @param {object} babelAst
|
||||
* @param {object} edit
|
||||
* @param {{ filePath: string, line: number, column: number }} parsedId
|
||||
* @returns {{ modified: true }|{ modified: false, error: string }}
|
||||
*/
|
||||
export function applyElementEdit(babelAst, edit, parsedId) {
|
||||
const targetNodePath = findJSXElementAtPosition(babelAst, parsedId.line, parsedId.column + 1);
|
||||
if (!targetNodePath) return { modified: false, error: 'Target node not found' };
|
||||
|
||||
const targetOpeningElement = targetNodePath.node;
|
||||
const parentElementNode = targetNodePath.parentPath?.node;
|
||||
const changes = edit.instruction?.changes;
|
||||
if (!Array.isArray(changes) || changes.length === 0) {
|
||||
return { modified: false, error: 'Edit has no changes' };
|
||||
}
|
||||
|
||||
const contentChanges = changes.filter(change => change.kind === 'content');
|
||||
const attributeChanges = changes.filter(change => change.kind === 'attribute');
|
||||
const styleChanges = changes.filter(change => change.kind === 'style');
|
||||
if (contentChanges.length + attributeChanges.length + styleChanges.length !== changes.length) {
|
||||
return { modified: false, error: 'Unsupported visual edit change kind' };
|
||||
}
|
||||
if (contentChanges.length > 1) {
|
||||
return { modified: false, error: 'Edit contains multiple content changes' };
|
||||
}
|
||||
if (changes.some(change =>
|
||||
(change.before !== null && typeof change.before !== 'string')
|
||||
|| (change.after !== null && typeof change.after !== 'string')
|
||||
)) {
|
||||
return { modified: false, error: 'Visual edit change values must be strings or null' };
|
||||
}
|
||||
if (contentChanges.some(change => change.target !== null)) {
|
||||
return { modified: false, error: 'Content changes must have a null target' };
|
||||
}
|
||||
if (contentChanges.length && (!parentElementNode || !t.isJSXElement(parentElementNode))) {
|
||||
return { modified: false, error: 'Could not apply content change to AST' };
|
||||
}
|
||||
if ([...attributeChanges, ...styleChanges].some(change => typeof change.target !== 'string' || !change.target)) {
|
||||
return { modified: false, error: 'Attribute and style changes require a target' };
|
||||
}
|
||||
if (attributeChanges.some(change => !WRITABLE_ATTRIBUTE_TARGETS.has(change.target))) {
|
||||
return { modified: false, error: 'Unsupported visual edit attribute target' };
|
||||
}
|
||||
if (styleChanges.some(change => !JSX_STYLE_PROPERTY_PATTERN.test(change.target))) {
|
||||
return { modified: false, error: 'Unsupported visual edit style target' };
|
||||
}
|
||||
if (styleChanges.length) {
|
||||
const styleAttribute = targetOpeningElement.attributes.find(attribute =>
|
||||
t.isJSXAttribute(attribute) && attribute.name?.name === 'style'
|
||||
);
|
||||
if (
|
||||
styleAttribute
|
||||
&& !(
|
||||
t.isJSXExpressionContainer(styleAttribute.value)
|
||||
&& t.isObjectExpression(styleAttribute.value.expression)
|
||||
)
|
||||
) {
|
||||
return { modified: false, error: 'Cannot write style — source value is not a static object literal' };
|
||||
}
|
||||
}
|
||||
|
||||
// Validate every attribute before mutating the AST so a later unsupported
|
||||
// dynamic value cannot leave an earlier change partially applied.
|
||||
for (const change of attributeChanges) {
|
||||
const existingAttribute = targetOpeningElement.attributes.find(attribute =>
|
||||
t.isJSXAttribute(attribute) && attribute.name?.name === change.target
|
||||
);
|
||||
const isWritable = !existingAttribute
|
||||
|| t.isStringLiteral(existingAttribute.value)
|
||||
|| (
|
||||
t.isJSXExpressionContainer(existingAttribute.value)
|
||||
&& t.isStringLiteral(existingAttribute.value.expression)
|
||||
);
|
||||
if (!isWritable) {
|
||||
return {
|
||||
modified: false,
|
||||
error: `Cannot write '${change.target}' — source value is not a static string literal`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const change of attributeChanges) {
|
||||
const result = setJsxAttribute(targetOpeningElement, change.target, change.after);
|
||||
if (!result.modified) return result;
|
||||
}
|
||||
|
||||
if (contentChanges.length) {
|
||||
// Icon components can't be rebuilt from rendered HTML, so their original nodes pass through.
|
||||
const preservedIcons = parentElementNode.children.filter(child =>
|
||||
t.isJSXElement(child)
|
||||
&& child.openingElement.selfClosing
|
||||
&& child.openingElement.name?.name
|
||||
&& /^[A-Z]/.test(child.openingElement.name.name)
|
||||
);
|
||||
parentElementNode.children = buildChildrenFromText(contentChanges[0].after ?? '', preservedIcons);
|
||||
}
|
||||
|
||||
if (styleChanges.length) {
|
||||
const style = Object.fromEntries(styleChanges.map(change => [change.target, change.after ?? '']));
|
||||
if (!setJsxStyleProperty(targetOpeningElement, style)) {
|
||||
return { modified: false, error: 'Cannot write style — source value is not a static object literal' };
|
||||
}
|
||||
}
|
||||
|
||||
return { modified: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip any lingering data-edit-* attributes from the AST and generate the new
|
||||
* source, returning the result entry for the API response.
|
||||
*
|
||||
* Must never write to disk: `/api/draft-save` is reachable unauthenticated from
|
||||
* the public preview origin, so persisting is the authenticated backend's job
|
||||
* via `POST /api/websites/:id/visual-edits`.
|
||||
*
|
||||
* @param {object} babelAst
|
||||
* @param {string} absoluteFilePath
|
||||
* @param {string} originalContent
|
||||
* @returns {{ filePath: string, newFileContent: string }}
|
||||
*/
|
||||
export function renderEditedSource(babelAst, absoluteFilePath, originalContent) {
|
||||
// Strip any data-edit-* attributes before generating source.
|
||||
const traverseFunction = traverseBabel.default || traverseBabel;
|
||||
traverseFunction(babelAst, {
|
||||
JSXOpeningElement(openingElementPath) {
|
||||
openingElementPath.node.attributes = openingElementPath.node.attributes.filter(attribute =>
|
||||
!(t.isJSXAttribute(attribute) && typeof attribute.name?.name === 'string'
|
||||
&& attribute.name.name.startsWith('data-edit-'))
|
||||
);
|
||||
},
|
||||
});
|
||||
const webRelativeFilePath = path.relative(VITE_PROJECT_ROOT, absoluteFilePath).split(path.sep).join('/');
|
||||
const output = generateSourceWithMap(babelAst, webRelativeFilePath, originalContent);
|
||||
return { filePath: webRelativeFilePath, newFileContent: output.code };
|
||||
}
|
||||
Reference in New Issue
Block a user