Files
Michael Ramos d2d2dba7fa feat(annotate): configurable extra markdown extensions (#1309)
* 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.
2026-08-13 09:47:18 -07:00

49 lines
2.1 KiB
TypeScript

import { afterEach, describe, expect, test } from "bun:test";
import {
getExtraMarkdownExtensions,
hasLinkedDocExtension,
setExtraMarkdownExtensions,
} from "./markdownExtensions";
// Module-level registry: every test must leave it empty for the next one.
afterEach(() => {
setExtraMarkdownExtensions([]);
});
describe("hasLinkedDocExtension", () => {
test("built-in document extensions are recognized without registration", () => {
expect(hasLinkedDocExtension("notes.md")).toBe(true);
expect(hasLinkedDocExtension("guide.mdx")).toBe(true);
expect(hasLinkedDocExtension("page.html")).toBe(true);
// Markdown links may carry a fragment; wiki-link targets may not (a
// fragment-less `[[notes]]` is what gets `.md` appended).
expect(hasLinkedDocExtension("notes.md#section", { allowFragment: true })).toBe(true);
expect(hasLinkedDocExtension("notes.md#section")).toBe(false);
expect(hasLinkedDocExtension("app.ts")).toBe(false);
});
// #1307: without this the renderer turns `[tour](tour.livemd)` into a dead
// external link, and `[[tour.livemd]]` into a request for `tour.livemd.md`.
test("a registered extension makes sibling docs openable", () => {
expect(hasLinkedDocExtension("tour.livemd")).toBe(false);
setExtraMarkdownExtensions([".livemd"]);
expect(hasLinkedDocExtension("tour.livemd")).toBe(true);
expect(hasLinkedDocExtension("../notebooks/TOUR.LiveMD")).toBe(true);
expect(hasLinkedDocExtension("tour.livemd#setup", { allowFragment: true })).toBe(true);
expect(hasLinkedDocExtension("tour.livemd.bin")).toBe(false);
});
test("the payload is normalized, so a malformed or denied entry registers nothing", () => {
setExtraMarkdownExtensions([".env", "livemd", "*.livemd", ".livemd"]);
expect(getExtraMarkdownExtensions()).toEqual([".livemd"]);
expect(hasLinkedDocExtension(".env")).toBe(false);
});
test("a missing payload clears the registry rather than throwing", () => {
setExtraMarkdownExtensions([".livemd"]);
setExtraMarkdownExtensions(undefined);
expect(getExtraMarkdownExtensions()).toEqual([]);
expect(hasLinkedDocExtension("tour.livemd")).toBe(false);
});
});