@nextpluggable/plugin 0.0.0-stage → 0.1.0
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.
- package/bin/nextpluggable-plugin.js +5 -0
- package/dist/build.d.ts +14 -0
- package/dist/build.js +230 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +40 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +42 -0
- package/dist/manifest.d.ts +143 -0
- package/dist/manifest.js +348 -0
- package/dist/server.d.ts +83 -0
- package/dist/server.js +30 -0
- package/package.json +62 -3
- package/README.md +0 -3
package/dist/build.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type PluginBuild } 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
|
+
}
|
|
9
|
+
export declare class PluginBuildError extends Error {
|
|
10
|
+
constructor(message: string);
|
|
11
|
+
}
|
|
12
|
+
export declare function buildPlugin(dir: string, options?: BuildPluginOptions): Promise<PluginBuild>;
|
|
13
|
+
/** Format any error from buildPlugin for a terminal. */
|
|
14
|
+
export declare function describeBuildError(err: unknown): string;
|
package/dist/build.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
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
|
+
export class PluginBuildError extends Error {
|
|
31
|
+
constructor(message) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = 'PluginBuildError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function findEntry(dir, explicit, base) {
|
|
37
|
+
if (explicit) {
|
|
38
|
+
const p = path.resolve(dir, explicit);
|
|
39
|
+
if (!existsSync(p))
|
|
40
|
+
throw new PluginBuildError(`Entry file "${explicit}" does not exist.`);
|
|
41
|
+
return p;
|
|
42
|
+
}
|
|
43
|
+
for (const ext of ['tsx', 'ts', 'jsx', 'js', 'mjs']) {
|
|
44
|
+
const p = path.join(dir, 'src', `${base}.${ext}`);
|
|
45
|
+
if (existsSync(p))
|
|
46
|
+
return p;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
|
|
51
|
+
function exportsOf(meta) {
|
|
52
|
+
for (const [file, out] of Object.entries(meta.outputs))
|
|
53
|
+
if (file.endsWith('.js') && out.entryPoint)
|
|
54
|
+
return out.exports;
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
async function readManifest(dir) {
|
|
58
|
+
const file = path.join(dir, 'plugin.json');
|
|
59
|
+
let raw;
|
|
60
|
+
try {
|
|
61
|
+
raw = await readFile(file, 'utf8');
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new PluginBuildError(`No plugin.json found in ${dir}.`);
|
|
65
|
+
}
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(raw);
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
throw new PluginBuildError(`plugin.json is not valid JSON: ${e.message}`);
|
|
72
|
+
}
|
|
73
|
+
return validateManifest(parsed);
|
|
74
|
+
}
|
|
75
|
+
async function resolveIcon(dir, icon) {
|
|
76
|
+
if (!icon)
|
|
77
|
+
return undefined;
|
|
78
|
+
const m = /\.(svg|png|webp|jpe?g)$/i.exec(icon);
|
|
79
|
+
if (!m)
|
|
80
|
+
return icon; // an emoji / short text
|
|
81
|
+
const file = path.resolve(dir, icon);
|
|
82
|
+
if (!file.startsWith(path.resolve(dir) + path.sep))
|
|
83
|
+
throw new PluginBuildError('"icon" must be a file inside the plugin directory.');
|
|
84
|
+
const data = await readFile(file).catch(() => {
|
|
85
|
+
throw new PluginBuildError(`Icon file "${icon}" does not exist.`);
|
|
86
|
+
});
|
|
87
|
+
if (data.byteLength > 32_000)
|
|
88
|
+
throw new PluginBuildError(`Icon "${icon}" is larger than 32 KB.`);
|
|
89
|
+
const ext = m[1].toLowerCase();
|
|
90
|
+
const mime = ext === 'svg' ? 'image/svg+xml' : ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : 'image/jpeg';
|
|
91
|
+
return `data:${mime};base64,${data.toString('base64')}`;
|
|
92
|
+
}
|
|
93
|
+
/** Evaluate the built server bundle to check that what it exports lines up with the manifest. */
|
|
94
|
+
function checkServer(code, manifest) {
|
|
95
|
+
const mod = { exports: {} };
|
|
96
|
+
const req = (name) => {
|
|
97
|
+
const builtin = process.getBuiltinModule(name);
|
|
98
|
+
if (!builtin)
|
|
99
|
+
throw new Error(`Cannot require "${name}": server bundles may only use Node built-ins at runtime.`);
|
|
100
|
+
return builtin;
|
|
101
|
+
};
|
|
102
|
+
try {
|
|
103
|
+
new Function('require', 'module', 'exports', code)(req, mod, mod.exports);
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
throw new PluginBuildError(`The server entry throws when loaded: ${e.message}`);
|
|
107
|
+
}
|
|
108
|
+
const server = (mod.exports.default ?? mod.exports);
|
|
109
|
+
if (!server || typeof server !== 'object')
|
|
110
|
+
throw new PluginBuildError('The server entry must `export default definePluginServer({ ... })`.');
|
|
111
|
+
const pageIds = new Set((manifest.pages ?? []).map((p) => p.id));
|
|
112
|
+
if (server.pages !== undefined) {
|
|
113
|
+
if (typeof server.pages !== 'object' || server.pages === null)
|
|
114
|
+
throw new PluginBuildError('`pages` in the server entry must be an object.');
|
|
115
|
+
for (const [id, fn] of Object.entries(server.pages)) {
|
|
116
|
+
if (!pageIds.has(id))
|
|
117
|
+
throw new PluginBuildError(`The server entry has a loader for page "${id}", which plugin.json does not declare.`);
|
|
118
|
+
if (typeof fn !== 'function')
|
|
119
|
+
throw new PluginBuildError(`The loader for page "${id}" must be a function.`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (server.api !== undefined) {
|
|
123
|
+
if (typeof server.api !== 'object' || server.api === null)
|
|
124
|
+
throw new PluginBuildError('`api` in the server entry must be an object.');
|
|
125
|
+
for (const [key, fn] of Object.entries(server.api)) {
|
|
126
|
+
if (!/^(GET|POST|PUT|PATCH|DELETE) \/\S*$/.test(key))
|
|
127
|
+
throw new PluginBuildError(`API route "${key}" must look like "GET /path" (methods: GET, POST, PUT, PATCH, DELETE).`);
|
|
128
|
+
if (typeof fn !== 'function')
|
|
129
|
+
throw new PluginBuildError(`API route "${key}" must be a function.`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
export async function buildPlugin(dir, options = {}) {
|
|
134
|
+
const log = options.log ?? (() => { });
|
|
135
|
+
const { build } = await import('esbuild').catch(() => {
|
|
136
|
+
throw new PluginBuildError('Building plugins needs esbuild. Install it next to @nextpluggable/plugin: npm install --save-dev esbuild');
|
|
137
|
+
});
|
|
138
|
+
dir = path.resolve(dir);
|
|
139
|
+
const manifest = await readManifest(dir);
|
|
140
|
+
const clientEntry = findEntry(dir, manifest.entry?.client, 'client');
|
|
141
|
+
const serverEntry = findEntry(dir, manifest.entry?.server, 'server');
|
|
142
|
+
const components = [
|
|
143
|
+
...(manifest.pages ?? []).map((p) => p.component),
|
|
144
|
+
...(manifest.slots ?? []).map((s) => s.component),
|
|
145
|
+
...(manifest.admin ?? []).map((a) => a.component),
|
|
146
|
+
];
|
|
147
|
+
if (components.length && !clientEntry)
|
|
148
|
+
throw new PluginBuildError('plugin.json declares pages or components, but there is no client entry (expected src/client.tsx).');
|
|
149
|
+
if (!clientEntry && !serverEntry)
|
|
150
|
+
throw new PluginBuildError('Nothing to build: add src/client.tsx and/or src/server.ts.');
|
|
151
|
+
const outDir = path.join(dir, 'dist');
|
|
152
|
+
const outputs = [];
|
|
153
|
+
if (clientEntry) {
|
|
154
|
+
const shared = {
|
|
155
|
+
entryPoints: { client: clientEntry },
|
|
156
|
+
absWorkingDir: dir,
|
|
157
|
+
bundle: true,
|
|
158
|
+
platform: 'browser',
|
|
159
|
+
target: 'es2020',
|
|
160
|
+
jsx: 'automatic',
|
|
161
|
+
external: [...SHARED_CLIENT_MODULES],
|
|
162
|
+
define: { 'process.env.NODE_ENV': '"production"' },
|
|
163
|
+
loader: ASSET_LOADERS,
|
|
164
|
+
legalComments: 'none',
|
|
165
|
+
outdir: outDir,
|
|
166
|
+
write: false,
|
|
167
|
+
logLevel: 'silent',
|
|
168
|
+
};
|
|
169
|
+
// Pass 1 (ESM, discarded): learn the entry's export names.
|
|
170
|
+
const analysis = await build({ ...shared, format: 'esm', metafile: true });
|
|
171
|
+
const exported = new Set(exportsOf(analysis.metafile));
|
|
172
|
+
const missing = components.filter((c) => !exported.has(c));
|
|
173
|
+
if (missing.length)
|
|
174
|
+
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'}.`);
|
|
175
|
+
// Pass 2: the real artifact.
|
|
176
|
+
const result = await build({ ...shared, format: 'cjs', minify: options.minify ?? true });
|
|
177
|
+
for (const f of result.outputFiles ?? []) {
|
|
178
|
+
if (f.path.endsWith('.js'))
|
|
179
|
+
outputs.push({ name: 'client', file: 'dist/client.js', contents: f.contents });
|
|
180
|
+
else if (f.path.endsWith('.css'))
|
|
181
|
+
outputs.push({ name: 'style', file: 'dist/style.css', contents: f.contents });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (serverEntry) {
|
|
185
|
+
const shared = {
|
|
186
|
+
entryPoints: { server: serverEntry },
|
|
187
|
+
absWorkingDir: dir,
|
|
188
|
+
bundle: true,
|
|
189
|
+
platform: 'node',
|
|
190
|
+
target: 'node20',
|
|
191
|
+
outdir: outDir,
|
|
192
|
+
write: false,
|
|
193
|
+
legalComments: 'none',
|
|
194
|
+
logLevel: 'silent',
|
|
195
|
+
};
|
|
196
|
+
const analysis = await build({ ...shared, format: 'esm', metafile: true });
|
|
197
|
+
if (!exportsOf(analysis.metafile).includes('default'))
|
|
198
|
+
throw new PluginBuildError('The server entry must `export default definePluginServer({ ... })`.');
|
|
199
|
+
const result = await build({ ...shared, format: 'cjs', minifySyntax: true });
|
|
200
|
+
const js = result.outputFiles?.find((f) => f.path.endsWith('.js'));
|
|
201
|
+
if (!js)
|
|
202
|
+
throw new PluginBuildError('The server entry produced no output.');
|
|
203
|
+
checkServer(js.text, manifest);
|
|
204
|
+
outputs.push({ name: 'server', file: 'dist/server.js', contents: js.contents });
|
|
205
|
+
}
|
|
206
|
+
const files = {};
|
|
207
|
+
await rm(outDir, { recursive: true, force: true });
|
|
208
|
+
await mkdir(outDir, { recursive: true });
|
|
209
|
+
for (const o of outputs) {
|
|
210
|
+
await writeFile(path.join(dir, o.file), o.contents);
|
|
211
|
+
const entry = { path: o.file, sha256: sha256(o.contents), bytes: Buffer.byteLength(o.contents) };
|
|
212
|
+
files[o.name] = entry;
|
|
213
|
+
log(` ${o.file} ${(entry.bytes / 1024).toFixed(1)} kB`);
|
|
214
|
+
}
|
|
215
|
+
const pluginBuild = { buildVersion: 1, manifest, icon: await resolveIcon(dir, manifest.icon), files };
|
|
216
|
+
await writeFile(path.join(outDir, 'build.json'), JSON.stringify(pluginBuild, null, 2) + '\n');
|
|
217
|
+
log(` dist/build.json`);
|
|
218
|
+
return pluginBuild;
|
|
219
|
+
}
|
|
220
|
+
/** Format any error from buildPlugin for a terminal. */
|
|
221
|
+
export function describeBuildError(err) {
|
|
222
|
+
if (err instanceof ManifestError || err instanceof PluginBuildError)
|
|
223
|
+
return err.message;
|
|
224
|
+
const e = err;
|
|
225
|
+
if (Array.isArray(e?.errors) && e.errors.length)
|
|
226
|
+
return e.errors
|
|
227
|
+
.map((x) => (x.location ? `${x.location.file}:${x.location.line}:${x.location.column}: ${x.text}` : x.text))
|
|
228
|
+
.join('\n');
|
|
229
|
+
return err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
230
|
+
}
|
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) });
|
|
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
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
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 const MANIFEST_VERSION = 1;
|
|
7
|
+
const ID_RE = /^[a-z][a-z0-9-]{1,48}[a-z0-9]$/;
|
|
8
|
+
const KEY_RE = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
|
|
9
|
+
const COMPONENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
10
|
+
const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
11
|
+
const SLOT_RE = /^[a-z][a-z0-9.-]{0,63}$/;
|
|
12
|
+
/** Names that collide with built-in object properties; refusing them keeps every lookup by id/key safe. */
|
|
13
|
+
const RESERVED = new Set(['constructor', 'prototype', 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable', 'toLocaleString', 'toString', 'valueOf']);
|
|
14
|
+
const isKey = (v) => typeof v === 'string' && KEY_RE.test(v) && !RESERVED.has(v);
|
|
15
|
+
const SETTING_TYPES = [
|
|
16
|
+
'text',
|
|
17
|
+
'textarea',
|
|
18
|
+
'number',
|
|
19
|
+
'toggle',
|
|
20
|
+
'select',
|
|
21
|
+
'color',
|
|
22
|
+
'url',
|
|
23
|
+
'email',
|
|
24
|
+
'secret',
|
|
25
|
+
];
|
|
26
|
+
export class ManifestError extends Error {
|
|
27
|
+
problems;
|
|
28
|
+
constructor(problems) {
|
|
29
|
+
super(`Invalid plugin manifest:\n- ${problems.join('\n- ')}`);
|
|
30
|
+
this.problems = problems;
|
|
31
|
+
this.name = 'ManifestError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Validate a page path pattern. Returns a problem description, or null when fine. */
|
|
35
|
+
export function checkPagePath(path) {
|
|
36
|
+
if (typeof path !== 'string' || !path.startsWith('/'))
|
|
37
|
+
return 'must start with "/"';
|
|
38
|
+
if (path.length > 200)
|
|
39
|
+
return 'is too long';
|
|
40
|
+
if (path !== '/' && path.endsWith('/'))
|
|
41
|
+
return 'must not end with "/"';
|
|
42
|
+
if (path === '/')
|
|
43
|
+
return 'cannot be the home page';
|
|
44
|
+
const segments = path.slice(1).split('/');
|
|
45
|
+
for (let i = 0; i < segments.length; i++) {
|
|
46
|
+
const seg = segments[i];
|
|
47
|
+
if (seg === '*') {
|
|
48
|
+
if (i !== segments.length - 1)
|
|
49
|
+
return '"*" is only allowed as the last segment';
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (seg.startsWith(':')) {
|
|
53
|
+
if (!KEY_RE.test(seg.slice(1)))
|
|
54
|
+
return `has an invalid parameter name "${seg}"`;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!/^[A-Za-z0-9._~-]+$/.test(seg) || seg === '.' || seg === '..')
|
|
58
|
+
return `has an invalid segment "${seg}" (use letters, digits, dashes)`;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
export function isStaticPath(path) {
|
|
63
|
+
return !path.split('/').some((s) => s === '*' || s.startsWith(':'));
|
|
64
|
+
}
|
|
65
|
+
function isRecord(v) {
|
|
66
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Validate an untrusted value as a manifest. Throws ManifestError listing every
|
|
70
|
+
* problem in words a plugin author can act on.
|
|
71
|
+
*/
|
|
72
|
+
export function validateManifest(input) {
|
|
73
|
+
const p = [];
|
|
74
|
+
if (!isRecord(input))
|
|
75
|
+
throw new ManifestError(['manifest must be a JSON object']);
|
|
76
|
+
const m = input;
|
|
77
|
+
if (m.manifestVersion !== MANIFEST_VERSION)
|
|
78
|
+
p.push(`"manifestVersion" must be ${MANIFEST_VERSION}`);
|
|
79
|
+
if (typeof m.id !== 'string' || !ID_RE.test(m.id) || RESERVED.has(m.id))
|
|
80
|
+
p.push('"id" must be 3-50 characters: lowercase letters, digits and dashes, starting with a letter');
|
|
81
|
+
if (typeof m.name !== 'string' || !m.name.trim() || m.name.length > 80)
|
|
82
|
+
p.push('"name" is required (max 80 characters)');
|
|
83
|
+
if (typeof m.version !== 'string' || !VERSION_RE.test(m.version))
|
|
84
|
+
p.push('"version" must look like 1.2.3');
|
|
85
|
+
for (const k of ['description', 'author', 'homepage', 'icon', 'category']) {
|
|
86
|
+
if (m[k] !== undefined && typeof m[k] !== 'string')
|
|
87
|
+
p.push(`"${k}" must be text`);
|
|
88
|
+
}
|
|
89
|
+
if (typeof m.description === 'string' && m.description.length > 500)
|
|
90
|
+
p.push('"description" is too long (max 500 characters)');
|
|
91
|
+
if (m.entry !== undefined) {
|
|
92
|
+
if (!isRecord(m.entry))
|
|
93
|
+
p.push('"entry" must be an object');
|
|
94
|
+
else
|
|
95
|
+
for (const k of ['client', 'server'])
|
|
96
|
+
if (m.entry[k] !== undefined && typeof m.entry[k] !== 'string')
|
|
97
|
+
p.push(`"entry.${k}" must be a file path`);
|
|
98
|
+
}
|
|
99
|
+
const pageIds = new Set();
|
|
100
|
+
const pagePaths = new Set();
|
|
101
|
+
if (m.pages !== undefined) {
|
|
102
|
+
if (!Array.isArray(m.pages))
|
|
103
|
+
p.push('"pages" must be a list');
|
|
104
|
+
else
|
|
105
|
+
m.pages.forEach((page, i) => {
|
|
106
|
+
const at = `pages[${i}]`;
|
|
107
|
+
if (!isRecord(page))
|
|
108
|
+
return void p.push(`${at} must be an object`);
|
|
109
|
+
if (!isKey(page.id))
|
|
110
|
+
p.push(`${at}.id must be a short identifier`);
|
|
111
|
+
else if (pageIds.has(page.id))
|
|
112
|
+
p.push(`${at}.id "${page.id}" is used twice`);
|
|
113
|
+
else
|
|
114
|
+
pageIds.add(page.id);
|
|
115
|
+
const pathProblem = checkPagePath(page.path);
|
|
116
|
+
if (pathProblem)
|
|
117
|
+
p.push(`${at}.path ${pathProblem}`);
|
|
118
|
+
else if (pagePaths.has(page.path))
|
|
119
|
+
p.push(`${at}.path "${String(page.path)}" is used twice`);
|
|
120
|
+
else
|
|
121
|
+
pagePaths.add(page.path);
|
|
122
|
+
if (typeof page.component !== 'string' || !COMPONENT_RE.test(page.component))
|
|
123
|
+
p.push(`${at}.component must be the name of an exported component`);
|
|
124
|
+
if (typeof page.title !== 'string' || !page.title.trim())
|
|
125
|
+
p.push(`${at}.title is required`);
|
|
126
|
+
if (page.description !== undefined && typeof page.description !== 'string')
|
|
127
|
+
p.push(`${at}.description must be text`);
|
|
128
|
+
if (page.configurablePath !== undefined && typeof page.configurablePath !== 'boolean')
|
|
129
|
+
p.push(`${at}.configurablePath must be true or false`);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (m.slots !== undefined) {
|
|
133
|
+
if (!Array.isArray(m.slots))
|
|
134
|
+
p.push('"slots" must be a list');
|
|
135
|
+
else
|
|
136
|
+
m.slots.forEach((slot, i) => {
|
|
137
|
+
const at = `slots[${i}]`;
|
|
138
|
+
if (!isRecord(slot))
|
|
139
|
+
return void p.push(`${at} must be an object`);
|
|
140
|
+
if (typeof slot.slot !== 'string' || !SLOT_RE.test(slot.slot))
|
|
141
|
+
p.push(`${at}.slot must be a slot name like "header.actions"`);
|
|
142
|
+
if (typeof slot.component !== 'string' || !COMPONENT_RE.test(slot.component))
|
|
143
|
+
p.push(`${at}.component must be the name of an exported component`);
|
|
144
|
+
if (slot.order !== undefined && typeof slot.order !== 'number')
|
|
145
|
+
p.push(`${at}.order must be a number`);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
if (m.admin !== undefined) {
|
|
149
|
+
const ids = new Set();
|
|
150
|
+
if (!Array.isArray(m.admin))
|
|
151
|
+
p.push('"admin" must be a list');
|
|
152
|
+
else
|
|
153
|
+
m.admin.forEach((a, i) => {
|
|
154
|
+
const at = `admin[${i}]`;
|
|
155
|
+
if (!isRecord(a))
|
|
156
|
+
return void p.push(`${at} must be an object`);
|
|
157
|
+
if (!isKey(a.id))
|
|
158
|
+
p.push(`${at}.id must be a short identifier`);
|
|
159
|
+
else if (ids.has(a.id))
|
|
160
|
+
p.push(`${at}.id "${a.id}" is used twice`);
|
|
161
|
+
else
|
|
162
|
+
ids.add(a.id);
|
|
163
|
+
if (typeof a.title !== 'string' || !a.title.trim())
|
|
164
|
+
p.push(`${at}.title is required`);
|
|
165
|
+
if (typeof a.component !== 'string' || !COMPONENT_RE.test(a.component))
|
|
166
|
+
p.push(`${at}.component must be the name of an exported component`);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (m.settings !== undefined) {
|
|
170
|
+
const keys = new Set();
|
|
171
|
+
if (!Array.isArray(m.settings))
|
|
172
|
+
p.push('"settings" must be a list');
|
|
173
|
+
else
|
|
174
|
+
m.settings.forEach((f, i) => {
|
|
175
|
+
const at = `settings[${i}]`;
|
|
176
|
+
if (!isRecord(f))
|
|
177
|
+
return void p.push(`${at} must be an object`);
|
|
178
|
+
if (!isKey(f.key))
|
|
179
|
+
p.push(`${at}.key must be a short identifier`);
|
|
180
|
+
else if (keys.has(f.key))
|
|
181
|
+
p.push(`${at}.key "${f.key}" is used twice`);
|
|
182
|
+
else
|
|
183
|
+
keys.add(f.key);
|
|
184
|
+
if (typeof f.label !== 'string' || !f.label.trim())
|
|
185
|
+
p.push(`${at}.label is required`);
|
|
186
|
+
if (!SETTING_TYPES.includes(f.type))
|
|
187
|
+
p.push(`${at}.type must be one of: ${SETTING_TYPES.join(', ')}`);
|
|
188
|
+
if (f.type === 'select') {
|
|
189
|
+
if (!Array.isArray(f.options) || f.options.length === 0)
|
|
190
|
+
p.push(`${at}.options is required for a select`);
|
|
191
|
+
else if (!f.options.every((o) => isRecord(o) && typeof o.value === 'string' && typeof o.label === 'string'))
|
|
192
|
+
p.push(`${at}.options must be a list of { value, label }`);
|
|
193
|
+
}
|
|
194
|
+
if (f.type === 'secret' && f.default !== undefined)
|
|
195
|
+
p.push(`${at} is a secret and cannot have a default`);
|
|
196
|
+
if (f.default !== undefined) {
|
|
197
|
+
const want = f.type === 'number' ? 'number' : f.type === 'toggle' ? 'boolean' : 'string';
|
|
198
|
+
if (typeof f.default !== want)
|
|
199
|
+
p.push(`${at}.default must be a ${want}`);
|
|
200
|
+
}
|
|
201
|
+
for (const k of ['min', 'max', 'step'])
|
|
202
|
+
if (f[k] !== undefined && typeof f[k] !== 'number')
|
|
203
|
+
p.push(`${at}.${k} must be a number`);
|
|
204
|
+
for (const k of ['help', 'placeholder', 'group'])
|
|
205
|
+
if (f[k] !== undefined && typeof f[k] !== 'string')
|
|
206
|
+
p.push(`${at}.${k} must be text`);
|
|
207
|
+
for (const k of ['required', 'serverOnly'])
|
|
208
|
+
if (f[k] !== undefined && typeof f[k] !== 'boolean')
|
|
209
|
+
p.push(`${at}.${k} must be true or false`);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (m.permissions !== undefined) {
|
|
213
|
+
if (!Array.isArray(m.permissions))
|
|
214
|
+
p.push('"permissions" must be a list');
|
|
215
|
+
else
|
|
216
|
+
m.permissions.forEach((perm, i) => {
|
|
217
|
+
const ok = perm === 'storage' ||
|
|
218
|
+
perm === 'email' ||
|
|
219
|
+
(typeof perm === 'string' && /^fetch:(\*|[a-z0-9.-]+(?::\d+)?)$/i.test(perm));
|
|
220
|
+
if (!ok)
|
|
221
|
+
p.push(`permissions[${i}] must be "storage", "email" or "fetch:<host>"`);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
if (p.length)
|
|
225
|
+
throw new ManifestError(p);
|
|
226
|
+
return m;
|
|
227
|
+
}
|
|
228
|
+
/** Default values declared by the manifest's settings. */
|
|
229
|
+
export function defaultConfig(manifest) {
|
|
230
|
+
const out = {};
|
|
231
|
+
for (const f of manifest.settings ?? [])
|
|
232
|
+
if (f.default !== undefined)
|
|
233
|
+
out[f.key] = f.default;
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
/** Whether a setting's value may be sent to browsers. */
|
|
237
|
+
export function isPublicSetting(field) {
|
|
238
|
+
return field.type !== 'secret' && !field.serverOnly;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Check and normalise values submitted for a plugin's settings.
|
|
242
|
+
* Returns cleaned values plus per-field messages written for a non-technical reader.
|
|
243
|
+
*/
|
|
244
|
+
export function validateSettings(manifest, input) {
|
|
245
|
+
const values = {};
|
|
246
|
+
const errors = {};
|
|
247
|
+
for (const f of manifest.settings ?? []) {
|
|
248
|
+
let v = input[f.key];
|
|
249
|
+
if (typeof v === 'string')
|
|
250
|
+
v = f.type === 'textarea' || f.type === 'secret' ? v : v.trim();
|
|
251
|
+
const empty = v === undefined || v === null || v === '';
|
|
252
|
+
if (empty) {
|
|
253
|
+
if (f.required)
|
|
254
|
+
errors[f.key] = `${f.label} is required.`;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
switch (f.type) {
|
|
258
|
+
case 'number': {
|
|
259
|
+
const n = typeof v === 'number' ? v : Number(v);
|
|
260
|
+
if (!Number.isFinite(n))
|
|
261
|
+
errors[f.key] = `${f.label} must be a number.`;
|
|
262
|
+
else if (f.min !== undefined && n < f.min)
|
|
263
|
+
errors[f.key] = `${f.label} must be at least ${f.min}.`;
|
|
264
|
+
else if (f.max !== undefined && n > f.max)
|
|
265
|
+
errors[f.key] = `${f.label} must be at most ${f.max}.`;
|
|
266
|
+
else
|
|
267
|
+
values[f.key] = n;
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
case 'toggle':
|
|
271
|
+
if (typeof v !== 'boolean')
|
|
272
|
+
errors[f.key] = `${f.label} must be on or off.`;
|
|
273
|
+
else
|
|
274
|
+
values[f.key] = v;
|
|
275
|
+
break;
|
|
276
|
+
case 'select':
|
|
277
|
+
if (typeof v !== 'string' || !f.options?.some((o) => o.value === v))
|
|
278
|
+
errors[f.key] = `Choose one of the options for ${f.label}.`;
|
|
279
|
+
else
|
|
280
|
+
values[f.key] = v;
|
|
281
|
+
break;
|
|
282
|
+
case 'color':
|
|
283
|
+
if (typeof v !== 'string' || !/^#[0-9a-fA-F]{6}$/.test(v))
|
|
284
|
+
errors[f.key] = `${f.label} must be a colour like #1d4ed8.`;
|
|
285
|
+
else
|
|
286
|
+
values[f.key] = v.toLowerCase();
|
|
287
|
+
break;
|
|
288
|
+
case 'url':
|
|
289
|
+
if (typeof v !== 'string' || !/^https?:\/\/\S+$/i.test(v))
|
|
290
|
+
errors[f.key] = `${f.label} must be a web address starting with http:// or https://.`;
|
|
291
|
+
else
|
|
292
|
+
values[f.key] = v;
|
|
293
|
+
break;
|
|
294
|
+
case 'email':
|
|
295
|
+
if (typeof v !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v))
|
|
296
|
+
errors[f.key] = `${f.label} must be an email address.`;
|
|
297
|
+
else
|
|
298
|
+
values[f.key] = v;
|
|
299
|
+
break;
|
|
300
|
+
default:
|
|
301
|
+
if (typeof v !== 'string')
|
|
302
|
+
errors[f.key] = `${f.label} must be text.`;
|
|
303
|
+
else if (v.length > 10_000)
|
|
304
|
+
errors[f.key] = `${f.label} is too long.`;
|
|
305
|
+
else
|
|
306
|
+
values[f.key] = v;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return { values, errors };
|
|
310
|
+
}
|
|
311
|
+
export function validateRepositoryIndex(input) {
|
|
312
|
+
if (!isRecord(input) || !Array.isArray(input.plugins) || !input.plugins.every((x) => typeof x === 'string'))
|
|
313
|
+
throw new ManifestError(['nextpluggable.json must contain a "plugins" list of directories']);
|
|
314
|
+
const plugins = input.plugins.map((d) => d.replace(/^\.?\/+/, '').replace(/\/+$/, ''));
|
|
315
|
+
if (plugins.some((d) => d.split('/').includes('..')))
|
|
316
|
+
throw new ManifestError(['plugin directories must stay inside the repository']);
|
|
317
|
+
return {
|
|
318
|
+
name: typeof input.name === 'string' ? input.name : undefined,
|
|
319
|
+
description: typeof input.description === 'string' ? input.description : undefined,
|
|
320
|
+
plugins,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
export function validateBuild(input) {
|
|
324
|
+
if (!isRecord(input) || input.buildVersion !== 1)
|
|
325
|
+
throw new ManifestError(['dist/build.json is missing or was made by an incompatible build tool']);
|
|
326
|
+
const manifest = validateManifest(input.manifest);
|
|
327
|
+
const files = {};
|
|
328
|
+
const rawFiles = isRecord(input.files) ? input.files : {};
|
|
329
|
+
for (const k of ['client', 'server', 'style']) {
|
|
330
|
+
const f = rawFiles[k];
|
|
331
|
+
if (f === undefined)
|
|
332
|
+
continue;
|
|
333
|
+
if (!isRecord(f) ||
|
|
334
|
+
typeof f.path !== 'string' ||
|
|
335
|
+
f.path.split('/').includes('..') ||
|
|
336
|
+
f.path.startsWith('/') ||
|
|
337
|
+
typeof f.sha256 !== 'string' ||
|
|
338
|
+
!/^[0-9a-f]{64}$/.test(f.sha256) ||
|
|
339
|
+
typeof f.bytes !== 'number')
|
|
340
|
+
throw new ManifestError([`dist/build.json has a malformed "${k}" file entry`]);
|
|
341
|
+
files[k] = { path: f.path, sha256: f.sha256, bytes: f.bytes };
|
|
342
|
+
}
|
|
343
|
+
const needsClient = (manifest.pages?.length ?? 0) + (manifest.slots?.length ?? 0) + (manifest.admin?.length ?? 0) > 0;
|
|
344
|
+
if (needsClient && !files.client)
|
|
345
|
+
throw new ManifestError(['the plugin declares pages or components but has no built client bundle']);
|
|
346
|
+
const icon = typeof input.icon === 'string' && input.icon.length <= 64_000 ? input.icon : undefined;
|
|
347
|
+
return { buildVersion: 1, manifest, icon, files };
|
|
348
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side API for plugin authors. Everything here is built on web
|
|
3
|
+
* standards (Request/Response) so the same plugin can run on any host adapter.
|
|
4
|
+
*/
|
|
5
|
+
/** A small key-value store private to the plugin. Values are JSON. */
|
|
6
|
+
export interface PluginStorage {
|
|
7
|
+
get<T = unknown>(key: string): Promise<T | null>;
|
|
8
|
+
set(key: string, value: unknown): Promise<void>;
|
|
9
|
+
delete(key: string): Promise<void>;
|
|
10
|
+
/** Keys that start with `prefix` (all keys when omitted), sorted. */
|
|
11
|
+
list(prefix?: string): Promise<string[]>;
|
|
12
|
+
}
|
|
13
|
+
export interface AdminUser {
|
|
14
|
+
id: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface PluginContext<C = Record<string, unknown>> {
|
|
18
|
+
pluginId: string;
|
|
19
|
+
/** All settings, including server-only values and decrypted secrets. */
|
|
20
|
+
config: C;
|
|
21
|
+
storage: PluginStorage;
|
|
22
|
+
site: {
|
|
23
|
+
/** Origin of the site, e.g. `https://example.com`. */
|
|
24
|
+
url: string;
|
|
25
|
+
};
|
|
26
|
+
/** Addresses of this plugin's pages keyed by page id. */
|
|
27
|
+
paths: Record<string, string>;
|
|
28
|
+
log(...args: unknown[]): void;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* What a page loader receives. There is deliberately no query string here:
|
|
32
|
+
* sites cache plugin pages, and a cached page cannot depend on it. Use address
|
|
33
|
+
* parameters (`/shop/:slug`, `/blog/page/:n`) for content search engines should
|
|
34
|
+
* see, and `useQuery()` in the browser for filters and searches.
|
|
35
|
+
*/
|
|
36
|
+
export interface PageContext<C = Record<string, unknown>> extends PluginContext<C> {
|
|
37
|
+
path: string;
|
|
38
|
+
params: Record<string, string>;
|
|
39
|
+
}
|
|
40
|
+
export interface PageMetadata {
|
|
41
|
+
title?: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
/** Absolute or site-relative canonical URL. Defaults to the page's own address. */
|
|
44
|
+
canonical?: string;
|
|
45
|
+
image?: string;
|
|
46
|
+
noindex?: boolean;
|
|
47
|
+
}
|
|
48
|
+
export interface PageResult<P = Record<string, unknown>> {
|
|
49
|
+
/** Passed to the page component as `data`. Must be JSON-serialisable. */
|
|
50
|
+
data?: P;
|
|
51
|
+
metadata?: PageMetadata;
|
|
52
|
+
/** schema.org structured data, rendered as JSON-LD. */
|
|
53
|
+
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
|
|
54
|
+
/** Respond with the site's 404 page. */
|
|
55
|
+
notFound?: boolean;
|
|
56
|
+
/** Redirect to another address. */
|
|
57
|
+
redirect?: string;
|
|
58
|
+
}
|
|
59
|
+
export interface ApiContext<C = Record<string, unknown>> extends PluginContext<C> {
|
|
60
|
+
request: Request;
|
|
61
|
+
/** Path below the plugin's API base, e.g. `/items/42`. */
|
|
62
|
+
path: string;
|
|
63
|
+
params: Record<string, string>;
|
|
64
|
+
/** The signed-in dashboard user, or null for public visitors. */
|
|
65
|
+
admin: AdminUser | null;
|
|
66
|
+
}
|
|
67
|
+
export type PageLoader<C = Record<string, unknown>> = (ctx: PageContext<C>) => PageResult | Promise<PageResult>;
|
|
68
|
+
export type ApiHandler<C = Record<string, unknown>> = (ctx: ApiContext<C>) => Response | Promise<Response>;
|
|
69
|
+
export interface PluginServer<C = Record<string, unknown>> {
|
|
70
|
+
/** Server loaders keyed by page id: fetch data and SEO metadata before the page renders. */
|
|
71
|
+
pages?: Record<string, PageLoader<C>>;
|
|
72
|
+
/** HTTP endpoints keyed by `"METHOD /path"`, e.g. `"POST /messages"` or `"GET /items/:id"`. */
|
|
73
|
+
api?: Record<string, ApiHandler<C>>;
|
|
74
|
+
onInstall?(ctx: PluginContext<C>): void | Promise<void>;
|
|
75
|
+
onUninstall?(ctx: PluginContext<C>): void | Promise<void>;
|
|
76
|
+
}
|
|
77
|
+
/** Identity helper that gives the server entry full type checking. */
|
|
78
|
+
export declare function definePluginServer<C = Record<string, unknown>>(server: PluginServer<C>): PluginServer<C>;
|
|
79
|
+
/** Restrict an endpoint to the signed-in dashboard user. */
|
|
80
|
+
export declare function adminOnly<C = Record<string, unknown>>(handler: ApiHandler<C>): ApiHandler<C>;
|
|
81
|
+
export declare function json(body: unknown, status?: number, headers?: Record<string, string>): Response;
|
|
82
|
+
/** Read a JSON request body, returning null when it is missing, malformed or larger than `maxBytes`. */
|
|
83
|
+
export declare function readJson<T = unknown>(request: Request, maxBytes?: number): Promise<T | null>;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side API for plugin authors. Everything here is built on web
|
|
3
|
+
* standards (Request/Response) so the same plugin can run on any host adapter.
|
|
4
|
+
*/
|
|
5
|
+
/** Identity helper that gives the server entry full type checking. */
|
|
6
|
+
export function definePluginServer(server) {
|
|
7
|
+
return server;
|
|
8
|
+
}
|
|
9
|
+
/** Restrict an endpoint to the signed-in dashboard user. */
|
|
10
|
+
export function adminOnly(handler) {
|
|
11
|
+
return (ctx) => (ctx.admin ? handler(ctx) : json({ error: 'Sign in to the dashboard to do this.' }, 401));
|
|
12
|
+
}
|
|
13
|
+
export function json(body, status = 200, headers) {
|
|
14
|
+
return new Response(JSON.stringify(body), {
|
|
15
|
+
status,
|
|
16
|
+
headers: { 'content-type': 'application/json; charset=utf-8', ...headers },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/** Read a JSON request body, returning null when it is missing, malformed or larger than `maxBytes`. */
|
|
20
|
+
export async function readJson(request, maxBytes = 100_000) {
|
|
21
|
+
try {
|
|
22
|
+
const text = await request.text();
|
|
23
|
+
if (text.length > maxBytes)
|
|
24
|
+
return null;
|
|
25
|
+
return JSON.parse(text);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nextpluggable/plugin",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SDK and build tool for writing NextPluggable plugins",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/rohittp0/NextPluggable.git",
|
|
8
|
+
"directory": "packages/plugin"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/rohittp0/NextPluggable#readme",
|
|
11
|
+
"bugs": "https://github.com/rohittp0/NextPluggable/issues",
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20.16"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"bin": {
|
|
20
|
+
"nextpluggable-plugin": "./bin/nextpluggable-plugin.js"
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"default": "./dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./server": {
|
|
28
|
+
"types": "./dist/server.d.ts",
|
|
29
|
+
"default": "./dist/server.js"
|
|
30
|
+
},
|
|
31
|
+
"./manifest": {
|
|
32
|
+
"types": "./dist/manifest.d.ts",
|
|
33
|
+
"default": "./dist/manifest.js"
|
|
34
|
+
},
|
|
35
|
+
"./build": {
|
|
36
|
+
"types": "./dist/build.d.ts",
|
|
37
|
+
"default": "./dist/build.js"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"dist",
|
|
42
|
+
"bin"
|
|
43
|
+
],
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"esbuild": ">=0.21",
|
|
46
|
+
"react": ">=18"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"esbuild": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
52
|
+
"react": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/react": "^19.2.0",
|
|
58
|
+
"esbuild": "^0.28.2",
|
|
59
|
+
"react": "^19.2.0"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "tsc -p tsconfig.json",
|
|
63
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
64
|
+
}
|
|
6
65
|
}
|
package/README.md
DELETED