mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
d2d2dba7fa
* feat(annotate): configurable extra markdown extensions (#1307) Adds a config-only `markdownExtensions` key to ~/.plannotator/config.json, e.g. { "markdownExtensions": [".livemd"] } for Livebook notebooks. A listed extension is accepted everywhere .md is on the annotate path: CLI target resolution, folder discovery and the file browser, /api/doc plus relative and wiki-link navigation between sibling docs, the 2MB size cap, and per-file version history. Listed extensions render as markdown with frontmatter stripped, never as raw HTML, and they only widen the accepted set. Design: - packages/core/annotatable.ts stays browser-safe and zero-dep. Its regexes and predicates now take an optional, defaulted-empty list of extra extensions, plus a normalizer and regex builders. - packages/shared/markdown-extensions.ts is the node-side seam: it reads config.json once per process through the existing loadConfig() and threads the normalized list into those pure functions. resolve-file re-exports the config-aware predicates so both runtimes pick them up; the Bun server, the Pi mirror, the OpenCode plugin and the CLI all go through them. - The annotate /api/plan payload ships the resolved list so the renderer can linkify links to sibling documents (module-level UI registry, empty by default, so nothing changes without config). Validation: entries must be dot-led, lowercase-normalized, and free of path separators, globs and whitespace. Invalid entries are dropped silently, built-ins are deduplicated, and `.env` is denylisted so config can never register it (annotate copies file contents into the data dir). Deliberately unchanged: the Pi plan-write allowlist (ALLOWED_PLAN_EXTENSIONS in tool-scope.ts) and Edit Mode source save (SOURCE_SAVE_FILE_REGEX), which keep their own narrower allowlists. * fix(annotate): deny the dotenv family and sandbox config-aware tests Review follow-ups on #1309: - deny the whole dotenv family (.prod.env, .env.local, ...) in normalizeMarkdownExtensions, not just the exact .env name - resolve config.json path per call instead of at module scope so PLANNOTATOR_DATA_DIR sandboxing works in single-process test runs - stop resolve-file.test.ts reading the real user config: pure predicate imports plus pinned empty extras on every resolve call - add the config.json -> memo -> predicate integration test using resetMarkdownExtensionsCache under a temp data dir * test(call-flow): make the stale-read advert test self-sufficient The read-only GET only probes the node runtime while Call flow is enabled. The stale-read test relied on earlier tests' settings POSTs leaking callFlow=true through the process-frozen config path; with lazy config resolution each sandbox is genuinely isolated, so the test now enables Call flow in its own data dir. Locally the dependency was masked by an fnm-shimmed sem sidecar spawning node coincidentally.
632 lines
18 KiB
TypeScript
632 lines
18 KiB
TypeScript
/**
|
|
* Smart markdown file resolution.
|
|
*
|
|
* Resolves a user-provided path to an absolute file path using three strategies:
|
|
* 1. Exact path (absolute or relative to cwd)
|
|
* 2. Case-insensitive relative path search within project root
|
|
* 3. Case-insensitive bare filename search within project root
|
|
*
|
|
* Used by both the CLI (`plannotator annotate`) and the `/api/doc` endpoint.
|
|
*/
|
|
|
|
import { homedir } from "os";
|
|
import { isAbsolute, join, resolve, win32 } from "path";
|
|
import { existsSync, readdirSync, type Dirent } from "fs";
|
|
import { readdir } from "node:fs/promises";
|
|
|
|
import { buildAnnotatableTextRegex } from "./annotatable";
|
|
import { getExtraMarkdownExtensions } from "./markdown-extensions";
|
|
import { CODE_FILE_REGEX as CODE_FILE_BASENAME_REGEX } from "./code-file";
|
|
export { CODE_FILE_REGEX, isCodeFilePath } from "./code-file";
|
|
export { MAX_ANNOTATABLE_FILE_BYTES } from "./annotatable";
|
|
/**
|
|
* Extension predicates re-exported here are the CONFIG-AWARE ones (#1307):
|
|
* they honor the user's `markdownExtensions` on top of the built-in set. The
|
|
* pure built-in constants stay in @plannotator/core/annotatable for browser
|
|
* code; server code must go through these so a configured `.livemd` is
|
|
* accepted everywhere `.md` is.
|
|
*/
|
|
export {
|
|
getAnnotatableTextRegex,
|
|
getAnnotatableDocRegex,
|
|
getAnnotatableExtensionsHint,
|
|
getExtraMarkdownExtensions,
|
|
isAnnotatableTextPath,
|
|
isAnnotatableDocPath,
|
|
} from "./markdown-extensions";
|
|
|
|
const WINDOWS_DRIVE_PATH_PATTERNS = [
|
|
/^\/cygdrive\/([a-zA-Z])\/(.+)$/,
|
|
/^\/([a-zA-Z])\/(.+)$/,
|
|
];
|
|
|
|
const IGNORED_DIRS = [
|
|
"node_modules/",
|
|
".git/",
|
|
"dist/",
|
|
"build/",
|
|
".next/",
|
|
"__pycache__/",
|
|
".obsidian/",
|
|
".trash/",
|
|
];
|
|
|
|
const CODE_IGNORED_DIRS = [
|
|
...IGNORED_DIRS,
|
|
".turbo/",
|
|
".cache/",
|
|
"target/",
|
|
"vendor/",
|
|
"coverage/",
|
|
".venv/",
|
|
".pytest_cache/",
|
|
];
|
|
|
|
const DEFAULT_FILE_BROWSER_MAX_FILES = 5_000;
|
|
|
|
/**
|
|
* Return the shared file-traversal budget used by resolution, cache warming,
|
|
* and file-browser discovery. Invalid or non-positive overrides fall back to
|
|
* the default.
|
|
*/
|
|
export function getFileBrowserMaxFiles(): number {
|
|
const value = Number.parseInt(
|
|
process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES ?? "",
|
|
10,
|
|
);
|
|
return Number.isFinite(value) && value > 0
|
|
? value
|
|
: DEFAULT_FILE_BROWSER_MAX_FILES;
|
|
}
|
|
|
|
export type ResolveResult =
|
|
| { kind: "found"; path: string }
|
|
| { kind: "not_found"; input: string }
|
|
| { kind: "ambiguous"; input: string; matches: string[] }
|
|
| { kind: "unavailable"; input: string };
|
|
|
|
function normalizeSeparators(input: string): string {
|
|
return input.replace(/\\/g, "/");
|
|
}
|
|
|
|
function stripTrailingSlashes(input: string): string {
|
|
return input.replace(/\/+$/, "");
|
|
}
|
|
|
|
export function expandHomePath(input: string, home = homedir()): string {
|
|
if (input === "~") {
|
|
return home;
|
|
}
|
|
|
|
if (input.startsWith("~/") || input.startsWith("~\\")) {
|
|
return join(home, input.slice(2));
|
|
}
|
|
|
|
return input;
|
|
}
|
|
|
|
export function stripWrappingQuotes(input: string): string {
|
|
if (input.length < 2) {
|
|
return input;
|
|
}
|
|
|
|
const first = input[0];
|
|
const last = input[input.length - 1];
|
|
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
return input.slice(1, -1);
|
|
}
|
|
|
|
return input;
|
|
}
|
|
|
|
export function normalizeUserPathInput(
|
|
input: string,
|
|
platform = process.platform,
|
|
): string {
|
|
const trimmedInput = input.trim();
|
|
const unquotedInput = stripWrappingQuotes(trimmedInput);
|
|
const expandedInput = expandHomePath(unquotedInput);
|
|
|
|
if (platform !== "win32") {
|
|
return expandedInput;
|
|
}
|
|
|
|
for (const pattern of WINDOWS_DRIVE_PATH_PATTERNS) {
|
|
const match = expandedInput.match(pattern);
|
|
if (!match) {
|
|
continue;
|
|
}
|
|
|
|
const [, driveLetter, rest] = match;
|
|
return `${driveLetter.toUpperCase()}:/${rest}`;
|
|
}
|
|
|
|
return expandedInput;
|
|
}
|
|
|
|
function isAbsoluteNormalizedUserPath(
|
|
input: string,
|
|
platform = process.platform,
|
|
): boolean {
|
|
if (hasWindowsDriveLetter(input)) {
|
|
return true;
|
|
}
|
|
|
|
return platform === "win32"
|
|
? win32.isAbsolute(input)
|
|
: isAbsolute(input);
|
|
}
|
|
|
|
export function isAbsoluteUserPath(
|
|
input: string,
|
|
platform = process.platform,
|
|
): boolean {
|
|
return isAbsoluteNormalizedUserPath(normalizeUserPathInput(input, platform), platform);
|
|
}
|
|
|
|
export function resolveUserPath(
|
|
input: string,
|
|
baseDir = process.cwd(),
|
|
platform = process.platform,
|
|
): string {
|
|
const normalizedInput = normalizeUserPathInput(input, platform);
|
|
if (!normalizedInput) {
|
|
return "";
|
|
}
|
|
return isAbsoluteNormalizedUserPath(normalizedInput, platform)
|
|
? resolveAbsolutePath(normalizedInput, platform)
|
|
: resolve(baseDir, normalizedInput);
|
|
}
|
|
|
|
function normalizeComparablePath(input: string): string {
|
|
return stripTrailingSlashes(normalizeSeparators(resolveUserPath(input)));
|
|
}
|
|
|
|
export function isWithinProjectRoot(candidate: string, projectRoot: string): boolean {
|
|
const normalizedCandidate = normalizeComparablePath(candidate);
|
|
const normalizedProjectRoot = normalizeComparablePath(projectRoot);
|
|
return (
|
|
normalizedCandidate === normalizedProjectRoot ||
|
|
normalizedCandidate.startsWith(`${normalizedProjectRoot}/`)
|
|
);
|
|
}
|
|
|
|
function getLowercaseBasename(input: string): string {
|
|
const normalizedInput = normalizeSeparators(input);
|
|
return normalizedInput.split("/").pop()!.toLowerCase();
|
|
}
|
|
|
|
function getLookupKey(input: string, isBareFilename: boolean): string {
|
|
return isBareFilename ? getLowercaseBasename(input) : input.toLowerCase();
|
|
}
|
|
|
|
function resolveAbsolutePath(
|
|
input: string,
|
|
platform = process.platform,
|
|
): string {
|
|
// Use win32.resolve for Windows paths regardless of reported platform
|
|
return platform === "win32" || hasWindowsDriveLetter(input)
|
|
? win32.resolve(input)
|
|
: resolve(input);
|
|
}
|
|
|
|
/**
|
|
* The set of files single-file annotate resolution accepts. Wider than
|
|
* markdown proper (#1029): any unambiguously plain-text format renders the
|
|
* way .txt does, plus the user's configured extra markdown extensions
|
|
* (#1307). HTML is excluded — it has its own resolution branch at the call
|
|
* sites.
|
|
*/
|
|
function isSearchableMarkdownPath(input: string, extra: readonly string[]): boolean {
|
|
return buildAnnotatableTextRegex(extra).test(input.trim());
|
|
}
|
|
|
|
/** Check if a path looks like a Windows absolute path (e.g. C:\ or C:/) */
|
|
function hasWindowsDriveLetter(input: string): boolean {
|
|
return /^[a-zA-Z]:[/\\]/.test(input);
|
|
}
|
|
|
|
/** Cross-platform file existence check using Node fs (more reliable than Bun.file in compiled exes) */
|
|
function fileExists(filePath: string): boolean {
|
|
try {
|
|
return existsSync(filePath);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
type FileWalkState = {
|
|
visitedFiles: number;
|
|
readonly limit: number;
|
|
};
|
|
|
|
/** Recursively walk a directory collecting files matching `fileMatcher`, skipping ignored dirs. */
|
|
function walkFiles(
|
|
dir: string,
|
|
root: string,
|
|
results: string[],
|
|
ignoredDirs: string[],
|
|
fileMatcher: (name: string) => boolean,
|
|
state: FileWalkState,
|
|
): void {
|
|
if (state.visitedFiles >= state.limit) return;
|
|
const entries = readdirSync(dir, { withFileTypes: true }) as Dirent[];
|
|
for (const entry of entries) {
|
|
if (state.visitedFiles >= state.limit) return;
|
|
if (entry.isDirectory()) {
|
|
if (ignoredDirs.some((d) => d === entry.name + "/")) continue;
|
|
try {
|
|
walkFiles(
|
|
join(dir, entry.name),
|
|
root,
|
|
results,
|
|
ignoredDirs,
|
|
fileMatcher,
|
|
state,
|
|
);
|
|
} catch {
|
|
/* skip dirs we can't read */
|
|
}
|
|
} else if (entry.isFile()) {
|
|
state.visitedFiles += 1;
|
|
if (fileMatcher(entry.name)) {
|
|
const relative = join(dir, entry.name)
|
|
.slice(root.length + 1)
|
|
.replace(/\\/g, "/");
|
|
results.push(relative);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function walkMarkdownFiles(
|
|
dir: string,
|
|
root: string,
|
|
results: string[],
|
|
ignoredDirs: string[],
|
|
extra: readonly string[],
|
|
): void {
|
|
const matcher = buildAnnotatableTextRegex(extra);
|
|
try {
|
|
walkFiles(
|
|
dir,
|
|
root,
|
|
results,
|
|
ignoredDirs,
|
|
(name) => matcher.test(name),
|
|
{ visitedFiles: 0, limit: getFileBrowserMaxFiles() },
|
|
);
|
|
} catch {
|
|
/* fail soft for markdown — preserves existing behavior */
|
|
}
|
|
}
|
|
|
|
// --- Code-file resolution (async, cached) ---
|
|
|
|
const FILE_LIST_CACHE_TTL_MS = 30_000;
|
|
const fileListCache = new Map<
|
|
string,
|
|
{ promise: Promise<string[] | null>; startedAt: number }
|
|
>();
|
|
|
|
function fileListCacheKey(projectRoot: string, kind: string): string {
|
|
return `${projectRoot}|${kind}`;
|
|
}
|
|
|
|
async function walkCodeFiles(
|
|
dir: string,
|
|
root: string,
|
|
results: string[],
|
|
state: FileWalkState,
|
|
): Promise<void> {
|
|
if (state.visitedFiles >= state.limit) return;
|
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (state.visitedFiles >= state.limit) return;
|
|
if (entry.isDirectory()) {
|
|
if (CODE_IGNORED_DIRS.some((d) => d === entry.name + "/")) continue;
|
|
try {
|
|
await walkCodeFiles(join(dir, entry.name), root, results, state);
|
|
} catch {
|
|
/* skip dirs we can't read */
|
|
}
|
|
} else if (entry.isFile()) {
|
|
state.visitedFiles += 1;
|
|
if (CODE_FILE_BASENAME_REGEX.test(entry.name)) {
|
|
const relative = join(dir, entry.name)
|
|
.slice(root.length + 1)
|
|
.replace(/\\/g, "/");
|
|
results.push(relative);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function startCodeWalk(projectRoot: string): Promise<string[] | null> {
|
|
try {
|
|
const results: string[] = [];
|
|
await walkCodeFiles(projectRoot, projectRoot, results, {
|
|
visitedFiles: 0,
|
|
limit: getFileBrowserMaxFiles(),
|
|
});
|
|
return results;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Trigger (or return the in-flight) walk of `projectRoot` for code files.
|
|
* Cached for `FILE_LIST_CACHE_TTL_MS`. Storing a Promise (not a value) makes
|
|
* concurrent callers piggyback on the same walk — first arrival wins.
|
|
*
|
|
* Returns `null` (wrapped in Promise) when the walk fails (perms, etc).
|
|
*/
|
|
export function warmFileListCache(
|
|
projectRoot: string,
|
|
kind: "code",
|
|
): Promise<string[] | null> {
|
|
const key = fileListCacheKey(projectRoot, kind);
|
|
const entry = fileListCache.get(key);
|
|
if (entry && Date.now() - entry.startedAt < FILE_LIST_CACHE_TTL_MS) {
|
|
return entry.promise;
|
|
}
|
|
const promise = startCodeWalk(projectRoot);
|
|
fileListCache.set(key, { promise, startedAt: Date.now() });
|
|
return promise;
|
|
}
|
|
|
|
/**
|
|
* Resolve a code-file path within a project root.
|
|
*
|
|
* Strategies:
|
|
* 1. Absolute path → use as-is.
|
|
* 2. Exact relative from project root.
|
|
* 3. If `baseDir` provided, literal `<baseDir>/<input>` existence check —
|
|
* lets out-of-tree linked docs resolve their own relative references
|
|
* (e.g. `../script.ts` in `~/notes/foo.md` finds `~/script.ts`).
|
|
* 4. Case-insensitive suffix match against the cached file list:
|
|
* - bare basename input → match any file with that basename;
|
|
* - input with `/` → match files whose path equals or ends with `/<input>`
|
|
* on a segment boundary (so `editor/App.tsx` matches `packages/editor/App.tsx`
|
|
* but not `myeditor/App.tsx`).
|
|
*
|
|
* `..` segments in the input are honored: only `./` is stripped before suffix
|
|
* matching. `../foo.ts` without a `baseDir` correctly falls through to
|
|
* not_found rather than fabricating a match against `foo.ts` somewhere in cwd.
|
|
*/
|
|
export async function resolveCodeFile(
|
|
input: string,
|
|
projectRoot: string,
|
|
baseDir?: string,
|
|
): Promise<ResolveResult> {
|
|
const originalInput = input.trim();
|
|
const unquotedInput = stripWrappingQuotes(originalInput);
|
|
const normalizedInput = normalizeUserPathInput(unquotedInput);
|
|
const searchInput = normalizeSeparators(normalizedInput);
|
|
|
|
if (!searchInput) {
|
|
return { kind: "not_found", input: originalInput };
|
|
}
|
|
|
|
if (isAbsoluteNormalizedUserPath(normalizedInput)) {
|
|
const absolutePath = resolveAbsolutePath(normalizedInput);
|
|
if (fileExists(absolutePath)) {
|
|
return { kind: "found", path: absolutePath };
|
|
}
|
|
return { kind: "not_found", input: originalInput };
|
|
}
|
|
|
|
const fromRoot = resolve(projectRoot, searchInput);
|
|
if (isWithinProjectRoot(fromRoot, projectRoot) && fileExists(fromRoot)) {
|
|
return { kind: "found", path: fromRoot };
|
|
}
|
|
|
|
if (baseDir) {
|
|
const fromBase = resolve(baseDir, searchInput);
|
|
if (fileExists(fromBase)) {
|
|
return { kind: "found", path: fromBase };
|
|
}
|
|
}
|
|
|
|
const fileList = await warmFileListCache(projectRoot, "code");
|
|
if (fileList === null) {
|
|
return { kind: "unavailable", input: originalInput };
|
|
}
|
|
|
|
// Strip leading `./` so suffix matching works on inputs like
|
|
// `./editor/App.tsx` — file list entries never carry that segment.
|
|
// `../` is intentionally NOT stripped: `..` is meaningful (escape parent),
|
|
// not noise. If we can't honor it via baseDir, the input has no
|
|
// suffix-match equivalent in the in-tree file list.
|
|
const cleanedInput = searchInput.replace(/^(?:\.\/)+/, "");
|
|
if (!cleanedInput || cleanedInput.startsWith("../")) {
|
|
return { kind: "not_found", input: originalInput };
|
|
}
|
|
const target = cleanedInput.toLowerCase();
|
|
const isBareFilename = !cleanedInput.includes("/");
|
|
const matches: string[] = [];
|
|
|
|
for (const f of fileList) {
|
|
const fl = f.toLowerCase();
|
|
if (isBareFilename) {
|
|
const base = fl.split("/").pop();
|
|
if (base === target) matches.push(resolve(projectRoot, f));
|
|
} else {
|
|
if (fl === target || fl.endsWith("/" + target)) {
|
|
matches.push(resolve(projectRoot, f));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (matches.length === 1) {
|
|
return { kind: "found", path: matches[0] };
|
|
}
|
|
if (matches.length > 1) {
|
|
return { kind: "ambiguous", input: originalInput, matches };
|
|
}
|
|
return { kind: "not_found", input: originalInput };
|
|
}
|
|
|
|
/**
|
|
* Resolve a markdown file path within a project root.
|
|
*
|
|
* @param input - User-provided path (absolute, relative, or bare filename)
|
|
* @param projectRoot - Project root directory to search within
|
|
*/
|
|
function resolveMarkdownFileCore(
|
|
input: string,
|
|
projectRoot: string,
|
|
extra: readonly string[],
|
|
): ResolveResult {
|
|
const normalizedInput = normalizeUserPathInput(input);
|
|
const searchInput = normalizeSeparators(normalizedInput);
|
|
const isBareFilename = !searchInput.includes("/");
|
|
const targetLookupKey = getLookupKey(searchInput, isBareFilename);
|
|
|
|
// Restrict to annotatable plain-text files (markdown + config formats)
|
|
if (!isSearchableMarkdownPath(normalizedInput, extra)) {
|
|
return { kind: "not_found", input };
|
|
}
|
|
|
|
// 1. Absolute path — use as-is (no project root restriction;
|
|
// the user explicitly typed the full path)
|
|
if (isAbsoluteNormalizedUserPath(normalizedInput)) {
|
|
const absolutePath = resolveAbsolutePath(normalizedInput);
|
|
if (fileExists(absolutePath)) {
|
|
return { kind: "found", path: absolutePath };
|
|
}
|
|
return { kind: "not_found", input };
|
|
}
|
|
|
|
// 2. Exact relative path from project root. An explicit path the user
|
|
// typed (one containing a separator, including `../` that escapes the
|
|
// root) is honored when it exists — the same trust already extended to
|
|
// absolute paths above. Bare filenames stay restricted to the fuzzy
|
|
// in-root search below so a stray `notes.md` can't resolve to a parent.
|
|
const fromRoot = resolve(projectRoot, searchInput);
|
|
if (
|
|
fileExists(fromRoot) &&
|
|
(isWithinProjectRoot(fromRoot, projectRoot) || searchInput.includes("/"))
|
|
) {
|
|
return { kind: "found", path: fromRoot };
|
|
}
|
|
|
|
// 3. Case-insensitive search (only scan markdown files)
|
|
const allFiles: string[] = [];
|
|
walkMarkdownFiles(projectRoot, projectRoot, allFiles, IGNORED_DIRS, extra);
|
|
const matches: string[] = [];
|
|
|
|
for (const match of allFiles) {
|
|
const normalizedMatch = normalizeSeparators(match);
|
|
const matchLookupKey = getLookupKey(normalizedMatch, isBareFilename);
|
|
|
|
if (matchLookupKey === targetLookupKey) {
|
|
const full = resolve(projectRoot, normalizedMatch);
|
|
if (isWithinProjectRoot(full, projectRoot)) {
|
|
matches.push(full);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (matches.length === 1) {
|
|
return { kind: "found", path: matches[0] };
|
|
}
|
|
if (matches.length > 1) {
|
|
const projectRootPrefix = `${normalizeComparablePath(projectRoot)}/`;
|
|
const relative = matches.map((match) =>
|
|
normalizeComparablePath(match).replace(projectRootPrefix, ""),
|
|
);
|
|
return { kind: "ambiguous", input, matches: relative };
|
|
}
|
|
|
|
return { kind: "not_found", input };
|
|
}
|
|
|
|
/**
|
|
* Resolve a markdown file path within a project root.
|
|
*
|
|
* @param input - User-provided path (absolute, relative, or bare filename)
|
|
* @param projectRoot - Project root directory to search within
|
|
* @param options.extraMarkdownExtensions - Configured extra markdown
|
|
* extensions (#1307). Defaults to the process-wide set resolved from
|
|
* `config.json`; pass an explicit list to resolve against a specific one.
|
|
*/
|
|
export function resolveMarkdownFile(
|
|
input: string,
|
|
projectRoot: string,
|
|
options?: { extraMarkdownExtensions?: readonly string[] },
|
|
): ResolveResult {
|
|
const originalInput = input.trim();
|
|
const unquotedInput = stripWrappingQuotes(originalInput);
|
|
const extra = options?.extraMarkdownExtensions ?? getExtraMarkdownExtensions();
|
|
|
|
const primary = resolveMarkdownFileCore(unquotedInput, projectRoot, extra);
|
|
if (primary.kind === "found") {
|
|
return primary;
|
|
}
|
|
if (primary.kind === "ambiguous") {
|
|
return { ...primary, input: originalInput };
|
|
}
|
|
|
|
if (!unquotedInput.startsWith("@")) {
|
|
return { kind: "not_found", input: originalInput };
|
|
}
|
|
|
|
const normalizedInput = unquotedInput.replace(/^@+/, "");
|
|
if (!normalizedInput) {
|
|
return { kind: "not_found", input: originalInput };
|
|
}
|
|
|
|
const fallback = resolveMarkdownFileCore(normalizedInput, projectRoot, extra);
|
|
if (fallback.kind === "found") {
|
|
return fallback;
|
|
}
|
|
if (fallback.kind === "ambiguous") {
|
|
return { ...fallback, input: originalInput };
|
|
}
|
|
|
|
return { kind: "not_found", input: originalInput };
|
|
}
|
|
|
|
/**
|
|
* Check if a directory contains at least one file matching the given extensions.
|
|
* Used to validate folder annotation targets.
|
|
*
|
|
* @param dirPath - Directory to search
|
|
* @param excludedDirs - Directory names to skip (with trailing slash, e.g. "node_modules/")
|
|
* @param extensions - Regex to match file extensions (default: markdown only)
|
|
*/
|
|
export function hasMarkdownFiles(
|
|
dirPath: string,
|
|
excludedDirs: string[] = IGNORED_DIRS,
|
|
extensions: RegExp = /\.mdx?$/i,
|
|
): boolean {
|
|
const state: FileWalkState = {
|
|
visitedFiles: 0,
|
|
limit: getFileBrowserMaxFiles(),
|
|
};
|
|
|
|
function walk(dir: string): boolean {
|
|
if (state.visitedFiles >= state.limit) return false;
|
|
let entries;
|
|
try {
|
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
return false;
|
|
}
|
|
for (const entry of entries) {
|
|
if (state.visitedFiles >= state.limit) return false;
|
|
if (entry.isDirectory()) {
|
|
if (excludedDirs.some((d) => d === entry.name + "/")) continue;
|
|
if (walk(join(dir, entry.name))) return true;
|
|
} else if (entry.isFile()) {
|
|
state.visitedFiles += 1;
|
|
if (extensions.test(entry.name)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
return walk(dirPath);
|
|
}
|