211 lines
7.1 KiB
JavaScript
211 lines
7.1 KiB
JavaScript
import nodePath from 'path';
|
|
import { parse } from '@babel/parser';
|
|
import traverseBabel from '@babel/traverse';
|
|
import * as t from '@babel/types';
|
|
import fs from 'fs';
|
|
import {
|
|
parseFileToAST,
|
|
generateSourceWithMap,
|
|
VITE_PROJECT_ROOT
|
|
} from '../utils/ast-utils.js';
|
|
import { isSameOriginRequest } from '../utils/same-origin.js';
|
|
import { groupEditsByFile, applyElementEdit, renderEditedSource } from './server/source-writer.js';
|
|
import {
|
|
EDITABLE_HTML_TAGS,
|
|
INLINE_EDIT_HTML_TAGS,
|
|
checkTagNameEditable,
|
|
validateImageSrc,
|
|
hasMeaningfulTextContent,
|
|
shouldDisableDueToChildren,
|
|
evalChildComposition,
|
|
subtreeRequiresAssistance,
|
|
hasEditableAncestor,
|
|
} from './transform/editability.js';
|
|
import {
|
|
detectUrlAttribute,
|
|
attachEditAttributes,
|
|
attachAssistedEditId,
|
|
} from './transform/inject-attributes.js';
|
|
|
|
export default function inlineEditPlugin() {
|
|
return {
|
|
name: 'vite-inline-edit-plugin',
|
|
enforce: 'pre',
|
|
|
|
transform(code, id) {
|
|
if (!/\.(jsx|tsx)$/.test(id) || !id.startsWith(VITE_PROJECT_ROOT) || id.includes('node_modules')) {
|
|
return null;
|
|
}
|
|
|
|
const relativeFilePath = nodePath.relative(VITE_PROJECT_ROOT, id);
|
|
const webRelativeFilePath = relativeFilePath.split(nodePath.sep).join('/');
|
|
|
|
try {
|
|
const babelAst = parse(code, {
|
|
sourceType: 'module',
|
|
plugins: ['jsx', 'typescript'],
|
|
errorRecovery: true
|
|
});
|
|
|
|
let attributesAdded = 0;
|
|
|
|
traverseBabel.default(babelAst, {
|
|
JSXOpeningElement(path) {
|
|
const openingNode = path.node;
|
|
const elementNode = path.parentPath.node;
|
|
|
|
if (!openingNode.loc) return;
|
|
|
|
const originalLength = openingNode.attributes.length;
|
|
openingNode.attributes = openingNode.attributes.filter(attribute =>
|
|
!(t.isJSXAttribute(attribute) && attribute.name?.name?.startsWith('data-edit-'))
|
|
);
|
|
if (openingNode.attributes.length < originalLength) {
|
|
attributesAdded++;
|
|
}
|
|
|
|
const line = openingNode.loc.start.line;
|
|
const column = openingNode.loc.start.column + 1;
|
|
const editId = `${webRelativeFilePath}:${line}:${column}`;
|
|
|
|
// Condition 1: an annotated ancestor already owns this subtree. Applies to
|
|
// assisted ids too, so a branch never ends up with nested edit targets.
|
|
if (hasEditableAncestor(path)) return;
|
|
|
|
// Condition 2: tags outside the editable list cannot be edited in place,
|
|
// but still qualify for an assisted id when they hold text of their own.
|
|
if (!checkTagNameEditable(openingNode, EDITABLE_HTML_TAGS)) {
|
|
if (checkTagNameEditable(openingNode, INLINE_EDIT_HTML_TAGS)) return;
|
|
|
|
if (t.isJSXElement(elementNode) && hasMeaningfulTextContent(elementNode)) {
|
|
attachAssistedEditId(openingNode, editId);
|
|
attributesAdded++;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Condition 3: image src must be a non-empty static string
|
|
const imageValidation = validateImageSrc(openingNode);
|
|
if (!imageValidation.isValid) {
|
|
attachAssistedEditId(openingNode, editId);
|
|
attributesAdded++;
|
|
return;
|
|
}
|
|
|
|
// Condition 4: mark as assisted-edit if spread props or dynamic children
|
|
if (shouldDisableDueToChildren(elementNode, openingNode)) {
|
|
attachAssistedEditId(openingNode, editId);
|
|
attributesAdded++;
|
|
return;
|
|
}
|
|
|
|
// Condition 5: skip or mark assisted-edit based on child composition
|
|
const childResult = evalChildComposition(elementNode, openingNode);
|
|
if (childResult === 'skip') return;
|
|
if (childResult === 'disabled') {
|
|
attachAssistedEditId(openingNode, editId);
|
|
attributesAdded++;
|
|
return;
|
|
}
|
|
|
|
// Condition 6: recursive subtree check — dynamic expressions,
|
|
// spread props, or conditionals anywhere below this element make
|
|
// a direct edit unsafe (it would overwrite the JSX with static text).
|
|
if (subtreeRequiresAssistance(elementNode)) {
|
|
attachAssistedEditId(openingNode, editId);
|
|
attributesAdded++;
|
|
return;
|
|
}
|
|
|
|
const urlAttributeName = detectUrlAttribute(openingNode);
|
|
attachEditAttributes(openingNode, editId, urlAttributeName);
|
|
attributesAdded++;
|
|
}
|
|
});
|
|
|
|
if (attributesAdded > 0) {
|
|
const output = generateSourceWithMap(babelAst, webRelativeFilePath, code);
|
|
return { code: output.code, map: output.map };
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
console.error(`[vite][visual-editor] Error transforming ${id}:`, error);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
// Computes the source a batch of draft edits would produce and returns it.
|
|
// Nothing is written here — the preview posts the result up to the editor,
|
|
// which persists it through the authenticated backend.
|
|
configureServer(server) {
|
|
server.middlewares.use('/api/draft-save', async (request, response, next) => {
|
|
if (request.method !== 'POST') return next();
|
|
|
|
// Defence in depth only: this endpoint is public and Sec-Fetch-Site is
|
|
// client-supplied, so it stops browser-driven cross-origin calls and
|
|
// nothing more. Authorisation happens on the backend persist route.
|
|
if (!isSameOriginRequest(request)) {
|
|
response.writeHead(403, { 'Content-Type': 'application/json' });
|
|
return response.end(JSON.stringify({ error: 'Forbidden' }));
|
|
}
|
|
|
|
let body = '';
|
|
request.on('data', chunk => { body += chunk.toString(); });
|
|
|
|
request.on('end', async () => {
|
|
let parsedBody;
|
|
try {
|
|
parsedBody = JSON.parse(body);
|
|
} catch {
|
|
response.writeHead(400, { 'Content-Type': 'application/json' });
|
|
return response.end(JSON.stringify({ error: 'Invalid JSON request body' }));
|
|
}
|
|
|
|
if (!parsedBody || typeof parsedBody !== 'object') {
|
|
response.writeHead(400, { 'Content-Type': 'application/json' });
|
|
return response.end(JSON.stringify({ error: 'Invalid JSON request body' }));
|
|
}
|
|
|
|
try {
|
|
const { draft } = parsedBody;
|
|
|
|
if (!draft || typeof draft !== 'object') {
|
|
response.writeHead(400, { 'Content-Type': 'application/json' });
|
|
return response.end(JSON.stringify({ error: 'Missing or invalid draft object' }));
|
|
}
|
|
|
|
const { editsByFile, results } = groupEditsByFile(draft);
|
|
|
|
for (const [absoluteFilePath, fileEdits] of editsByFile) {
|
|
const originalContent = fs.readFileSync(absoluteFilePath, 'utf-8');
|
|
const babelAst = parseFileToAST(absoluteFilePath);
|
|
let fileModified = false;
|
|
|
|
for (const { draftKey, edit, parsedId } of fileEdits) {
|
|
const { modified, error } = applyElementEdit(babelAst, edit, parsedId);
|
|
if (!modified) {
|
|
results.push({ draftKey, success: false, error });
|
|
continue;
|
|
}
|
|
fileModified = true;
|
|
}
|
|
|
|
if (fileModified) {
|
|
results.push(renderEditedSource(babelAst, absoluteFilePath, originalContent));
|
|
}
|
|
}
|
|
|
|
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
response.end(JSON.stringify({ success: true, results }));
|
|
} catch (error) {
|
|
console.error('[vite][visual-editor] Error in draft-save:', error);
|
|
response.writeHead(500, { 'Content-Type': 'application/json' });
|
|
response.end(JSON.stringify({ error: 'Internal server error during draft save.' }));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
};
|
|
}
|