Initial commit: Korrith website, assessment API, PocketBase schema, Docker/Coolify deployment
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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.' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user