@nextpluggable/plugin 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import('../dist/cli.js').catch((err) => {
3
+ console.error(err?.code === 'ERR_MODULE_NOT_FOUND' ? 'nextpluggable-plugin is not built yet. Run `pnpm --filter @nextpluggable/plugin build`.' : err);
4
+ process.exit(1);
5
+ });
@@ -0,0 +1,21 @@
1
+ import { type PluginBuild, type PluginManifest } from './manifest.js';
2
+ /** Modules the host provides to client bundles at runtime. */
3
+ export declare const SHARED_CLIENT_MODULES: readonly ["react", "react/jsx-runtime", "react-dom", "@nextpluggable/plugin"];
4
+ export interface BuildPluginOptions {
5
+ minify?: boolean;
6
+ /** Called with human-readable progress lines. */
7
+ log?: (line: string) => void;
8
+ /** Called with advice that does not stop the build, such as a page with nothing to show in search results. */
9
+ warn?: (line: string) => void;
10
+ }
11
+ /**
12
+ * Things worth fixing for search engines that the manifest alone gives away. The
13
+ * site fills gaps with sensible values, so these are advice, not errors.
14
+ */
15
+ export declare function seoAdvice(manifest: PluginManifest): string[];
16
+ export declare class PluginBuildError extends Error {
17
+ constructor(message: string);
18
+ }
19
+ export declare function buildPlugin(dir: string, options?: BuildPluginOptions): Promise<PluginBuild>;
20
+ /** Format any error from buildPlugin for a terminal. */
21
+ export declare function describeBuildError(err: unknown): string;
package/dist/build.js ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Turns a plugin's source into the three artifacts a host installs:
3
+ *
4
+ * dist/client.js React components, as the body of a CommonJS module. `react`,
5
+ * `react-dom` and `@nextpluggable/plugin` stay external and are
6
+ * supplied by the host on both server (SSR) and browser.
7
+ * dist/server.js Page loaders + API handlers, self-contained CommonJS for Node.
8
+ * dist/style.css Any CSS imported by the client entry.
9
+ * dist/build.json Manifest + file hashes — the only file a host reads to list a plugin.
10
+ *
11
+ * `dist/` is meant to be committed, the same way JavaScript GitHub Actions ship,
12
+ * so that hosts can install straight from a repository at a pinned commit.
13
+ */
14
+ import { createHash } from 'node:crypto';
15
+ import { existsSync } from 'node:fs';
16
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
17
+ import path from 'node:path';
18
+ import { ManifestError, validateManifest } from './manifest.js';
19
+ /** Modules the host provides to client bundles at runtime. */
20
+ export const SHARED_CLIENT_MODULES = ['react', 'react/jsx-runtime', 'react-dom', '@nextpluggable/plugin'];
21
+ const ASSET_LOADERS = {
22
+ '.svg': 'dataurl',
23
+ '.png': 'dataurl',
24
+ '.jpg': 'dataurl',
25
+ '.jpeg': 'dataurl',
26
+ '.gif': 'dataurl',
27
+ '.webp': 'dataurl',
28
+ '.woff2': 'dataurl',
29
+ };
30
+ /**
31
+ * Things worth fixing for search engines that the manifest alone gives away. The
32
+ * site fills gaps with sensible values, so these are advice, not errors.
33
+ */
34
+ export function seoAdvice(manifest) {
35
+ const advice = [];
36
+ for (const page of manifest.pages ?? []) {
37
+ if (!page.description?.trim())
38
+ advice.push(`Page "${page.id}" has no "description". Search results will show the plugin's description instead; add one or two sentences written for visitors.`);
39
+ else if (page.description.length > 200)
40
+ advice.push(`Page "${page.id}" has a description of ${page.description.length} characters. Search engines cut it at about 160.`);
41
+ if (page.title.length > 65)
42
+ advice.push(`Page "${page.id}" has a title of ${page.title.length} characters. Search engines cut it at about 60.`);
43
+ }
44
+ return advice;
45
+ }
46
+ export class PluginBuildError extends Error {
47
+ constructor(message) {
48
+ super(message);
49
+ this.name = 'PluginBuildError';
50
+ }
51
+ }
52
+ function findEntry(dir, explicit, base) {
53
+ if (explicit) {
54
+ const p = path.resolve(dir, explicit);
55
+ if (!existsSync(p))
56
+ throw new PluginBuildError(`Entry file "${explicit}" does not exist.`);
57
+ return p;
58
+ }
59
+ for (const ext of ['tsx', 'ts', 'jsx', 'js', 'mjs']) {
60
+ const p = path.join(dir, 'src', `${base}.${ext}`);
61
+ if (existsSync(p))
62
+ return p;
63
+ }
64
+ return null;
65
+ }
66
+ const sha256 = (data) => createHash('sha256').update(data).digest('hex');
67
+ function exportsOf(meta) {
68
+ for (const [file, out] of Object.entries(meta.outputs))
69
+ if (file.endsWith('.js') && out.entryPoint)
70
+ return out.exports;
71
+ return [];
72
+ }
73
+ async function readManifest(dir) {
74
+ const file = path.join(dir, 'plugin.json');
75
+ let raw;
76
+ try {
77
+ raw = await readFile(file, 'utf8');
78
+ }
79
+ catch {
80
+ throw new PluginBuildError(`No plugin.json found in ${dir}.`);
81
+ }
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(raw);
85
+ }
86
+ catch (e) {
87
+ throw new PluginBuildError(`plugin.json is not valid JSON: ${e.message}`);
88
+ }
89
+ return validateManifest(parsed);
90
+ }
91
+ async function resolveIcon(dir, icon) {
92
+ if (!icon)
93
+ return undefined;
94
+ const m = /\.(svg|png|webp|jpe?g)$/i.exec(icon);
95
+ if (!m)
96
+ return icon; // an emoji / short text
97
+ const file = path.resolve(dir, icon);
98
+ if (!file.startsWith(path.resolve(dir) + path.sep))
99
+ throw new PluginBuildError('"icon" must be a file inside the plugin directory.');
100
+ const data = await readFile(file).catch(() => {
101
+ throw new PluginBuildError(`Icon file "${icon}" does not exist.`);
102
+ });
103
+ if (data.byteLength > 32_000)
104
+ throw new PluginBuildError(`Icon "${icon}" is larger than 32 KB.`);
105
+ const ext = m[1].toLowerCase();
106
+ const mime = ext === 'svg' ? 'image/svg+xml' : ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : 'image/jpeg';
107
+ return `data:${mime};base64,${data.toString('base64')}`;
108
+ }
109
+ /** Evaluate the built server bundle to check that what it exports lines up with the manifest. */
110
+ function checkServer(code, manifest) {
111
+ const mod = { exports: {} };
112
+ const req = (name) => {
113
+ const builtin = process.getBuiltinModule(name);
114
+ if (!builtin)
115
+ throw new Error(`Cannot require "${name}": server bundles may only use Node built-ins at runtime.`);
116
+ return builtin;
117
+ };
118
+ try {
119
+ new Function('require', 'module', 'exports', code)(req, mod, mod.exports);
120
+ }
121
+ catch (e) {
122
+ throw new PluginBuildError(`The server entry throws when loaded: ${e.message}`);
123
+ }
124
+ const server = (mod.exports.default ?? mod.exports);
125
+ if (!server || typeof server !== 'object')
126
+ throw new PluginBuildError('The server entry must `export default definePluginServer({ ... })`.');
127
+ const pageIds = new Set((manifest.pages ?? []).map((p) => p.id));
128
+ if (server.pages !== undefined) {
129
+ if (typeof server.pages !== 'object' || server.pages === null)
130
+ throw new PluginBuildError('`pages` in the server entry must be an object.');
131
+ for (const [id, fn] of Object.entries(server.pages)) {
132
+ if (!pageIds.has(id))
133
+ throw new PluginBuildError(`The server entry has a loader for page "${id}", which plugin.json does not declare.`);
134
+ if (typeof fn !== 'function')
135
+ throw new PluginBuildError(`The loader for page "${id}" must be a function.`);
136
+ }
137
+ }
138
+ if (server.sitemap !== undefined && typeof server.sitemap !== 'function')
139
+ throw new PluginBuildError('`sitemap` in the server entry must be a function.');
140
+ if (server.api !== undefined) {
141
+ if (typeof server.api !== 'object' || server.api === null)
142
+ throw new PluginBuildError('`api` in the server entry must be an object.');
143
+ for (const [key, fn] of Object.entries(server.api)) {
144
+ if (!/^(GET|POST|PUT|PATCH|DELETE) \/\S*$/.test(key))
145
+ throw new PluginBuildError(`API route "${key}" must look like "GET /path" (methods: GET, POST, PUT, PATCH, DELETE).`);
146
+ if (typeof fn !== 'function')
147
+ throw new PluginBuildError(`API route "${key}" must be a function.`);
148
+ }
149
+ }
150
+ }
151
+ export async function buildPlugin(dir, options = {}) {
152
+ const log = options.log ?? (() => { });
153
+ const warn = options.warn ?? (() => { });
154
+ const { build } = await import('esbuild').catch(() => {
155
+ throw new PluginBuildError('Building plugins needs esbuild. Install it next to @nextpluggable/plugin: npm install --save-dev esbuild');
156
+ });
157
+ dir = path.resolve(dir);
158
+ const manifest = await readManifest(dir);
159
+ const clientEntry = findEntry(dir, manifest.entry?.client, 'client');
160
+ const serverEntry = findEntry(dir, manifest.entry?.server, 'server');
161
+ const components = [
162
+ ...(manifest.pages ?? []).map((p) => p.component),
163
+ ...(manifest.slots ?? []).map((s) => s.component),
164
+ ...(manifest.admin ?? []).map((a) => a.component),
165
+ ];
166
+ if (components.length && !clientEntry)
167
+ throw new PluginBuildError('plugin.json declares pages or components, but there is no client entry (expected src/client.tsx).');
168
+ if (!clientEntry && !serverEntry)
169
+ throw new PluginBuildError('Nothing to build: add src/client.tsx and/or src/server.ts.');
170
+ const outDir = path.join(dir, 'dist');
171
+ const outputs = [];
172
+ if (clientEntry) {
173
+ const shared = {
174
+ entryPoints: { client: clientEntry },
175
+ absWorkingDir: dir,
176
+ bundle: true,
177
+ platform: 'browser',
178
+ target: 'es2020',
179
+ jsx: 'automatic',
180
+ external: [...SHARED_CLIENT_MODULES],
181
+ define: { 'process.env.NODE_ENV': '"production"' },
182
+ loader: ASSET_LOADERS,
183
+ legalComments: 'none',
184
+ outdir: outDir,
185
+ write: false,
186
+ logLevel: 'silent',
187
+ };
188
+ // Pass 1 (ESM, discarded): learn the entry's export names.
189
+ const analysis = await build({ ...shared, format: 'esm', metafile: true });
190
+ const exported = new Set(exportsOf(analysis.metafile));
191
+ const missing = components.filter((c) => !exported.has(c));
192
+ if (missing.length)
193
+ throw new PluginBuildError(`plugin.json refers to component${missing.length > 1 ? 's' : ''} ${missing.map((c) => `"${c}"`).join(', ')}, but the client entry does not export ${missing.length > 1 ? 'them' : 'it'}.`);
194
+ // Pass 2: the real artifact.
195
+ const result = await build({ ...shared, format: 'cjs', minify: options.minify ?? true });
196
+ for (const f of result.outputFiles ?? []) {
197
+ if (f.path.endsWith('.js'))
198
+ outputs.push({ name: 'client', file: 'dist/client.js', contents: f.contents });
199
+ else if (f.path.endsWith('.css'))
200
+ outputs.push({ name: 'style', file: 'dist/style.css', contents: f.contents });
201
+ }
202
+ }
203
+ if (serverEntry) {
204
+ const shared = {
205
+ entryPoints: { server: serverEntry },
206
+ absWorkingDir: dir,
207
+ bundle: true,
208
+ platform: 'node',
209
+ target: 'node20',
210
+ outdir: outDir,
211
+ write: false,
212
+ legalComments: 'none',
213
+ logLevel: 'silent',
214
+ };
215
+ const analysis = await build({ ...shared, format: 'esm', metafile: true });
216
+ if (!exportsOf(analysis.metafile).includes('default'))
217
+ throw new PluginBuildError('The server entry must `export default definePluginServer({ ... })`.');
218
+ const result = await build({ ...shared, format: 'cjs', minifySyntax: true });
219
+ const js = result.outputFiles?.find((f) => f.path.endsWith('.js'));
220
+ if (!js)
221
+ throw new PluginBuildError('The server entry produced no output.');
222
+ checkServer(js.text, manifest);
223
+ outputs.push({ name: 'server', file: 'dist/server.js', contents: js.contents });
224
+ }
225
+ const files = {};
226
+ await rm(outDir, { recursive: true, force: true });
227
+ await mkdir(outDir, { recursive: true });
228
+ for (const o of outputs) {
229
+ await writeFile(path.join(dir, o.file), o.contents);
230
+ const entry = { path: o.file, sha256: sha256(o.contents), bytes: Buffer.byteLength(o.contents) };
231
+ files[o.name] = entry;
232
+ log(` ${o.file} ${(entry.bytes / 1024).toFixed(1)} kB`);
233
+ }
234
+ const pluginBuild = { buildVersion: 1, manifest, icon: await resolveIcon(dir, manifest.icon), files };
235
+ await writeFile(path.join(outDir, 'build.json'), JSON.stringify(pluginBuild, null, 2) + '\n');
236
+ log(` dist/build.json`);
237
+ for (const line of seoAdvice(manifest))
238
+ warn(line);
239
+ return pluginBuild;
240
+ }
241
+ /** Format any error from buildPlugin for a terminal. */
242
+ export function describeBuildError(err) {
243
+ if (err instanceof ManifestError || err instanceof PluginBuildError)
244
+ return err.message;
245
+ const e = err;
246
+ if (Array.isArray(e?.errors) && e.errors.length)
247
+ return e.errors
248
+ .map((x) => (x.location ? `${x.location.file}:${x.location.line}:${x.location.column}: ${x.text}` : x.text))
249
+ .join('\n');
250
+ return err instanceof Error ? (err.stack ?? err.message) : String(err);
251
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,40 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { buildPlugin, describeBuildError } from './build.js';
4
+ import { validateManifest } from './manifest.js';
5
+ const HELP = `nextpluggable-plugin — build tool for NextPluggable plugins
6
+
7
+ Usage:
8
+ nextpluggable-plugin build [dir] Build dist/ (client.js, server.js, style.css, build.json)
9
+ nextpluggable-plugin validate [dir] Check plugin.json without building
10
+
11
+ Options:
12
+ --no-minify Keep the client bundle readable
13
+ `;
14
+ async function main() {
15
+ const args = process.argv.slice(2);
16
+ const flags = new Set(args.filter((a) => a.startsWith('--')));
17
+ const [command, dirArg] = args.filter((a) => !a.startsWith('--'));
18
+ const dir = path.resolve(dirArg ?? '.');
19
+ if (!command || flags.has('--help') || command === 'help') {
20
+ console.log(HELP);
21
+ return command ? 0 : 1;
22
+ }
23
+ if (command === 'validate') {
24
+ const manifest = validateManifest(JSON.parse(await readFile(path.join(dir, 'plugin.json'), 'utf8')));
25
+ console.log(`✓ ${manifest.name} ${manifest.version} — plugin.json is valid`);
26
+ return 0;
27
+ }
28
+ if (command === 'build') {
29
+ const started = Date.now();
30
+ const result = await buildPlugin(dir, { minify: !flags.has('--no-minify'), log: (l) => console.log(l), warn: (l) => console.log(` ! ${l}`) });
31
+ console.log(`✓ Built ${result.manifest.name} ${result.manifest.version} in ${Date.now() - started} ms`);
32
+ return 0;
33
+ }
34
+ console.error(`Unknown command "${command}".\n\n${HELP}`);
35
+ return 1;
36
+ }
37
+ main().then((code) => process.exit(code), (err) => {
38
+ console.error(`✗ ${describeBuildError(err)}`);
39
+ process.exit(1);
40
+ });
@@ -0,0 +1,64 @@
1
+ import type { ComponentType, ReactNode } from 'react';
2
+ export type { PluginManifest, SettingField, PluginPage } from './manifest.js';
3
+ export interface PluginInfo {
4
+ id: string;
5
+ version: string;
6
+ /** Base URL of this plugin's HTTP API, e.g. `/api/nextpluggable/p/cart`. */
7
+ apiBase: string;
8
+ /** Present when rendered as a page. */
9
+ page?: {
10
+ id: string;
11
+ path: string;
12
+ params: Record<string, string>;
13
+ };
14
+ /** Addresses of this plugin's pages keyed by page id (as configured by the site owner). */
15
+ paths: Record<string, string>;
16
+ }
17
+ export interface LinkProps {
18
+ href: string;
19
+ children?: ReactNode;
20
+ className?: string;
21
+ style?: Record<string, string | number>;
22
+ title?: string;
23
+ 'aria-label'?: string;
24
+ }
25
+ export interface Navigation {
26
+ pathname: string;
27
+ push(href: string): void;
28
+ replace(href: string): void;
29
+ back(): void;
30
+ }
31
+ /** Calls this plugin's own API. Path is relative to the plugin, e.g. `api('/items')`. */
32
+ export type PluginApi = (path: string, init?: RequestInit & {
33
+ json?: unknown;
34
+ }) => Promise<Response>;
35
+ export interface HostRuntime {
36
+ usePluginConfig<T = Record<string, unknown>>(): T;
37
+ usePlugin(): PluginInfo;
38
+ usePluginApi(): PluginApi;
39
+ useNavigation(): Navigation;
40
+ useQuery(): URLSearchParams;
41
+ Link: ComponentType<LinkProps>;
42
+ }
43
+ /** The plugin's settings as filled in by the site owner (server-only and secret values excluded). */
44
+ export declare function usePluginConfig<T = Record<string, unknown>>(): T;
45
+ /** Identity and addresses of the running plugin. */
46
+ export declare function usePlugin(): PluginInfo;
47
+ /** A `fetch` bound to this plugin's API. Pass `{ json }` to send a JSON body. */
48
+ export declare function usePluginApi(): PluginApi;
49
+ /** Current location plus client-side navigation, provided by the host's router. */
50
+ export declare function useNavigation(): Navigation;
51
+ /**
52
+ * The page's query string, for filters, searches and the like. It is only ever
53
+ * known in the browser: it is empty during server rendering and on the first
54
+ * render there, then fills in. (Pages may be cached and shared between
55
+ * visitors, so the server never puts a query string into HTML.)
56
+ */
57
+ export declare function useQuery(): URLSearchParams;
58
+ /** Client-side link rendered with the host's router. */
59
+ export declare const Link: ComponentType<LinkProps>;
60
+ /** Props every page component receives in addition to what its server loader returned. */
61
+ export interface PageProps<P = Record<string, unknown>> {
62
+ params: Record<string, string>;
63
+ data: P;
64
+ }
package/dist/index.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Client-side API for plugin authors.
3
+ *
4
+ * `nextpluggable-plugin build` never bundles this module: it is left as an
5
+ * external and the host site supplies the real implementation at load time, so
6
+ * a plugin shares the host's React, router and context instead of shipping its own.
7
+ */
8
+ import { createElement } from 'react';
9
+ const HOST_KEY = Symbol.for('nextpluggable.host-runtime');
10
+ function host() {
11
+ const h = globalThis[HOST_KEY];
12
+ if (!h)
13
+ throw new Error('@nextpluggable/plugin was used outside a NextPluggable host. Build the plugin with `nextpluggable-plugin build` and install it from the dashboard.');
14
+ return h;
15
+ }
16
+ /** The plugin's settings as filled in by the site owner (server-only and secret values excluded). */
17
+ export function usePluginConfig() {
18
+ return host().usePluginConfig();
19
+ }
20
+ /** Identity and addresses of the running plugin. */
21
+ export function usePlugin() {
22
+ return host().usePlugin();
23
+ }
24
+ /** A `fetch` bound to this plugin's API. Pass `{ json }` to send a JSON body. */
25
+ export function usePluginApi() {
26
+ return host().usePluginApi();
27
+ }
28
+ /** Current location plus client-side navigation, provided by the host's router. */
29
+ export function useNavigation() {
30
+ return host().useNavigation();
31
+ }
32
+ /**
33
+ * The page's query string, for filters, searches and the like. It is only ever
34
+ * known in the browser: it is empty during server rendering and on the first
35
+ * render there, then fills in. (Pages may be cached and shared between
36
+ * visitors, so the server never puts a query string into HTML.)
37
+ */
38
+ export function useQuery() {
39
+ return host().useQuery();
40
+ }
41
+ /** Client-side link rendered with the host's router. */
42
+ export const Link = (props) => createElement(host().Link, props);
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The plugin manifest (`plugin.json`) — the contract between a plugin and any host.
3
+ * Everything a non-technical site owner sees about a plugin (name, what it adds,
4
+ * its settings form, what it is allowed to do) is derived from this file.
5
+ */
6
+ export declare const MANIFEST_VERSION = 1;
7
+ export type SettingType = 'text' | 'textarea' | 'number' | 'toggle' | 'select' | 'color' | 'url' | 'email' | 'secret';
8
+ export interface SettingOption {
9
+ value: string;
10
+ label: string;
11
+ }
12
+ export interface SettingField {
13
+ /** Key under which the value is exposed in the plugin's config object. */
14
+ key: string;
15
+ /** Human label shown in the settings form. */
16
+ label: string;
17
+ type: SettingType;
18
+ /** One plain-language sentence shown under the field. */
19
+ help?: string;
20
+ default?: string | number | boolean;
21
+ required?: boolean;
22
+ placeholder?: string;
23
+ /** Section heading the field is grouped under in the form. */
24
+ group?: string;
25
+ /** `select` only. */
26
+ options?: SettingOption[];
27
+ /** `number` only. */
28
+ min?: number;
29
+ max?: number;
30
+ step?: number;
31
+ /**
32
+ * Keep the value on the server (never sent to browsers). `secret` fields are
33
+ * always server-only and are additionally encrypted at rest.
34
+ */
35
+ serverOnly?: boolean;
36
+ }
37
+ export interface PluginPage {
38
+ /** Stable id; the server entry's `pages[id]` loader is matched by it. */
39
+ id: string;
40
+ /**
41
+ * Default address of the page, e.g. `/emi-calculator` or `/shop/:slug`.
42
+ * `:name` matches one segment, a trailing `*` matches the rest.
43
+ */
44
+ path: string;
45
+ /** Name of the React component exported from the client entry. */
46
+ component: string;
47
+ /** Shown in the dashboard ("Adds a page: EMI Calculator"). Also the default <title>. */
48
+ title: string;
49
+ description?: string;
50
+ /** Let the site owner change the address from the dashboard. Default true for static paths. */
51
+ configurablePath?: boolean;
52
+ }
53
+ export interface PluginSlotContribution {
54
+ /** Name of a `<Slot name="…">` the host site renders, e.g. `header.actions`, `body.end`. */
55
+ slot: string;
56
+ component: string;
57
+ order?: number;
58
+ }
59
+ export interface PluginAdminPage {
60
+ id: string;
61
+ /** Tab title in the dashboard, e.g. "Messages". */
62
+ title: string;
63
+ component: string;
64
+ }
65
+ /**
66
+ * What the plugin does on the server, in plain language. Shown to the site
67
+ * owner before installing. (v1 hosts display these; sandboxed hosts enforce them.)
68
+ */
69
+ export type PluginPermission = 'storage' | 'email' | `fetch:${string}`;
70
+ export interface PluginManifest {
71
+ manifestVersion: typeof MANIFEST_VERSION;
72
+ /** Globally unique, URL-safe id: lowercase letters, digits, dashes. */
73
+ id: string;
74
+ name: string;
75
+ version: string;
76
+ description?: string;
77
+ author?: string;
78
+ homepage?: string;
79
+ /** An emoji, or a path to a small .svg/.png relative to the plugin directory. */
80
+ icon?: string;
81
+ category?: string;
82
+ /** Source entry points (used by the build only). */
83
+ entry?: {
84
+ client?: string;
85
+ server?: string;
86
+ };
87
+ pages?: PluginPage[];
88
+ slots?: PluginSlotContribution[];
89
+ admin?: PluginAdminPage[];
90
+ settings?: SettingField[];
91
+ permissions?: PluginPermission[];
92
+ }
93
+ /** Written by `nextpluggable-plugin build` to `dist/build.json`; the only file a host must read to list a plugin. */
94
+ export interface PluginBuild {
95
+ buildVersion: 1;
96
+ manifest: PluginManifest;
97
+ /** Icon resolved to something directly renderable (emoji or data: URI). */
98
+ icon?: string;
99
+ files: {
100
+ client?: BuiltFile;
101
+ server?: BuiltFile;
102
+ style?: BuiltFile;
103
+ };
104
+ }
105
+ export interface BuiltFile {
106
+ /** Path relative to the plugin directory. */
107
+ path: string;
108
+ sha256: string;
109
+ bytes: number;
110
+ }
111
+ /** Optional `nextpluggable.json` at the root of a repository that hosts several plugins. */
112
+ export interface RepositoryIndex {
113
+ name?: string;
114
+ description?: string;
115
+ /** Plugin directories relative to the repository root. */
116
+ plugins: string[];
117
+ }
118
+ export declare class ManifestError extends Error {
119
+ readonly problems: string[];
120
+ constructor(problems: string[]);
121
+ }
122
+ /** Validate a page path pattern. Returns a problem description, or null when fine. */
123
+ export declare function checkPagePath(path: unknown): string | null;
124
+ export declare function isStaticPath(path: string): boolean;
125
+ /**
126
+ * Validate an untrusted value as a manifest. Throws ManifestError listing every
127
+ * problem in words a plugin author can act on.
128
+ */
129
+ export declare function validateManifest(input: unknown): PluginManifest;
130
+ /** Default values declared by the manifest's settings. */
131
+ export declare function defaultConfig(manifest: PluginManifest): Record<string, string | number | boolean>;
132
+ /** Whether a setting's value may be sent to browsers. */
133
+ export declare function isPublicSetting(field: SettingField): boolean;
134
+ /**
135
+ * Check and normalise values submitted for a plugin's settings.
136
+ * Returns cleaned values plus per-field messages written for a non-technical reader.
137
+ */
138
+ export declare function validateSettings(manifest: PluginManifest, input: Record<string, unknown>): {
139
+ values: Record<string, string | number | boolean>;
140
+ errors: Record<string, string>;
141
+ };
142
+ export declare function validateRepositoryIndex(input: unknown): RepositoryIndex;
143
+ export declare function validateBuild(input: unknown): PluginBuild;