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

58 lines
2.3 KiB
TypeScript

/**
* Extra markdown extensions, renderer side (#1307).
*
* The server resolves `markdownExtensions` from `~/.plannotator/config.json`
* and ships the normalized list with the annotate payload. The renderer needs
* it for one job: deciding whether a relative link or a wiki-link target names
* a local document it should open in the linked-doc overlay (`/api/doc`) or a
* plain external link. Without it, `[notes](notes.livemd)` renders as a dead
* external link even though the server would happily serve it.
*
* Module-level registry seam, like `skillReferences.ts`: a host (or the app's
* own boot code) registers the list once, everything else reads it. Empty by
* default, so nothing changes for a user with no config.
*
* The built-in set here is deliberately NARROWER than the annotatable set on
* the server (`.md`/`.mdx`/`.txt`/`.html`/`.htm` only) — widening it is a
* separate decision. Extras are added on top of it.
*/
import { normalizeMarkdownExtensions } from "@plannotator/core/annotatable";
/** Built-in extensions the renderer treats as openable local documents. */
const BUILTIN_LINKED_DOC_REGEX = /\.(mdx?|txt|html?)$/i;
let extraExtensions: string[] = [];
/**
* Register the extra markdown extensions for this page. Values are normalized
* with the same rules the server applies (dot-led, lowercased, `.env` denied),
* so a hostile or malformed payload cannot inject regex or path fragments.
*/
export function setExtraMarkdownExtensions(value: unknown): void {
extraExtensions = normalizeMarkdownExtensions(value);
}
/** The registered extra extensions (normalized, possibly empty). */
export function getExtraMarkdownExtensions(): string[] {
return extraExtensions;
}
/**
* Does this link target name a local document the linked-doc overlay can open?
*
* `allowFragment` mirrors the two call sites this replaced: markdown links
* accept a trailing `#fragment` (stripped by the caller before navigating),
* wiki-link targets do not.
*/
export function hasLinkedDocExtension(
target: string,
options?: { allowFragment?: boolean },
): boolean {
const trimmed = target.trim();
const path = options?.allowFragment ? trimmed.replace(/#.*$/, "") : trimmed;
if (BUILTIN_LINKED_DOC_REGEX.test(path)) return true;
const lower = path.toLowerCase();
return extraExtensions.some((ext) => lower.endsWith(ext));
}