nextpluggable 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 +288 -2
- package/bin/nextpluggable.js +10 -0
- package/dist/api/handler.d.ts +10 -0
- package/dist/api/handler.js +188 -0
- package/dist/auth/custom.d.ts +13 -0
- package/dist/auth/custom.js +12 -0
- package/dist/auth/password.d.ts +10 -0
- package/dist/auth/password.js +66 -0
- package/dist/auth/types.d.ts +23 -0
- package/dist/auth/types.js +1 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +123 -0
- package/dist/client/host-runtime.d.ts +9 -0
- package/dist/client/host-runtime.js +71 -0
- package/dist/client/island.d.ts +28 -0
- package/dist/client/island.js +177 -0
- package/dist/client/ssr-guard.d.ts +11 -0
- package/dist/client/ssr-guard.js +100 -0
- package/dist/config.d.ts +55 -0
- package/dist/config.js +57 -0
- package/dist/core/installer.d.ts +30 -0
- package/dist/core/installer.js +340 -0
- package/dist/core/routes.d.ts +20 -0
- package/dist/core/routes.js +95 -0
- package/dist/core/runtime.d.ts +34 -0
- package/dist/core/runtime.js +195 -0
- package/dist/core/sitemap.d.ts +12 -0
- package/dist/core/sitemap.js +83 -0
- package/dist/core/state.d.ts +13 -0
- package/dist/core/state.js +23 -0
- package/dist/core/store.d.ts +17 -0
- package/dist/core/store.js +58 -0
- package/dist/core/types.d.ts +69 -0
- package/dist/core/types.js +1 -0
- package/dist/crypto.d.ts +14 -0
- package/dist/crypto.js +61 -0
- package/dist/dashboard/Dashboard.d.ts +11 -0
- package/dist/dashboard/Dashboard.js +324 -0
- package/dist/dashboard/styles.d.ts +2 -0
- package/dist/dashboard/styles.js +129 -0
- package/dist/dashboard/ui.d.ts +61 -0
- package/dist/dashboard/ui.js +135 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +29 -0
- package/dist/globals.d.ts +24 -0
- package/dist/globals.js +24 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +65 -0
- package/dist/next/dashboard-page.d.ts +5 -0
- package/dist/next/dashboard-page.js +23 -0
- package/dist/next/plugin-page.d.ts +16 -0
- package/dist/next/plugin-page.js +91 -0
- package/dist/next/seo.d.ts +32 -0
- package/dist/next/seo.js +86 -0
- package/dist/next/slot.d.ts +7 -0
- package/dist/next/slot.js +43 -0
- package/dist/next/style.d.ts +10 -0
- package/dist/next/style.js +13 -0
- package/dist/sources/github.d.ts +19 -0
- package/dist/sources/github.js +109 -0
- package/dist/sources/local.d.ts +3 -0
- package/dist/sources/local.js +22 -0
- package/dist/sources/reader.d.ts +17 -0
- package/dist/sources/reader.js +78 -0
- package/dist/storage/file.d.ts +12 -0
- package/dist/storage/file.js +61 -0
- package/dist/storage/memory.d.ts +3 -0
- package/dist/storage/memory.js +22 -0
- package/dist/storage/postgres.d.ts +15 -0
- package/dist/storage/postgres.js +39 -0
- package/dist/storage/types.d.ts +18 -0
- package/dist/storage/types.js +15 -0
- package/dist/storage/upstash.d.ts +15 -0
- package/dist/storage/upstash.js +61 -0
- package/dist/views.d.ts +112 -0
- package/dist/views.js +124 -0
- package/package.json +48 -3
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
export class ApiError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
fields;
|
|
7
|
+
constructor(message, status, fields) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.fields = fields;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function createApi(apiPath, onUnauthorized) {
|
|
14
|
+
return async (method, path, body) => {
|
|
15
|
+
let res;
|
|
16
|
+
try {
|
|
17
|
+
res = await fetch(`${apiPath}${path}`, {
|
|
18
|
+
method,
|
|
19
|
+
credentials: 'same-origin',
|
|
20
|
+
headers: { 'x-nextpluggable': '1', ...(body !== undefined ? { 'content-type': 'application/json' } : {}) },
|
|
21
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
22
|
+
cache: 'no-store',
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new ApiError('Could not reach the site. Check your connection and try again.', 0);
|
|
27
|
+
}
|
|
28
|
+
const data = (await res.json().catch(() => null));
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
if (res.status === 401 && path.startsWith('/admin'))
|
|
31
|
+
onUnauthorized();
|
|
32
|
+
throw new ApiError(data?.error ?? 'Something went wrong. Please try again.', res.status, data?.fields);
|
|
33
|
+
}
|
|
34
|
+
return data;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function Button(props) {
|
|
38
|
+
const { variant, small, busy, className, children, disabled, ...rest } = props;
|
|
39
|
+
const cls = ['np-btn', variant ? `np-btn-${variant}` : '', small ? 'np-btn-sm' : '', className ?? ''].filter(Boolean).join(' ');
|
|
40
|
+
return (_jsxs("button", { type: "button", ...rest, className: cls, disabled: disabled || busy, "aria-busy": busy || undefined, children: [busy ? _jsx("span", { className: "np-spin", "aria-hidden": "true" }) : null, children] }));
|
|
41
|
+
}
|
|
42
|
+
export function Switch(props) {
|
|
43
|
+
return (_jsxs("label", { className: "np-switch", children: [_jsx("input", { type: "checkbox", role: "switch", checked: props.checked, disabled: props.disabled, "aria-label": props.label, onChange: (e) => props.onChange(e.target.checked) }), _jsx("i", { "aria-hidden": "true" }), props.showLabel ? _jsx("span", { children: props.label }) : null] }));
|
|
44
|
+
}
|
|
45
|
+
export function PluginIcon({ icon, name }) {
|
|
46
|
+
return (_jsx("span", { className: "np-icon", "aria-hidden": "true", children: icon?.startsWith('data:') ? _jsx("img", { src: icon, alt: "" }) : (icon ?? name.slice(0, 1).toUpperCase()) }));
|
|
47
|
+
}
|
|
48
|
+
export function Modal(props) {
|
|
49
|
+
const ref = React.useRef(null);
|
|
50
|
+
const titleId = React.useId();
|
|
51
|
+
const { onClose } = props;
|
|
52
|
+
React.useEffect(() => {
|
|
53
|
+
const previous = document.activeElement;
|
|
54
|
+
ref.current?.focus();
|
|
55
|
+
const onKey = (e) => e.key === 'Escape' && onClose();
|
|
56
|
+
document.addEventListener('keydown', onKey);
|
|
57
|
+
return () => {
|
|
58
|
+
document.removeEventListener('keydown', onKey);
|
|
59
|
+
previous?.focus?.();
|
|
60
|
+
};
|
|
61
|
+
}, [onClose]);
|
|
62
|
+
return (_jsx("div", { className: "np-overlay", onMouseDown: (e) => e.target === e.currentTarget && onClose(), children: _jsxs("div", { className: "np-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId, tabIndex: -1, ref: ref, children: [_jsx("header", { children: _jsx("h2", { id: titleId, children: props.title }) }), _jsx("div", { className: "np-modal-body", children: props.children }), _jsx("footer", { children: props.footer })] }) }));
|
|
63
|
+
}
|
|
64
|
+
export function Toasts({ toasts, dismiss }) {
|
|
65
|
+
return (_jsx("div", { className: "np-toasts", role: "status", "aria-live": "polite", children: toasts.map((t) => (_jsxs("div", { className: `np-toast${t.kind === 'error' ? ' np-toast-error' : ''}`, children: [_jsx("span", { children: t.text }), _jsx("button", { type: "button", "aria-label": "Dismiss", onClick: () => dismiss(t.id), children: "\u00D7" })] }, t.id))) }));
|
|
66
|
+
}
|
|
67
|
+
/** One auto-generated form control for a plugin setting. */
|
|
68
|
+
export function SettingInput(props) {
|
|
69
|
+
const { field, value, error, onChange } = props;
|
|
70
|
+
const id = React.useId();
|
|
71
|
+
const describedBy = [field.help ? `${id}-help` : '', error ? `${id}-err` : ''].filter(Boolean).join(' ') || undefined;
|
|
72
|
+
const common = { id, 'aria-invalid': error ? true : undefined, 'aria-describedby': describedBy, className: 'np-input' };
|
|
73
|
+
let control;
|
|
74
|
+
switch (field.type) {
|
|
75
|
+
case 'toggle':
|
|
76
|
+
return (_jsxs("div", { className: "np-field", children: [_jsx(Switch, { checked: Boolean(value), onChange: onChange, label: field.label, showLabel: true }), field.help ? _jsx("span", { className: "np-help", children: field.help }) : null, error ? _jsx("span", { className: "np-error", children: error }) : null] }));
|
|
77
|
+
case 'textarea':
|
|
78
|
+
control = _jsx("textarea", { ...common, value: value ?? '', placeholder: field.placeholder, onChange: (e) => onChange(e.target.value) });
|
|
79
|
+
break;
|
|
80
|
+
case 'number':
|
|
81
|
+
control = (_jsx("input", { ...common, type: "number", inputMode: "decimal", value: value === undefined ? '' : String(value), min: field.min, max: field.max, step: field.step ?? 'any', placeholder: field.placeholder, onChange: (e) => onChange(e.target.value === '' ? undefined : Number(e.target.value)) }));
|
|
82
|
+
break;
|
|
83
|
+
case 'select':
|
|
84
|
+
control = (_jsxs("select", { ...common, value: value ?? '', onChange: (e) => onChange(e.target.value || undefined), children: [!field.required || value === undefined ? _jsx("option", { value: "", children: "Choose\u2026" }) : null, field.options?.map((o) => (_jsx("option", { value: o.value, children: o.label }, o.value)))] }));
|
|
85
|
+
break;
|
|
86
|
+
case 'color':
|
|
87
|
+
control = (_jsxs("span", { className: "np-color", children: [_jsx("input", { type: "color", "aria-label": `${field.label} picker`, value: /^#[0-9a-f]{6}$/i.test(String(value)) ? String(value) : '#000000', onChange: (e) => onChange(e.target.value) }), _jsx("input", { ...common, style: { maxWidth: 140 }, value: value ?? '', placeholder: field.placeholder ?? '#2563eb', onChange: (e) => onChange(e.target.value) })] }));
|
|
88
|
+
break;
|
|
89
|
+
case 'secret':
|
|
90
|
+
control = (_jsx("input", { ...common, type: "password", autoComplete: "new-password", value: value ?? '', placeholder: props.secretSet ? 'Saved. Type to replace it' : field.placeholder, onChange: (e) => onChange(e.target.value === '' ? undefined : e.target.value) }));
|
|
91
|
+
break;
|
|
92
|
+
default:
|
|
93
|
+
control = (_jsx("input", { ...common, type: field.type === 'url' ? 'url' : field.type === 'email' ? 'email' : 'text', value: value ?? '', placeholder: field.placeholder, onChange: (e) => onChange(e.target.value) }));
|
|
94
|
+
}
|
|
95
|
+
return (_jsxs("div", { className: "np-field", children: [_jsxs("label", { htmlFor: id, children: [field.label, field.required ? _jsx("span", { "aria-hidden": "true", children: " *" }) : null] }), control, field.help ? (_jsx("span", { className: "np-help", id: `${id}-help`, children: field.help })) : null, field.type === 'secret' ? _jsx("span", { className: "np-help", children: "Kept private on the server. It is never shown again after saving." }) : null, error ? (_jsx("span", { className: "np-error", id: `${id}-err`, children: error })) : null] }));
|
|
96
|
+
}
|
|
97
|
+
const SLOT_NAMES = {
|
|
98
|
+
'header.actions': 'your site header',
|
|
99
|
+
header: 'your site header',
|
|
100
|
+
footer: 'your site footer',
|
|
101
|
+
'body.end': 'every page',
|
|
102
|
+
sidebar: 'your sidebar',
|
|
103
|
+
};
|
|
104
|
+
/** What a plugin adds and is allowed to do, in plain language. */
|
|
105
|
+
export function describePlugin(p) {
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const page of p.pages)
|
|
108
|
+
out.push(`Adds the page “${page.title}” at ${page.path}`);
|
|
109
|
+
for (const slot of p.slots)
|
|
110
|
+
out.push(`Shows something in ${SLOT_NAMES[slot] ?? `the “${slot}” area of your site`}`);
|
|
111
|
+
for (const title of p.adminPages)
|
|
112
|
+
out.push(`Adds a “${title}” screen to this dashboard`);
|
|
113
|
+
if (p.permissions.includes('storage'))
|
|
114
|
+
out.push('Saves its own data on your site');
|
|
115
|
+
if (p.permissions.includes('email'))
|
|
116
|
+
out.push('Sends email from your site');
|
|
117
|
+
for (const perm of p.permissions)
|
|
118
|
+
if (perm.startsWith('fetch:'))
|
|
119
|
+
out.push(perm === 'fetch:*' ? 'Connects to other services on the internet' : `Connects to ${perm.slice(6)}`);
|
|
120
|
+
if (p.hasServer)
|
|
121
|
+
out.push('Runs its own code on your site’s server');
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
export function timeAgo(iso) {
|
|
125
|
+
if (!iso)
|
|
126
|
+
return 'never';
|
|
127
|
+
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
|
128
|
+
if (s < 90)
|
|
129
|
+
return 'just now';
|
|
130
|
+
if (s < 3600)
|
|
131
|
+
return `${Math.round(s / 60)} minutes ago`;
|
|
132
|
+
if (s < 86_400)
|
|
133
|
+
return `${Math.round(s / 3600)} hour${Math.round(s / 3600) === 1 ? '' : 's'} ago`;
|
|
134
|
+
return `${Math.round(s / 86_400)} day${Math.round(s / 86_400) === 1 ? '' : 's'} ago`;
|
|
135
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** An error whose message is safe and useful to show to a non-technical dashboard user. */
|
|
2
|
+
export declare class HttpError extends Error {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
readonly fields?: Record<string, string> | undefined;
|
|
5
|
+
constructor(status: number, message: string, fields?: Record<string, string> | undefined);
|
|
6
|
+
}
|
|
7
|
+
export declare function jsonResponse(body: unknown, status?: number, headers?: Record<string, string>): Response;
|
|
8
|
+
/**
|
|
9
|
+
* A plugin's stored files are gone. Normally this means it was removed (or
|
|
10
|
+
* updated) on another server instance and this instance's cached list is stale.
|
|
11
|
+
*/
|
|
12
|
+
export declare class PluginGoneError extends Error {
|
|
13
|
+
readonly pluginId: string;
|
|
14
|
+
constructor(pluginId: string);
|
|
15
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** An error whose message is safe and useful to show to a non-technical dashboard user. */
|
|
2
|
+
export class HttpError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
fields;
|
|
5
|
+
constructor(status, message, fields) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.fields = fields;
|
|
9
|
+
this.name = 'HttpError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function jsonResponse(body, status = 200, headers) {
|
|
13
|
+
return new Response(JSON.stringify(body), {
|
|
14
|
+
status,
|
|
15
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A plugin's stored files are gone. Normally this means it was removed (or
|
|
20
|
+
* updated) on another server instance and this instance's cached list is stale.
|
|
21
|
+
*/
|
|
22
|
+
export class PluginGoneError extends Error {
|
|
23
|
+
pluginId;
|
|
24
|
+
constructor(pluginId) {
|
|
25
|
+
super(`The files of plugin "${pluginId}" are missing from storage. If it should be installed, remove it and install it again.`);
|
|
26
|
+
this.pluginId = pluginId;
|
|
27
|
+
this.name = 'PluginGoneError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-wide state. Next.js compiles server components, route handlers and
|
|
3
|
+
* the SSR pass of client components as separate module graphs, so module-level
|
|
4
|
+
* variables are not shared between them — `globalThis` is.
|
|
5
|
+
*/
|
|
6
|
+
export declare const GLOBAL_KEY: unique symbol;
|
|
7
|
+
export interface Globals {
|
|
8
|
+
/** Client bundle source keyed by module key, handed from the server layer to the SSR layer. */
|
|
9
|
+
ssrCode: Map<string, string>;
|
|
10
|
+
/** Evaluated plugin server modules keyed by content hash. */
|
|
11
|
+
serverModules: Map<string, unknown>;
|
|
12
|
+
/** Immutable stored files keyed by storage key. */
|
|
13
|
+
files: Map<string, string>;
|
|
14
|
+
/** Cached list of installed plugins, keyed by the instance's API path. */
|
|
15
|
+
state: Map<string, {
|
|
16
|
+
at: number;
|
|
17
|
+
value: unknown;
|
|
18
|
+
}>;
|
|
19
|
+
/** Failed sign-in timestamps keyed by client address. */
|
|
20
|
+
loginAttempts: Map<string, number[]>;
|
|
21
|
+
warned: Set<string>;
|
|
22
|
+
}
|
|
23
|
+
export declare function globals(): Globals;
|
|
24
|
+
export declare function warnOnce(key: string, message: string): void;
|
package/dist/globals.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-wide state. Next.js compiles server components, route handlers and
|
|
3
|
+
* the SSR pass of client components as separate module graphs, so module-level
|
|
4
|
+
* variables are not shared between them — `globalThis` is.
|
|
5
|
+
*/
|
|
6
|
+
export const GLOBAL_KEY = Symbol.for('nextpluggable.state');
|
|
7
|
+
export function globals() {
|
|
8
|
+
const g = globalThis;
|
|
9
|
+
return (g[GLOBAL_KEY] ??= {
|
|
10
|
+
ssrCode: new Map(),
|
|
11
|
+
serverModules: new Map(),
|
|
12
|
+
files: new Map(),
|
|
13
|
+
state: new Map(),
|
|
14
|
+
loginAttempts: new Map(),
|
|
15
|
+
warned: new Set(),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export function warnOnce(key, message) {
|
|
19
|
+
const g = globals();
|
|
20
|
+
if (g.warned.has(key))
|
|
21
|
+
return;
|
|
22
|
+
g.warned.add(key);
|
|
23
|
+
console.warn(`[nextpluggable] ${message}`);
|
|
24
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type NextPluggableConfig } from './config.js';
|
|
2
|
+
export type { NextPluggableConfig } from './config.js';
|
|
3
|
+
export type { AdminUser, AuthAdapter } from './auth/types.js';
|
|
4
|
+
export type { StorageAdapter } from './storage/types.js';
|
|
5
|
+
export type { PluginPageProps } from './next/plugin-page.js';
|
|
6
|
+
export type { SlotProps } from './next/slot.js';
|
|
7
|
+
export type { PluginSitemapEntry } from './core/sitemap.js';
|
|
8
|
+
export { passwordAuth, type PasswordAuthOptions } from './auth/password.js';
|
|
9
|
+
export { customAuth, type CustomAuthOptions } from './auth/custom.js';
|
|
10
|
+
export { fileStorage, type FileStorageOptions } from './storage/file.js';
|
|
11
|
+
export { memoryStorage } from './storage/memory.js';
|
|
12
|
+
export { upstashStorage, type UpstashStorageOptions } from './storage/upstash.js';
|
|
13
|
+
export { postgresStorage, type PostgresStorageOptions } from './storage/postgres.js';
|
|
14
|
+
/**
|
|
15
|
+
* Set up NextPluggable for a Next.js site. Call once (e.g. in `lib/nextpluggable.ts`)
|
|
16
|
+
* and wire the returned pieces into three route files — see the README.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createNextPluggable(config?: NextPluggableConfig): {
|
|
19
|
+
/** Route handlers for `app/api/nextpluggable/[...np]/route.ts`. */
|
|
20
|
+
handlers: {
|
|
21
|
+
GET: (request: Request) => Promise<Response>;
|
|
22
|
+
POST: (request: Request) => Promise<Response>;
|
|
23
|
+
PUT: (request: Request) => Promise<Response>;
|
|
24
|
+
PATCH: (request: Request) => Promise<Response>;
|
|
25
|
+
DELETE: (request: Request) => Promise<Response>;
|
|
26
|
+
};
|
|
27
|
+
/** Page for `app/admin/plugins/[[...np]]/page.tsx`. */
|
|
28
|
+
DashboardPage: () => Promise<import("react").ReactNode>;
|
|
29
|
+
dashboardMetadata: import("next").Metadata;
|
|
30
|
+
/** Page for the root catch-all `app/[...slug]/page.tsx`: serves pages added by plugins. */
|
|
31
|
+
PluginPage: (props: import("./index.js").PluginPageProps) => Promise<import("react").ReactNode>;
|
|
32
|
+
generatePluginMetadata: (props: import("./index.js").PluginPageProps, parent?: import("next").ResolvingMetadata) => Promise<import("next").Metadata>;
|
|
33
|
+
/**
|
|
34
|
+
* `await np.hasPluginPage('/some/address')`: does a plugin serve a page there? Use it in a
|
|
35
|
+
* catch-all route of your own to decide whether to render `PluginPage` or your own content.
|
|
36
|
+
*/
|
|
37
|
+
hasPluginPage: (pathname: string) => Promise<boolean>;
|
|
38
|
+
/**
|
|
39
|
+
* `await np.sitemap()`: the address of every page that switched-on plugins serve and
|
|
40
|
+
* search engines may index, as `{ url, lastModified? }`. Add them to `app/sitemap.ts`.
|
|
41
|
+
* Never throws.
|
|
42
|
+
*/
|
|
43
|
+
sitemap: () => Promise<import("./index.js").PluginSitemapEntry[]>;
|
|
44
|
+
/** `<Slot name="…" />` — a place in your layout that plugins can fill. */
|
|
45
|
+
Slot: ({ name }: import("./index.js").SlotProps) => Promise<import("react").ReactNode>;
|
|
46
|
+
};
|
|
47
|
+
export type NextPluggable = ReturnType<typeof createNextPluggable>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { revalidatePath } from 'next/cache';
|
|
2
|
+
import { createHandler } from './api/handler.js';
|
|
3
|
+
import { resolveConfig } from './config.js';
|
|
4
|
+
import { pluginSitemap } from './core/sitemap.js';
|
|
5
|
+
import { createDashboardPage, dashboardMetadata } from './next/dashboard-page.js';
|
|
6
|
+
import { createPluginPage } from './next/plugin-page.js';
|
|
7
|
+
import { createSlot } from './next/slot.js';
|
|
8
|
+
export { passwordAuth } from './auth/password.js';
|
|
9
|
+
export { customAuth } from './auth/custom.js';
|
|
10
|
+
export { fileStorage } from './storage/file.js';
|
|
11
|
+
export { memoryStorage } from './storage/memory.js';
|
|
12
|
+
export { upstashStorage } from './storage/upstash.js';
|
|
13
|
+
export { postgresStorage } from './storage/postgres.js';
|
|
14
|
+
/**
|
|
15
|
+
* Set up NextPluggable for a Next.js site. Call once (e.g. in `lib/nextpluggable.ts`)
|
|
16
|
+
* and wire the returned pieces into three route files — see the README.
|
|
17
|
+
*/
|
|
18
|
+
export function createNextPluggable(config = {}) {
|
|
19
|
+
const getConfig = resolveConfig(config);
|
|
20
|
+
const handle = createHandler(getConfig, {
|
|
21
|
+
// Anything cached by Next.js (static pages, ISR) is refreshed when plugins change.
|
|
22
|
+
onSiteChange() {
|
|
23
|
+
try {
|
|
24
|
+
revalidatePath('/', 'layout');
|
|
25
|
+
revalidatePath('/sitemap.xml');
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
console.warn('[nextpluggable] Could not refresh cached pages:', e);
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
const { PluginPage, generatePluginMetadata, hasPluginPage } = createPluginPage(getConfig);
|
|
33
|
+
return {
|
|
34
|
+
/** Route handlers for `app/api/nextpluggable/[...np]/route.ts`. */
|
|
35
|
+
handlers: { GET: handle, POST: handle, PUT: handle, PATCH: handle, DELETE: handle },
|
|
36
|
+
/** Page for `app/admin/plugins/[[...np]]/page.tsx`. */
|
|
37
|
+
DashboardPage: createDashboardPage(getConfig),
|
|
38
|
+
dashboardMetadata,
|
|
39
|
+
/** Page for the root catch-all `app/[...slug]/page.tsx`: serves pages added by plugins. */
|
|
40
|
+
PluginPage,
|
|
41
|
+
generatePluginMetadata,
|
|
42
|
+
/**
|
|
43
|
+
* `await np.hasPluginPage('/some/address')`: does a plugin serve a page there? Use it in a
|
|
44
|
+
* catch-all route of your own to decide whether to render `PluginPage` or your own content.
|
|
45
|
+
*/
|
|
46
|
+
hasPluginPage,
|
|
47
|
+
/**
|
|
48
|
+
* `await np.sitemap()`: the address of every page that switched-on plugins serve and
|
|
49
|
+
* search engines may index, as `{ url, lastModified? }`. Add them to `app/sitemap.ts`.
|
|
50
|
+
* Never throws.
|
|
51
|
+
*/
|
|
52
|
+
sitemap: async () => {
|
|
53
|
+
try {
|
|
54
|
+
return await pluginSitemap(getConfig());
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
// Setup that is not finished (no secret yet) must not break the site's sitemap.
|
|
58
|
+
console.error('[nextpluggable] Plugin pages could not be listed for the sitemap:', e);
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
/** `<Slot name="…" />` — a place in your layout that plugins can fill. */
|
|
63
|
+
Slot: createSlot(getConfig),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import type { ResolvedConfig } from '../config.js';
|
|
4
|
+
export declare const dashboardMetadata: Metadata;
|
|
5
|
+
export declare function createDashboardPage(getConfig: () => ResolvedConfig): () => Promise<ReactNode>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { headers } from 'next/headers';
|
|
3
|
+
import { sessionView } from '../api/handler.js';
|
|
4
|
+
import { Dashboard } from '../dashboard/Dashboard.js';
|
|
5
|
+
export const dashboardMetadata = {
|
|
6
|
+
title: 'Plugins',
|
|
7
|
+
robots: { index: false, follow: false },
|
|
8
|
+
};
|
|
9
|
+
export function createDashboardPage(getConfig) {
|
|
10
|
+
/** Server component for the dashboard route. Checks who is signed in, then hands over to the client UI. */
|
|
11
|
+
return async function DashboardPage() {
|
|
12
|
+
let cfg;
|
|
13
|
+
try {
|
|
14
|
+
cfg = getConfig();
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
return _jsx(Dashboard, { apiPath: "", dashboardPath: "", siteName: "", session: { user: null, mode: 'password', configured: false }, setupError: e instanceof Error ? e.message.replace('[nextpluggable] ', '') : 'Setup is incomplete.' });
|
|
18
|
+
}
|
|
19
|
+
const incoming = await headers();
|
|
20
|
+
const request = new Request(new URL(cfg.dashboardPath, cfg.siteUrl), { headers: new Headers([...incoming.entries()]) });
|
|
21
|
+
return _jsx(Dashboard, { apiPath: cfg.apiPath, dashboardPath: cfg.dashboardPath, siteName: cfg.siteName, session: await sessionView(cfg, request) });
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Metadata, ResolvingMetadata } from 'next';
|
|
2
|
+
import { type ReactNode } from 'react';
|
|
3
|
+
import type { PluginInfo } from '@nextpluggable/plugin';
|
|
4
|
+
import type { ResolvedConfig } from '../config.js';
|
|
5
|
+
import type { InstallRecord } from '../core/types.js';
|
|
6
|
+
type RouteParams = Promise<Record<string, string | string[] | undefined>>;
|
|
7
|
+
export interface PluginPageProps {
|
|
8
|
+
params: RouteParams;
|
|
9
|
+
}
|
|
10
|
+
export declare function pluginInfo(cfg: ResolvedConfig, install: InstallRecord, page?: PluginInfo['page']): PluginInfo;
|
|
11
|
+
export declare function createPluginPage(getConfig: () => ResolvedConfig): {
|
|
12
|
+
PluginPage: (props: PluginPageProps) => Promise<ReactNode>;
|
|
13
|
+
generatePluginMetadata: (props: PluginPageProps, parent?: ResolvingMetadata) => Promise<Metadata>;
|
|
14
|
+
hasPluginPage: (pathname: string) => Promise<boolean>;
|
|
15
|
+
};
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { notFound, redirect } from 'next/navigation';
|
|
3
|
+
import { cache } from 'react';
|
|
4
|
+
import { PluginIsland } from '../client/island.js';
|
|
5
|
+
import { effectivePaths, findPage, normalizePathname } from '../core/routes.js';
|
|
6
|
+
import { prepareClient, publicConfig, runPageLoader } from '../core/runtime.js';
|
|
7
|
+
import { getInstalledPlugins, invalidateInstalledPlugins } from '../core/state.js';
|
|
8
|
+
import { PluginGoneError } from '../errors.js';
|
|
9
|
+
import { pageJsonLd, pageMetadata, pageSeo } from './seo.js';
|
|
10
|
+
import { PluginStyle } from './style.js';
|
|
11
|
+
/** The pathname a catch-all route was called with, whatever the segment is named. */
|
|
12
|
+
async function pathnameOf(params) {
|
|
13
|
+
const values = Object.values(await params);
|
|
14
|
+
const catchAll = values.find(Array.isArray);
|
|
15
|
+
if (catchAll)
|
|
16
|
+
return normalizePathname(catchAll.join('/'));
|
|
17
|
+
return normalizePathname(typeof values[0] === 'string' ? values[0] : '/');
|
|
18
|
+
}
|
|
19
|
+
export function pluginInfo(cfg, install, page) {
|
|
20
|
+
return { id: install.id, version: install.manifest.version, apiBase: `${cfg.apiPath}/p/${install.id}`, paths: effectivePaths(install), page };
|
|
21
|
+
}
|
|
22
|
+
export function createPluginPage(getConfig) {
|
|
23
|
+
// `cache` de-duplicates the work between generateMetadata and the page within one request.
|
|
24
|
+
const run = cache(async (pathname) => {
|
|
25
|
+
const attempt = async () => {
|
|
26
|
+
const cfg = getConfig();
|
|
27
|
+
const match = findPage(await getInstalledPlugins(cfg), pathname);
|
|
28
|
+
if (!match)
|
|
29
|
+
return null;
|
|
30
|
+
const result = await runPageLoader(cfg, match.install, match.page.id, { path: pathname, params: match.params });
|
|
31
|
+
return { pathname, match, result, assets: await prepareClient(cfg, match.install) };
|
|
32
|
+
};
|
|
33
|
+
try {
|
|
34
|
+
return await attempt();
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
if (!(e instanceof PluginGoneError))
|
|
38
|
+
throw e;
|
|
39
|
+
// This instance's list of plugins was stale (the plugin was just removed or updated elsewhere): look again.
|
|
40
|
+
invalidateInstalledPlugins(getConfig());
|
|
41
|
+
try {
|
|
42
|
+
return await attempt();
|
|
43
|
+
}
|
|
44
|
+
catch (again) {
|
|
45
|
+
if (again instanceof PluginGoneError)
|
|
46
|
+
return null;
|
|
47
|
+
throw again;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
// Only `params` is read, never the query string, cookies or headers, so this
|
|
52
|
+
// route can be statically cached as well as rendered per request.
|
|
53
|
+
const resolveRequest = async (props) => run(await pathnameOf(props.params));
|
|
54
|
+
/**
|
|
55
|
+
* Metadata for the plugin page at the requested address. Pass Next.js's second
|
|
56
|
+
* `generateMetadata` argument through as `parent`, so a page without a sharing
|
|
57
|
+
* image of its own keeps the site's.
|
|
58
|
+
*/
|
|
59
|
+
async function generatePluginMetadata(props, parent) {
|
|
60
|
+
const loaded = await resolveRequest(props);
|
|
61
|
+
if (!loaded || loaded.result.notFound || loaded.result.redirect)
|
|
62
|
+
return {};
|
|
63
|
+
const cfg = getConfig();
|
|
64
|
+
const inherited = parent ? await Promise.resolve(parent).catch(() => null) : null;
|
|
65
|
+
return pageMetadata(cfg, pageSeo(cfg, loaded.match.install, loaded.match.page, loaded.pathname, loaded.result), inherited);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Server component for a root catch-all route. Renders the plugin page that
|
|
69
|
+
* owns the requested address, or the site's 404 when none does.
|
|
70
|
+
*/
|
|
71
|
+
async function PluginPage(props) {
|
|
72
|
+
const loaded = await resolveRequest(props);
|
|
73
|
+
if (!loaded || loaded.result.notFound || !loaded.assets)
|
|
74
|
+
notFound();
|
|
75
|
+
const { match, result, pathname, assets } = loaded;
|
|
76
|
+
if (result.redirect)
|
|
77
|
+
redirect(result.redirect);
|
|
78
|
+
const cfg = getConfig();
|
|
79
|
+
const jsonLd = pageJsonLd(cfg, pageSeo(cfg, match.install, match.page, pathname, result), result);
|
|
80
|
+
return (_jsxs(_Fragment, { children: [_jsx(PluginStyle, { assets: assets }), jsonLd.map((item, i) => (_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(item).replace(/</g, '\\u003c') } }, i))), _jsx(PluginIsland, { moduleKey: assets.moduleKey, src: assets.scriptSrc, component: match.page.component, info: pluginInfo(cfg, match.install, { id: match.page.id, path: pathname, params: match.params }), config: publicConfig(match.install), props: { data: result.data ?? {}, params: match.params }, errorFallback: _jsx("p", { style: { padding: '48px 16px', textAlign: 'center' }, children: "This page could not be displayed. Please try again later." }) })] }));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Whether an installed, switched-on plugin serves a page at this address. For sites
|
|
84
|
+
* whose own catch-all route has to choose between a plugin page and something of its
|
|
85
|
+
* own: unlike `PluginPage`, it never triggers the site's 404.
|
|
86
|
+
*/
|
|
87
|
+
async function hasPluginPage(pathname) {
|
|
88
|
+
return findPage(await getInstalledPlugins(getConfig()), normalizePathname(pathname)) !== null;
|
|
89
|
+
}
|
|
90
|
+
return { PluginPage, generatePluginMetadata, hasPluginPage };
|
|
91
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a plugin page tells search engines and sharing previews. A plugin may say
|
|
3
|
+
* as little as nothing: every gap is filled here, so a page is complete by default.
|
|
4
|
+
*/
|
|
5
|
+
import type { Metadata, ResolvedMetadata } from 'next';
|
|
6
|
+
import type { PluginPage } from '@nextpluggable/plugin/manifest';
|
|
7
|
+
import type { PageResult } from '@nextpluggable/plugin/server';
|
|
8
|
+
import type { ResolvedConfig } from '../config.js';
|
|
9
|
+
import type { InstallRecord } from '../core/types.js';
|
|
10
|
+
export interface PageSeo {
|
|
11
|
+
title: string;
|
|
12
|
+
/** Never empty: the loader's, else the page's, else the plugin's, else the title. */
|
|
13
|
+
description: string;
|
|
14
|
+
canonical?: string;
|
|
15
|
+
image?: string;
|
|
16
|
+
imageAlt: string;
|
|
17
|
+
type: 'website' | 'article';
|
|
18
|
+
publishedTime?: string;
|
|
19
|
+
modifiedTime?: string;
|
|
20
|
+
noindex: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** An absolute URL on this site, or undefined when the value cannot be one. */
|
|
23
|
+
export declare function absoluteUrl(cfg: Pick<ResolvedConfig, 'siteUrl'>, value: string): string | undefined;
|
|
24
|
+
export declare function pageSeo(cfg: Pick<ResolvedConfig, 'siteUrl'>, install: InstallRecord, page: PluginPage, pathname: string, result: PageResult): PageSeo;
|
|
25
|
+
/**
|
|
26
|
+
* Next.js metadata for the page. `inherited` is what the layouts above already
|
|
27
|
+
* declared: a page that brings no image of its own keeps the site's sharing image,
|
|
28
|
+
* site name and Twitter account, but never the site's title or description.
|
|
29
|
+
*/
|
|
30
|
+
export declare function pageMetadata(cfg: Pick<ResolvedConfig, 'publicSiteName'>, seo: PageSeo, inherited?: ResolvedMetadata | null): Metadata;
|
|
31
|
+
/** Structured data for the page: the plugin's own, or a plain description of the page when it gives none. */
|
|
32
|
+
export declare function pageJsonLd(cfg: Pick<ResolvedConfig, 'siteUrl' | 'publicSiteName'>, seo: PageSeo, result: PageResult): Record<string, unknown>[];
|
package/dist/next/seo.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const clean = (value) => {
|
|
2
|
+
if (typeof value !== 'string')
|
|
3
|
+
return undefined;
|
|
4
|
+
const text = value.replace(/\s+/g, ' ').trim();
|
|
5
|
+
return text || undefined;
|
|
6
|
+
};
|
|
7
|
+
/** An absolute URL on this site, or undefined when the value cannot be one. */
|
|
8
|
+
export function absoluteUrl(cfg, value) {
|
|
9
|
+
try {
|
|
10
|
+
return new URL(value, `${cfg.siteUrl}/`).toString();
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function pageSeo(cfg, install, page, pathname, result) {
|
|
17
|
+
const meta = result.metadata ?? {};
|
|
18
|
+
const title = clean(meta.title) ?? page.title;
|
|
19
|
+
const image = clean(meta.image);
|
|
20
|
+
return {
|
|
21
|
+
title,
|
|
22
|
+
description: clean(meta.description) ?? clean(page.description) ?? clean(install.manifest.description) ?? title,
|
|
23
|
+
canonical: absoluteUrl(cfg, clean(meta.canonical) ?? pathname),
|
|
24
|
+
image: image ? absoluteUrl(cfg, image) : undefined,
|
|
25
|
+
imageAlt: clean(meta.imageAlt) ?? title,
|
|
26
|
+
type: meta.type === 'article' ? 'article' : 'website',
|
|
27
|
+
publishedTime: clean(meta.publishedTime),
|
|
28
|
+
modifiedTime: clean(meta.modifiedTime),
|
|
29
|
+
noindex: meta.noindex === true,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Next.js metadata for the page. `inherited` is what the layouts above already
|
|
34
|
+
* declared: a page that brings no image of its own keeps the site's sharing image,
|
|
35
|
+
* site name and Twitter account, but never the site's title or description.
|
|
36
|
+
*/
|
|
37
|
+
export function pageMetadata(cfg, seo, inherited) {
|
|
38
|
+
const og = inherited?.openGraph;
|
|
39
|
+
const tw = inherited?.twitter;
|
|
40
|
+
const ownImage = seo.image ? [{ url: seo.image, alt: seo.imageAlt }] : undefined;
|
|
41
|
+
// "player" and "app" cards need fields a page cannot supply.
|
|
42
|
+
const inheritedCard = tw?.card === 'summary' || tw?.card === 'summary_large_image' ? tw.card : undefined;
|
|
43
|
+
return {
|
|
44
|
+
title: seo.title,
|
|
45
|
+
description: seo.description,
|
|
46
|
+
alternates: seo.canonical ? { canonical: seo.canonical } : undefined,
|
|
47
|
+
openGraph: {
|
|
48
|
+
type: seo.type,
|
|
49
|
+
title: seo.title,
|
|
50
|
+
description: seo.description,
|
|
51
|
+
url: seo.canonical,
|
|
52
|
+
siteName: og?.siteName ?? cfg.publicSiteName,
|
|
53
|
+
locale: og?.locale,
|
|
54
|
+
images: ownImage ?? og?.images,
|
|
55
|
+
...(seo.type === 'article' ? { publishedTime: seo.publishedTime, modifiedTime: seo.modifiedTime } : {}),
|
|
56
|
+
},
|
|
57
|
+
twitter: {
|
|
58
|
+
card: ownImage ? 'summary_large_image' : (inheritedCard ?? 'summary'),
|
|
59
|
+
title: seo.title,
|
|
60
|
+
description: seo.description,
|
|
61
|
+
site: tw?.site ?? undefined,
|
|
62
|
+
creator: tw?.creator ?? undefined,
|
|
63
|
+
images: ownImage ?? tw?.images,
|
|
64
|
+
},
|
|
65
|
+
robots: seo.noindex ? { index: false, follow: false } : undefined,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Structured data for the page: the plugin's own, or a plain description of the page when it gives none. */
|
|
69
|
+
export function pageJsonLd(cfg, seo, result) {
|
|
70
|
+
if (result.jsonLd)
|
|
71
|
+
return Array.isArray(result.jsonLd) ? result.jsonLd : [result.jsonLd];
|
|
72
|
+
if (seo.noindex)
|
|
73
|
+
return [];
|
|
74
|
+
return [
|
|
75
|
+
{
|
|
76
|
+
'@context': 'https://schema.org',
|
|
77
|
+
'@type': 'WebPage',
|
|
78
|
+
name: seo.title,
|
|
79
|
+
description: seo.description,
|
|
80
|
+
...(seo.canonical ? { url: seo.canonical } : {}),
|
|
81
|
+
...(seo.image ? { image: seo.image } : {}),
|
|
82
|
+
...(seo.modifiedTime ? { dateModified: seo.modifiedTime } : {}),
|
|
83
|
+
...(cfg.publicSiteName ? { isPartOf: { '@type': 'WebSite', name: cfg.publicSiteName, url: cfg.siteUrl } } : {}),
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { ResolvedConfig } from '../config.js';
|
|
3
|
+
export interface SlotProps {
|
|
4
|
+
/** Slot name, e.g. `header.actions`, `footer`, `body.end`. Plugins declare which slots they fill. */
|
|
5
|
+
name: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function createSlot(getConfig: () => ResolvedConfig): ({ name }: SlotProps) => Promise<ReactNode>;
|