nextpluggable 0.1.0 → 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 ADDED
@@ -0,0 +1,289 @@
1
+ # NextPluggable
2
+
3
+ WordPress-style plugins for Next.js. Add one package to a site and its owner gets a
4
+ dashboard where they connect GitHub repositories that hold plugins, then install,
5
+ configure, update and remove those plugins themselves: no developer, no rebuild, no
6
+ redeploy.
7
+
8
+ Plugins are full features, not UI snippets. One plugin can add server-rendered pages
9
+ that search engines index, HTTP endpoints, stored data, widgets in the site's layout,
10
+ a settings form, and its own screens inside the dashboard.
11
+
12
+ > **Status: working prototype.** Everything described here is
13
+ > implemented and covered by tests in this repository. See [Limits](#limits) before
14
+ > relying on it, and [docs/research.md](https://github.com/rohittp0/NextPluggable/blob/main/docs/research.md) for why it is built this way.
15
+
16
+ **Documentation: <https://rohittp0.github.io/NextPluggable/>.** Each guide also exists
17
+ as Markdown written for coding agents: give yours
18
+ [`integrate.md`](https://rohittp0.github.io/NextPluggable/integrate.md) to add
19
+ NextPluggable to a site, or
20
+ [`build.md`](https://rohittp0.github.io/NextPluggable/build.md) to write plugins.
21
+
22
+ ## What the site owner does
23
+
24
+ 1. Opens `/admin/plugins` and signs in.
25
+ 2. Pastes the address of a GitHub repository under **Sources**. For a private
26
+ repository, the dashboard walks them through creating a read-only access token.
27
+ 3. Picks a plugin under **Add plugins**. A confirmation lists, in plain language, what
28
+ it adds and what it is allowed to do.
29
+ 4. The plugin is live about a second later. Its settings are an ordinary form; the
30
+ address of each page it adds can be changed; it can be paused, updated or removed
31
+ with one click.
32
+
33
+ ## What the developer does, once
34
+
35
+ ```bash
36
+ npm install nextpluggable
37
+ npx nextpluggable init
38
+ ```
39
+
40
+ `init` adds four small files to an App Router project and never overwrites existing
41
+ ones:
42
+
43
+ | File | Purpose |
44
+ |---|---|
45
+ | `lib/nextpluggable.ts` | Settings: sign-in, storage, site name |
46
+ | `app/admin/plugins/[[...np]]/page.tsx` | The dashboard |
47
+ | `app/api/nextpluggable/[...np]/route.ts` | The dashboard's API, plugin files, and plugins' own endpoints |
48
+ | `app/[...slug]/page.tsx` | Serves the pages plugins add. Your own routes always win; unknown addresses still get your 404 page. Optional page caching is two commented lines in this file |
49
+
50
+ It also writes a generated dashboard password and secret to `.env.local`.
51
+
52
+ If the site already has a root catch-all route, `init` leaves it alone. Serve plugin
53
+ pages from it yourself: render `<np.PluginPage params={params} />` where your own lookup
54
+ finds nothing, or, when that route never returns a 404 (a landing page for any address,
55
+ say), ask first:
56
+
57
+ ```tsx
58
+ if (await np.hasPluginPage(`/${segments.join('/')}`)) return <np.PluginPage params={params} />;
59
+ ```
60
+
61
+ Optionally, mark places in your layout that plugins may fill:
62
+
63
+ ```tsx
64
+ import { np } from '@/lib/nextpluggable';
65
+
66
+ <nav>
67
+ …
68
+ <np.Slot name="header.actions" />
69
+ </nav>
70
+ …
71
+ <np.Slot name="body.end" />
72
+ ```
73
+
74
+ ### Settings
75
+
76
+ ```ts
77
+ import { createNextPluggable, customAuth, upstashStorage } from 'nextpluggable';
78
+
79
+ export const np = createNextPluggable({
80
+ siteName: 'Harbor Lending',
81
+ // Who may use the dashboard. Default: one password from NEXTPLUGGABLE_PASSWORD.
82
+ auth: customAuth({
83
+ getUser: async (request) => {
84
+ const session = await getSessionSomehow(request); // Auth.js, Clerk, your own…
85
+ return session?.role === 'admin' ? { id: session.userId, name: session.name } : null;
86
+ },
87
+ loginUrl: '/login',
88
+ }),
89
+ // Where data lives. Default: a .nextpluggable folder.
90
+ storage: upstashStorage(),
91
+ });
92
+ ```
93
+
94
+ | Storage | Use it when |
95
+ |---|---|
96
+ | `fileStorage()` (default) | Local development, or one long-running server with a disk. Set `NEXTPLUGGABLE_DATA_DIR` to choose the folder |
97
+ | `postgresStorage({ query })` | You already have Postgres. Pass your client's query function, e.g. `(text, params) => pool.query(text, params)` |
98
+ | `upstashStorage()` | Serverless hosts such as Vercel. Reads `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` |
99
+ | `memoryStorage()` | Tests |
100
+
101
+ | Environment variable | Meaning |
102
+ |---|---|
103
+ | `NEXTPLUGGABLE_PASSWORD` | Dashboard password (when using the default sign-in) |
104
+ | `NEXTPLUGGABLE_SECRET` | Signs sessions and encrypts stored secrets. Required in production |
105
+ | `NEXT_PUBLIC_SITE_URL` | Public address of the site, used for canonical links |
106
+ | `NEXTPLUGGABLE_GITHUB_TOKEN` | Optional token used for every GitHub source that has none of its own; also lifts GitHub's limit of 60 requests an hour |
107
+
108
+ ## Writing plugins
109
+
110
+ A plugin is a folder with a manifest, React components and optional server code:
111
+
112
+ ```
113
+ my-plugin/
114
+ plugin.json what it adds, its settings form, what it may do
115
+ src/client.tsx React components: pages, layout widgets, dashboard screens
116
+ src/server.ts page loaders (data + SEO metadata) and HTTP endpoints
117
+ dist/ built output, committed
118
+ ```
119
+
120
+ ```bash
121
+ npm install --save-dev @nextpluggable/plugin esbuild
122
+ npx nextpluggable-plugin build
123
+ ```
124
+
125
+ A repository can hold one plugin at its root, or several listed in a root
126
+ `nextpluggable.json`. Hosts install straight from the repository at an exact commit.
127
+ The full guide is in [docs/build.md](https://github.com/rohittp0/NextPluggable/blob/main/docs/build.md).
128
+
129
+ This repository is itself a plugin source with two reference plugins:
130
+
131
+ | Plugin | Shows |
132
+ |---|---|
133
+ | [EMI Calculator](https://github.com/rohittp0/NextPluggable/tree/main/plugins/emi-calculator) | A standalone tool: one server-rendered, interactive page with a dozen settings. 5 kB of JavaScript |
134
+ | [Enquiry Form](https://github.com/rohittp0/NextPluggable/tree/main/plugins/enquiry-form) | A full-stack feature: a page, a public endpoint with validation, stored data, a floating button on every page, an inbox inside the dashboard, and an encrypted webhook setting |
135
+
136
+ ## How it works
137
+
138
+ - **Loading.** A plugin ships prebuilt bundles that leave React to the host. When a
139
+ plugin page is requested, the site server-renders the plugin's component inside the
140
+ page using its own React, then the browser loads the same bundle as a normal script
141
+ and hydrates. Plugin code is never part of the site's build.
142
+ - **SEO.** Plugin pages are real server-rendered HTML, complete for search engines by
143
+ default: their own title, description, canonical link, Open Graph and Twitter tags,
144
+ structured data, and an entry in the sitemap (`np.sitemap()`). A plugin can say more;
145
+ it never has to say anything. Addresses no plugin owns return a real 404.
146
+ - **Speed and cost.** Sharing the site's React keeps plugin bundles small (the EMI
147
+ calculator is 5 kB). A plugin's stylesheet is written into the page and its script
148
+ loads without blocking, so a widget on every page adds no request the browser waits
149
+ for. By default plugin pages are rendered on each request, which is
150
+ always correct. They can instead be cached like static pages and refreshed the moment
151
+ anything changes in the dashboard; see [Caching plugin pages](#caching-plugin-pages).
152
+ - **Safety net.** A plugin that crashes while rendering is contained: a broken widget
153
+ disappears and a broken page shows a short message, while the rest of the site, and
154
+ the dashboard needed to remove it, keep working. A plugin that fails to start is not
155
+ installed, and a failed update leaves the working version in place.
156
+
157
+ ## Caching plugin pages
158
+
159
+ Uncommenting two lines in the generated `app/[...slug]/page.tsx` makes Next.js render
160
+ each plugin page once and serve it from its cache until something changes in the
161
+ dashboard:
162
+
163
+ ```ts
164
+ export const generateStaticParams = async () => [];
165
+ export const revalidate = 3600;
166
+ ```
167
+
168
+ This is the cheapest way to run, and worth doing on sites that are mostly public
169
+ content. Two things to know first, both measured on Next.js 16.4:
170
+
171
+ - **Your root layout must not read `cookies()` or `headers()` on the server.** If it
172
+ does, cached plugin pages and unknown addresses fail with a 500 error in production
173
+ builds. It does not show up in `next dev`, so check with `next build && next start`.
174
+ - **Next.js stores the address of the request that first generated a cached page,
175
+ query string included, in that page's inline data**, and serves it to later
176
+ visitors. This is how Next.js caches any on-demand page, not something specific to
177
+ plugins. If links to your site can carry private values in the query string, leave
178
+ caching off.
179
+
180
+ Do not use `export const dynamic = 'force-static'` for this route: with it, a
181
+ component that reads the query string has the first visitor's value rendered into the
182
+ page everyone receives.
183
+
184
+ Whatever the mode, plugin components never render the query string on the server: the
185
+ SDK only makes it available in the browser.
186
+
187
+ ## Security
188
+
189
+ - **Plugins are trusted code.** A plugin's server code runs inside your site and can do
190
+ anything your site can, exactly like a WordPress plugin or an npm package. There is
191
+ no sandbox yet. Connect only sources you trust.
192
+ - What protects you today: only sources the owner connects are used; every install is
193
+ pinned to an exact commit; every file is checked against the fingerprint recorded
194
+ when the plugin was built; the install dialog states what the plugin does.
195
+ - The dashboard requires a sign-in for everything. Sessions are signed, http-only
196
+ cookies. Writes are protected against cross-site request forgery. Repeated wrong
197
+ passwords are slowed down.
198
+ - Access tokens and secret settings are encrypted at rest and never sent back to the
199
+ browser. Settings marked server-only never reach public pages.
200
+ - Folder-based sources (for plugin development) are off in production.
201
+
202
+ ## Limits
203
+
204
+ - **Next.js Cache Components mode is not supported yet.** With `cacheComponents: true`
205
+ the build fails. This is the first item on the roadmap because Next.js plans to make
206
+ that mode the default in version 17.
207
+ - **Node.js runtime only**, 20.16 or newer. It does not run on Cloudflare Workers or
208
+ other runtimes that forbid evaluating code.
209
+ - **Serverless hosts need a database adapter**; the default file storage needs a disk.
210
+ - **Several server instances:** a dashboard change reaches the other instances'
211
+ in-memory plugin list within about five seconds, and cached pages rely on your host
212
+ propagating Next.js revalidation. This has been tested on a single instance only.
213
+ - **Do not put a `loading.tsx` above the catch-all route** while it is uncached (the
214
+ default). Next.js would start streaming before the plugin lookup finishes, and
215
+ unknown addresses would return 200 with `noindex` instead of a 404. With caching
216
+ switched on they return a real 404 either way.
217
+ - **Plugins cannot read the query string on the server.** Pages that vary for search
218
+ engines should use address segments (`/shop/page/2`); filters and searches read the
219
+ query in the browser.
220
+ - **Plugin UI is React**, and plugins cannot use Next.js-only features such as server
221
+ components or server actions.
222
+ - **Plugin storage is a simple key-value store**: fine for settings, messages and small
223
+ catalogues, not yet for large or heavily queried data.
224
+
225
+ ## Roadmap
226
+
227
+ 1. Cache Components support.
228
+ 2. One-click GitHub connection for private repositories (a GitHub App created from the
229
+ dashboard) in place of pasted tokens.
230
+ 3. Sandboxed execution of plugin server code, with the permissions list enforced.
231
+ 4. A shopping cart reference plugin, and richer storage (collections with queries).
232
+ 5. A second host adapter (Astro) on the same plugin format.
233
+
234
+ ## Repository
235
+
236
+ | Path | Contents |
237
+ |---|---|
238
+ | `packages/nextpluggable` | The package sites install: dashboard, sign-in, storage, GitHub sources, installer, runtime |
239
+ | `packages/plugin` | `@nextpluggable/plugin`: the SDK and build tool for plugin authors |
240
+ | `plugins/` | Reference plugins, with their built `dist/` committed |
241
+ | `e2e/` | Browser test of the whole owner journey against a mock GitHub API, and the small Next.js site (`e2e/site`) it runs against |
242
+ | `docs/` | The documentation site, served by GitHub Pages: the guides ([integrate](https://github.com/rohittp0/NextPluggable/blob/main/docs/integrate.md), [build plugins](https://github.com/rohittp0/NextPluggable/blob/main/docs/build.md)) and [research and decisions](https://github.com/rohittp0/NextPluggable/blob/main/docs/research.md) |
243
+ | `scripts/` | The release version script and the documentation site generator |
244
+
245
+ ### Checks
246
+
247
+ ```bash
248
+ pnpm build # packages, then plugins
249
+ pnpm typecheck
250
+ pnpm test # unit tests; set TEST_POSTGRES_URL to include the Postgres adapter
251
+ pnpm e2e # browser test, run twice (pages uncached, then cached); needs Playwright with Chromium
252
+ ```
253
+
254
+ Rebuilding the plugins must leave `plugins/*/dist` unchanged: builds are reproducible,
255
+ and the committed output is what sites install.
256
+
257
+ ### Documentation site
258
+
259
+ GitHub Pages serves the `docs/` folder as it is. The guides are written once, as
260
+ `docs/integrate.md` and `docs/build.md`: those files are what coding agents read, and
261
+ `pnpm docs:build` renders them to the HTML pages people read. Commit the generated
262
+ pages with the Markdown change; `pnpm test` fails when they are out of date. Changes
263
+ under `docs/` do not publish a release.
264
+
265
+ ### Releases
266
+
267
+ Every merge to `main` publishes `nextpluggable` and `@nextpluggable/plugin` to npm
268
+ under one shared version ([`release.yml`](.github/workflows/release.yml)), tags the
269
+ commit and creates a GitHub release. The patch number goes up by one each time. For a
270
+ minor or major release, set the new version in both `packages/*/package.json` files in
271
+ the pull request; it is published as it is, and patch releases continue from there.
272
+
273
+ No npm token is stored in the repository. npm lets the workflow publish because each
274
+ package names it as its [trusted publisher](https://docs.npmjs.com/trusted-publishers),
275
+ which can only be set up once a package exists. So there is a one-time setup:
276
+
277
+ 1. Publish the first version by hand, signed in to npm as yourself:
278
+
279
+ ```bash
280
+ pnpm install && pnpm build
281
+ (cd packages/plugin && pnpm publish --access public --no-git-checks)
282
+ (cd packages/nextpluggable && pnpm publish --access public --no-git-checks)
283
+ ```
284
+
285
+ 2. On npmjs.com, open each package's settings and add a trusted publisher: GitHub
286
+ Actions, user `rohittp0`, repository `NextPluggable`, workflow `release.yml`, with
287
+ `npm publish` allowed.
288
+ 3. Merge to `main` within two days. npm drops a trusted publisher that has not
289
+ published by then.
@@ -85,7 +85,7 @@ async function serveAsset(cfg, pluginId, hash, name) {
85
85
  return new Response(body, {
86
86
  headers: {
87
87
  'content-type': asset.type,
88
- 'cache-control': 'public, max-age=31536000, immutable',
88
+ 'cache-control': 'public, max-age=31536000, s-maxage=31536000, immutable',
89
89
  'x-content-type-options': 'nosniff',
90
90
  },
91
91
  });
package/dist/cli.js CHANGED
@@ -66,6 +66,10 @@ export default np.PluginPage;
66
66
  }
67
67
  if (['tsx', 'jsx', 'js'].some((ext) => existsSync(path.join(appDir, `loading.${ext}`))))
68
68
  result.notes.push('Your app has a root loading file. While plugin pages are uncached (the default), it makes addresses that do not exist return "200 OK" with a noindex tag instead of a 404. To get real 404s, switch on caching in the catch-all route (see the comment in that file) or move the loading file into a route group.');
69
+ const sitemap = ['ts', 'tsx', 'js', 'jsx'].map((ext) => path.join(appDir, `sitemap.${ext}`)).find((f) => existsSync(f));
70
+ result.notes.push(sitemap
71
+ ? `List plugin pages in your sitemap: add \`...(await np.sitemap())\` to the entries ${rel(sitemap)} returns.`
72
+ : 'Your site has no sitemap. To list plugin pages for search engines, create app/sitemap.ts that returns `await np.sitemap()` along with your own pages.');
69
73
  // Secrets for local development.
70
74
  const envFile = path.join(root, '.env.local');
71
75
  const env = existsSync(envFile) ? await readFile(envFile, 'utf8') : '';
package/dist/config.d.ts CHANGED
@@ -25,6 +25,12 @@ export interface NextPluggableConfig {
25
25
  allowLocalSources?: boolean;
26
26
  /** How long the list of installed plugins is cached in memory per server instance. Default 5000 (0 in development). */
27
27
  cacheTtlMs?: number;
28
+ /**
29
+ * Write small plugin stylesheets into the page instead of linking to them, so a plugin
30
+ * never adds a request the browser must wait for. Default true. Set false on sites
31
+ * whose Content Security Policy forbids inline styles.
32
+ */
33
+ inlineStyles?: boolean;
28
34
  }
29
35
  export interface ResolvedConfig {
30
36
  auth: AuthAdapter;
@@ -34,6 +40,9 @@ export interface ResolvedConfig {
34
40
  apiPath: string;
35
41
  siteUrl: string;
36
42
  siteName: string;
43
+ /** The site name only when the developer set one: used where a placeholder would be wrong (sharing previews, structured data). */
44
+ publicSiteName: string | undefined;
45
+ inlineStyles: boolean;
37
46
  githubApiUrl: string;
38
47
  githubToken: string | undefined;
39
48
  allowLocalSources: boolean;
package/dist/config.js CHANGED
@@ -45,6 +45,8 @@ export function resolveConfig(config) {
45
45
  apiPath: cleanPath(config.apiPath ?? '/api/nextpluggable', 'apiPath'),
46
46
  siteUrl: (config.siteUrl ?? defaultSiteUrl()).replace(/\/+$/, ''),
47
47
  siteName: config.siteName ?? 'Your site',
48
+ publicSiteName: config.siteName?.trim() || undefined,
49
+ inlineStyles: config.inlineStyles ?? true,
48
50
  githubApiUrl: (config.github?.apiUrl ?? process.env.NEXTPLUGGABLE_GITHUB_API_URL ?? 'https://api.github.com').replace(/\/+$/, ''),
49
51
  githubToken: config.github?.token ?? process.env.NEXTPLUGGABLE_GITHUB_TOKEN ?? undefined,
50
52
  allowLocalSources: config.allowLocalSources ?? (!production || process.env.NEXTPLUGGABLE_ALLOW_LOCAL_SOURCES === '1'),
@@ -18,6 +18,8 @@ export interface ClientAssets {
18
18
  moduleKey: string;
19
19
  scriptSrc: string;
20
20
  styleHref?: string;
21
+ /** The stylesheet itself, when it is small enough to be written into the page. */
22
+ styleCss?: string;
21
23
  }
22
24
  /**
23
25
  * Make a plugin's client bundle available to the SSR pass of this process and
@@ -118,6 +118,8 @@ export async function loadServer(cfg, install) {
118
118
  cache.set(key, server);
119
119
  return server;
120
120
  }
121
+ /** Larger stylesheets are linked: inlining them would bloat every page that shows the plugin. */
122
+ const INLINE_STYLE_MAX_BYTES = 32_000;
121
123
  /**
122
124
  * Make a plugin's client bundle available to the SSR pass of this process and
123
125
  * return the URLs the browser loads it from.
@@ -137,10 +139,13 @@ export async function prepareClient(cfg, install) {
137
139
  ssr.delete(k);
138
140
  ssr.set(moduleKey, code);
139
141
  }
142
+ const style = install.files.style;
143
+ const styleCss = style && cfg.inlineStyles && style.bytes <= INLINE_STYLE_MAX_BYTES ? ((await getFile(cfg.storage, install.id, 'style', style.sha256)) ?? undefined) : undefined;
140
144
  return {
141
145
  moduleKey,
142
146
  scriptSrc: assetUrl(cfg, install.id, client.sha256, 'client.js'),
143
- styleHref: install.files.style ? assetUrl(cfg, install.id, install.files.style.sha256, 'style.css') : undefined,
147
+ styleHref: style ? assetUrl(cfg, install.id, style.sha256, 'style.css') : undefined,
148
+ styleCss,
144
149
  };
145
150
  }
146
151
  export async function runPageLoader(cfg, install, pageId, input) {
@@ -0,0 +1,12 @@
1
+ import type { ResolvedConfig } from '../config.js';
2
+ export interface PluginSitemapEntry {
3
+ url: string;
4
+ lastModified?: Date;
5
+ }
6
+ /**
7
+ * Every address that switched-on plugins serve and that search engines may index, for
8
+ * the site's sitemap. Never throws: a plugin that fails while listing its pages keeps
9
+ * what it had listed so far, and a storage outage yields an empty list rather than a
10
+ * broken sitemap.
11
+ */
12
+ export declare function pluginSitemap(cfg: ResolvedConfig): Promise<PluginSitemapEntry[]>;
@@ -0,0 +1,83 @@
1
+ import { isStaticPath } from '@nextpluggable/plugin/manifest';
2
+ import { effectivePaths, findPage, normalizePathname } from './routes.js';
3
+ import { loadServer, pluginContext, runPageLoader } from './runtime.js';
4
+ import { getInstalledPlugins } from './state.js';
5
+ /** A plugin may list this many addresses; a sitemap file holds 50,000 in all. */
6
+ const MAX_PER_PLUGIN = 10_000;
7
+ const toDate = (value) => {
8
+ if (typeof value !== 'string' && !(value instanceof Date))
9
+ return undefined;
10
+ const date = new Date(value);
11
+ return Number.isNaN(date.getTime()) ? undefined : date;
12
+ };
13
+ async function entriesOf(cfg, install, add) {
14
+ const paths = effectivePaths(install);
15
+ const updated = toDate(install.updatedAt);
16
+ // Pages with a fixed address. The loader decides whether the page exists and may be indexed.
17
+ for (const page of install.manifest.pages ?? []) {
18
+ const address = paths[page.id];
19
+ if (!isStaticPath(address))
20
+ continue;
21
+ const result = await runPageLoader(cfg, install, page.id, { path: address, params: {} });
22
+ if (result.notFound || result.redirect || result.metadata?.noindex)
23
+ continue;
24
+ const canonical = result.metadata?.canonical;
25
+ // A page that names another address as the real one is not listed under its own.
26
+ if (canonical && new URL(canonical, `${cfg.siteUrl}/`).pathname !== address)
27
+ continue;
28
+ add(address, toDate(result.metadata?.modifiedTime) ?? updated);
29
+ }
30
+ // Pages with parameters: only the plugin knows which addresses exist.
31
+ const server = await loadServer(cfg, install);
32
+ if (typeof server?.sitemap !== 'function')
33
+ return;
34
+ const listed = await server.sitemap(pluginContext(cfg, install));
35
+ if (!Array.isArray(listed))
36
+ return;
37
+ const own = { [install.id]: install };
38
+ for (const entry of listed.slice(0, MAX_PER_PLUGIN)) {
39
+ const raw = typeof entry === 'string' ? entry : entry?.path;
40
+ if (typeof raw !== 'string' || !raw.startsWith('/'))
41
+ continue;
42
+ const address = normalizePathname(raw);
43
+ // A plugin can only add addresses of its own pages.
44
+ if (!findPage(own, address))
45
+ continue;
46
+ add(address, typeof entry === 'string' ? undefined : toDate(entry.lastModified));
47
+ }
48
+ }
49
+ /**
50
+ * Every address that switched-on plugins serve and that search engines may index, for
51
+ * the site's sitemap. Never throws: a plugin that fails while listing its pages keeps
52
+ * what it had listed so far, and a storage outage yields an empty list rather than a
53
+ * broken sitemap.
54
+ */
55
+ export async function pluginSitemap(cfg) {
56
+ const out = new Map();
57
+ try {
58
+ for (const install of Object.values(await getInstalledPlugins(cfg))) {
59
+ if (!install.enabled)
60
+ continue;
61
+ try {
62
+ await entriesOf(cfg, install, (address, lastModified) => {
63
+ let url;
64
+ try {
65
+ url = new URL(address, `${cfg.siteUrl}/`).toString();
66
+ }
67
+ catch {
68
+ return;
69
+ }
70
+ if (!out.has(url))
71
+ out.set(url, lastModified ? { url, lastModified } : { url });
72
+ });
73
+ }
74
+ catch (e) {
75
+ console.error(`[nextpluggable] Plugin "${install.id}" could not list all of its pages for the sitemap:`, e);
76
+ }
77
+ }
78
+ }
79
+ catch (e) {
80
+ console.error('[nextpluggable] Plugin pages could not be listed for the sitemap:', e);
81
+ }
82
+ return [...out.values()];
83
+ }
@@ -84,7 +84,7 @@ export function SettingInput(props) {
84
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
85
  break;
86
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: "#2563eb", onChange: (e) => onChange(e.target.value) })] }));
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
88
  break;
89
89
  case 'secret':
90
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) }));
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export type { AdminUser, AuthAdapter } from './auth/types.js';
4
4
  export type { StorageAdapter } from './storage/types.js';
5
5
  export type { PluginPageProps } from './next/plugin-page.js';
6
6
  export type { SlotProps } from './next/slot.js';
7
+ export type { PluginSitemapEntry } from './core/sitemap.js';
7
8
  export { passwordAuth, type PasswordAuthOptions } from './auth/password.js';
8
9
  export { customAuth, type CustomAuthOptions } from './auth/custom.js';
9
10
  export { fileStorage, type FileStorageOptions } from './storage/file.js';
@@ -28,12 +29,18 @@ export declare function createNextPluggable(config?: NextPluggableConfig): {
28
29
  dashboardMetadata: import("next").Metadata;
29
30
  /** Page for the root catch-all `app/[...slug]/page.tsx`: serves pages added by plugins. */
30
31
  PluginPage: (props: import("./index.js").PluginPageProps) => Promise<import("react").ReactNode>;
31
- generatePluginMetadata: (props: import("./index.js").PluginPageProps) => Promise<import("next").Metadata>;
32
+ generatePluginMetadata: (props: import("./index.js").PluginPageProps, parent?: import("next").ResolvingMetadata) => Promise<import("next").Metadata>;
32
33
  /**
33
34
  * `await np.hasPluginPage('/some/address')`: does a plugin serve a page there? Use it in a
34
35
  * catch-all route of your own to decide whether to render `PluginPage` or your own content.
35
36
  */
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[]>;
37
44
  /** `<Slot name="…" />` — a place in your layout that plugins can fill. */
38
45
  Slot: ({ name }: import("./index.js").SlotProps) => Promise<import("react").ReactNode>;
39
46
  };
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { revalidatePath } from 'next/cache';
2
2
  import { createHandler } from './api/handler.js';
3
3
  import { resolveConfig } from './config.js';
4
+ import { pluginSitemap } from './core/sitemap.js';
4
5
  import { createDashboardPage, dashboardMetadata } from './next/dashboard-page.js';
5
6
  import { createPluginPage } from './next/plugin-page.js';
6
7
  import { createSlot } from './next/slot.js';
@@ -21,6 +22,7 @@ export function createNextPluggable(config = {}) {
21
22
  onSiteChange() {
22
23
  try {
23
24
  revalidatePath('/', 'layout');
25
+ revalidatePath('/sitemap.xml');
24
26
  }
25
27
  catch (e) {
26
28
  console.warn('[nextpluggable] Could not refresh cached pages:', e);
@@ -42,6 +44,21 @@ export function createNextPluggable(config = {}) {
42
44
  * catch-all route of your own to decide whether to render `PluginPage` or your own content.
43
45
  */
44
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
+ },
45
62
  /** `<Slot name="…" />` — a place in your layout that plugins can fill. */
46
63
  Slot: createSlot(getConfig),
47
64
  };
@@ -1,4 +1,4 @@
1
- import type { Metadata } from 'next';
1
+ import type { Metadata, ResolvingMetadata } from 'next';
2
2
  import { type ReactNode } from 'react';
3
3
  import type { PluginInfo } from '@nextpluggable/plugin';
4
4
  import type { ResolvedConfig } from '../config.js';
@@ -10,7 +10,7 @@ export interface PluginPageProps {
10
10
  export declare function pluginInfo(cfg: ResolvedConfig, install: InstallRecord, page?: PluginInfo['page']): PluginInfo;
11
11
  export declare function createPluginPage(getConfig: () => ResolvedConfig): {
12
12
  PluginPage: (props: PluginPageProps) => Promise<ReactNode>;
13
- generatePluginMetadata: (props: PluginPageProps) => Promise<Metadata>;
13
+ generatePluginMetadata: (props: PluginPageProps, parent?: ResolvingMetadata) => Promise<Metadata>;
14
14
  hasPluginPage: (pathname: string) => Promise<boolean>;
15
15
  };
16
16
  export {};
@@ -6,6 +6,8 @@ import { effectivePaths, findPage, normalizePathname } from '../core/routes.js';
6
6
  import { prepareClient, publicConfig, runPageLoader } from '../core/runtime.js';
7
7
  import { getInstalledPlugins, invalidateInstalledPlugins } from '../core/state.js';
8
8
  import { PluginGoneError } from '../errors.js';
9
+ import { pageJsonLd, pageMetadata, pageSeo } from './seo.js';
10
+ import { PluginStyle } from './style.js';
9
11
  /** The pathname a catch-all route was called with, whatever the segment is named. */
10
12
  async function pathnameOf(params) {
11
13
  const values = Object.values(await params);
@@ -49,31 +51,18 @@ export function createPluginPage(getConfig) {
49
51
  // Only `params` is read, never the query string, cookies or headers, so this
50
52
  // route can be statically cached as well as rendered per request.
51
53
  const resolveRequest = async (props) => run(await pathnameOf(props.params));
52
- async function generatePluginMetadata(props) {
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) {
53
60
  const loaded = await resolveRequest(props);
54
61
  if (!loaded || loaded.result.notFound || loaded.result.redirect)
55
62
  return {};
56
63
  const cfg = getConfig();
57
- const meta = loaded.result.metadata ?? {};
58
- const title = meta.title ?? loaded.match.page.title;
59
- const description = meta.description ?? loaded.match.page.description;
60
- const absolute = (value) => {
61
- try {
62
- return new URL(value, cfg.siteUrl).toString();
63
- }
64
- catch {
65
- return undefined;
66
- }
67
- };
68
- const canonical = absolute(meta.canonical ?? loaded.pathname);
69
- const image = meta.image ? absolute(meta.image) : undefined;
70
- return {
71
- title,
72
- description,
73
- alternates: canonical ? { canonical } : undefined,
74
- openGraph: { title, description, url: canonical, type: 'website', images: image ? [image] : undefined },
75
- robots: meta.noindex ? { index: false, follow: false } : undefined,
76
- };
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);
77
66
  }
78
67
  /**
79
68
  * Server component for a root catch-all route. Renders the plugin page that
@@ -87,8 +76,8 @@ export function createPluginPage(getConfig) {
87
76
  if (result.redirect)
88
77
  redirect(result.redirect);
89
78
  const cfg = getConfig();
90
- const jsonLd = result.jsonLd ? (Array.isArray(result.jsonLd) ? result.jsonLd : [result.jsonLd]) : [];
91
- return (_jsxs(_Fragment, { children: [assets.styleHref ? _jsx("link", { rel: "stylesheet", href: assets.styleHref, precedence: "nextpluggable" }) : null, 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." }) })] }));
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." }) })] }));
92
81
  }
93
82
  /**
94
83
  * Whether an installed, switched-on plugin serves a page at this address. For sites
@@ -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>[];
@@ -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
+ }
package/dist/next/slot.js CHANGED
@@ -5,6 +5,7 @@ import { prepareClient, publicConfig } from '../core/runtime.js';
5
5
  import { getInstalledPlugins } from '../core/state.js';
6
6
  import { PluginGoneError } from '../errors.js';
7
7
  import { pluginInfo } from './plugin-page.js';
8
+ import { PluginStyle } from './style.js';
8
9
  export function createSlot(getConfig) {
9
10
  /**
10
11
  * A place in the site's layout where installed plugins may render something
@@ -29,7 +30,7 @@ export function createSlot(getConfig) {
29
30
  });
30
31
  if (!assets)
31
32
  return null;
32
- return (_jsxs(Fragment, { children: [assets.styleHref ? _jsx("link", { rel: "stylesheet", href: assets.styleHref, precedence: "nextpluggable" }) : null, _jsx(PluginIsland, { moduleKey: assets.moduleKey, src: assets.scriptSrc, component: slot.component, info: pluginInfo(cfg, install), config: publicConfig(install) })] }, `${install.id}:${slot.component}`));
33
+ return (_jsxs(Fragment, { children: [_jsx(PluginStyle, { assets: assets }), _jsx(PluginIsland, { moduleKey: assets.moduleKey, src: assets.scriptSrc, component: slot.component, info: pluginInfo(cfg, install), config: publicConfig(install) })] }, `${install.id}:${slot.component}`));
33
34
  }));
34
35
  return _jsx(_Fragment, { children: rendered });
35
36
  }
@@ -0,0 +1,10 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { ClientAssets } from '../core/runtime.js';
3
+ /**
4
+ * A plugin's CSS. Small stylesheets are written into the page itself, so a plugin
5
+ * never adds a request the browser must wait for before it can paint. React keeps
6
+ * one copy per page however many of the plugin's components are on it.
7
+ */
8
+ export declare function PluginStyle({ assets }: {
9
+ assets: ClientAssets;
10
+ }): ReactNode;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * A plugin's CSS. Small stylesheets are written into the page itself, so a plugin
4
+ * never adds a request the browser must wait for before it can paint. React keeps
5
+ * one copy per page however many of the plugin's components are on it.
6
+ */
7
+ export function PluginStyle({ assets }) {
8
+ if (!assets.styleHref)
9
+ return null;
10
+ if (assets.styleCss !== undefined)
11
+ return (_jsx("style", { href: assets.styleHref, precedence: "nextpluggable", children: assets.styleCss }));
12
+ return _jsx("link", { rel: "stylesheet", href: assets.styleHref, precedence: "nextpluggable" });
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nextpluggable",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "WordPress-style plugins for Next.js: a dashboard where site owners install full-featured plugins from GitHub repositories, with no rebuild.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,7 +30,7 @@
30
30
  "bin"
31
31
  ],
32
32
  "dependencies": {
33
- "@nextpluggable/plugin": "0.1.0"
33
+ "@nextpluggable/plugin": "0.1.1"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "next": ">=15.0.0",