import fs from 'node:fs'; import path from 'node:path'; import type { Plugin } from 'vite'; type PreprocessMode = 'serve' | 'build'; type AnnotationSourceMarkdownNode = { type?: unknown; markdownPath?: unknown; markdown?: unknown; children?: unknown; [key: string]: unknown; }; type AnnotationSourceLike = { directory?: unknown; [key: string]: unknown; }; export type ResolvedAnnotationMarkdownPath = | { ok: true; absolutePath: string; projectRelativePath: string; prototypeDir: string; } | { ok: false; reason: string; }; export interface AnnotationSourceMarkdownPreprocessResult { source: T; watchFiles: string[]; } export interface AnnotationSourceMarkdownPreprocessOptions { projectRoot: string; sourceFilePath: string; source: T; mode?: PreprocessMode; } function createProjectRelativePath(projectRoot: string, absolutePath: string): string { return path.relative(projectRoot, absolutePath).split(path.sep).join('/'); } function isPathInside(parentPath: string, childPath: string): boolean { const relativePath = path.relative(parentPath, childPath); return relativePath === '' || ( !relativePath.startsWith('..') && !path.isAbsolute(relativePath) ); } function safeDecodePath(value: string): string | null { try { return decodeURIComponent(value); } catch { return null; } } function isWindowsAbsolutePath(value: string): boolean { return /^[a-z]:[\\/]/iu.test(value); } function getPrototypeDirFromAnnotationSourcePath( projectRoot: string, sourceFilePath: string, ): string | null { const resolvedProjectRoot = path.resolve(projectRoot); const resolvedSourcePath = path.resolve(sourceFilePath); const relativeSourcePath = createProjectRelativePath(resolvedProjectRoot, resolvedSourcePath); const parts = relativeSourcePath.split('/'); if ( parts.length !== 4 || parts[0] !== 'src' || parts[1] !== 'prototypes' || !parts[2] || parts[2].startsWith('.') || parts[2].includes('\0') || parts[3] !== 'annotation-source.json' ) { return null; } const prototypeDir = path.resolve(resolvedProjectRoot, 'src', 'prototypes', parts[2]); return isPathInside(resolvedProjectRoot, prototypeDir) ? prototypeDir : null; } export function resolveAnnotationMarkdownPath( projectRoot: string, sourceFilePath: string, markdownPath: unknown, ): ResolvedAnnotationMarkdownPath { const prototypeDir = getPrototypeDirFromAnnotationSourcePath(projectRoot, sourceFilePath); if (!prototypeDir) { return { ok: false, reason: 'annotation-source.json is not under src/prototypes/' }; } const rawPath = String(markdownPath ?? '').trim(); const decodedPath = safeDecodePath(rawPath); if (!rawPath || !decodedPath) { return { ok: false, reason: 'markdownPath is empty or malformed' }; } if ( rawPath.includes('\0') || decodedPath.includes('\0') || path.isAbsolute(rawPath) || path.isAbsolute(decodedPath) || isWindowsAbsolutePath(rawPath) || isWindowsAbsolutePath(decodedPath) || rawPath.includes('\\') || decodedPath.includes('\\') ) { return { ok: false, reason: 'markdownPath must be a relative POSIX path' }; } const rawSegments = rawPath.split('/'); const decodedSegments = decodedPath.split('/'); const hasUnsafeSegment = [...rawSegments, ...decodedSegments].some((segment) => ( segment === '' || segment === '.' || segment === '..' )); if (hasUnsafeSegment) { return { ok: false, reason: 'markdownPath contains unsafe path segments' }; } const absolutePath = path.resolve(prototypeDir, decodedPath); const resolvedProjectRoot = path.resolve(projectRoot); if (!isPathInside(prototypeDir, absolutePath) || !isPathInside(resolvedProjectRoot, absolutePath)) { return { ok: false, reason: 'markdownPath escapes the prototype directory' }; } return { ok: true, absolutePath, projectRelativePath: createProjectRelativePath(resolvedProjectRoot, absolutePath), prototypeDir, }; } function preprocessDirectoryNode( node: unknown, options: { projectRoot: string; sourceFilePath: string; mode: PreprocessMode; watchFiles: Set; }, ): unknown { if (!node || typeof node !== 'object' || Array.isArray(node)) { return node; } const record = node as AnnotationSourceMarkdownNode; if (record.type === 'folder') { return { ...record, children: Array.isArray(record.children) ? record.children.map((child) => preprocessDirectoryNode(child, options)) : record.children, }; } if (record.type !== 'markdown') { return record; } const nextNode: AnnotationSourceMarkdownNode = { ...record }; delete nextNode.markdownEditUrl; if (typeof record.markdownPath !== 'string' || !record.markdownPath.trim()) { return nextNode; } const resolved = resolveAnnotationMarkdownPath( options.projectRoot, options.sourceFilePath, record.markdownPath, ); if (resolved.ok === false) { if (options.mode === 'serve') { console.warn(`[annotation-source] Ignored unsafe markdownPath "${record.markdownPath}": ${resolved.reason}`); } return nextNode; } options.watchFiles.add(resolved.absolutePath); if (fs.existsSync(resolved.absolutePath)) { nextNode.markdown = fs.readFileSync(resolved.absolutePath, 'utf8'); } else if (options.mode === 'serve') { console.warn(`[annotation-source] Markdown file not found for markdownPath "${record.markdownPath}": ${resolved.projectRelativePath}`); } return nextNode; } export function preprocessAnnotationSourceMarkdown( options: AnnotationSourceMarkdownPreprocessOptions, ): AnnotationSourceMarkdownPreprocessResult { const mode = options.mode ?? 'build'; const source = options.source; const directoryRecord = source?.directory && typeof source.directory === 'object' && !Array.isArray(source.directory) ? source.directory as Record : null; const nodes = directoryRecord?.nodes; if (!Array.isArray(nodes) || !getPrototypeDirFromAnnotationSourcePath(options.projectRoot, options.sourceFilePath)) { return { source, watchFiles: [] }; } const watchFiles = new Set(); const nextSource = { ...source, directory: { ...directoryRecord, nodes: nodes.map((node) => preprocessDirectoryNode(node, { projectRoot: options.projectRoot, sourceFilePath: options.sourceFilePath, mode, watchFiles, })), }, } as T; return { source: nextSource, watchFiles: Array.from(watchFiles), }; } function cleanModuleId(id: string): string { return id.split(/[?#]/u)[0] || id; } export function createAnnotationSourceMarkdownPlugin( projectRoot = process.cwd(), options: { mode?: PreprocessMode } = {}, ): Plugin { const mode = options.mode ?? 'build'; return { name: 'axhub-annotation-source-markdown', enforce: 'pre', transform(code, id) { const filePath = cleanModuleId(id); if (path.basename(filePath) !== 'annotation-source.json') { return null; } if (!getPrototypeDirFromAnnotationSourcePath(projectRoot, filePath)) { return null; } const source = JSON.parse(code) as AnnotationSourceLike; const result = preprocessAnnotationSourceMarkdown({ projectRoot, sourceFilePath: filePath, source, mode, }); for (const watchFile of result.watchFiles) { this.addWatchFile(watchFile); } return `${JSON.stringify(result.source, null, 2)}\n`; }, }; }