diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 36dd328..03f7d8f 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -34,6 +34,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Ensure setup-bun's bun takes precedence + run: echo "$(dirname $(which bun))" >> $GITHUB_PATH + - name: Run Claude Code Review id: claude-review uses: anthropics/claude-code-action@v1 diff --git a/GEMINI.md b/GEMINI.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index 26ad76f..a0bd2f6 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,32 @@ See `config.example.json` for the full structure. The config maps space names to **Including spaces from other configs:** Use `includeSpacesFrom` to import space definitions from other config files. This is useful for aggregating spaces from multiple projects into a central config, reducing the need to specify `--config` on CLI commands. Duplicate space names are not allowed. +**Plugins:** Use `plugins` to load parse plugins that read spaces from non-markdown sources. The built-in markdown plugin is always available without any declaration. Plugins are tried in order; the first to return a result wins. The `plugins` field is a map of plugin name to plugin config, and can be declared at the top level (applies to all spaces) or per-space (overrides the top level): + +```json +{ + "spaces": [ + { + "name": "ProductX", + "path": "/path/to/space", + "plugins": { + "markdown": { "fieldMap": { "record_type": "type" } } + } + } + ], + "plugins": { + "ost-tools-confluence": { "baseUrl": "https://example.atlassian.net" } + } +} +``` + +All plugin names must start with `ost-tools-` (the prefix is optional in config and normalised on load). The special name `markdown` refers to the built-in markdown plugin. External plugins are resolved in order: config-adjacent (`{configDir}/plugins/{name}`), then npm. Each plugin must export a `configSchema` JSON Schema; config is validated against it on load. Fields annotated `format: 'path'` in a plugin's `configSchema` are resolved relative to the config file directory. + +**Markdown plugin config** fields (set under `plugins.markdown` per space): +- `templateDir` — directory containing template files (used by `template-sync`) +- `templatePrefix` — filename prefix for templates (default blank) +- `fieldMap` — maps file/frontmatter field names to canonical schema field names (e.g. `{ "record_type": "type" }`) + ### Spaces A space is a named directory or single file registered in the config. Spaces let you reference content by name instead of path: @@ -228,7 +254,7 @@ ost-tools template-sync [--space name] [--schema path/to/my-schema.json] [--crea Keeps Obsidian template files in sync with schema examples: - Matches markdown files in the template directory (defined in config) by `type` field - Rewrites frontmatter using description fields and property `examples` -- `templatePrefix` in config (default blank) sets a naming convention for templates (`{templatePrefix}{type}.md`). This will be used to check existing filenames, and create new templates with `--create-missing`. +- `templatePrefix` in `plugins.markdown` config (default blank) sets a naming convention for templates (`{templatePrefix}{type}.md`). This will be used to check existing filenames, and create new templates with `--create-missing`. - `--dry-run` previews changes without writing files ## Development diff --git a/bun.lock b/bun.lock index 254dd11..66c36e2 100644 --- a/bun.lock +++ b/bun.lock @@ -8,10 +8,8 @@ "ajv": "^8.18.0", "chokidar": "^5.0.0", "commander": "^14.0.3", - "glob": "^13.0.6", "gray-matter": "^4.0.3", "js-yaml": "^4.1.1", - "json5": "^2.2.3", "jsonata": "^2.1.0", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.1", @@ -51,8 +49,6 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.7", "", { "os": "win32", "cpu": "x64" }, "sha512-qEpGjSkPC3qX4ycbMUthXvi9CkRq7kZpkqMY1OyhmYlYLnANnooDQ7hDerM8+0NJ+DZKVnsIc07h30XOpt7LtQ=="], - "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], - "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], @@ -73,10 +69,6 @@ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - "balanced-match": ["balanced-match@4.0.2", "", { "dependencies": { "jackspeak": "^4.2.3" } }, "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg=="], - - "brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="], - "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -107,32 +99,24 @@ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - "jsonata": ["jsonata@2.1.0", "", {}, "sha512-OCzaRMK8HobtX8fp37uIVmL8CY1IGc/a6gLsDqz3quExFR09/U78HUzWYr7T31UEB6+Eu0/8dkVD5fFDOl9a8w=="], "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], - "lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], - "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], @@ -213,14 +197,8 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], - "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], - - "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], - "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], diff --git a/config.example.json b/config.example.json index 8fd6f77..136e62b 100644 --- a/config.example.json +++ b/config.example.json @@ -6,11 +6,14 @@ "path": "/path/to/ProductX/Opportunity Solution Tree", "schema": "/path/to/custom/schemas/my-projectx-schema.json", "miroBoardId": "iAmAB04rdId", - "miroFrameId": "1234567789" - // "templateDir": "../templates", - // "templatePrefix": "prodx-" + "miroFrameId": "1234567789", + "plugins": { + "markdown": { + "templateDir": "../templates", + "templatePrefix": "prodx-" + } + } } ], - "schema": "/path/to/custom/schemas/my-general-schema.json", - "templateDir": "/path/to/ProductX/Templates" + "schema": "/path/to/custom/schemas/my-general-schema.json" } diff --git a/docs/concepts.md b/docs/concepts.md index 9c0dfa7..443e69f 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -260,3 +260,19 @@ An **anchor** is a block anchor (e.g. `^goal1`) appended to a heading in a `type `identified` → `wondering` → `exploring` → `active` → `paused` → `completed` → `archived` Status is required on all node types at _validation_ time. Note however that currently the `space on a page` parser chooses to apply a default. + + +--- + +## Plugin + +A **plugin** is a module that extends the tool's capabilities. + +Plugins that support parsing produce raw `SpaceNode[]` results given a suitable configuration. Graph edge resolution (`resolveGraphEdges`) is called by the core afterwards. + +The `plugins` field in config is a **map** from plugin name to plugin config object. All plugin names must start with `ost-tools-`, but the prefix is optional in config and normalised on load. Built-in plugins take precedence and all are loaded by default. External plugin names specified in config are then resolved in order: + +1. Config-adjacent: `{configDir}/plugins/{ost-tools-name}` +2. npm: a package matching `ost-tools-*` + +Each plugin declares a `configSchema` JSON Schema; the loader validates the config block against it before invoking the plugin. Config fields annotated with `format: 'path'` are resolved relative to `configDir` by the loader. diff --git a/package.json b/package.json index 0965e8c..a14cfbc 100644 --- a/package.json +++ b/package.json @@ -38,10 +38,8 @@ "ajv": "^8.18.0", "chokidar": "^5.0.0", "commander": "^14.0.3", - "glob": "^13.0.6", "gray-matter": "^4.0.3", "js-yaml": "^4.1.1", - "json5": "^2.2.3", "jsonata": "^2.1.0", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.1", diff --git a/skills/ost-tools/SKILL.md b/skills/ost-tools/SKILL.md index 9560df2..2ae6d09 100644 --- a/skills/ost-tools/SKILL.md +++ b/skills/ost-tools/SKILL.md @@ -54,6 +54,7 @@ dump Output parsed node data as JSON diagram Generate Mermaid diagram miro-sync Sync to Miro board (requires MIRO_TOKEN env var + miroBoardId in config) template-sync Sync Obsidian templates from schema examples +plugins List available plugins ``` Run `bunx ost-tools --help` or `bunx ost-tools --help` for flags. @@ -94,6 +95,33 @@ what the rule actually sees in the `current` object, then adjust the rule in the | `could not find node '[[Title]]'` | Broken wikilink | Fix the title in the link or ensure the target file exists and has that title | | `JSONata error: ...` | Syntax error in schema `$metadata.rules` | Verify the expression with `dump` and a JSONata tester | +## Plugins + +ost-tools supports **plugins** for extending capabilities. Currently, parse plugins allow reading spaces from sources other than markdown (which is a built-in plugin). + +Declare plugins in config as a of plugin name → config object: + +```json +{ + "spaces": [ + { + "name": "PDFSpace", + "path": "https://...", + "plugins": { + "ost-tools-pdf": { "baseUrl": "https://example.pdfstore.net" } + } + } + ] +} +``` + +All plugin names must start with `ost-tools-` (the prefix is optional in config and normalised on load). External plugins are resolved in order: config-adjacent (`{configDir}/plugins/{name}`), then npm. + +**Markdown plugin config** (under `plugins.markdown` in a space entry): +- `templateDir` — directory for template files used by `template-sync`, and to exclude templates when parsing and validating +- `templatePrefix` — filename prefix for templates (default blank) +- `fieldMap` — maps file field names to canonical schema field names (e.g. `{ "record_type": "type" }`) + ## References - **`references/schema-authoring.md`** — schema file structure, `$metadata`, `fieldMap`, JSONata rules diff --git a/src/commands/diagram.ts b/src/commands/diagram.ts index fd60ed1..83bf3e0 100644 --- a/src/commands/diagram.ts +++ b/src/commands/diagram.ts @@ -20,20 +20,14 @@ function safeNodeId(id: string): string { return id.replace(/[^a-zA-Z0-9_-]/g, '_'); } -export async function diagram( - path: string, - options: { schema: string; output?: string; templateDir?: string }, -): Promise { +export async function diagram(path: string, options: { schema: string; output?: string }): Promise { const { schema, validator } = loadSchema(options.schema); const hierarchyLevels = schema.metadata.hierarchy?.levels ?? []; - const readResult = await readSpace(path, { - schemaPath: options.schema, - templateDir: options.templateDir, - }); + const readResult = await readSpace(path, { schemaPath: options.schema }); const spaceNodes: SpaceNode[] = readResult.nodes; - const skipped = readResult.kind === 'directory' ? readResult.skipped : []; - const nonSpace = readResult.kind === 'directory' ? readResult.nonSpace : []; + const skipped = (readResult.diagnostics?.skipped as string[]) ?? []; + const nonSpace = (readResult.diagnostics?.nonSpace as string[]) ?? []; // Validate nodes const validNodes: SpaceNode[] = []; diff --git a/src/commands/dump.ts b/src/commands/dump.ts index 768f6fd..5a307eb 100644 --- a/src/commands/dump.ts +++ b/src/commands/dump.ts @@ -1,13 +1,7 @@ -import JSON5 from 'json5'; +import { JSON5 } from 'bun'; import { readSpace } from '../read/read-space'; export async function dump(path: string) { - const result = await readSpace(path); - if (result.kind === 'page') { - const { nodes, diagnostics } = result; - console.log(JSON5.stringify({ nodes, diagnostics }, null, 2)); - } else { - const { nodes, skipped, nonSpace } = result; - console.log(JSON5.stringify({ nodes, skipped, nonSpace }, null, 2)); - } + const { nodes, source, diagnostics } = await readSpace(path); + console.log(JSON5.stringify({ nodes, source, diagnostics }, null, 2)); } diff --git a/src/commands/plugins.ts b/src/commands/plugins.ts new file mode 100644 index 0000000..257755f --- /dev/null +++ b/src/commands/plugins.ts @@ -0,0 +1,24 @@ +import { existsSync, readdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { configPath } from '../config'; +import { builtinPlugins } from '../plugins'; +import { CONFIG_PLUGINS_DIR, PLUGIN_PREFIX } from '../plugins/util'; + +export function listPlugins(): void { + console.log('Built-in plugins:'); + for (const plugin of builtinPlugins) { + console.log(` ${plugin.name}`); + } + + const cfgDir = dirname(resolve(configPath())); + const pluginsDir = join(cfgDir, CONFIG_PLUGINS_DIR); + if (existsSync(pluginsDir)) { + const entries = readdirSync(pluginsDir).filter((e) => e.startsWith(PLUGIN_PREFIX)); + if (entries.length > 0) { + console.log('\nConfig-adjacent plugins:'); + for (const entry of entries) { + console.log(` ${entry}`); + } + } + } +} diff --git a/src/commands/spaces.ts b/src/commands/spaces.ts index 20cbdcb..81de77e 100644 --- a/src/commands/spaces.ts +++ b/src/commands/spaces.ts @@ -1,6 +1,11 @@ -import { basename, join, resolve } from 'node:path'; +import { basename, resolve } from 'node:path'; import { configPath, loadConfig, resolveSchema } from '../config'; +function renderConfigValue(v: unknown): string { + if (typeof v === 'object' && v !== null) return JSON.stringify(v); + return String(v); +} + export function listSpaces(): void { const path = resolve(configPath()); const config = loadConfig(); @@ -9,18 +14,21 @@ export function listSpaces(): void { console.log(`${space.name}`); console.log(` path: ${space.path}`); console.log(` schema: ${basename(resolveSchema(undefined, config, space))}`); - const templateDir = space.templateDir ?? config.templateDir; - if (templateDir) { - const templateFormat = space.templatePrefix ?? config.templatePrefix ?? ''; - const fullTemplatePath = templateFormat ? join(templateDir, `${templateFormat}*.md`) : templateDir; - console.log(` templates: ${fullTemplatePath}`); - } if (space.miroBoardId) console.log(` miro: configured`); - if (space.fieldMap && Object.keys(space.fieldMap).length > 0) { - const mappings = Object.entries(space.fieldMap) - .map(([k, v]) => `${k} → ${v}`) - .join(', '); - console.log(` fieldMap: ${mappings}`); + const plugins = space.plugins ?? {}; + if (Object.keys(plugins).length > 0) { + console.log(' plugins:'); + for (const [name, cfg] of Object.entries(plugins)) { + const entries = Object.entries(cfg); + if (entries.length === 0) { + console.log(` ${name}`); + } else { + console.log(` ${name}:`); + for (const [k, v] of entries) { + console.log(` ${k}: ${renderConfigValue(v)}`); + } + } + } } console.log(''); } diff --git a/src/commands/template-sync.ts b/src/commands/template-sync.ts index 32b3e9d..76da23c 100644 --- a/src/commands/template-sync.ts +++ b/src/commands/template-sync.ts @@ -1,10 +1,11 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { AnySchemaObject } from 'ajv'; -import { glob } from 'glob'; +import { Glob } from 'bun'; import matter from 'gray-matter'; import yaml from 'js-yaml'; -import { invertFieldMap } from '../config'; +import { getMarkdownConfig } from '../plugins/markdown'; +import { invertFieldMap } from '../plugins/markdown/util'; import { loadSchema } from '../schema/schema'; import { mergeVariantProperties, resolveRef } from '../schema/schema-refs'; import type { HierarchyLevel, Relationship, SchemaWithMetadata } from '../types'; @@ -249,23 +250,24 @@ export function generateNewContent( } export async function templateSync( - templateDir: string, + plugins: Record> | undefined, options: { schema: string; - templatePrefix: string; dryRun?: boolean; createMissing?: boolean; - fieldMap?: Record; }, ) { + const { templateDir, templatePrefix = '', fieldMap = {} } = getMarkdownConfig(plugins); + if (!templateDir) { + console.error('Error: templateDir not set in plugins.ost-tools-markdown config for this space'); + process.exit(1); + } const { schema, registry } = loadSchema(options.schema); - const templatePrefix = options.templatePrefix; - const fieldMap = options.fieldMap ?? {}; const typeVariants = getTypeVariants(schema, registry); const matchedTypes = new Set(); - const files = await glob('*.md', { cwd: templateDir, absolute: true }); + const files = await Array.fromAsync(new Glob('*.md').scan({ cwd: templateDir, absolute: true })); const dryRun = options.dryRun ?? false; let filesModified = 0; let filesCreated = 0; diff --git a/src/commands/validate.ts b/src/commands/validate.ts index c4b3c1c..24a3b24 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -21,8 +21,7 @@ interface ValidationResult { ruleViolations: RuleViolation[]; hierarchyViolations: GraphViolation[]; orphanCount: number; - skipped: string[]; - nonSpace: string[]; + parseIgnored: string[]; } /** @@ -130,17 +129,12 @@ function formatErrors( return formatted; } -export async function validate(path: string, options: { schema: string; templateDir?: string }): Promise { +export async function validate(path: string, options: { schema: string }): Promise { const { schema, registry, validator } = loadSchema(options.schema); const metadata = schema.metadata; - const readResult = await readSpace(path, { - schemaPath: options.schema, - templateDir: options.templateDir, - }); - const { nodes } = readResult; - const skipped = readResult.kind === 'directory' ? readResult.skipped : []; - const nonSpace = readResult.kind === 'directory' ? readResult.nonSpace : []; + const readResult = await readSpace(path, { schemaPath: options.schema }); + const { nodes, parseIgnored } = readResult; const result: ValidationResult = { validCount: 0, @@ -151,8 +145,7 @@ export async function validate(path: string, options: { schema: string; template ruleViolations: [], hierarchyViolations: [], orphanCount: 0, - skipped, - nonSpace: nonSpace, + parseIgnored: parseIgnored || [], }; for (const node of nodes) { @@ -236,18 +229,11 @@ export async function validate(path: string, options: { schema: string; template console.log(fmt(' Rule violations', result.ruleViolations.length, true)); console.log(fmt(' Hierarchy violations', result.hierarchyViolations.length, true)); console.log(fmt(' Orphans (hierarchy nodes - no parent)', result.orphanCount, true, true)); - console.log('Skipped'); - console.log(fmt(' No frontmatter', result.skipped.length, true, true)); - console.log(fmt(' No type field', result.nonSpace.length, true, true)); + console.log(fmt(' Ignored during parsing', result.parseIgnored.length, true, true)); - if (result.skipped.length > 0) { - console.log(`\nSkipped files (no frontmatter):`); - for (const f of result.skipped) console.log(` ${f}`); - } - - if (result.nonSpace.length > 0) { - console.log(`\nNon-space files (no type field):`); - for (const f of result.nonSpace) console.log(` ${f}`); + if (result.parseIgnored.length > 0) { + console.log(`\nIgnored during parsing:`); + for (const f of result.parseIgnored) console.log(` ${f}`); } if (result.nodeErrors.length > 0) { diff --git a/src/config.ts b/src/config.ts index 0ed8493..7845d34 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,8 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import Ajv from 'ajv'; -import JSON5 from 'json5'; +import { JSON5 } from 'bun'; +import { normalizePluginName } from './plugins/util'; import { bundledSchemasDir } from './schema/schema'; const CONFIG_SCHEMA = { @@ -16,48 +17,36 @@ const CONFIG_SCHEMA = { name: { type: 'string', pattern: '^[a-z0-9_-]+$' }, path: { type: 'string' }, schema: { type: 'string' }, - templateDir: { type: 'string' }, - templatePrefix: { type: 'string' }, miroBoardId: { type: 'string' }, miroFrameId: { type: 'string' }, - fieldMap: { type: 'object', additionalProperties: { type: 'string' } }, + plugins: { type: 'object', additionalProperties: { type: 'object' } }, }, required: ['name', 'path'], additionalProperties: false, }, }, schema: { type: 'string' }, - templateDir: { type: 'string' }, - templatePrefix: { type: 'string' }, includeSpacesFrom: { type: 'array', items: { type: 'string' } }, }, required: ['spaces'], additionalProperties: false, }; -export interface SpaceConfig { +export type SpaceConfig = { name: string; path: string; schema?: string; - templateDir?: string; - templatePrefix?: string; miroBoardId?: string; miroFrameId?: string; - /** - * Maps file/frontmatter field names to canonical field names expected by the schema. - * Applied on read (frontmatter → schemaData) and reversed on write (template-sync). - * Example: { "record_type": "type" } renames `record_type` in files to `type` internally. - */ - fieldMap?: Record; -} + /** Plugin name → plugin config map. Overrides top-level plugins when set. */ + plugins?: Record>; +}; -export interface Config { +export type Config = { spaces: SpaceConfig[]; schema?: string; - templateDir?: string; - templatePrefix?: string; includeSpacesFrom?: string[]; -} +}; let _configPathOverride: string | undefined; const _spaceSourceFiles = new Map(); @@ -92,6 +81,13 @@ export function configPath(): string { return xdgPath; } +function normalizePlugins( + plugins: Record> | undefined, +): Record> | undefined { + if (!plugins) return undefined; + return Object.fromEntries(Object.entries(plugins).map(([name, cfg]) => [normalizePluginName(name), cfg])); +} + function resolveRelativePaths(config: Config, configDir: string): Config { const rel = (p: string | undefined): string | undefined => { if (!p || isAbsolute(p)) return p; @@ -100,12 +96,11 @@ function resolveRelativePaths(config: Config, configDir: string): Config { return { ...config, schema: rel(config.schema), - templateDir: rel(config.templateDir), spaces: config.spaces.map((s) => ({ ...s, path: rel(s.path)!, schema: rel(s.schema), - templateDir: rel(s.templateDir), + plugins: normalizePlugins(s.plugins), })), }; } @@ -176,46 +171,6 @@ export function resolveSchema(cliArg: string | undefined, config: Config, space? return cliArg ?? space?.schema ?? config.schema ?? join(bundledSchemasDir, 'general.json'); } -export interface TemplateSettings { - templateDir: string; - templatePrefix: string; -} - -/** Resolve template settings: space-level config > global config. */ -export function resolveTemplateSettings(config: Config, space?: SpaceConfig): TemplateSettings { - const templateDir = space?.templateDir ?? config.templateDir; - if (!templateDir) { - throw new Error('templateDir not found in config (global or per-space)'); - } - const templatePrefix = space?.templatePrefix ?? config.templatePrefix ?? ''; - return { templateDir, templatePrefix }; -} - -/** - * Apply field remapping to a data object. - * Renames keys according to fieldMap (file field name → canonical field name). - * Fields not in the map are passed through unchanged. - */ -export function applyFieldMap( - data: Record, - fieldMap: Record | undefined, -): Record { - if (!fieldMap || Object.keys(fieldMap).length === 0) return data; - const result: Record = {}; - for (const [key, value] of Object.entries(data)) { - result[fieldMap[key] ?? key] = value; - } - return result; -} - -/** - * Invert a fieldMap (file→canonical) to produce a reverse map (canonical→file). - * Used for write operations (e.g. template-sync) to translate back to file field names. - */ -export function invertFieldMap(fieldMap: Record): Record { - return Object.fromEntries(Object.entries(fieldMap).map(([src, canonical]) => [canonical, src])); -} - type StringFields = { [K in keyof T]: T[K] extends string | undefined ? K : never }[keyof T]; /** Update a string field on a space entry and persist config. */ diff --git a/src/index.ts b/src/index.ts index d8bd54f..4d8beda 100755 --- a/src/index.ts +++ b/src/index.ts @@ -6,19 +6,13 @@ import chokidar from 'chokidar'; import { Command } from 'commander'; import { diagram } from './commands/diagram'; import { dump } from './commands/dump'; +import { listPlugins } from './commands/plugins'; import { listSchemas, showSchema } from './commands/schemas'; import { show } from './commands/show'; import { listSpaces } from './commands/spaces'; import { templateSync } from './commands/template-sync'; import { validate } from './commands/validate'; -import { - getConfigSourceFiles, - loadConfig, - resolveSchema, - resolveSpacePath, - resolveTemplateSettings, - setConfigPath, -} from './config'; +import { getConfigSourceFiles, loadConfig, resolveSchema, resolveSpacePath, setConfigPath } from './config'; import { miroSync } from './integrations/miro/sync'; import { bundledSchemasDir } from './schema/schema'; @@ -48,7 +42,6 @@ program const space = config.spaces.find((s) => s.name === spaceOrDir); const spacePath = space?.path ?? resolveSpacePath(spaceOrDir, config); const schemaPath = resolveSchema(options.schema, config, space); - const templateDir = space?.templateDir ?? config.templateDir; if (options.watch) { // Watch mode - set up watchers and re-run on changes @@ -71,7 +64,7 @@ program let exitCode = 0; const innerValidate = async () => { try { - exitCode = await validate(spacePath, { schema: schemaPath, templateDir }); + exitCode = await validate(spacePath, { schema: schemaPath }); } catch (error) { console.error(`❌ Error during validation: ${error instanceof Error ? error.message : String(error)}`); exitCode = 1; @@ -113,7 +106,7 @@ program process.exit(exitCode); }); } else { - const exitCode = await validate(spacePath, { schema: schemaPath, templateDir }); + const exitCode = await validate(spacePath, { schema: schemaPath }); process.exit(exitCode); } }); @@ -130,7 +123,6 @@ program diagram(space?.path ?? resolveSpacePath(spaceOrDir, config), { ...options, schema: resolveSchema(options.schema, config, space), - templateDir: space?.templateDir ?? config.templateDir, }); }); @@ -169,15 +161,14 @@ program console.error(`Error: Unknown space "${options.space}"`); process.exit(1); } - const { templateDir, templatePrefix } = resolveTemplateSettings(config, space); - templateSync(templateDir, { + templateSync(space?.plugins, { ...options, schema: resolveSchema(options.schema, config, space), - templatePrefix, - fieldMap: space?.fieldMap, }); }); +program.command('plugins').description('List available plugins').action(listPlugins); + const spacesCmd = new Command('spaces').description('List configured spaces'); spacesCmd .command('list', { isDefault: true }) @@ -211,7 +202,61 @@ program .description('Show full README documentation') .action(() => { const readme = readFileSync(join(import.meta.dir, '..', 'README.md'), 'utf-8'); - process.stdout.write(readme); + const cols = process.stdout.columns ?? 80; + const rendered = Bun.markdown.render(readme, { + heading: (children, { level }) => { + const prefix = '#'.repeat(level); + if (level === 1) return `\x1b[1;4m${prefix} ${children}\x1b[0m\n\n`; + if (level === 2) + return `\n\x1b[1m${prefix} ${children}\x1b[0m\n${'─'.repeat(Math.min(children.length + level + 1, cols))}\n`; + return `\n\x1b[1m${prefix} ${children}\x1b[0m\n`; + }, + paragraph: (children) => `${children}\n\n`, + strong: (children) => `\x1b[1m**${children}**\x1b[22m`, + emphasis: (children) => `\x1b[3m*${children}*\x1b[23m`, + codespan: (children) => `\x1b[96m\`${children}\`\x1b[39m`, + code: (children, meta) => { + const lang = meta?.language ?? ''; + return `\x1b[96m\`\`\`${lang}\n${children}\`\`\`\x1b[0m\n`; + }, + blockquote: (children) => + `${children + .split('\n') + .map((l) => `\x1b[2m> ${l}\x1b[0m`) + .join('\n')}\n`, + table: (children) => `${children}\n`, + thead: (children) => { + // Reconstruct the markdown separator row by counting columns in the rendered header row + // ANSI codes never contain '|', so pipe-counting works on raw output + const colCount = (children.split('\n')[0] ?? '').split('|').length - 2; + const sep = `\x1b[2m| ${Array(colCount).fill('---').join(' | ')} |\x1b[0m\n`; + return `${children}${sep}`; + }, + tbody: (children) => children, + tr: (children) => `${children}|\n`, + th: (children) => `| \x1b[1m${children}\x1b[22m `, + td: (children) => `| ${children} `, + list: (children) => `${children}\n`, + listItem: (children, meta) => { + // Bun's ListItemMeta type is missing depth/ordered/index — cast via unknown + const { + depth = 0, + ordered = false, + index = 0, + } = meta as unknown as { + depth: number; + ordered: boolean; + index: number; + }; + const indent = ' '.repeat(depth); + const bullet = ordered ? `${index + 1}.` : '-'; + return `${indent}${bullet} ${children.trimEnd()}\n`; + }, + hr: () => `\x1b[2m---\x1b[0m\n`, + link: (children, { href }) => + children === href ? `\x1b[4;34m${href}\x1b[0m` : `[${children}](\x1b[4;34m${href}\x1b[0m)`, + }); + process.stdout.write(rendered); }); program.parse(); diff --git a/src/plugins/index.ts b/src/plugins/index.ts new file mode 100644 index 0000000..8132b9d --- /dev/null +++ b/src/plugins/index.ts @@ -0,0 +1,5 @@ +import { markdownPlugin } from './markdown'; +import type { OstToolsPlugin } from './util'; + +/** All built-in plugins, in default load order. */ +export const builtinPlugins: OstToolsPlugin[] = [markdownPlugin]; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts new file mode 100644 index 0000000..b07d688 --- /dev/null +++ b/src/plugins/loader.ts @@ -0,0 +1,95 @@ +import { existsSync } from 'node:fs'; +import { isAbsolute, join, resolve } from 'node:path'; +import Ajv, { type AnySchemaObject } from 'ajv'; +import { builtinPlugins } from '.'; +import { CONFIG_PLUGINS_DIR, normalizePluginName, type OstToolsPlugin, PLUGIN_PREFIX } from './util'; + +export type LoadedPlugin = { + plugin: OstToolsPlugin; + pluginConfig: Record; +}; + +/** + * Walk a plugin's configSchema and resolve any string fields annotated with + * format:'path' relative to configDir. + */ +function resolveConfigPaths( + schema: AnySchemaObject, + config: Record, + configDir: string, +): Record { + const props = schema.properties as Record | undefined; + if (!props) return config; + const result = { ...config }; + for (const [key, propSchema] of Object.entries(props)) { + if (propSchema.format === 'path' && typeof result[key] === 'string') { + const value = result[key] as string; + if (!isAbsolute(value)) { + result[key] = resolve(configDir, value); + } + } + } + return result; +} + +/** + * Resolve an external plugin by canonical name. + * Resolution order: config-adjacent ({configDir}/plugins/{name}) → npm (import(name)). + */ +async function resolveExternalPlugin(name: string, configDir: string): Promise { + const localPath = join(configDir, CONFIG_PLUGINS_DIR, name); + const module = existsSync(localPath) || existsSync(`${localPath}.ts`) ? await import(localPath) : await import(name); + const plugin = (module as { default?: OstToolsPlugin }).default ?? (module as OstToolsPlugin); + if (!plugin || typeof plugin.name !== 'string') { + throw new Error(`Plugin "${name}" must export an OstToolsPlugin as its default export`); + } + return plugin; +} + +/** + * Load plugins for a space. + * + * Built-in plugins are always included (with config from the map if declared, else {}). + * External plugins are loaded from the map and prepended in declaration order. + * Resolution order for external plugins: config-adjacent → npm. + * Fields annotated with format:'path' in a plugin's configSchema are resolved + * relative to configDir. + */ +export async function loadPlugins( + pluginMap: Record>, + configDir: string, +): Promise { + const builtinsByName = new Map(builtinPlugins.map((p) => [p.name, p])); + const ajv = new Ajv(); + ajv.addFormat('path', () => true); + const loaded: LoadedPlugin[] = []; + + // External plugins: entries in the map that are not built-in names + for (const [rawName, rawConfig] of Object.entries(pluginMap)) { + const name = normalizePluginName(rawName); + if (builtinsByName.has(name)) continue; + if (!name.startsWith(PLUGIN_PREFIX)) { + throw new Error(`Plugin name must start with "${PLUGIN_PREFIX}" (got "${rawName}")`); + } + const plugin = await resolveExternalPlugin(name, configDir); + const pluginConfig = resolveConfigPaths(plugin.configSchema, rawConfig, configDir); + const validate = ajv.compile(plugin.configSchema); + if (!validate(pluginConfig)) { + throw new Error(`Invalid config for plugin "${name}": ${JSON.stringify(validate.errors)}`); + } + loaded.push({ plugin, pluginConfig }); + } + + // Built-in plugins: always loaded, config taken from map if declared (with or without prefix) + for (const builtin of builtinPlugins) { + const rawConfig = pluginMap[builtin.name] ?? pluginMap[builtin.name.slice(PLUGIN_PREFIX.length)] ?? {}; + const pluginConfig = resolveConfigPaths(builtin.configSchema, rawConfig, configDir); + const validate = ajv.compile(builtin.configSchema); + if (!validate(pluginConfig)) { + throw new Error(`Invalid config for plugin "${builtin.name}": ${JSON.stringify(validate.errors)}`); + } + loaded.push({ plugin: builtin, pluginConfig }); + } + + return loaded; +} diff --git a/src/plugins/markdown/index.ts b/src/plugins/markdown/index.ts new file mode 100644 index 0000000..275bb35 --- /dev/null +++ b/src/plugins/markdown/index.ts @@ -0,0 +1,37 @@ +import { statSync } from 'node:fs'; +import type { OstToolsPlugin, ParseResult, PluginContext } from '../util'; +import { PLUGIN_PREFIX } from '../util'; +import { readSpaceDirectory, readSpaceOnAPage } from './read-space'; + +export type MarkdownPluginConfig = { + templateDir?: string; + fieldMap?: Record; + templatePrefix?: string; +}; + +export const MARKDOWN_CONFIG_SCHEMA = { + type: 'object', + properties: { + templateDir: { type: 'string', format: 'path' }, // format is hint to config loader to resolve relative directories + fieldMap: { type: 'object', additionalProperties: { type: 'string' } }, + templatePrefix: { type: 'string' }, + }, + additionalProperties: false, +}; + +export function getMarkdownConfig(plugins?: Record>): MarkdownPluginConfig { + return (plugins?.[`${PLUGIN_PREFIX}markdown`] ?? {}) as MarkdownPluginConfig; +} + +async function parse(context: PluginContext): Promise { + if (statSync(context.spacePath).isFile()) { + return readSpaceOnAPage(context); + } + return await readSpaceDirectory(context); +} + +export const markdownPlugin: OstToolsPlugin = { + name: `${PLUGIN_PREFIX}markdown`, + configSchema: MARKDOWN_CONFIG_SCHEMA, + parse, +}; diff --git a/src/read/parse-embedded.ts b/src/plugins/markdown/parse-embedded.ts similarity index 97% rename from src/read/parse-embedded.ts rename to src/plugins/markdown/parse-embedded.ts index 4d78af1..a0bc6c0 100644 --- a/src/read/parse-embedded.ts +++ b/src/plugins/markdown/parse-embedded.ts @@ -4,31 +4,24 @@ import { toString as mdastToString } from 'mdast-util-to-string'; import remarkGfm from 'remark-gfm'; import remarkParse from 'remark-parse'; import { unified } from 'unified'; -import { applyFieldMap } from '../config'; -import type { SharedEmbeddingFields } from '../schema/metadata-contract'; -import { resolveNodeType } from '../schema/schema'; -import type { - EdgeDefinition, - HierarchyLevel, - Relationship, - SchemaMetadata, - SpaceNode, - SpaceOnAPageDiagnostics, -} from '../types'; +import type { SharedEmbeddingFields } from '../../schema/metadata-contract'; +import { resolveNodeType } from '../../schema/schema'; +import type { EdgeDefinition, HierarchyLevel, Relationship, SchemaMetadata, SpaceNode } from '../../types'; +import { applyFieldMap } from './util'; /** Type values that identify a space_on_a_page container (not themselves space nodes). */ export const ON_A_PAGE_TYPES = ['ost_on_a_page', 'space_on_a_page']; -export const DEFAULT_STATUS = 'identified'; +const DEFAULT_STATUS = 'identified'; -export interface StackEntry { +type StackEntry = { depth: number; title: string; /** Empty string marks an untyped heading placeholder (typed-page mode, i.e. not space_on_a_page). */ nodeType: string; /** Preferred wikilink key used when this heading acts as a parent. */ refTarget: string; -} +}; /** * Normalized embedding definition — works for both hierarchy levels and relationships. @@ -42,12 +35,12 @@ interface EmbeddingDefinition extends EdgeDefinition, SharedEmbeddingFields { } /** Active grouping context — replaces ad-hoc pendingMatch. */ -interface GroupingState { +type GroupingState = { definition: EmbeddingDefinition; semanticParent: { ref: string | undefined; node: SpaceNode | undefined }; headingNode: SpaceNode; emitted: boolean; -} +}; /** Detect a bare wikilink `[[...]]` and return the inner target, or undefined. */ export function isWikilink(text: string): string | undefined { @@ -323,7 +316,8 @@ export interface ExtractEmbeddedOptions { export interface ExtractEmbeddedResult { nodes: SpaceNode[]; - diagnostics: SpaceOnAPageDiagnostics; + preambleNodeCount: number; + terminatedHeadings: string[]; } /** @@ -362,10 +356,8 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio type ParseState = 'preamble' | 'active' | 'done'; let parseState: ParseState = 'preamble'; - const diagnostics: SpaceOnAPageDiagnostics = { - preambleNodeCount: 0, - terminatedHeadings: [], - }; + let preambleNodeCount = 0; + const terminatedHeadings: string[] = []; /** * Returns the nearest typed parent context, skipping stack entries at depth >= headingDepth @@ -554,7 +546,7 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio const rawTitle = mdastToString(child); const { cleanText: afterBracketed } = extractBracketedFields(rawTitle); const { cleanText: title } = extractAnchor(afterBracketed); - diagnostics.terminatedHeadings.push(title); + terminatedHeadings.push(title); } continue; } @@ -645,7 +637,7 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio activeNode = headingNode; } } else if (parseState !== 'active') { - diagnostics.preambleNodeCount++; + preambleNodeCount++; } else if (child.type === 'list') { const parentRef = currentParentRef(); const list = child as List; @@ -846,5 +838,5 @@ export function extractEmbeddedNodes(body: string, options: ExtractEmbeddedOptio } } - return { nodes, diagnostics }; + return { nodes, preambleNodeCount, terminatedHeadings }; } diff --git a/src/plugins/markdown/read-space.ts b/src/plugins/markdown/read-space.ts new file mode 100644 index 0000000..92efcb5 --- /dev/null +++ b/src/plugins/markdown/read-space.ts @@ -0,0 +1,117 @@ +import { readFileSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; +import { Glob } from 'bun'; +import matter from 'gray-matter'; +import { resolveGraphEdges } from '../../read/resolve-graph-edges'; +import { resolveNodeType } from '../../schema/schema'; +import type { SpaceNode } from '../../types'; +import type { ParseResult, PluginContext } from '../util'; +import type { MarkdownPluginConfig } from '.'; +import { extractEmbeddedNodes, ON_A_PAGE_TYPES } from './parse-embedded'; +import { applyFieldMap } from './util'; + +type ReadSpaceDirectoryOptions = { + includeOnAPageFiles?: boolean; +}; + +export function readSpaceOnAPage(context: PluginContext): ParseResult { + const { spacePath: filePath, resolvedSchemaPath, metadata } = context; + const raw = readFileSync(filePath, 'utf-8'); + const { data: frontmatter, content: body } = matter(raw); + + const pageType = frontmatter.type as string | undefined; + if (pageType !== undefined && !ON_A_PAGE_TYPES.includes(pageType)) { + throw new Error( + `Expected a space_on_a_page file but got type "${pageType}" in ${filePath}. ` + + `Use a directory path to validate a space containing typed node files.`, + ); + } + + const hierarchyLevels = metadata.hierarchy?.levels; + if (!hierarchyLevels || hierarchyLevels.length === 0) { + throw new Error( + `Schema at ${resolvedSchemaPath} must define "$metadata.hierarchy.levels" to read a space_on_a_page file.`, + ); + } + + const pageTitle = basename(filePath, '.md'); + const { nodes, preambleNodeCount, terminatedHeadings } = extractEmbeddedNodes(body, { + pageTitle, + pageType: 'space_on_a_page', + metadata, + }); + + resolveGraphEdges(nodes, hierarchyLevels, metadata.relationships, metadata.typeAliases); + return { nodes, diagnostics: { kind: 'page', preambleNodeCount, terminatedHeadings } }; +} + +export async function readSpaceDirectory( + context: PluginContext, + options?: ReadSpaceDirectoryOptions, +): Promise { + const { spacePath: directory, metadata } = context; + const mdCfg = context.pluginConfig as MarkdownPluginConfig; + + const hierarchyLevels = metadata.hierarchy?.levels ?? []; + const fieldMap = mdCfg.fieldMap; + + const templateDir = mdCfg.templateDir; + const absoluteTemplateDir = templateDir ? resolve(templateDir) : undefined; + + const files = await Array.fromAsync(new Glob('**/*.md').scan({ cwd: directory, followSymlinks: true })); + const nodes: SpaceNode[] = []; + const skipped: string[] = []; + const nonSpace: string[] = []; + + for (const file of files) { + const absoluteFilePath = resolve(directory, file); + + if (absoluteTemplateDir && absoluteFilePath.startsWith(absoluteTemplateDir)) { + continue; + } + + const content = readFileSync(join(directory, file), 'utf-8'); + const parsed = matter(content); + + if (!parsed.data || Object.keys(parsed.data).length === 0) { + skipped.push(file); + continue; + } + + const data = applyFieldMap(parsed.data, fieldMap); + + if (!data.type) { + nonSpace.push(file); + continue; + } + + if (ON_A_PAGE_TYPES.includes(data.type as string) && !options?.includeOnAPageFiles) { + continue; + } + + const pageType = data.type as string; + const fileBase = basename(file, '.md'); + const title = (data.title as string) ?? fileBase; + + nodes.push({ + label: file, + schemaData: { title, ...data }, + linkTargets: [title, fileBase], + resolvedParents: [], + resolvedType: resolveNodeType(pageType, metadata.typeAliases), + }); + + if (!ON_A_PAGE_TYPES.includes(pageType)) { + const { nodes: embedded } = extractEmbeddedNodes(parsed.content, { + pageTitle: fileBase, + pageType, + metadata, + fieldMap, + }); + nodes.push(...embedded); + } + } + + resolveGraphEdges(nodes, hierarchyLevels, metadata.relationships, metadata.typeAliases); + return { nodes, parseIgnored: [...skipped, ...nonSpace], diagnostics: { kind: 'directory' } }; +} diff --git a/src/plugins/markdown/util.ts b/src/plugins/markdown/util.ts new file mode 100644 index 0000000..1c7cbd0 --- /dev/null +++ b/src/plugins/markdown/util.ts @@ -0,0 +1,24 @@ +/** + * Apply field remapping to a data object. + * Renames keys according to fieldMap (file field name → canonical field name). + * Fields not in the map are passed through unchanged. + */ +export function applyFieldMap( + data: Record, + fieldMap: Record | undefined, +): Record { + if (!fieldMap || Object.keys(fieldMap).length === 0) return data; + const result: Record = {}; + for (const [key, value] of Object.entries(data)) { + result[fieldMap[key] ?? key] = value; + } + return result; +} + +/** + * Invert a fieldMap (file→canonical) to produce a reverse map (canonical→file). + * Used for write operations (e.g. template-sync) to translate back to file field names. + */ +export function invertFieldMap(fieldMap: Record): Record { + return Object.fromEntries(Object.entries(fieldMap).map(([src, canonical]) => [canonical, src])); +} diff --git a/src/plugins/util.ts b/src/plugins/util.ts new file mode 100644 index 0000000..7a0571a --- /dev/null +++ b/src/plugins/util.ts @@ -0,0 +1,49 @@ +import type { AnySchemaObject } from 'ajv'; +import type { Config, SpaceConfig } from '../config'; +import type { SchemaMetadata, SpaceNode } from '../types'; + +export const PLUGIN_PREFIX = 'ost-tools-'; +export const CONFIG_PLUGINS_DIR = 'plugins'; + +/** Normalize a plugin name to its canonical prefixed form. */ +export function normalizePluginName(name: string): string { + return name.startsWith(PLUGIN_PREFIX) ? name : `${PLUGIN_PREFIX}${name}`; +} + +export type PluginContext = { + /** Absolute path to the space (file or directory). */ + spacePath: string; + /** Matching space config entry, if the path is a registered space. */ + space: SpaceConfig | undefined; + /** Full loaded config. */ + config: Config; + /** Absolute path to the resolved schema. */ + resolvedSchemaPath: string; + /** Parsed schema metadata. */ + metadata: SchemaMetadata; + /** Validated config for this plugin invocation. */ + pluginConfig: Record; +}; + +export type ParseResult = { + nodes: SpaceNode[]; + /** Paths/items the plugin skipped during parsing, for any reason. */ + parseIgnored?: string[]; + /** Plugin diagnostics: keyed scalar or list values. */ + diagnostics?: Record; +}; + +export type ParseHook = (context: PluginContext) => Promise; + +export type OstToolsPlugin = { + name: string; + /** JSON Schema used to validate the plugin's config block. + * TODO: support a path annotation (e.g. format or keyword) so the loader can resolve + * config fields that are filesystem paths relative to the config file, rather than + * each plugin hardcoding that knowledge in the core config resolver. */ + configSchema: AnySchemaObject; + parse?: ParseHook; + // Future: canHandle?(context) → boolean | Promise for deterministic routing. + // Intent: replace null-return fallthrough with explicit match/no-match. Orchestrator would + // require exactly one plugin to claim the space; ambiguity or no match is an error. +}; diff --git a/src/read/context.ts b/src/read/context.ts new file mode 100644 index 0000000..27ca58e --- /dev/null +++ b/src/read/context.ts @@ -0,0 +1,13 @@ +import { resolve } from 'node:path'; +import { loadConfig, resolveSchema } from '../config'; +import type { PluginContext } from '../plugins/util'; +import { loadMetadata } from '../schema/schema'; + +export function loadSpaceContext(path: string, schemaPath?: string): PluginContext { + const absolutePath = resolve(path); + const config = loadConfig(); + const space = config.spaces.find((s) => resolve(s.path) === absolutePath); + const resolvedSchemaPath = resolveSchema(schemaPath, config, space); + const metadata = loadMetadata(resolvedSchemaPath); + return { spacePath: absolutePath, space, config, resolvedSchemaPath, metadata, pluginConfig: {} }; +} diff --git a/src/read/read-space.ts b/src/read/read-space.ts index b73dd4d..193e2ad 100644 --- a/src/read/read-space.ts +++ b/src/read/read-space.ts @@ -1,143 +1,23 @@ -import { readFileSync, statSync } from 'node:fs'; -import { basename, join, resolve } from 'node:path'; -import { glob } from 'glob'; -import matter from 'gray-matter'; -import { applyFieldMap, loadConfig, resolveSchema } from '../config'; -import { loadMetadata, resolveNodeType } from '../schema/schema'; -import type { SchemaMetadata, SpaceDirectoryReadResult, SpaceNode, SpaceOnAPageReadResult } from '../types'; -import { extractEmbeddedNodes, ON_A_PAGE_TYPES } from './parse-embedded'; -import { resolveGraphEdges } from './resolve-graph-edges'; - -export interface ReadSpaceDirectoryOptions { - includeOnAPageFiles?: boolean; - schemaPath?: string; - templateDir?: string; -} - -export type ReadSpaceResult = - | ({ kind: 'page' } & SpaceOnAPageReadResult) - | ({ kind: 'directory' } & SpaceDirectoryReadResult); - -interface SpaceContext { - space: ReturnType['spaces'][number] | undefined; - config: ReturnType; - resolvedSchemaPath: string; - metadata: SchemaMetadata; -} - -function loadSpaceContext(path: string, schemaPath?: string): SpaceContext { - const config = loadConfig(); - const space = config.spaces.find((s) => resolve(s.path) === resolve(path)); - const resolvedSchemaPath = resolveSchema(schemaPath, config, space); - const metadata = loadMetadata(resolvedSchemaPath); - return { space, config, resolvedSchemaPath, metadata }; -} - -export function readSpaceOnAPage(filePath: string, schemaPath?: string): SpaceOnAPageReadResult { - const raw = readFileSync(filePath, 'utf-8'); - const { data: frontmatter, content: body } = matter(raw); - - const pageType = frontmatter.type as string | undefined; - if (pageType !== undefined && !ON_A_PAGE_TYPES.includes(pageType)) { - throw new Error( - `Expected a space_on_a_page file but got type "${pageType}" in ${filePath}. ` + - `Use a directory path to validate a space containing typed node files.`, - ); - } - - const { resolvedSchemaPath, metadata } = loadSpaceContext(filePath, schemaPath); - const hierarchyLevels = metadata.hierarchy?.levels; - if (!hierarchyLevels || hierarchyLevels.length === 0) { - throw new Error( - `Schema at ${resolvedSchemaPath} must define "$metadata.hierarchy.levels" to read a space_on_a_page file.`, - ); - } - - const pageTitle = basename(filePath, '.md'); - const { nodes, diagnostics } = extractEmbeddedNodes(body, { - pageTitle, - pageType: 'space_on_a_page', - metadata, - }); - - resolveGraphEdges(nodes, hierarchyLevels, metadata.relationships, metadata.typeAliases); - return { nodes, diagnostics }; -} - -export async function readSpaceDirectory( - directory: string, - options?: ReadSpaceDirectoryOptions, -): Promise { - const absoluteDirectory = resolve(directory); - const { space, config, metadata } = loadSpaceContext(absoluteDirectory, options?.schemaPath); - - const hierarchyLevels = metadata.hierarchy?.levels ?? []; - const fieldMap = space?.fieldMap; - - const templateDir = options?.templateDir ?? space?.templateDir ?? config.templateDir; - const absoluteTemplateDir = templateDir ? resolve(templateDir) : undefined; - - const files = await glob('**/*.md', { cwd: directory, absolute: false, follow: true }); - const nodes: SpaceNode[] = []; - const skipped: string[] = []; - const nonSpace: string[] = []; - - for (const file of files) { - const absoluteFilePath = resolve(directory, file); - - if (absoluteTemplateDir && absoluteFilePath.startsWith(absoluteTemplateDir)) { - continue; - } - - const content = readFileSync(join(directory, file), 'utf-8'); - const parsed = matter(content); - - if (!parsed.data || Object.keys(parsed.data).length === 0) { - skipped.push(file); - continue; - } - - const data = applyFieldMap(parsed.data, fieldMap); - - if (!data.type) { - nonSpace.push(file); - continue; - } - - if (ON_A_PAGE_TYPES.includes(data.type as string) && !options?.includeOnAPageFiles) { - continue; - } - - const pageType = data.type as string; - const fileBase = basename(file, '.md'); - const title = (data.title as string) ?? fileBase; - - nodes.push({ - label: file, - schemaData: { title, ...data }, - linkTargets: [title, fileBase], - resolvedParents: [], - resolvedType: resolveNodeType(pageType, metadata.typeAliases), - }); - - if (!ON_A_PAGE_TYPES.includes(pageType)) { - const { nodes: embedded } = extractEmbeddedNodes(parsed.content, { - pageTitle: fileBase, - pageType, - metadata, - fieldMap, - }); - nodes.push(...embedded); +import { dirname } from 'node:path'; +import { configPath } from '../config'; +import { loadPlugins } from '../plugins/loader'; +import type { ReadSpaceResult } from '../types'; +import { loadSpaceContext } from './context'; + +export async function readSpace(path: string, options?: { schemaPath?: string }): Promise { + const context = loadSpaceContext(path, options?.schemaPath); + + const pluginMap: Record> = context.space?.plugins ?? {}; + const cfgDir = dirname(configPath()); + const loaded = await loadPlugins(pluginMap, cfgDir); + + for (const { plugin, pluginConfig } of loaded) { + if (!plugin.parse) continue; + const result = await plugin.parse({ ...context, pluginConfig }); + if (result !== null) { + return { nodes: result.nodes, source: plugin.name, diagnostics: result.diagnostics }; } } - resolveGraphEdges(nodes, hierarchyLevels, metadata.relationships, metadata.typeAliases); - return { nodes, skipped, nonSpace }; -} - -export async function readSpace(path: string, options: ReadSpaceDirectoryOptions = {}): Promise { - if (statSync(path).isFile()) { - return { kind: 'page', ...readSpaceOnAPage(path, options.schemaPath) }; - } - return { kind: 'directory', ...(await readSpaceDirectory(path, options)) }; + throw new Error(`No plugin handled space at: ${path}`); } diff --git a/src/schema/schema.ts b/src/schema/schema.ts index 917108f..b6e2169 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -3,7 +3,7 @@ import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isDeepStrictEqual } from 'node:util'; import Ajv, { type AnySchemaObject, type ValidateFunction } from 'ajv'; -import JSON5 from 'json5'; +import { JSON5 } from 'bun'; import type { HierarchyLevel, RuleCategory, SchemaMetadata, SchemaWithMetadata } from '../types'; import { type MetadataContract, diff --git a/src/types.ts b/src/types.ts index 3bb6e7d..5113454 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,7 +41,7 @@ export type Relationship = Omit; rules?: Rule[]; relationships?: Relationship[]; -} +}; -export interface SchemaWithMetadata extends SchemaObject { +export type SchemaWithMetadata = SchemaObject & { metadata: SchemaMetadata; -} +}; + +export type ReadSpaceResult = { + nodes: SpaceNode[]; + /** Name of the plugin that produced the nodes. */ + source: string; + /** Paths/items skipped during parsing. */ + parseIgnored?: string[]; + /** Plugin diagnostics: keyed scalar or list values. */ + diagnostics?: Record; +}; diff --git a/tests/template-sync.test.ts b/tests/commands/template-sync.test.ts similarity index 93% rename from tests/template-sync.test.ts rename to tests/commands/template-sync.test.ts index 482b0da..f28799e 100644 --- a/tests/template-sync.test.ts +++ b/tests/commands/template-sync.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'bun:test'; import type { AnySchemaObject } from 'ajv'; -import type { TypeVariant } from '../src/commands/template-sync'; -import { generateNewContent } from '../src/commands/template-sync'; -import type { Relationship, SchemaWithMetadata } from '../src/types'; +import type { TypeVariant } from '../../src/commands/template-sync'; +import { generateNewContent } from '../../src/commands/template-sync'; +import type { Relationship, SchemaWithMetadata } from '../../src/types'; describe('template-sync - generateNewContent', () => { const schema: SchemaWithMetadata = { diff --git a/tests/config.test.ts b/tests/config.test.ts index 00ad351..a3bf45f 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,8 +1,8 @@ import { afterAll, beforeEach, describe, expect, it } from 'bun:test'; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import JSON5 from 'json5'; -import { loadConfig, setConfigPath, updateSpaceField } from '../src/config'; +import { JSON5 } from 'bun'; +import { type Config, loadConfig, setConfigPath, updateSpaceField } from '../src/config'; const testDir = join(process.cwd(), 'tmp-config-test'); const mainConfigPath = join(testDir, 'main-config.json'); @@ -300,11 +300,11 @@ describe('loadConfig with includeSpacesFrom', () => { updateSpaceField('included-space', 'miroFrameId', 'new-frame-id'); // Verify the included config file was updated - const updatedConfig = JSON5.parse(readFileSync(otherConfigPath, 'utf-8')); + const updatedConfig = JSON5.parse(readFileSync(otherConfigPath, 'utf-8')) as Config; expect(updatedConfig.spaces[0].miroFrameId).toBe('new-frame-id'); // Verify the main config was not modified - const mainConfig = JSON5.parse(readFileSync(mainConfigPath, 'utf-8')); + const mainConfig = JSON5.parse(readFileSync(mainConfigPath, 'utf-8')) as Config; expect(mainConfig.spaces[0].name).toBe('main-space'); }); }); diff --git a/tests/fixtures/plugins/ost-tools-custom-plugin.ts b/tests/fixtures/plugins/ost-tools-custom-plugin.ts new file mode 100644 index 0000000..af79fa5 --- /dev/null +++ b/tests/fixtures/plugins/ost-tools-custom-plugin.ts @@ -0,0 +1,15 @@ +import type { OstToolsPlugin, ParseResult } from '../../../src/plugins/util'; + +/** A plugin that returns a fixed set of nodes (for testing first-match-wins). */ +const customPlugin: OstToolsPlugin = { + name: 'ost-tools-custom-plugin', + configSchema: { type: 'object' }, + async parse(): Promise { + return { + nodes: [], + diagnostics: { source: 'custom' }, + }; + }, +}; + +export default customPlugin; diff --git a/tests/fixtures/plugins/ost-tools-invalid-plugin.ts b/tests/fixtures/plugins/ost-tools-invalid-plugin.ts new file mode 100644 index 0000000..1cd6f3c --- /dev/null +++ b/tests/fixtures/plugins/ost-tools-invalid-plugin.ts @@ -0,0 +1,2 @@ +// Intentionally invalid: no name property +export default { parse: async () => null }; diff --git a/tests/fixtures/plugins/ost-tools-null-plugin.ts b/tests/fixtures/plugins/ost-tools-null-plugin.ts new file mode 100644 index 0000000..6a492c9 --- /dev/null +++ b/tests/fixtures/plugins/ost-tools-null-plugin.ts @@ -0,0 +1,12 @@ +import type { OstToolsPlugin } from '../../../src/plugins/util'; + +/** A plugin that always returns null (never handles any space). */ +const nullPlugin: OstToolsPlugin = { + name: 'ost-tools-null-plugin', + configSchema: { type: 'object' }, + async parse() { + return null; + }, +}; + +export default nullPlugin; diff --git a/tests/plugins/loader.test.ts b/tests/plugins/loader.test.ts new file mode 100644 index 0000000..447ccdc --- /dev/null +++ b/tests/plugins/loader.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'bun:test'; +import { join } from 'node:path'; +import { loadPlugins } from '../../src/plugins/loader'; + +// configDir points at tests/fixtures/ so convention resolution looks in tests/fixtures/plugins/ +const CONFIG_DIR = join(import.meta.dir, '../fixtures'); + +describe('loadPlugins', () => { + describe('built-in plugins', () => { + it('always includes built-in plugins when no map is given', async () => { + const loaded = await loadPlugins({}, CONFIG_DIR); + expect(loaded).toHaveLength(1); + expect(loaded[0]!.plugin.name).toBe('ost-tools-markdown'); + expect(loaded[0]!.pluginConfig).toEqual({}); + }); + + it('built-in plugins come after external plugins', async () => { + const loaded = await loadPlugins({ 'ost-tools-null-plugin': {} }, CONFIG_DIR); + expect(loaded[0]!.plugin.name).toBe('ost-tools-null-plugin'); + expect(loaded[1]!.plugin.name).toBe('ost-tools-markdown'); + }); + + it('passes config from map to built-in plugin when declared by full name', async () => { + const mdConfig = { templateDir: '/some/dir', templatePrefix: 'tmpl-' }; + const loaded = await loadPlugins({ 'ost-tools-markdown': mdConfig }, CONFIG_DIR); + expect(loaded.find((l) => l.plugin.name === 'ost-tools-markdown')!.pluginConfig).toMatchObject(mdConfig); + }); + + it('passes config from map to built-in plugin when declared by short name', async () => { + const mdConfig = { templateDir: '/some/dir', templatePrefix: 'tmpl-' }; + const loaded = await loadPlugins({ markdown: mdConfig }, CONFIG_DIR); + expect(loaded.find((l) => l.plugin.name === 'ost-tools-markdown')!.pluginConfig).toMatchObject(mdConfig); + }); + + it('rejects invalid built-in plugin config against configSchema', async () => { + await expect(loadPlugins({ markdown: { templateDir: 123 as unknown as string } }, CONFIG_DIR)).rejects.toThrow( + 'Invalid config for plugin "ost-tools-markdown"', + ); + }); + + it('resolves format:path fields in built-in config relative to configDir', async () => { + const loaded = await loadPlugins({ markdown: { templateDir: 'my-templates' } }, CONFIG_DIR); + const cfg = loaded.find((l) => l.plugin.name === 'ost-tools-markdown')!.pluginConfig; + expect(cfg.templateDir).toBe(join(CONFIG_DIR, 'my-templates')); + }); + }); + + describe('external plugins', () => { + it('resolves plugin by name from config-adjacent plugins/ directory', async () => { + const loaded = await loadPlugins({ 'ost-tools-null-plugin': {} }, CONFIG_DIR); + expect(loaded.some((l) => l.plugin.name === 'ost-tools-null-plugin')).toBe(true); + }); + + it('also resolves short name for external plugins', async () => { + const loaded = await loadPlugins({ 'null-plugin': {} }, CONFIG_DIR); + expect(loaded.some((l) => l.plugin.name === 'ost-tools-null-plugin')).toBe(true); + }); + + it('rejects names that cannot be prefixed to a valid ost-tools- name and are not resolvable', async () => { + await expect(loadPlugins({ 'ost-tools-nonexistent': {} }, CONFIG_DIR)).rejects.toThrow(); + }); + + it('throws when plugin module lacks a name string', async () => { + await expect(loadPlugins({ 'ost-tools-invalid-plugin': {} }, CONFIG_DIR)).rejects.toThrow( + 'must export an OstToolsPlugin', + ); + }); + + it('passes pluginConfig to external plugins', async () => { + const config = { someOption: 'value' }; + const loaded = await loadPlugins({ 'ost-tools-null-plugin': config }, CONFIG_DIR); + expect(loaded[0]!.pluginConfig).toMatchObject(config); + }); + }); +}); diff --git a/tests/read/parse-embedded-hierarchy-embedding.test.ts b/tests/plugins/markdown/parse-embedded-hierarchy-embedding.test.ts similarity index 97% rename from tests/read/parse-embedded-hierarchy-embedding.test.ts rename to tests/plugins/markdown/parse-embedded-hierarchy-embedding.test.ts index 6a7f407..08e014f 100644 --- a/tests/read/parse-embedded-hierarchy-embedding.test.ts +++ b/tests/plugins/markdown/parse-embedded-hierarchy-embedding.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'bun:test'; -import { extractEmbeddedNodes } from '../../src/read/parse-embedded'; -import type { HierarchyLevel } from '../../src/types'; -import { makeLevel, makeRelationship } from '../test-helpers'; +import { extractEmbeddedNodes } from '../../../src/plugins/markdown/parse-embedded'; +import type { HierarchyLevel } from '../../../src/types'; +import { makeLevel, makeRelationship } from '../../test-helpers'; const HIERARCHY: HierarchyLevel[] = [ makeLevel('phase'), diff --git a/tests/read/parse-embedded-relationships.test.ts b/tests/plugins/markdown/parse-embedded-relationships.test.ts similarity index 98% rename from tests/read/parse-embedded-relationships.test.ts rename to tests/plugins/markdown/parse-embedded-relationships.test.ts index e9744a8..8cb6d3c 100644 --- a/tests/read/parse-embedded-relationships.test.ts +++ b/tests/plugins/markdown/parse-embedded-relationships.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'bun:test'; -import { extractEmbeddedNodes } from '../../src/read/parse-embedded'; -import type { Relationship } from '../../src/types'; -import { makeLevel } from '../test-helpers'; +import { extractEmbeddedNodes } from '../../../src/plugins/markdown/parse-embedded'; +import type { Relationship } from '../../../src/types'; +import { makeLevel } from '../../test-helpers'; describe('extractEmbeddedNodes - relationships', () => { const hierarchy = ['vision', 'mission', 'goal', 'opportunity', 'solution', 'experiment']; diff --git a/tests/read/parse-embedded.test.ts b/tests/plugins/markdown/parse-embedded.test.ts similarity index 90% rename from tests/read/parse-embedded.test.ts rename to tests/plugins/markdown/parse-embedded.test.ts index 7edfea0..9b6ac83 100644 --- a/tests/read/parse-embedded.test.ts +++ b/tests/plugins/markdown/parse-embedded.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test'; -import { normalizeHeadingSectionTarget } from '../../src/read/parse-embedded'; +import { normalizeHeadingSectionTarget } from '../../../src/plugins/markdown/parse-embedded'; describe('normalizeHeadingSectionTarget', () => { it('matches observed Obsidian bookmark normalization for special separators', () => { diff --git a/tests/read/read-space-directory-general.test.ts b/tests/plugins/markdown/read-space-directory-general.test.ts similarity index 84% rename from tests/read/read-space-directory-general.test.ts rename to tests/plugins/markdown/read-space-directory-general.test.ts index 71f36bd..94a1668 100644 --- a/tests/read/read-space-directory-general.test.ts +++ b/tests/plugins/markdown/read-space-directory-general.test.ts @@ -1,17 +1,18 @@ import { beforeAll, describe, expect, it } from 'bun:test'; import { join } from 'node:path'; -import { readSpaceDirectory } from '../../src/read/read-space'; -import type { SpaceDirectoryReadResult } from '../../src/types'; +import { readSpaceDirectory } from '../../../src/plugins/markdown/read-space'; +import type { ParseResult } from '../../../src/plugins/util'; +import { loadSpaceContext } from '../../../src/read/context'; -const VALID_DIR = join(import.meta.dir, '..', 'fixtures/general/valid-ost'); -const INVALID_DIR = join(import.meta.dir, '..', 'fixtures/general/invalid-ost'); +const VALID_DIR = join(import.meta.dir, '../../fixtures/general/valid-ost'); +const INVALID_DIR = join(import.meta.dir, '../../fixtures/general/invalid-ost'); describe('readSpaceDirectory', () => { describe('valid-ost directory', () => { - let result: SpaceDirectoryReadResult; + let result: ParseResult; beforeAll(async () => { - result = await readSpaceDirectory(VALID_DIR); + result = await readSpaceDirectory(loadSpaceContext(VALID_DIR)); }); it('returns 12 OST nodes (5 original + vision_page + 2 embedded + solution_page + anchor_vision + 2 embedded)', () => { @@ -24,11 +25,11 @@ describe('readSpaceDirectory', () => { }); it('skips no-frontmatter.md', () => { - expect(result.skipped).toContain('no-frontmatter.md'); + expect(result.parseIgnored).toContain('no-frontmatter.md'); }); - it('puts meeting-notes.md in nonSpace', () => { - expect(result.nonSpace).toContain('meeting-notes.md'); + it('puts meeting-notes.md in parseIgnored', () => { + expect(result.parseIgnored).toContain('meeting-notes.md'); }); it('skipped files do not appear in nodes', () => { @@ -51,17 +52,16 @@ describe('readSpaceDirectory', () => { expect(result.nodes.every((n) => n.label !== 'Community OST.md')).toBe(true); }); - it('Community OST.md does not appear in skipped or nonSpace', () => { - expect(result.skipped.includes('Community OST.md')).toBe(false); - expect(result.nonSpace.includes('Community OST.md')).toBe(false); + it('Community OST.md does not appear in parseIgnored', () => { + expect(result.parseIgnored?.includes('Community OST.md')).toBe(false); }); }); describe('embedded nodes in typed pages', () => { - let result: SpaceDirectoryReadResult; + let result: ParseResult; beforeAll(async () => { - result = await readSpaceDirectory(VALID_DIR); + result = await readSpaceDirectory(loadSpaceContext(VALID_DIR)); }); it('includes vision_page.md as its own node', () => { @@ -110,10 +110,10 @@ describe('readSpaceDirectory', () => { }); describe('anchor-implied type inference', () => { - let result: SpaceDirectoryReadResult; + let result: ParseResult; beforeAll(async () => { - result = await readSpaceDirectory(VALID_DIR); + result = await readSpaceDirectory(loadSpaceContext(VALID_DIR)); }); it('infers type "mission" from ^mission anchor', () => { @@ -153,7 +153,7 @@ describe('readSpaceDirectory', () => { describe('invalid-ost directory', () => { it('returns all 3 nodes regardless of schema validity', async () => { - const result = await readSpaceDirectory(INVALID_DIR); + const result = await readSpaceDirectory(loadSpaceContext(INVALID_DIR)); expect(result.nodes).toHaveLength(3); }); }); diff --git a/tests/read/read-space-on-a-page-general.test.ts b/tests/plugins/markdown/read-space-on-a-page-general.test.ts similarity index 84% rename from tests/read/read-space-on-a-page-general.test.ts rename to tests/plugins/markdown/read-space-on-a-page-general.test.ts index 0de1d19..fe932eb 100644 --- a/tests/read/read-space-on-a-page-general.test.ts +++ b/tests/plugins/markdown/read-space-on-a-page-general.test.ts @@ -1,16 +1,17 @@ import { beforeAll, describe, expect, it } from 'bun:test'; import { join } from 'node:path'; -import { readSpaceOnAPage } from '../../src/read/read-space'; -import type { SpaceOnAPageReadResult } from '../../src/types'; +import { readSpaceOnAPage } from '../../../src/plugins/markdown/read-space'; +import type { ParseResult } from '../../../src/plugins/util'; +import { loadSpaceContext } from '../../../src/read/context'; -const VALID_PAGE = join(import.meta.dir, '..', 'fixtures/general/on-a-page-valid.md'); -const SKIP_PAGE = join(import.meta.dir, '..', 'fixtures/general/on-a-page-heading-skip.md'); +const VALID_PAGE = join(import.meta.dir, '../../fixtures/general/on-a-page-valid.md'); +const SKIP_PAGE = join(import.meta.dir, '../../fixtures/general/on-a-page-heading-skip.md'); describe('readSpaceOnAPage - on-a-page-valid.md (space_on_a_page)', () => { - let result: SpaceOnAPageReadResult; + let result: ParseResult; beforeAll(() => { - result = readSpaceOnAPage(VALID_PAGE); + result = readSpaceOnAPage(loadSpaceContext(VALID_PAGE)); }); describe('heading type inference', () => { @@ -101,11 +102,11 @@ describe('readSpaceOnAPage - on-a-page-valid.md (space_on_a_page)', () => { describe('preamble and terminator', () => { it('counts at least one preamble node', () => { - expect(result.diagnostics.preambleNodeCount).toBeGreaterThanOrEqual(1); + expect(result.diagnostics?.preambleNodeCount).toBeGreaterThanOrEqual(1); }); it('records Archived Vision in terminatedHeadings', () => { - expect(result.diagnostics.terminatedHeadings).toContain('Archived Vision'); + expect(result.diagnostics?.terminatedHeadings).toContain('Archived Vision'); }); it('does not include Archived Vision in nodes', () => { @@ -116,14 +117,14 @@ describe('readSpaceOnAPage - on-a-page-valid.md (space_on_a_page)', () => { describe('heading level skip error', () => { it('throws when heading level is skipped (H1 to H3)', () => { - expect(() => readSpaceOnAPage(SKIP_PAGE)).toThrow(/Heading level skipped/); + expect(() => readSpaceOnAPage(loadSpaceContext(SKIP_PAGE))).toThrow(/Heading level skipped/); }); }); describe('typed file rejection', () => { it('throws when given a typed node file instead of space_on_a_page', () => { - const typedFile = join(import.meta.dir, '..', 'fixtures/general/valid-ost/Personal Vision.md'); - expect(() => readSpaceOnAPage(typedFile)).toThrow(/Expected a space_on_a_page file/); + const typedFile = join(import.meta.dir, '../../fixtures/general/valid-ost/Personal Vision.md'); + expect(() => readSpaceOnAPage(loadSpaceContext(typedFile))).toThrow(/Expected a space_on_a_page file/); }); }); }); diff --git a/tests/read/relationship-embedded-nodes.test.ts b/tests/plugins/markdown/relationship-embedded-nodes.test.ts similarity index 88% rename from tests/read/relationship-embedded-nodes.test.ts rename to tests/plugins/markdown/relationship-embedded-nodes.test.ts index 49a732e..96c0c53 100644 --- a/tests/read/relationship-embedded-nodes.test.ts +++ b/tests/plugins/markdown/relationship-embedded-nodes.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test'; -import { extractEmbeddedNodes } from '../../src/read/parse-embedded'; -import { makeLevel, makeRelationship } from '../test-helpers'; +import { extractEmbeddedNodes } from '../../../src/plugins/markdown/parse-embedded'; +import { makeLevel, makeRelationship } from '../../test-helpers'; describe('Embedded nodes with parent-side relationships', () => { it('should create child nodes when heading anchor matches relationship type', () => { diff --git a/tests/read/read-space.test.ts b/tests/read/read-space.test.ts new file mode 100644 index 0000000..df88c66 --- /dev/null +++ b/tests/read/read-space.test.ts @@ -0,0 +1,100 @@ +import { afterAll, beforeEach, describe, expect, it } from 'bun:test'; +import { existsSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { setConfigPath } from '../../src/config'; +import { readSpace } from '../../src/read/read-space'; + +const VALID_DIR = join(import.meta.dir, '../fixtures/general/valid-ost'); +const VALID_PAGE = join(import.meta.dir, '../fixtures/general/on-a-page-valid.md'); +// Config written here so configDir = tests/fixtures/, resolving plugins from tests/fixtures/plugins/ +const FIXTURES_DIR = join(import.meta.dir, '../fixtures'); +const TMP_CONFIG = join(FIXTURES_DIR, '_tmp-orchestrate-config.json'); + +describe('readSpace', () => { + beforeEach(() => { + setConfigPath(undefined); + if (existsSync(TMP_CONFIG)) rmSync(TMP_CONFIG); + }); + + afterAll(() => { + if (existsSync(TMP_CONFIG)) rmSync(TMP_CONFIG); + setConfigPath(undefined); + }); + + describe('default behaviour (markdown plugin)', () => { + it('reads a directory space and returns source: ost-tools-markdown', async () => { + const result = await readSpace(VALID_DIR); + expect(result.source).toBe('ost-tools-markdown'); + expect(result.nodes.length).toBeGreaterThan(0); + }); + + it('returns kind:directory diagnostics for directory spaces', async () => { + const result = await readSpace(VALID_DIR); + expect(result.diagnostics?.kind).toBe('directory'); + }); + + it('reads a space_on_a_page file and returns source: ost-tools-markdown', async () => { + const result = await readSpace(VALID_PAGE); + expect(result.source).toBe('ost-tools-markdown'); + expect(result.nodes.length).toBeGreaterThan(0); + }); + + it('returns kind:page diagnostics for page spaces', async () => { + const result = await readSpace(VALID_PAGE); + expect(result.diagnostics?.kind).toBe('page'); + }); + + it('resolves graph edges (nodes have resolvedParents populated)', async () => { + const result = await readSpace(VALID_DIR); + const nodesWithParents = result.nodes.filter((n) => n.resolvedParents.length > 0); + expect(nodesWithParents.length).toBeGreaterThan(0); + }); + }); + + describe('plugin fallthrough (null plugin before markdown)', () => { + it('falls through to markdown when first plugin returns null', async () => { + writeFileSync( + TMP_CONFIG, + JSON.stringify({ + spaces: [{ name: 'test', path: VALID_DIR, plugins: { 'ost-tools-null-plugin': {} } }], + }), + ); + setConfigPath(TMP_CONFIG); + + const result = await readSpace(VALID_DIR); + expect(result.source).toBe('ost-tools-markdown'); + expect(result.nodes.length).toBeGreaterThan(0); + }); + }); + + describe('first-match-wins', () => { + it('uses first plugin that returns non-null, skips markdown', async () => { + writeFileSync( + TMP_CONFIG, + JSON.stringify({ + spaces: [{ name: 'test', path: VALID_DIR, plugins: { 'ost-tools-custom-plugin': {} } }], + }), + ); + setConfigPath(TMP_CONFIG); + + const result = await readSpace(VALID_DIR); + expect(result.source).toBe('ost-tools-custom-plugin'); + expect(result.diagnostics?.source).toBe('custom'); + }); + }); + + describe('space-level plugins', () => { + it('uses custom plugin configured on the space', async () => { + writeFileSync( + TMP_CONFIG, + JSON.stringify({ + spaces: [{ name: 'test', path: VALID_DIR, plugins: { 'ost-tools-custom-plugin': {} } }], + }), + ); + setConfigPath(TMP_CONFIG); + + const result = await readSpace(VALID_DIR); + expect(result.source).toBe('ost-tools-custom-plugin'); + }); + }); +}); diff --git a/tests/util/build-target-index.test.ts b/tests/read/wikilink-utils.test.ts similarity index 100% rename from tests/util/build-target-index.test.ts rename to tests/read/wikilink-utils.test.ts diff --git a/tests/schema/schema-metadata.test.ts b/tests/schema/schema-metadata.test.ts index fa067ce..5aba58e 100644 --- a/tests/schema/schema-metadata.test.ts +++ b/tests/schema/schema-metadata.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test'; import { join } from 'node:path'; -import { readSpaceOnAPage } from '../../src/read/read-space'; +import { readSpaceOnAPage } from '../../src/plugins/markdown/read-space'; +import { loadSpaceContext } from '../../src/read/context'; import { bundledSchemasDir, createValidator, loadMetadata } from '../../src/schema/schema'; const FIXTURES_DIR = join(import.meta.dir, '..', 'fixtures/schema-metadata'); @@ -37,7 +38,7 @@ describe('schema metadata', () => { }); it('fails to read space_on_a_page when hierarchy metadata is absent', () => { - expect(() => readSpaceOnAPage(ON_A_PAGE_FIXTURE_PATH, ALIAS_ONLY_SCHEMA_PATH)).toThrow( + expect(() => readSpaceOnAPage(loadSpaceContext(ON_A_PAGE_FIXTURE_PATH, ALIAS_ONLY_SCHEMA_PATH))).toThrow( 'must define "$metadata.hierarchy.levels"', ); }); diff --git a/tests/validate-general.test.ts b/tests/validate/general.test.ts similarity index 89% rename from tests/validate-general.test.ts rename to tests/validate/general.test.ts index 99cb02f..03087ce 100644 --- a/tests/validate-general.test.ts +++ b/tests/validate/general.test.ts @@ -1,16 +1,17 @@ import { beforeAll, describe, expect, it } from 'bun:test'; import { join } from 'node:path'; -import { readSpaceDirectory, readSpaceOnAPage } from '../src/read/read-space'; -import { resolveGraphEdges } from '../src/read/resolve-graph-edges'; -import { bundledSchemasDir, createValidator, loadMetadata } from '../src/schema/schema'; -import { validateGraph } from '../src/schema/validate-graph'; -import type { SpaceNode } from '../src/types'; -import { makeLevel } from './test-helpers'; +import { readSpaceDirectory, readSpaceOnAPage } from '../../src/plugins/markdown/read-space'; +import { loadSpaceContext } from '../../src/read/context'; +import { resolveGraphEdges } from '../../src/read/resolve-graph-edges'; +import { bundledSchemasDir, createValidator, loadMetadata } from '../../src/schema/schema'; +import { validateGraph } from '../../src/schema/validate-graph'; +import type { SpaceNode } from '../../src/types'; +import { makeLevel } from '../test-helpers'; const DEFAULT_SCHEMA_PATH = join(bundledSchemasDir, 'general.json'); -const VALID_DIR = join(import.meta.dir, 'fixtures/general/valid-ost'); -const INVALID_DIR = join(import.meta.dir, 'fixtures/general/invalid-ost'); -const VALID_PAGE = join(import.meta.dir, 'fixtures/general/on-a-page-valid.md'); +const VALID_DIR = join(import.meta.dir, '../fixtures/general/valid-ost'); +const INVALID_DIR = join(import.meta.dir, '../fixtures/general/invalid-ost'); +const VALID_PAGE = join(import.meta.dir, '../fixtures/general/on-a-page-valid.md'); const validateNode = createValidator(DEFAULT_SCHEMA_PATH); const metadata = loadMetadata(DEFAULT_SCHEMA_PATH); @@ -20,7 +21,7 @@ describe('Schema validation', () => { let nodes: SpaceNode[]; beforeAll(async () => { - ({ nodes } = await readSpaceDirectory(VALID_DIR)); + ({ nodes } = await readSpaceDirectory(loadSpaceContext(VALID_DIR))); }); it('all 12 nodes pass schema validation', () => { @@ -40,7 +41,7 @@ describe('Schema validation', () => { let nodes: SpaceNode[]; beforeAll(() => { - ({ nodes } = readSpaceOnAPage(VALID_PAGE)); + ({ nodes } = readSpaceOnAPage(loadSpaceContext(VALID_PAGE))); }); it('all nodes pass schema validation', () => { @@ -55,7 +56,7 @@ describe('Schema validation', () => { let nodes: SpaceNode[]; beforeAll(async () => { - ({ nodes } = await readSpaceDirectory(INVALID_DIR)); + ({ nodes } = await readSpaceDirectory(loadSpaceContext(INVALID_DIR))); }); it('missing-status.md fails schema validation (no status field)', () => { @@ -293,7 +294,9 @@ describe('Schema validation', () => { describe('duplicate title detection', () => { it('detects duplicate titles from same filename in different directories', async () => { - const { nodes } = await readSpaceDirectory(join(import.meta.dir, 'fixtures/general/duplicate-titles')); + const { nodes } = await readSpaceDirectory( + loadSpaceContext(join(import.meta.dir, '../fixtures/general/duplicate-titles')), + ); const titleCounts = new Map(); for (const node of nodes) { const title = node.schemaData.title as string; @@ -309,7 +312,9 @@ describe('Schema validation', () => { }); it('detects duplicate titles from embedded nodes', () => { - const { nodes } = readSpaceOnAPage(join(import.meta.dir, 'fixtures/general/duplicate-embedded.md')); + const { nodes } = readSpaceOnAPage( + loadSpaceContext(join(import.meta.dir, '../fixtures/general/duplicate-embedded.md')), + ); const titleCounts = new Map(); for (const node of nodes) { const title = node.schemaData.title as string; diff --git a/tests/validate-relationships.test.ts b/tests/validate/relationships.test.ts similarity index 96% rename from tests/validate-relationships.test.ts rename to tests/validate/relationships.test.ts index 3c9df7b..7ddd558 100644 --- a/tests/validate-relationships.test.ts +++ b/tests/validate/relationships.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'bun:test'; -import { resolveGraphEdges } from '../src/read/resolve-graph-edges'; -import { validateGraph } from '../src/schema/validate-graph'; -import type { SchemaMetadata } from '../src/types'; -import { makeLevel, makeNode, makeRelationship } from './test-helpers'; +import { resolveGraphEdges } from '../../src/read/resolve-graph-edges'; +import { validateGraph } from '../../src/schema/validate-graph'; +import type { SchemaMetadata } from '../../src/types'; +import { makeLevel, makeNode, makeRelationship } from '../test-helpers'; describe('validateGraph - Relationships', () => { const metadata: SchemaMetadata = { diff --git a/tests/validate-strict.test.ts b/tests/validate/strict.test.ts similarity index 89% rename from tests/validate-strict.test.ts rename to tests/validate/strict.test.ts index 41c8136..c55ffb2 100644 --- a/tests/validate-strict.test.ts +++ b/tests/validate/strict.test.ts @@ -1,14 +1,15 @@ import { beforeAll, describe, expect, it } from 'bun:test'; import { join } from 'node:path'; -import { readSpaceDirectory, readSpaceOnAPage } from '../src/read/read-space'; -import { bundledSchemasDir, createValidator } from '../src/schema/schema'; -import type { SpaceNode } from '../src/types'; +import { readSpaceDirectory, readSpaceOnAPage } from '../../src/plugins/markdown/read-space'; +import { loadSpaceContext } from '../../src/read/context'; +import { bundledSchemasDir, createValidator } from '../../src/schema/schema'; +import type { SpaceNode } from '../../src/types'; const STRICT_SCHEMA_PATH = join(bundledSchemasDir, 'strict_ost.json'); -const VALID_DIR = join(import.meta.dir, 'fixtures/strict_ost/valid-directory'); -const INVALID_DIR = join(import.meta.dir, 'fixtures/strict_ost/invalid'); -const VALID_ON_A_PAGE = join(import.meta.dir, 'fixtures/strict_ost/ost-on-a-page.md'); -const VALID_TREE = join(import.meta.dir, 'fixtures/strict_ost/valid-tree.md'); +const VALID_DIR = join(import.meta.dir, '../fixtures/strict_ost/valid-directory'); +const INVALID_DIR = join(import.meta.dir, '../fixtures/strict_ost/invalid'); +const VALID_ON_A_PAGE = join(import.meta.dir, '../fixtures/strict_ost/ost-on-a-page.md'); +const VALID_TREE = join(import.meta.dir, '../fixtures/strict_ost/valid-tree.md'); const validateNode = createValidator(STRICT_SCHEMA_PATH); @@ -194,7 +195,7 @@ describe('Strict OST schema validation', () => { let nodes: SpaceNode[]; beforeAll(async () => { - ({ nodes } = await readSpaceDirectory(VALID_DIR, { schemaPath: STRICT_SCHEMA_PATH })); + ({ nodes } = await readSpaceDirectory(loadSpaceContext(VALID_DIR, STRICT_SCHEMA_PATH))); }); it('reads all 4 nodes from valid-directory', () => { @@ -212,7 +213,7 @@ describe('Strict OST schema validation', () => { let nodes: SpaceNode[]; beforeAll(() => { - ({ nodes } = readSpaceOnAPage(VALID_ON_A_PAGE, STRICT_SCHEMA_PATH)); + ({ nodes } = readSpaceOnAPage(loadSpaceContext(VALID_ON_A_PAGE, STRICT_SCHEMA_PATH))); }); it('extracts nodes from ost-on-a-page.md', () => { @@ -230,7 +231,7 @@ describe('Strict OST schema validation', () => { let nodes: SpaceNode[]; beforeAll(() => { - ({ nodes } = readSpaceOnAPage(VALID_TREE, STRICT_SCHEMA_PATH)); + ({ nodes } = readSpaceOnAPage(loadSpaceContext(VALID_TREE, STRICT_SCHEMA_PATH))); }); it('extracts nodes from valid-tree.md', () => { @@ -249,7 +250,7 @@ describe('Strict OST schema validation', () => { beforeAll(async () => { // Read from invalid directory - note that readSpaceDirectory doesn't validate - ({ nodes } = await readSpaceDirectory(INVALID_DIR)); + ({ nodes } = await readSpaceDirectory(loadSpaceContext(INVALID_DIR))); }); it('rejects vision type (not allowed in strict_ost)', () => { diff --git a/tsconfig.json b/tsconfig.json index eadc025..ccfd8bd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,7 @@ "noEmit": false, "outDir": "dist", "rootDir": "src", + "paths": { "@/*": ["./src/*"], "*": ["./*"] }, // Best practices "strict": true,