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.
165 lines
6.4 KiB
TypeScript
165 lines
6.4 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import {
|
|
buildAnnotatableDocRegex,
|
|
buildAnnotatableExtensionsHint,
|
|
buildAnnotatableTextRegex,
|
|
isAnnotatableDocPath,
|
|
isAnnotatableTextPath,
|
|
shouldStripFrontmatter,
|
|
} from "./annotatable";
|
|
import {
|
|
getAnnotatableTextRegex,
|
|
getExtraMarkdownExtensions,
|
|
normalizeMarkdownExtensions,
|
|
resetMarkdownExtensionsCache,
|
|
resolveMarkdownExtensions,
|
|
} from "./markdown-extensions";
|
|
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
import { tmpdir } from "os";
|
|
import { join } from "path";
|
|
|
|
describe("normalizeMarkdownExtensions", () => {
|
|
test("keeps well-formed dot-led extensions, lowercased and deduplicated", () => {
|
|
expect(normalizeMarkdownExtensions([".livemd", ".LiveMD", " .qmd "])).toEqual([
|
|
".livemd",
|
|
".qmd",
|
|
]);
|
|
});
|
|
|
|
test("drops entries that are not usable extensions instead of failing the session", () => {
|
|
expect(
|
|
normalizeMarkdownExtensions([
|
|
"livemd", // dotless
|
|
"", // empty
|
|
".", // dot only
|
|
"*.livemd", // glob
|
|
"docs/*.livemd", // path
|
|
"..\\win.livemd", // separator
|
|
".live md", // whitespace
|
|
42, // not a string
|
|
null,
|
|
{ ext: ".livemd" },
|
|
]),
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("rejects non-array values", () => {
|
|
expect(normalizeMarkdownExtensions(".livemd")).toEqual([]);
|
|
expect(normalizeMarkdownExtensions(undefined)).toEqual([]);
|
|
expect(normalizeMarkdownExtensions({ 0: ".livemd" })).toEqual([]);
|
|
});
|
|
|
|
// SECURITY: annotate copies file contents into the data dir, so `.env` is a
|
|
// deliberate exclusion from the built-in set. Config must not be a way back
|
|
// in, in any casing or with surrounding whitespace.
|
|
test(".env can never be registered through config", () => {
|
|
expect(normalizeMarkdownExtensions([".env"])).toEqual([]);
|
|
expect(normalizeMarkdownExtensions([" .ENV "])).toEqual([]);
|
|
expect(isAnnotatableTextPath(".env", normalizeMarkdownExtensions([".env"]))).toBe(false);
|
|
expect(isAnnotatableTextPath("app/.env", [".livemd"])).toBe(false);
|
|
});
|
|
|
|
// The rationale covers the whole dotenv family, not just the exact name:
|
|
// `db.prod.env` and `.env.local` hold secrets exactly like `.env` does.
|
|
test("the dotenv family is denied as a family", () => {
|
|
expect(normalizeMarkdownExtensions([".prod.env"])).toEqual([]);
|
|
expect(normalizeMarkdownExtensions([".local.env"])).toEqual([]);
|
|
expect(normalizeMarkdownExtensions([".env.local"])).toEqual([]);
|
|
expect(normalizeMarkdownExtensions([".ENV.LOCAL", " .Prod.Env "])).toEqual([]);
|
|
// Non-dotenv names that merely contain "env" stay registerable.
|
|
expect(normalizeMarkdownExtensions([".envrc", ".environment"])).toEqual([
|
|
".envrc",
|
|
".environment",
|
|
]);
|
|
});
|
|
|
|
test("built-in extensions are dropped rather than duplicated into the regex", () => {
|
|
expect(normalizeMarkdownExtensions([".md", ".html", ".env.example", ".livemd"])).toEqual([
|
|
".livemd",
|
|
]);
|
|
});
|
|
|
|
test("resolveMarkdownExtensions reads the config key", () => {
|
|
expect(resolveMarkdownExtensions({})).toEqual([]);
|
|
expect(resolveMarkdownExtensions({ markdownExtensions: [".livemd"] })).toEqual([".livemd"]);
|
|
expect(resolveMarkdownExtensions({ markdownExtensions: ["livemd"] })).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("configured extensions in the annotatable predicates", () => {
|
|
const extra = [".livemd"];
|
|
|
|
test("a configured extension is annotatable text, a document, and matched by both regexes", () => {
|
|
expect(isAnnotatableTextPath("notes.livemd", extra)).toBe(true);
|
|
expect(isAnnotatableDocPath("notes.livemd", extra)).toBe(true);
|
|
expect(buildAnnotatableTextRegex(extra).test("notes.livemd")).toBe(true);
|
|
expect(buildAnnotatableDocRegex(extra).test("notes.livemd")).toBe(true);
|
|
});
|
|
|
|
test("without configuration the same path stays unsupported", () => {
|
|
expect(isAnnotatableTextPath("notes.livemd")).toBe(false);
|
|
expect(isAnnotatableDocPath("notes.livemd")).toBe(false);
|
|
expect(buildAnnotatableTextRegex().test("notes.livemd")).toBe(false);
|
|
});
|
|
|
|
test("the extension must terminate the path, and its dot is not a wildcard", () => {
|
|
expect(isAnnotatableTextPath("notes.livemd.bin", extra)).toBe(false);
|
|
expect(isAnnotatableTextPath("noteszlivemd", extra)).toBe(false);
|
|
});
|
|
|
|
test("configuring extensions never narrows the built-in set", () => {
|
|
expect(isAnnotatableTextPath("notes.md", extra)).toBe(true);
|
|
expect(isAnnotatableDocPath("page.html", extra)).toBe(true);
|
|
expect(isAnnotatableTextPath("app.ts", extra)).toBe(false);
|
|
});
|
|
|
|
// Extras are markdown, so frontmatter is stripped for them — unlike the
|
|
// built-in plain-text formats, where `---` is real content.
|
|
test("configured extensions strip frontmatter like .md does", () => {
|
|
expect(shouldStripFrontmatter("notes.livemd", extra)).toBe(true);
|
|
expect(shouldStripFrontmatter("notes.livemd")).toBe(true);
|
|
expect(shouldStripFrontmatter("deploy.yaml", extra)).toBe(false);
|
|
});
|
|
|
|
test("the supported-types hint names the configured extensions", () => {
|
|
expect(buildAnnotatableExtensionsHint(extra)).toContain(".livemd");
|
|
expect(buildAnnotatableExtensionsHint()).not.toContain(".livemd");
|
|
});
|
|
});
|
|
|
|
// The config.json -> memo -> predicate seam, end to end. This is the one
|
|
// integration the explicit-list tests above cannot regress-guard: a break in
|
|
// loadConfig plumbing or the memo would leave every configured extension
|
|
// silently ignored at runtime.
|
|
describe("config-file integration (sandboxed data dir)", () => {
|
|
test("memoized config read feeds the predicates, once per process", () => {
|
|
const prevDataDir = process.env.PLANNOTATOR_DATA_DIR;
|
|
const dataDir = mkdtempSync(join(tmpdir(), "plannotator-mdext-"));
|
|
try {
|
|
process.env.PLANNOTATOR_DATA_DIR = dataDir;
|
|
writeFileSync(
|
|
join(dataDir, "config.json"),
|
|
JSON.stringify({ markdownExtensions: [".livemd"] }),
|
|
);
|
|
resetMarkdownExtensionsCache();
|
|
expect(getExtraMarkdownExtensions()).toEqual([".livemd"]);
|
|
expect(getAnnotatableTextRegex().test("notebooks/tour.livemd")).toBe(true);
|
|
|
|
// Memoized: a mid-session config edit must not change the accepted
|
|
// set until the next process (or an explicit reset).
|
|
writeFileSync(
|
|
join(dataDir, "config.json"),
|
|
JSON.stringify({ markdownExtensions: [".qmd"] }),
|
|
);
|
|
expect(getExtraMarkdownExtensions()).toEqual([".livemd"]);
|
|
resetMarkdownExtensionsCache();
|
|
expect(getExtraMarkdownExtensions()).toEqual([".qmd"]);
|
|
} finally {
|
|
if (prevDataDir === undefined) delete process.env.PLANNOTATOR_DATA_DIR;
|
|
else process.env.PLANNOTATOR_DATA_DIR = prevDataDir;
|
|
resetMarkdownExtensionsCache();
|
|
rmSync(dataDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|