@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.
- package/README.md +922 -2
- package/bin/nextpluggable-plugin.js +5 -0
- package/dist/build.d.ts +21 -0
- package/dist/build.js +251 -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 +111 -0
- package/dist/server.js +30 -0
- package/package.json +62 -3
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,111 @@
|
|
|
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
|
+
/**
|
|
41
|
+
* How a page presents itself to search engines and when shared. Everything is
|
|
42
|
+
* optional: the site fills in whatever is left out (the page's title and
|
|
43
|
+
* description from the manifest, a canonical link to the page itself, the
|
|
44
|
+
* site's own sharing image), so a page is complete without any of this.
|
|
45
|
+
*/
|
|
46
|
+
export interface PageMetadata {
|
|
47
|
+
title?: string;
|
|
48
|
+
/** One or two sentences for search results. About 150 characters. */
|
|
49
|
+
description?: string;
|
|
50
|
+
/** Absolute or site-relative canonical URL. Defaults to the page's own address. */
|
|
51
|
+
canonical?: string;
|
|
52
|
+
/** Absolute or site-relative image shown when the page is shared. Defaults to the site's own. */
|
|
53
|
+
image?: string;
|
|
54
|
+
/** What the image shows, for people who cannot see it. Defaults to the title. */
|
|
55
|
+
imageAlt?: string;
|
|
56
|
+
/** `article` for a dated piece of writing (a post, a news item). Default `website`. */
|
|
57
|
+
type?: 'website' | 'article';
|
|
58
|
+
/** For articles: ISO dates of first publication and last change. */
|
|
59
|
+
publishedTime?: string;
|
|
60
|
+
modifiedTime?: string;
|
|
61
|
+
/** Keep the page out of search engines (a thank-you page, a preview). */
|
|
62
|
+
noindex?: boolean;
|
|
63
|
+
}
|
|
64
|
+
/** One address for the site's sitemap: a path, or a path with the ISO date it last changed. */
|
|
65
|
+
export type SitemapEntry = string | {
|
|
66
|
+
path: string;
|
|
67
|
+
lastModified?: string;
|
|
68
|
+
};
|
|
69
|
+
export interface PageResult<P = Record<string, unknown>> {
|
|
70
|
+
/** Passed to the page component as `data`. Must be JSON-serialisable. */
|
|
71
|
+
data?: P;
|
|
72
|
+
metadata?: PageMetadata;
|
|
73
|
+
/** schema.org structured data, rendered as JSON-LD. */
|
|
74
|
+
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
|
|
75
|
+
/** Respond with the site's 404 page. */
|
|
76
|
+
notFound?: boolean;
|
|
77
|
+
/** Redirect to another address. */
|
|
78
|
+
redirect?: string;
|
|
79
|
+
}
|
|
80
|
+
export interface ApiContext<C = Record<string, unknown>> extends PluginContext<C> {
|
|
81
|
+
request: Request;
|
|
82
|
+
/** Path below the plugin's API base, e.g. `/items/42`. */
|
|
83
|
+
path: string;
|
|
84
|
+
params: Record<string, string>;
|
|
85
|
+
/** The signed-in dashboard user, or null for public visitors. */
|
|
86
|
+
admin: AdminUser | null;
|
|
87
|
+
}
|
|
88
|
+
export type PageLoader<C = Record<string, unknown>> = (ctx: PageContext<C>) => PageResult | Promise<PageResult>;
|
|
89
|
+
export type ApiHandler<C = Record<string, unknown>> = (ctx: ApiContext<C>) => Response | Promise<Response>;
|
|
90
|
+
export interface PluginServer<C = Record<string, unknown>> {
|
|
91
|
+
/** Server loaders keyed by page id: fetch data and SEO metadata before the page renders. */
|
|
92
|
+
pages?: Record<string, PageLoader<C>>;
|
|
93
|
+
/** HTTP endpoints keyed by `"METHOD /path"`, e.g. `"POST /messages"` or `"GET /items/:id"`. */
|
|
94
|
+
api?: Record<string, ApiHandler<C>>;
|
|
95
|
+
/**
|
|
96
|
+
* The addresses of this plugin's pages that have parameters (`/shop/:slug`), for the
|
|
97
|
+
* site's sitemap: one entry per product, article and so on. Pages with a fixed
|
|
98
|
+
* address are listed by the site already. Addresses that do not belong to one of
|
|
99
|
+
* the plugin's pages are ignored.
|
|
100
|
+
*/
|
|
101
|
+
sitemap?(ctx: PluginContext<C>): SitemapEntry[] | Promise<SitemapEntry[]>;
|
|
102
|
+
onInstall?(ctx: PluginContext<C>): void | Promise<void>;
|
|
103
|
+
onUninstall?(ctx: PluginContext<C>): void | Promise<void>;
|
|
104
|
+
}
|
|
105
|
+
/** Identity helper that gives the server entry full type checking. */
|
|
106
|
+
export declare function definePluginServer<C = Record<string, unknown>>(server: PluginServer<C>): PluginServer<C>;
|
|
107
|
+
/** Restrict an endpoint to the signed-in dashboard user. */
|
|
108
|
+
export declare function adminOnly<C = Record<string, unknown>>(handler: ApiHandler<C>): ApiHandler<C>;
|
|
109
|
+
export declare function json(body: unknown, status?: number, headers?: Record<string, string>): Response;
|
|
110
|
+
/** Read a JSON request body, returning null when it is missing, malformed or larger than `maxBytes`. */
|
|
111
|
+
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.1",
|
|
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
|
}
|