Files
korrithwebsite/apps/web/plugins/visual-editor/vite-plugin-react-inline-editor.js
T

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.' }));
}
});
});
}
};
}