268 lines
7.6 KiB
TypeScript
268 lines
7.6 KiB
TypeScript
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<T extends AnnotationSourceLike = AnnotationSourceLike> {
|
|
source: T;
|
|
watchFiles: string[];
|
|
}
|
|
|
|
export interface AnnotationSourceMarkdownPreprocessOptions<T extends AnnotationSourceLike = AnnotationSourceLike> {
|
|
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/<id>' };
|
|
}
|
|
|
|
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<string>;
|
|
},
|
|
): 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<T extends AnnotationSourceLike>(
|
|
options: AnnotationSourceMarkdownPreprocessOptions<T>,
|
|
): AnnotationSourceMarkdownPreprocessResult<T> {
|
|
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<string, unknown>
|
|
: null;
|
|
const nodes = directoryRecord?.nodes;
|
|
if (!Array.isArray(nodes) || !getPrototypeDirFromAnnotationSourcePath(options.projectRoot, options.sourceFilePath)) {
|
|
return { source, watchFiles: [] };
|
|
}
|
|
|
|
const watchFiles = new Set<string>();
|
|
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`;
|
|
},
|
|
};
|
|
}
|