初始化项目版本

This commit is contained in:
Axhub Make
2026-07-29 16:04:39 +08:00
commit 4305a1082b
2629 changed files with 760590 additions and 0 deletions

View File

@@ -0,0 +1,267 @@
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`;
},
};
}