feat: sync full workspace including web modules, docs, and configurations to Gitea
Optimized the root .gitignore to exclude virtual environments, node modules, and temp folders to ensure clean and lightweight version tracking. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
227
axhub-make/vite-plugins/specDocApiPlugin.ts
Normal file
227
axhub-make/vite-plugins/specDocApiPlugin.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import type { Plugin } from 'vite';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import formidable from 'formidable';
|
||||
|
||||
import {
|
||||
isPathInside,
|
||||
resolveDocumentPathFromDocUrl,
|
||||
resolveUniqueFilePath,
|
||||
sanitizeImageUploadFileName,
|
||||
SPEC_DOC_IMAGE_ALLOWED_EXTENSIONS,
|
||||
SPEC_DOC_IMAGE_MAX_FILE_SIZE,
|
||||
SPEC_DOC_IMAGE_MIME_TO_EXTENSION,
|
||||
} from './utils/docUtils';
|
||||
import { getRequestPathname } from './utils/httpUtils';
|
||||
|
||||
export function specDocApiPlugin(): Plugin {
|
||||
return {
|
||||
name: 'spec-doc-api-plugin',
|
||||
configureServer(server: any) {
|
||||
server.middlewares.use((req: any, res: any, next: any) => {
|
||||
const pathname = getRequestPathname(req);
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
if (req.method === 'POST' && pathname === '/api/spec-doc/upload-image') {
|
||||
const uploadDir = path.resolve(projectRoot, 'temp', 'spec-doc-images');
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
|
||||
const form = formidable({
|
||||
uploadDir,
|
||||
keepExtensions: true,
|
||||
multiples: false,
|
||||
maxFileSize: SPEC_DOC_IMAGE_MAX_FILE_SIZE,
|
||||
});
|
||||
|
||||
form.parse(req, (parseError: any, fields: any, files: any) => {
|
||||
const removeTempFile = (tempPath: string) => {
|
||||
if (!tempPath) return;
|
||||
try {
|
||||
if (fs.existsSync(tempPath)) {
|
||||
fs.unlinkSync(tempPath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors.
|
||||
}
|
||||
};
|
||||
|
||||
if (parseError) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify({ error: parseError?.message || 'Failed to parse multipart payload' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const docUrlField = Array.isArray(fields?.docUrl) ? fields.docUrl[0] : fields?.docUrl;
|
||||
const docUrl = String(docUrlField || '').trim();
|
||||
if (!docUrl) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify({ error: 'Missing docUrl' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadedFileValue = files?.file ?? files?.files;
|
||||
const uploadedFile = Array.isArray(uploadedFileValue) ? uploadedFileValue[0] : uploadedFileValue;
|
||||
if (!uploadedFile) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify({ error: 'Missing file' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const tempPath = String(uploadedFile?.filepath || uploadedFile?.path || '').trim();
|
||||
if (!tempPath || !fs.existsSync(tempPath)) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify({ error: 'Uploaded file is missing from temporary storage' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = resolveDocumentPathFromDocUrl(docUrl, req.headers.host, projectRoot);
|
||||
if ('status' in resolved) {
|
||||
res.statusCode = resolved.status;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify({ error: resolved.error }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(resolved.docPath)) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify({ error: 'Document not found' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const originalName = String(
|
||||
uploadedFile?.originalFilename || uploadedFile?.newFilename || uploadedFile?.name || path.basename(tempPath),
|
||||
).trim();
|
||||
const mimeType = String(uploadedFile?.mimetype || uploadedFile?.type || '').toLowerCase();
|
||||
const originalExt = path.extname(originalName).toLowerCase();
|
||||
const extByMime = SPEC_DOC_IMAGE_MIME_TO_EXTENSION[mimeType] || '';
|
||||
const normalizedExt = SPEC_DOC_IMAGE_ALLOWED_EXTENSIONS.has(originalExt)
|
||||
? originalExt
|
||||
: extByMime;
|
||||
|
||||
if (!mimeType.startsWith('image/') && !SPEC_DOC_IMAGE_ALLOWED_EXTENSIONS.has(originalExt)) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify({ error: 'Only image files are allowed' }));
|
||||
return;
|
||||
}
|
||||
if (!normalizedExt || !SPEC_DOC_IMAGE_ALLOWED_EXTENSIONS.has(normalizedExt)) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify({ error: 'Unsupported image type' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const docDir = path.dirname(resolved.docPath);
|
||||
const docsRoot = path.resolve(projectRoot, 'src', 'docs');
|
||||
const isDocsEntry = isPathInside(docsRoot, resolved.docPath);
|
||||
const assetsDir = isDocsEntry
|
||||
? path.join(docDir, 'assets')
|
||||
: path.join(docDir, 'assets', 'images');
|
||||
fs.mkdirSync(assetsDir, { recursive: true });
|
||||
|
||||
const safeFileName = sanitizeImageUploadFileName(
|
||||
originalName || `image${normalizedExt}`,
|
||||
mimeType,
|
||||
);
|
||||
const finalPath = resolveUniqueFilePath(assetsDir, safeFileName);
|
||||
if (!isPathInside(assetsDir, finalPath)) {
|
||||
res.statusCode = 403;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify({ error: 'Forbidden path' }));
|
||||
return;
|
||||
}
|
||||
|
||||
fs.copyFileSync(tempPath, finalPath);
|
||||
|
||||
const relativePath = path.relative(projectRoot, finalPath).split(path.sep).join('/');
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
url: isDocsEntry
|
||||
? `assets/${path.basename(finalPath)}`
|
||||
: `assets/images/${path.basename(finalPath)}`,
|
||||
path: relativePath,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
console.error('Error uploading spec doc image:', error);
|
||||
res.statusCode = 500;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify({ error: error?.message || 'Image upload failed' }));
|
||||
} finally {
|
||||
removeTempFile(tempPath);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== 'POST' || pathname !== '/api/spec-doc/save') {
|
||||
return next();
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const bodyText = Buffer.concat(chunks).toString('utf8');
|
||||
const body = bodyText ? JSON.parse(bodyText) : {};
|
||||
const docUrl = typeof body?.docUrl === 'string' ? body.docUrl : '';
|
||||
const content = typeof body?.content === 'string' ? body.content : '';
|
||||
|
||||
if (!docUrl) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify({ error: 'Missing docUrl' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = resolveDocumentPathFromDocUrl(docUrl, req.headers.host, projectRoot);
|
||||
if ('status' in resolved) {
|
||||
res.statusCode = resolved.status;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify({ error: resolved.error }));
|
||||
return;
|
||||
}
|
||||
|
||||
const srcRoot = path.resolve(projectRoot, 'src');
|
||||
const relativeToSrc = path.relative(srcRoot, resolved.docPath).split(path.sep).join('/');
|
||||
const [entryType] = relativeToSrc.split('/');
|
||||
const fileName = path.basename(resolved.docPath).toLowerCase();
|
||||
const isAllowedEntryType = ['components', 'prototypes', 'themes'].includes(entryType || '');
|
||||
const isSpecFile = fileName === 'spec.md' || fileName === 'prd.md';
|
||||
if (!isAllowedEntryType || !isSpecFile) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify({ error: 'Only spec.md/prd.md under components/prototypes/themes can be saved' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(resolved.docPath)) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify({ error: 'Document not found' }));
|
||||
return;
|
||||
}
|
||||
|
||||
fs.writeFileSync(resolved.docPath, content, 'utf8');
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify({ success: true, path: `/${relativeToSrc}` }));
|
||||
} catch (error: any) {
|
||||
console.error('Error saving spec doc:', error);
|
||||
res.statusCode = 500;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify({ error: error?.message || 'Save failed' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user