diff --git a/.gitignore b/.gitignore index 65b1df76..38840456 100644 --- a/.gitignore +++ b/.gitignore @@ -75,4 +75,5 @@ docs/.next # CLI build output cli/dist/ -.neoboard.local \ No newline at end of file +.neoboard.local + diff --git a/app/package.json b/app/package.json index fa728bdd..f4524d0c 100644 --- a/app/package.json +++ b/app/package.json @@ -3,6 +3,8 @@ "version": "0.0.1", "private": true, "scripts": { + "predev": "node ../scripts/generate-plugin-imports.mjs", + "prebuild": "node ../scripts/generate-plugin-imports.mjs", "dev": "next dev --turbopack", "build": "next build --webpack", "start": "next start", diff --git a/app/src/plugins/__tests__/external-plugins-bootstrap.test.ts b/app/src/plugins/__tests__/external-plugins-bootstrap.test.ts new file mode 100644 index 00000000..e5aba666 --- /dev/null +++ b/app/src/plugins/__tests__/external-plugins-bootstrap.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { ChartPlugin } from "@/lib/plugin/chart-plugin-registry"; +import { createPluginRegistry } from "@/lib/plugin/chart-plugin-registry"; + +// Lightweight plugin factory for tests — ONLY fields the bootstrap cares +// about. Full plugin coverage lives in bar.test.tsx and friends. +function makePlugin(type: string): ChartPlugin { + return { + type, + label: type, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + component: (() => null) as any, + transform: (d) => d, + options: [], + capabilities: { + supportsClickAction: false, + supportsStyling: false, + isECharts: false, + requiresQuery: true, + }, + }; +} + +/** + * Mirrors the external-plugin registration loop in plugins/index.ts. + * Keeping the decision here lets us unit-test it without executing the + * full bootstrap side effect. + */ +function registerExternalPlugins( + registry: ReturnType, + entries: Array<{ plugin: ChartPlugin; overrides: boolean }>, +) { + for (const { plugin, overrides } of entries) { + if (registry.has(plugin.type)) { + if (!overrides) { + throw new Error( + `External plugin "${plugin.type}" conflicts with an existing plugin. ` + + `Set "overrides": true in neoboard-plugins.json to replace the built-in.`, + ); + } + registry.unregister(plugin.type); + } + registry.register(plugin); + } +} + +describe("external plugin bootstrap — overrides logic", () => { + let registry: ReturnType; + + beforeEach(() => { + registry = createPluginRegistry(); + }); + + it("registers an external plugin with a unique type", () => { + registerExternalPlugins(registry, [ + { plugin: makePlugin("heatmap"), overrides: false }, + ]); + expect(registry.has("heatmap")).toBe(true); + }); + + it("rejects an external plugin that conflicts with a built-in when overrides=false", () => { + registry.register(makePlugin("bar")); + expect(() => + registerExternalPlugins(registry, [ + { plugin: makePlugin("bar"), overrides: false }, + ]), + ).toThrow(/conflicts with an existing plugin/); + expect(() => + registerExternalPlugins(registry, [ + { plugin: makePlugin("bar"), overrides: false }, + ]), + ).toThrow(/overrides.*true/); + }); + + it("replaces a built-in when overrides=true", () => { + const builtin = makePlugin("bar"); + builtin.label = "Built-in Bar"; + registry.register(builtin); + + const external = makePlugin("bar"); + external.label = "External Bar"; + registerExternalPlugins(registry, [{ plugin: external, overrides: true }]); + + expect(registry.get("bar")?.label).toBe("External Bar"); + }); + + it("registers multiple external plugins in order", () => { + registerExternalPlugins(registry, [ + { plugin: makePlugin("heatmap"), overrides: false }, + { plugin: makePlugin("funnel"), overrides: false }, + ]); + expect(registry.getTypes()).toEqual( + expect.arrayContaining(["heatmap", "funnel"]), + ); + }); + + it("first conflict aborts the batch — subsequent entries are not registered", () => { + registry.register(makePlugin("bar")); + expect(() => + registerExternalPlugins(registry, [ + { plugin: makePlugin("bar"), overrides: false }, + { plugin: makePlugin("safe"), overrides: false }, + ]), + ).toThrow(); + // "safe" never got registered because the loop threw on "bar" + expect(registry.has("safe")).toBe(false); + }); + + it("empty entries array is a no-op", () => { + const before = registry.getTypes().length; + registerExternalPlugins(registry, []); + expect(registry.getTypes().length).toBe(before); + }); +}); + +describe("plugin bootstrap — end-to-end (real registry)", () => { + it("exports pluginRegistry with built-ins registered", async () => { + // Mock the heavy component-library dep so the bootstrap doesn't + // load the full chart suite in the test env. + vi.doMock("@neoboard/components", () => ({ + MarkdownWidget: () => null, + getChartOptions: () => [], + })); + const { pluginRegistry } = await import("../index"); + expect(pluginRegistry.has("bar")).toBe(true); + expect(pluginRegistry.has("markdown")).toBe(true); + vi.doUnmock("@neoboard/components"); + }); +}); diff --git a/app/src/plugins/external-plugins.generated.ts b/app/src/plugins/external-plugins.generated.ts new file mode 100644 index 00000000..761091a4 --- /dev/null +++ b/app/src/plugins/external-plugins.generated.ts @@ -0,0 +1,13 @@ +/** + * AUTO-GENERATED — do not edit by hand. + * Source: neoboard-plugins.json + * Regenerate: node scripts/generate-plugin-imports.mjs + */ +import type { ChartPlugin } from "@/lib/plugin/chart-plugin-registry"; + +export interface ExternalPluginEntry { + plugin: ChartPlugin; + overrides: boolean; +} + +export const EXTERNAL_PLUGINS: ExternalPluginEntry[] = []; diff --git a/app/src/plugins/index.ts b/app/src/plugins/index.ts index 798d4244..25095f62 100644 --- a/app/src/plugins/index.ts +++ b/app/src/plugins/index.ts @@ -16,6 +16,7 @@ import { pluginRegistry } from "./registry"; import { CHART_TYPES } from "./chart-types"; +import { EXTERNAL_PLUGINS } from "./external-plugins.generated"; import { markdownPlugin } from "./markdown"; import { barPlugin } from "./bar"; import { linePlugin } from "./line"; @@ -64,6 +65,24 @@ for (const plugin of BUILT_IN_PLUGINS) { pluginRegistry.register(plugin); } +// ── External plugins (from neoboard-plugins.json) ─────────────────────── +// Registered AFTER built-ins so external plugins can replace a built-in +// chart type — but only when their manifest entry has `overrides: true`. +// Same-type duplicates without overrides throw loudly so operators spot +// the conflict at startup instead of debugging a silent replacement. +for (const { plugin, overrides } of EXTERNAL_PLUGINS) { + if (pluginRegistry.has(plugin.type)) { + if (!overrides) { + throw new Error( + `External plugin "${plugin.type}" conflicts with an existing plugin. ` + + `Set "overrides": true in neoboard-plugins.json to replace the built-in.`, + ); + } + pluginRegistry.unregister(plugin.type); + } + pluginRegistry.register(plugin); +} + // ── Startup validation ────────────────────────────────────────────────── // Verify that every chart type declared in CHART_TYPES has a registered // plugin. A mismatch is a dev-time bug, not a runtime error. diff --git a/docs/plugins/authoring.md b/docs/plugins/authoring.md new file mode 100644 index 00000000..941eef76 --- /dev/null +++ b/docs/plugins/authoring.md @@ -0,0 +1,150 @@ +# Authoring external chart plugins for NeoBoard + +NeoBoard loads external chart plugins at **build time** via a manifest +at the repository root: `neoboard-plugins.json`. This doc walks through +writing a plugin, wiring it into the manifest, and the trust model you +sign up for as an operator. + +## Trust model (read this first) + +- Plugins run **in-process** inside the NeoBoard Next.js app. They have + the same access as any first-party code: React context, the plugin + registry, the network, environment variables in the browser bundle. +- Declaring a plugin in `neoboard-plugins.json` requires filesystem + + commit access to the repo and an `npm install`. This is **not** a + runtime-pluggable surface — there is no UI or API to add a plugin. +- **There is no sandbox.** A malicious plugin can exfiltrate the user's + session. Treat external plugins the same as any other npm dependency + you bundle into your production build. +- Review plugin source before adding it to the manifest. Prefer plugins + you've authored, or packages from a vendor you trust. + +## Plugin shape + +A plugin is a `ChartPlugin` object (see +`app/src/lib/plugin/chart-plugin-registry.ts` for the full type). The +minimum viable plugin: + +```ts +import { defineChartPlugin } from "@neoboard/app/plugin-sdk"; // see note below +import { MyChart } from "./component"; + +export default defineChartPlugin({ + type: "heatmap", + label: "Heatmap", + component: MyChart, + transform: (rows) => rows, // whatever shape your component consumes +}); +``` + +Fields at a glance: + +| Field | Required | What it does | +| ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `type` | yes | Unique chart identifier (e.g. `"heatmap"`). Must not collide with a built-in unless you also set `"overrides": true` in the manifest. | +| `label` | yes | Human-readable name in the chart picker. | +| `component` | yes | React component rendered inside the widget card. | +| `transform` | yes | `(rawRows) → yourShape` — normalizes the query result for your component. | +| `options` | no | Chart-option panel config. See `app/src/plugins/bar/options.ts` for a full example. | +| `queryHint` | no | Short example query + expected columns shown in the editor. | +| `compatibleWith` | no | Array of connector types (`"neo4j"`, `"postgresql"`). Omit = all. | +| `stylingTargets` | no | Conditional-styling targets (e.g. `[{ value: "color", label: "Color" }]`). | +| `capabilities` | no | Overrides for `supportsClickAction`, `supportsStyling`, `isECharts`, `requiresQuery`. | +| `settingsSchema` | no | Zod schema for the settings object. Recommended — lets you `.parse()` raw settings in your component. | + +## Package layout + +Your plugin should be an npm package. Minimum structure: + +``` +my-neoboard-plugin/ +├── package.json +├── src/ +│ ├── index.ts # exports default plugin +│ ├── component.tsx # React component +│ └── settings.ts # Zod schema (optional) +└── tsconfig.json +``` + +`package.json` must declare `"main"` / `"module"` / `"types"` that point +at a published bundle — NeoBoard imports your package like any other npm +dep. Peer-depend on `react` and (if you use them) `zod` and +`@neoboard/components` so they don't get bundled twice. + +```json +{ + "name": "@myorg/neoboard-heatmap", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "peerDependencies": { + "react": ">=19" + } +} +``` + +## Wiring it in + +1. `npm install @myorg/neoboard-heatmap` in the NeoBoard repo root. +2. Add an entry to `neoboard-plugins.json`: + + ```json + { + "$schema": "./neoboard-plugins.schema.json", + "plugins": [{ "package": "@myorg/neoboard-heatmap" }] + } + ``` + +3. Run `npm run generate:plugins` (or just start dev/build — it's wired + into `predev` and `prebuild`). +4. A new chart type `"heatmap"` appears in the widget editor. + +### Named exports + +By default the generator imports the package's `default` export. To use +a named export: + +```json +{ "package": "@myorg/neoboard-charts", "export": "heatmap" } +``` + +### Overriding a built-in + +External plugins that reuse a built-in chart type (`"bar"`, `"pie"`, +etc.) are **rejected at startup** unless the manifest entry opts in: + +```json +{ "package": "@myorg/neoboard-bar-plus", "overrides": true } +``` + +This is deliberate — silent overrides are a nightmare to debug. If +you're replacing a built-in, say so out loud. + +## Development loop + +The manifest is resolved at build time, so you'll want a local +workflow: + +1. `npm link` your plugin package during development so changes in + your plugin are immediately visible in NeoBoard's `node_modules`. +2. Restart the dev server after editing `neoboard-plugins.json` (the + import statements are regenerated by the `predev` hook). + +## Failure modes + +| Symptom | Likely cause | +| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Cannot find module '@myorg/foo'` at build time | Package not installed. Run `npm install` in the repo root. | +| `External plugin "bar" conflicts with an existing plugin` at startup | Your plugin's `type` matches a built-in and `overrides` is `false`. Either rename or set `"overrides": true`. | +| Widget renders blank / throws | Your plugin's component threw. `ChartErrorBoundary` (each widget has one) catches it and shows an inline error. Check the browser console for the stack. | +| Chart options panel is empty | `options` is undefined. Declare option keys in your plugin. | + +## Testing your plugin + +Treat your plugin as a normal React library: unit-test the `transform` +function, snapshot-test the component with sample data, ship it as an +npm package with its own test suite. NeoBoard does not run your tests. + +Integration testing against a real NeoBoard install is the most +reliable signal. A stripped-down example lives in +`examples/plugin-sparkline/` (coming soon) — clone it, rename, ship. diff --git a/neoboard-plugins.json b/neoboard-plugins.json new file mode 100644 index 00000000..cf07acfb --- /dev/null +++ b/neoboard-plugins.json @@ -0,0 +1,4 @@ +{ + "$schema": "./neoboard-plugins.schema.json", + "plugins": [] +} diff --git a/neoboard-plugins.schema.json b/neoboard-plugins.schema.json new file mode 100644 index 00000000..45262220 --- /dev/null +++ b/neoboard-plugins.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/alfredo1996/neoboard/blob/main/neoboard-plugins.schema.json", + "title": "NeoBoard external plugin manifest", + "description": "Declares external chart plugins to import at build time. Each entry must resolve to an npm package that is installed (declared in app/package.json or a workspace) and exports a ChartPlugin.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "plugins": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["package"], + "properties": { + "package": { + "type": "string", + "minLength": 1, + "description": "The npm package name to import (e.g. '@myorg/neoboard-heatmap' or './plugins/my-local')." + }, + "export": { + "type": "string", + "minLength": 1, + "default": "default", + "description": "Named export to import. Defaults to 'default' (the package's default export)." + }, + "overrides": { + "type": "boolean", + "default": false, + "description": "When true, this plugin is allowed to replace a built-in plugin with the same chart type. Defaults to false — duplicates are rejected at registration." + } + } + } + } + }, + "required": ["plugins"] +} diff --git a/package.json b/package.json index 5dd7064b..b7711ddb 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "cli" ], "scripts": { + "generate:plugins": "node scripts/generate-plugin-imports.mjs", + "predev": "node scripts/generate-plugin-imports.mjs", + "prebuild": "node scripts/generate-plugin-imports.mjs", "dev": "npm -w app run dev", "build": "npm -w connection run build && npm -w app run build", "test": "npm -w app run test && npm -w component run test && npm -w cli run test", diff --git a/scripts/__tests__/generate-plugin-imports.test.mjs b/scripts/__tests__/generate-plugin-imports.test.mjs new file mode 100644 index 00000000..25ada69e --- /dev/null +++ b/scripts/__tests__/generate-plugin-imports.test.mjs @@ -0,0 +1,247 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + validateEntry, + validateManifest, + renderSource, + runGenerator, +} from "../generate-plugin-imports.mjs"; + +// --------------------------------------------------------------------------- +// validateEntry +// --------------------------------------------------------------------------- + +describe("validateEntry", () => { + it("returns null for a minimal valid entry", () => { + assert.equal(validateEntry({ package: "foo" }, 0), null); + }); + + it("returns null for a full valid entry", () => { + assert.equal( + validateEntry( + { package: "foo", export: "myExport", overrides: true }, + 0, + ), + null, + ); + }); + + it("rejects non-object entry", () => { + assert.match(validateEntry("not-an-object", 0), /must be an object/); + assert.match(validateEntry(null, 2), /must be an object/); + }); + + it("rejects missing package", () => { + assert.match(validateEntry({}, 0), /package must be a non-empty string/); + }); + + it("rejects empty package string", () => { + assert.match( + validateEntry({ package: " " }, 0), + /package must be a non-empty string/, + ); + }); + + it("rejects non-string export", () => { + assert.match( + validateEntry({ package: "foo", export: 42 }, 0), + /export must be a non-empty string/, + ); + }); + + it("rejects non-boolean overrides", () => { + assert.match( + validateEntry({ package: "foo", overrides: "yes" }, 0), + /overrides must be a boolean/, + ); + }); + + it("rejects unknown keys", () => { + assert.match( + validateEntry({ package: "foo", extraKey: true }, 0), + /unknown key "extraKey"/, + ); + }); + + it("includes the index in error messages", () => { + assert.match(validateEntry({}, 5), /plugins\[5\]/); + }); +}); + +// --------------------------------------------------------------------------- +// validateManifest +// --------------------------------------------------------------------------- + +describe("validateManifest", () => { + it("accepts a valid empty manifest", () => { + const { errors, entries } = validateManifest({ plugins: [] }); + assert.deepEqual(errors, []); + assert.deepEqual(entries, []); + }); + + it("accepts a manifest with multiple entries", () => { + const { errors, entries } = validateManifest({ + plugins: [ + { package: "@a/one" }, + { package: "@a/two", export: "named", overrides: true }, + ], + }); + assert.deepEqual(errors, []); + assert.deepEqual(entries, [ + { package: "@a/one", export: "default", overrides: false }, + { package: "@a/two", export: "named", overrides: true }, + ]); + }); + + it("rejects non-object manifest", () => { + const { errors } = validateManifest("nope"); + assert.match(errors[0], /must be a JSON object/); + }); + + it("rejects missing plugins array", () => { + const { errors } = validateManifest({}); + assert.match(errors[0], /plugins must be an array/); + }); + + it("detects duplicate package+export pairs", () => { + const { errors } = validateManifest({ + plugins: [{ package: "@a/dup" }, { package: "@a/dup" }], + }); + assert.equal(errors.length, 1); + assert.match(errors[0], /duplicate entry/); + }); + + it("allows same package with different exports", () => { + const { errors, entries } = validateManifest({ + plugins: [ + { package: "@a/multi" }, + { package: "@a/multi", export: "heatmap" }, + ], + }); + assert.deepEqual(errors, []); + assert.equal(entries.length, 2); + }); + + it("accumulates errors from multiple bad entries", () => { + const { errors } = validateManifest({ + plugins: [{ package: "" }, { package: "ok" }, { package: 42 }], + }); + assert.equal(errors.length, 2); + }); +}); + +// --------------------------------------------------------------------------- +// renderSource +// --------------------------------------------------------------------------- + +describe("renderSource", () => { + it("emits an empty array when no entries", () => { + const src = renderSource([]); + assert.match(src, /EXTERNAL_PLUGINS: ExternalPluginEntry\[\] = \[\]/); + }); + + it("emits default imports with numbered aliases", () => { + const src = renderSource([ + { package: "@a/one", export: "default", overrides: false }, + { package: "@a/two", export: "default", overrides: true }, + ]); + assert.match(src, /import externalPlugin0 from "@a\/one";/); + assert.match(src, /import externalPlugin1 from "@a\/two";/); + assert.match(src, /plugin: externalPlugin0, overrides: false/); + assert.match(src, /plugin: externalPlugin1, overrides: true/); + }); + + it("emits named imports with 'as alias' syntax", () => { + const src = renderSource([ + { package: "@a/one", export: "heatmap", overrides: false }, + ]); + assert.match(src, /import \{ heatmap as externalPlugin0 \} from "@a\/one";/); + }); + + it("includes AUTO-GENERATED header", () => { + const src = renderSource([]); + assert.match(src, /AUTO-GENERATED/); + assert.match(src, /neoboard-plugins.json/); + }); +}); + +// --------------------------------------------------------------------------- +// runGenerator (end-to-end with temp files) +// --------------------------------------------------------------------------- + +describe("runGenerator", () => { + function withTempDir(fn) { + const dir = mkdtempSync(join(tmpdir(), "neoboard-plugins-")); + const manifest = join(dir, "neoboard-plugins.json"); + const output = join(dir, "out.ts"); + return fn({ dir, manifest, output }); + } + + it("writes the output when manifest is valid and empty", () => { + withTempDir(({ manifest, output }) => { + writeFileSync(manifest, JSON.stringify({ plugins: [] })); + const result = runGenerator({ + manifestPath: manifest, + outputPath: output, + }); + assert.equal(result.ok, true); + assert.equal(result.wrote, true); + assert.ok(existsSync(output)); + }); + }); + + it("is idempotent — second run with unchanged manifest does not rewrite", () => { + withTempDir(({ manifest, output }) => { + writeFileSync(manifest, JSON.stringify({ plugins: [] })); + runGenerator({ manifestPath: manifest, outputPath: output }); + const first = readFileSync(output, "utf8"); + const second = runGenerator({ + manifestPath: manifest, + outputPath: output, + }); + assert.equal(second.wrote, false); + assert.equal(readFileSync(output, "utf8"), first); + }); + }); + + it("fails with a clear error when manifest is missing", () => { + withTempDir(({ dir }) => { + const result = runGenerator({ + manifestPath: join(dir, "missing.json"), + outputPath: join(dir, "out.ts"), + }); + assert.equal(result.ok, false); + assert.match(result.errors[0], /Manifest not found/); + }); + }); + + it("fails with a clear error when manifest has invalid JSON", () => { + withTempDir(({ manifest, output }) => { + writeFileSync(manifest, "{ bad json"); + const result = runGenerator({ + manifestPath: manifest, + outputPath: output, + }); + assert.equal(result.ok, false); + assert.match(result.errors[0], /not valid JSON/); + }); + }); + + it("fails with validation errors when manifest has bad entries", () => { + withTempDir(({ manifest, output }) => { + writeFileSync( + manifest, + JSON.stringify({ plugins: [{ package: "" }] }), + ); + const result = runGenerator({ + manifestPath: manifest, + outputPath: output, + }); + assert.equal(result.ok, false); + assert.match(result.errors[0], /package must be a non-empty string/); + }); + }); +}); diff --git a/scripts/generate-plugin-imports.mjs b/scripts/generate-plugin-imports.mjs new file mode 100644 index 00000000..cd722333 --- /dev/null +++ b/scripts/generate-plugin-imports.mjs @@ -0,0 +1,243 @@ +#!/usr/bin/env node +/** + * Generate app/src/plugins/external-plugins.generated.ts from + * neoboard-plugins.json. + * + * Runs as predev and prebuild. Reads the manifest at the repo root, + * validates each entry, and emits a TypeScript module that the plugin + * bootstrap imports. All resolution happens at build time so the + * webpack graph stays static — no runtime dynamic imports. + * + * Exit code 1 on: + * - manifest missing / unparseable + * - entries fail shape validation + * - duplicate package+export pairs + * + * Idempotent: writes the output file only when its contents would + * change, so downstream tools that watch mtimes don't trigger spuriously. + */ + +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, ".."); +const MANIFEST_PATH = resolve(REPO_ROOT, "neoboard-plugins.json"); +const OUTPUT_PATH = resolve( + REPO_ROOT, + "app", + "src", + "plugins", + "external-plugins.generated.ts", +); + +/** + * Validate a manifest entry. Returns an error message or null. + * Kept in sync with neoboard-plugins.schema.json. + * + * @param {unknown} entry + * @param {number} index + * @returns {string | null} + */ +export function validateEntry(entry, index) { + if (typeof entry !== "object" || entry === null) { + return `plugins[${index}] must be an object`; + } + const e = /** @type {Record} */ (entry); + if (typeof e.package !== "string" || e.package.trim() === "") { + return `plugins[${index}].package must be a non-empty string`; + } + if ( + e.export !== undefined && + (typeof e.export !== "string" || e.export.trim() === "") + ) { + return `plugins[${index}].export must be a non-empty string when provided`; + } + if (e.overrides !== undefined && typeof e.overrides !== "boolean") { + return `plugins[${index}].overrides must be a boolean when provided`; + } + const allowed = new Set(["package", "export", "overrides"]); + for (const key of Object.keys(e)) { + if (!allowed.has(key)) { + return `plugins[${index}] has unknown key "${key}"`; + } + } + return null; +} + +/** + * Validate the full manifest. Returns an array of error messages + * (empty when valid). + * + * @param {unknown} raw + * @returns {{ errors: string[]; entries: Array<{ package: string; export: string; overrides: boolean }> }} + */ +export function validateManifest(raw) { + const errors = []; + if (typeof raw !== "object" || raw === null) { + return { errors: ["manifest must be a JSON object"], entries: [] }; + } + const m = /** @type {Record} */ (raw); + if (!Array.isArray(m.plugins)) { + return { errors: ["manifest.plugins must be an array"], entries: [] }; + } + + const entries = []; + const seen = new Set(); + for (let i = 0; i < m.plugins.length; i++) { + const err = validateEntry(m.plugins[i], i); + if (err) { + errors.push(err); + continue; + } + const e = /** @type {Record} */ (m.plugins[i]); + const normalized = { + package: /** @type {string} */ (e.package), + export: typeof e.export === "string" ? e.export : "default", + overrides: e.overrides === true, + }; + const key = `${normalized.package}::${normalized.export}`; + if (seen.has(key)) { + errors.push( + `plugins[${i}]: duplicate entry for "${normalized.package}" export "${normalized.export}"`, + ); + continue; + } + seen.add(key); + entries.push(normalized); + } + return { errors, entries }; +} + +/** + * Generate the TypeScript source for external-plugins.generated.ts. + * + * @param {Array<{ package: string; export: string; overrides: boolean }>} entries + * @returns {string} + */ +export function renderSource(entries) { + const header = `/** + * AUTO-GENERATED — do not edit by hand. + * Source: neoboard-plugins.json + * Regenerate: node scripts/generate-plugin-imports.mjs + */ +import type { ChartPlugin } from "@/lib/plugin/chart-plugin-registry"; +`; + + if (entries.length === 0) { + return `${header} +export interface ExternalPluginEntry { + plugin: ChartPlugin; + overrides: boolean; +} + +export const EXTERNAL_PLUGINS: ExternalPluginEntry[] = []; +`; + } + + const imports = entries + .map((e, i) => { + const alias = `externalPlugin${i}`; + if (e.export === "default") { + return `import ${alias} from "${e.package}";`; + } + return `import { ${e.export} as ${alias} } from "${e.package}";`; + }) + .join("\n"); + + const arrayEntries = entries + .map( + (e, i) => + ` { plugin: externalPlugin${i}, overrides: ${e.overrides} }, // ${e.package} (${e.export})`, + ) + .join("\n"); + + return `${header} +${imports} + +export interface ExternalPluginEntry { + plugin: ChartPlugin; + overrides: boolean; +} + +export const EXTERNAL_PLUGINS: ExternalPluginEntry[] = [ +${arrayEntries} +]; +`; +} + +/** + * Run the generator. Returns an exit-code-ish result for testing. + * CLI wrapper below calls `process.exit` on failure. + * + * @param {object} [opts] + * @param {string} [opts.manifestPath] + * @param {string} [opts.outputPath] + * @returns {{ ok: boolean; errors: string[]; wrote: boolean }} + */ +export function runGenerator(opts = {}) { + const manifestPath = opts.manifestPath ?? MANIFEST_PATH; + const outputPath = opts.outputPath ?? OUTPUT_PATH; + + if (!existsSync(manifestPath)) { + return { + ok: false, + errors: [`Manifest not found at ${manifestPath}`], + wrote: false, + }; + } + + let raw; + try { + raw = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch (err) { + return { + ok: false, + errors: [ + `Manifest is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, + ], + wrote: false, + }; + } + + const { errors, entries } = validateManifest(raw); + if (errors.length > 0) { + return { ok: false, errors, wrote: false }; + } + + const source = renderSource(entries); + + // Idempotent write — skip if content matches (preserves mtime for + // watchers that care). + const existing = existsSync(outputPath) + ? readFileSync(outputPath, "utf8") + : null; + if (existing === source) { + return { ok: true, errors: [], wrote: false }; + } + + writeFileSync(outputPath, source, "utf8"); + return { ok: true, errors: [], wrote: true }; +} + +// --------------------------------------------------------------------------- +// CLI entry — only runs when invoked directly (not when imported by tests). +// --------------------------------------------------------------------------- + +const invokedDirectly = + process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (invokedDirectly) { + const result = runGenerator(); + if (!result.ok) { + console.error("neoboard-plugins.json validation failed:"); + for (const e of result.errors) console.error(` - ${e}`); + process.exit(1); + } + if (result.wrote) { + console.log( + `Generated ${OUTPUT_PATH.replace(REPO_ROOT + "/", "")} from manifest`, + ); + } +}