@avocadostudio-ai/site-sdk 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +212 -2
  2. package/dist/create-site-page.d.ts +38 -8
  3. package/dist/create-site-page.js +59 -8
  4. package/dist/draft-common.d.ts +32 -0
  5. package/dist/draft-common.js +58 -0
  6. package/dist/draft-context-core.js +39 -6
  7. package/dist/draft-context-core.test.d.ts +10 -0
  8. package/dist/draft-context-core.test.js +146 -0
  9. package/dist/editor-cors.d.ts +12 -0
  10. package/dist/editor-cors.js +31 -6
  11. package/dist/editor-cors.test.d.ts +1 -0
  12. package/dist/editor-cors.test.js +66 -0
  13. package/dist/editor-manifest.d.ts +2 -3
  14. package/dist/editor-manifest.js +12 -64
  15. package/dist/editor-matcher.d.ts +27 -0
  16. package/dist/editor-matcher.js +34 -0
  17. package/dist/editor-query.js +7 -1
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.js +2 -0
  20. package/dist/integration-check.js +11 -1
  21. package/dist/manifest-utils.d.ts +13 -0
  22. package/dist/manifest-utils.js +30 -3
  23. package/dist/manifest-utils.test.d.ts +1 -0
  24. package/dist/manifest-utils.test.js +72 -0
  25. package/dist/middleware.d.ts +21 -19
  26. package/dist/middleware.js +19 -22
  27. package/dist/next-config.test.d.ts +1 -0
  28. package/dist/next-config.test.js +253 -0
  29. package/dist/page-metadata.d.ts +66 -0
  30. package/dist/page-metadata.js +110 -0
  31. package/dist/page-metadata.test.d.ts +1 -0
  32. package/dist/page-metadata.test.js +105 -0
  33. package/dist/proxy.d.ts +58 -0
  34. package/dist/proxy.js +50 -0
  35. package/dist/proxy.test.d.ts +1 -0
  36. package/dist/proxy.test.js +72 -0
  37. package/dist/publish/field-diff.d.ts +191 -0
  38. package/dist/publish/field-diff.js +252 -0
  39. package/dist/publish/field-diff.test.d.ts +1 -0
  40. package/dist/publish/field-diff.test.js +286 -0
  41. package/dist/server/orchestrator.d.ts +1 -117
  42. package/dist/server/orchestrator.js +14 -733
  43. package/next-config.d.ts +68 -0
  44. package/next-config.mjs +358 -0
  45. package/package.json +63 -19
@@ -1,24 +1,10 @@
1
- import { NextResponse, type NextRequest } from "next/server";
1
+ import { type EditorProxyOptions } from "./proxy.ts";
2
2
  /**
3
3
  * Options for the editor middleware factory.
4
+ *
5
+ * @deprecated Use {@link EditorProxyOptions} from `@avocadostudio-ai/site-sdk/proxy`.
4
6
  */
5
- export type EditorMiddlewareOptions = {
6
- /**
7
- * The internal route prefix that the dynamic editor/draft page lives under.
8
- * @default "/preview-draft"
9
- */
10
- previewRoute?: string;
11
- /**
12
- * Query parameter that signals an editor iframe request.
13
- * @default "__editor"
14
- */
15
- editorParam?: string;
16
- /**
17
- * Name of the Next.js draft-mode bypass cookie.
18
- * @default "__prerender_bypass"
19
- */
20
- draftCookie?: string;
21
- };
7
+ export type EditorMiddlewareOptions = EditorProxyOptions;
22
8
  /**
23
9
  * Create a Next.js middleware function that rewrites editor/draft requests
24
10
  * to a dynamic preview route, keeping the main page route fully static.
@@ -28,9 +14,25 @@ export type EditorMiddlewareOptions = {
28
14
  * import { createEditorMiddleware } from "@avocadostudio-ai/site-sdk/middleware"
29
15
  * export const { middleware, config } = createEditorMiddleware()
30
16
  * ```
17
+ *
18
+ * @deprecated Next.js 16 renamed the `middleware` file convention to `proxy`.
19
+ * Rename `middleware.ts` to `proxy.ts` and switch to `createEditorProxy` from
20
+ * `@avocadostudio-ai/site-sdk/proxy`. Note that Next 16 rejects this destructured
21
+ * form — `config` must be a static object literal in the file:
22
+ * ```ts
23
+ * import { createEditorProxy } from "@avocadostudio-ai/site-sdk/proxy"
24
+ *
25
+ * export const proxy = createEditorProxy().proxy
26
+ *
27
+ * export const config = {
28
+ * matcher: ["/((?!_next|preview-draft|api|favicon\\.ico|icon\\.svg|logos/|generated-images/|.*\\.).*)"],
29
+ * }
30
+ * ```
31
+ * This entry point stays for Next.js 15 sites, where the destructured form
32
+ * works, and returns the identical rewrite under the older export name.
31
33
  */
32
34
  export declare function createEditorMiddleware(options?: EditorMiddlewareOptions): {
33
- middleware: (request: NextRequest) => NextResponse<unknown>;
35
+ middleware: (request: import("next/server").NextRequest) => import("next/server").NextResponse<unknown>;
34
36
  config: {
35
37
  matcher: string[];
36
38
  };
@@ -1,4 +1,4 @@
1
- import { NextResponse } from "next/server";
1
+ import { createEditorProxy } from "./proxy.js";
2
2
  /**
3
3
  * Create a Next.js middleware function that rewrites editor/draft requests
4
4
  * to a dynamic preview route, keeping the main page route fully static.
@@ -8,27 +8,24 @@ import { NextResponse } from "next/server";
8
8
  * import { createEditorMiddleware } from "@avocadostudio-ai/site-sdk/middleware"
9
9
  * export const { middleware, config } = createEditorMiddleware()
10
10
  * ```
11
+ *
12
+ * @deprecated Next.js 16 renamed the `middleware` file convention to `proxy`.
13
+ * Rename `middleware.ts` to `proxy.ts` and switch to `createEditorProxy` from
14
+ * `@avocadostudio-ai/site-sdk/proxy`. Note that Next 16 rejects this destructured
15
+ * form — `config` must be a static object literal in the file:
16
+ * ```ts
17
+ * import { createEditorProxy } from "@avocadostudio-ai/site-sdk/proxy"
18
+ *
19
+ * export const proxy = createEditorProxy().proxy
20
+ *
21
+ * export const config = {
22
+ * matcher: ["/((?!_next|preview-draft|api|favicon\\.ico|icon\\.svg|logos/|generated-images/|.*\\.).*)"],
23
+ * }
24
+ * ```
25
+ * This entry point stays for Next.js 15 sites, where the destructured form
26
+ * works, and returns the identical rewrite under the older export name.
11
27
  */
12
28
  export function createEditorMiddleware(options) {
13
- const previewRoute = options?.previewRoute ?? "/preview-draft";
14
- const editorParam = options?.editorParam ?? "__editor";
15
- const draftCookie = options?.draftCookie ?? "__prerender_bypass";
16
- function middleware(request) {
17
- const isEditor = request.nextUrl.searchParams.get(editorParam) === "1";
18
- const hasDraftCookie = request.cookies.has(draftCookie);
19
- if (isEditor || hasDraftCookie) {
20
- const url = request.nextUrl.clone();
21
- url.pathname = `${previewRoute}${url.pathname}`;
22
- return NextResponse.rewrite(url);
23
- }
24
- return NextResponse.next();
25
- }
26
- // Escape special regex chars in the route prefix (strip leading slash for the pattern)
27
- const escapedRoute = previewRoute.slice(1).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
28
- const config = {
29
- matcher: [
30
- `/((?!_next|${escapedRoute}|api|favicon\\.ico|icon\\.svg|logos/|generated-images/|.*\\.).*)`
31
- ],
32
- };
33
- return { middleware, config };
29
+ const { proxy, config } = createEditorProxy(options);
30
+ return { middleware: proxy, config };
34
31
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,253 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ // @ts-expect-error — plain ESM, deliberately not TypeScript (see the module's own note)
7
+ import { withAvocado, linkedAvocadoPackages, AVOCADO_SERVER_EXTERNALS } from "../next-config.mjs";
8
+ /**
9
+ * The list of packages to transpile is derived, not written down, because the
10
+ * hand-written version breaks retroactively: adding a package to Avocado, or
11
+ * re-exporting one from another, silently breaks every consumer that pinned the
12
+ * old list. That is not hypothetical — it took out every route of the Sanity
13
+ * example the day `@avocadostudio-ai/richtext` was added.
14
+ */
15
+ function fixture(packages) {
16
+ const root = mkdtempSync(join(tmpdir(), "avocado-next-config-"));
17
+ for (const [name, pkg] of Object.entries(packages)) {
18
+ const [scope, bare] = name.split("/");
19
+ const dir = join(root, "node_modules", scope, bare);
20
+ mkdirSync(dir, { recursive: true });
21
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name, ...pkg }));
22
+ }
23
+ return root;
24
+ }
25
+ test("a package whose entry point is TypeScript needs transpiling", () => {
26
+ const root = fixture({ "@avocadostudio-ai/shared": { main: "src/index.ts" } });
27
+ assert.deepEqual(linkedAvocadoPackages(root), ["@avocadostudio-ai/shared"]);
28
+ });
29
+ test("a package installed from a registry does not", () => {
30
+ // Published, `main` points at built JavaScript — listing it would be noise.
31
+ const root = fixture({ "@avocadostudio-ai/shared": { main: "dist/index.js" } });
32
+ assert.deepEqual(linkedAvocadoPackages(root), []);
33
+ });
34
+ test("a TypeScript entry hidden in an exports map still counts", () => {
35
+ const root = fixture({
36
+ "@avocadostudio-ai/blocks": {
37
+ main: "dist/index.js",
38
+ exports: { ".": { import: "./src/index.ts" }, "./styles.css": "./src/styles.css" }
39
+ }
40
+ });
41
+ assert.deepEqual(linkedAvocadoPackages(root), ["@avocadostudio-ai/blocks"]);
42
+ });
43
+ test("both scopes are scanned", () => {
44
+ const root = fixture({
45
+ "@avocadostudio-ai/richtext": { main: "src/index.ts" },
46
+ "@ai-site-editor/immersive-widget": { main: "src/index.ts" },
47
+ "@unrelated/thing": { main: "src/index.ts" }
48
+ });
49
+ assert.deepEqual(linkedAvocadoPackages(root), [
50
+ "@ai-site-editor/immersive-widget",
51
+ "@avocadostudio-ai/richtext"
52
+ ]);
53
+ });
54
+ test("withAvocado adds what is missing and keeps what was declared", () => {
55
+ const root = fixture({
56
+ "@avocadostudio-ai/shared": { main: "src/index.ts" },
57
+ "@avocadostudio-ai/richtext": { main: "src/index.ts" }
58
+ });
59
+ const config = withAvocado({ transpilePackages: ["@avocadostudio-ai/shared", "some-other-package"], reactStrictMode: true }, { cwd: root, silent: true });
60
+ assert.deepEqual(config.transpilePackages, [
61
+ "@avocadostudio-ai/shared",
62
+ "some-other-package",
63
+ "@avocadostudio-ai/richtext"
64
+ ]);
65
+ assert.equal(config.reactStrictMode, true, "the rest of the config is untouched");
66
+ });
67
+ test("a config that already declares everything is returned unchanged", () => {
68
+ /*
69
+ * The other two halves are switched off because they always have something to
70
+ * add — image hosts, and the server externals — and this test is about the
71
+ * transpile contract on its own: when there is nothing to add, the wrapper
72
+ * hands back the very object it was given rather than a copy.
73
+ */
74
+ const root = fixture({ "@avocadostudio-ai/shared": { main: "src/index.ts" } });
75
+ const input = { transpilePackages: ["@avocadostudio-ai/shared"] };
76
+ assert.equal(withAvocado(input, { cwd: root, silent: true, images: false, serverExternals: false }), input);
77
+ });
78
+ test("merging image hosts does not disturb the transpile list", () => {
79
+ const root = fixture({ "@avocadostudio-ai/shared": { main: "src/index.ts" } });
80
+ const config = withAvocado({ transpilePackages: ["some-other-package"] }, { cwd: root, silent: true });
81
+ assert.deepEqual(config.transpilePackages, ["some-other-package", "@avocadostudio-ai/shared"]);
82
+ assert.ok(config.images.remotePatterns.length > 0);
83
+ });
84
+ test("a config with no transpilePackages at all gets the full list", () => {
85
+ const root = fixture({ "@avocadostudio-ai/shared": { main: "src/index.ts" } });
86
+ assert.deepEqual(withAvocado({}, { cwd: root, silent: true }).transpilePackages, ["@avocadostudio-ai/shared"]);
87
+ });
88
+ test("an unreadable manifest is skipped rather than thrown on", () => {
89
+ const root = mkdtempSync(join(tmpdir(), "avocado-next-config-"));
90
+ const dir = join(root, "node_modules", "@avocadostudio-ai", "broken");
91
+ mkdirSync(dir, { recursive: true });
92
+ writeFileSync(join(dir, "package.json"), "{ not json");
93
+ assert.deepEqual(linkedAvocadoPackages(root), []);
94
+ });
95
+ test("a directory that does not exist returns nothing instead of failing the build", () => {
96
+ // The whole point of the helper is that it cannot break `next.config`.
97
+ assert.deepEqual(linkedAvocadoPackages(join(tmpdir(), "definitely-not-here-9f3a")), []);
98
+ });
99
+ test("this workspace's own linked packages are found", () => {
100
+ const found = linkedAvocadoPackages(new URL("..", import.meta.url).pathname);
101
+ assert.ok(found.includes("@avocadostudio-ai/shared"), `shared missing from ${found.join(", ")}`);
102
+ assert.ok(found.includes("@avocadostudio-ai/blocks"), `blocks missing from ${found.join(", ")}`);
103
+ });
104
+ test("a real consumer picks up the package that broke it", () => {
105
+ /*
106
+ * pnpm links only declared dependencies, so this has to be asked from a real
107
+ * app rather than from this package: `site-sdk` does not depend on
108
+ * `richtext`, but the Sanity example does — transitively, through `shared`,
109
+ * which is exactly the shape that caught everyone out.
110
+ */
111
+ const found = linkedAvocadoPackages(new URL("../../../examples/sanity-site", import.meta.url).pathname);
112
+ assert.ok(found.includes("@avocadostudio-ai/richtext"), `richtext missing from ${found.join(", ")}`);
113
+ });
114
+ // ── images.remotePatterns ────────────────────────────────────────────────
115
+ //
116
+ // Four apps in this repo carried the same hand-copied `remotePatterns` block,
117
+ // and the integration docs never mentioned it — so an integrator who followed
118
+ // them got a 500 the first time a user generated an image.
119
+ const NO_ENV = { NODE_ENV: "production" };
120
+ /** A tree with no linked Avocado packages, so these tests see only the image merge. */
121
+ const EMPTY_DIR = mkdtempSync(join(tmpdir(), "avocado-next-config-images-"));
122
+ test("Avocado's own image hosts are added to a config that declares none", () => {
123
+ const config = withAvocado({}, { cwd: EMPTY_DIR, env: NO_ENV });
124
+ const hosts = config.images.remotePatterns.map((p) => p.hostname);
125
+ assert.ok(hosts.includes("images.unsplash.com"), hosts.join(", "));
126
+ assert.ok(hosts.includes("oaidalleapiprodscus.blob.core.windows.net"), hosts.join(", "));
127
+ assert.ok(hosts.includes("placehold.co"), hosts.join(", "));
128
+ });
129
+ test("the app's own hosts are kept, first and unduplicated", () => {
130
+ const config = withAvocado({
131
+ images: {
132
+ formats: ["image/avif"],
133
+ remotePatterns: [
134
+ { protocol: "https", hostname: "cdn.sanity.io" },
135
+ { protocol: "https", hostname: "images.unsplash.com" },
136
+ ],
137
+ },
138
+ }, { cwd: EMPTY_DIR, env: NO_ENV });
139
+ const hosts = config.images.remotePatterns.map((p) => p.hostname);
140
+ assert.equal(hosts[0], "cdn.sanity.io", "the app's own list must keep its order");
141
+ assert.equal(hosts.filter((h) => h === "images.unsplash.com").length, 1, "duplicated a host the app declared");
142
+ // Unrelated image settings survive.
143
+ assert.deepEqual(config.images.formats, ["image/avif"]);
144
+ });
145
+ test("the orchestrator's own origin is allowed when it is configured", () => {
146
+ const config = withAvocado({}, { cwd: EMPTY_DIR, env: { ORCHESTRATOR_URL: "https://orch.example.com" } });
147
+ const match = config.images.remotePatterns.find((p) => p.hostname === "orch.example.com");
148
+ assert.ok(match, "orchestrator host missing");
149
+ assert.equal(match.protocol, "https");
150
+ });
151
+ test("a port on the orchestrator origin is carried through", () => {
152
+ const config = withAvocado({}, { cwd: EMPTY_DIR, env: { ORCHESTRATOR_URL: "http://localhost:4200" } });
153
+ const match = config.images.remotePatterns.find((p) => p.hostname === "localhost");
154
+ assert.ok(match, "orchestrator host missing");
155
+ assert.equal(match.port, "4200");
156
+ });
157
+ test("localhost is assumed in development but never in production", () => {
158
+ const dev = withAvocado({}, { cwd: EMPTY_DIR, env: { NODE_ENV: "development" } });
159
+ assert.ok(dev.images.remotePatterns.some((p) => p.hostname === "localhost"), "a local build should reach a local orchestrator without extra config");
160
+ const prod = withAvocado({}, { cwd: EMPTY_DIR, env: NO_ENV });
161
+ assert.equal(prod.images.remotePatterns.some((p) => p.hostname === "localhost"), false, "a deployed site must not open its image optimizer to localhost");
162
+ });
163
+ test("a garbage orchestrator URL is ignored rather than thrown on", () => {
164
+ const config = withAvocado({}, { cwd: EMPTY_DIR, env: { ...NO_ENV, ORCHESTRATOR_URL: "not a url" } });
165
+ assert.ok(Array.isArray(config.images.remotePatterns));
166
+ });
167
+ test("images: false leaves the config's image settings untouched", () => {
168
+ const config = withAvocado({}, { cwd: EMPTY_DIR, images: false, env: NO_ENV });
169
+ assert.equal(config.images, undefined);
170
+ });
171
+ /*
172
+ * Server externals.
173
+ *
174
+ * `orchestrator-core` reaches native binaries and provider SDKs, and a bundler
175
+ * that swallows either produces a build that succeeds: `sharp` bundled crashes
176
+ * loading its `.node` file on the first request, and Turbopack — which resolves
177
+ * `await import(...)` statically — fails `Module not found` over an optional
178
+ * peer that was deliberately never installed.
179
+ *
180
+ * `serverExternalPackages` is the documented knob and is not sufficient alone:
181
+ * `transpilePackages`, which this same helper fills in, wins for a transitive
182
+ * dependency. Both halves are asserted here because shipping one is shipping a
183
+ * fix that does not hold.
184
+ */
185
+ /** Run the merged `webpack` hook and read back what it externalised. */
186
+ function serverExternals(config) {
187
+ const result = config.webpack({}, { isServer: true });
188
+ const fns = (result.externals ?? []).filter((e) => typeof e === "function");
189
+ return (request) => {
190
+ for (const fn of fns) {
191
+ let answer;
192
+ fn({ request }, (_e, r) => { answer = r; });
193
+ if (answer)
194
+ return answer;
195
+ }
196
+ return undefined;
197
+ };
198
+ }
199
+ test("the native and provider dependencies are declared external", () => {
200
+ const config = withAvocado({}, { cwd: EMPTY_DIR, env: NO_ENV });
201
+ for (const name of ["better-sqlite3", "sharp", "@google/genai", "googleapis"]) {
202
+ assert.ok(config.serverExternalPackages.includes(name), `${name} must not be bundled into the server build`);
203
+ }
204
+ });
205
+ test("an app's own server externals are kept, and not duplicated", () => {
206
+ const config = withAvocado({ serverExternalPackages: ["@sanity/client", "sharp"] }, { cwd: EMPTY_DIR, env: NO_ENV });
207
+ assert.equal(config.serverExternalPackages[0], "@sanity/client", "the app's own list keeps its order");
208
+ assert.equal(config.serverExternalPackages.filter((n) => n === "sharp").length, 1, "a package the app already listed must not appear twice");
209
+ });
210
+ test("the webpack hook externalises the same packages, because the array alone does not", () => {
211
+ // `transpilePackages` overrides `serverExternalPackages` for a transitive
212
+ // dependency — which is exactly how orchestrator-core reaches sharp.
213
+ const external = serverExternals(withAvocado({}, { cwd: EMPTY_DIR, env: NO_ENV }));
214
+ assert.equal(external("better-sqlite3"), "commonjs better-sqlite3");
215
+ // A deep import has to travel with the package root, or half of it is bundled.
216
+ assert.equal(external("sharp/lib/libvips.js"), "commonjs sharp/lib/libvips.js");
217
+ // A package that merely starts with the same letters is not a match.
218
+ assert.equal(external("sharpen-image"), undefined);
219
+ assert.equal(external("react"), undefined);
220
+ });
221
+ test("the client bundle is left alone", () => {
222
+ const config = withAvocado({}, { cwd: EMPTY_DIR, env: NO_ENV });
223
+ assert.equal(config.webpack({}, { isServer: false }).externals, undefined);
224
+ });
225
+ test("an app's own webpack hook still runs, and still wins", () => {
226
+ const calls = [];
227
+ const config = withAvocado({
228
+ webpack(webpackConfig, context) {
229
+ calls.push(`app:${context.isServer}`);
230
+ return { ...webpackConfig, resolve: { extensionAlias: { ".js": [".ts"] } } };
231
+ },
232
+ }, { cwd: EMPTY_DIR, env: NO_ENV });
233
+ const result = config.webpack({}, { isServer: true });
234
+ assert.deepEqual(calls, ["app:true"], "the app's hook must be called exactly once");
235
+ assert.deepEqual(result.resolve.extensionAlias, { ".js": [".ts"] }, "whatever the app's hook did survives");
236
+ assert.equal(serverExternals(config)("sharp"), "commonjs sharp");
237
+ });
238
+ test("serverExternals: false leaves both halves to the app", () => {
239
+ const config = withAvocado({}, { cwd: EMPTY_DIR, serverExternals: false, env: NO_ENV });
240
+ assert.equal(config.serverExternalPackages, undefined);
241
+ assert.equal(config.webpack, undefined, "no hook is attached at all");
242
+ });
243
+ test("externals are applied even when there is nothing to transpile", () => {
244
+ // The transpile derivation returns early twice — on a config that already
245
+ // lists every linked package, and on any filesystem error. Neither may skip
246
+ // the externals, which is why they are applied first.
247
+ const config = withAvocado({}, { cwd: "/nonexistent-path-for-this-test", env: NO_ENV });
248
+ assert.ok(config.serverExternalPackages.includes("better-sqlite3"));
249
+ });
250
+ test("the exported list is what the helper actually applies", () => {
251
+ const config = withAvocado({}, { cwd: EMPTY_DIR, env: NO_ENV });
252
+ assert.deepEqual(config.serverExternalPackages, AVOCADO_SERVER_EXTERNALS);
253
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Page metadata derivation — the `<title>`, description, and social card a
3
+ * `PageDoc` implies.
4
+ *
5
+ * This lived in `apps/site/lib/seo.ts` for most of the project's life, which
6
+ * meant the one route in the repo that emitted correct metadata was the one
7
+ * route that did *not* go through `createSitePage`. Every integrator using the
8
+ * factory shipped pages with no title and no description. Moving it here makes
9
+ * the good behaviour the default instead of the reference app's private trick.
10
+ *
11
+ * Nothing in this file imports from `next`. The return type is structural and
12
+ * assignable to Next's `Metadata`, but the derivation itself is just data, so
13
+ * it stays usable from a non-Next renderer.
14
+ */
15
+ import type { PageDoc } from "./types.ts";
16
+ export declare const DEFAULT_SITE_DESCRIPTION = "Welcome to our site.";
17
+ /** Structurally assignable to Next's `Metadata`, without importing it. */
18
+ export type PageMetadata = {
19
+ title?: string;
20
+ description?: string;
21
+ openGraph?: {
22
+ title?: string;
23
+ description?: string;
24
+ images?: string[];
25
+ siteName?: string;
26
+ url?: string;
27
+ type?: "website";
28
+ };
29
+ twitter?: {
30
+ card?: "summary_large_image";
31
+ title?: string;
32
+ description?: string;
33
+ images?: string[];
34
+ };
35
+ alternates?: {
36
+ canonical?: string;
37
+ };
38
+ robots?: {
39
+ index: boolean;
40
+ follow: boolean;
41
+ };
42
+ };
43
+ export declare function stripMarkdown(input: string): string;
44
+ export declare function truncateForMeta(input: string, maxLength?: number): string;
45
+ /**
46
+ * The best description available for a page: its own, then the first block
47
+ * that reads like prose, then a generated fallback. Never returns empty — a
48
+ * missing description is worse than a generic one.
49
+ */
50
+ export declare function derivePageDescription(page: Pick<PageDoc, "title" | "meta" | "blocks">): string;
51
+ /** The page's own title, falling back to the document title. */
52
+ export declare function derivePageTitle(page: Pick<PageDoc, "title" | "meta">): string;
53
+ export type BuildPageMetadataOptions = {
54
+ /** Appended as `Title — Site Name` when the page has no title of its own to carry. */
55
+ siteName?: string;
56
+ /** Absolute URL of this page, used for the canonical link and `og:url`. */
57
+ canonical?: string;
58
+ };
59
+ /**
60
+ * Build the full metadata object for a page.
61
+ *
62
+ * A social card needs an image to render as anything but a text link, so the
63
+ * `summary_large_image` twitter card is only claimed when there is actually an
64
+ * image to put in it.
65
+ */
66
+ export declare function buildPageMetadata(page: Pick<PageDoc, "title" | "meta" | "blocks">, options?: BuildPageMetadataOptions): PageMetadata;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Page metadata derivation — the `<title>`, description, and social card a
3
+ * `PageDoc` implies.
4
+ *
5
+ * This lived in `apps/site/lib/seo.ts` for most of the project's life, which
6
+ * meant the one route in the repo that emitted correct metadata was the one
7
+ * route that did *not* go through `createSitePage`. Every integrator using the
8
+ * factory shipped pages with no title and no description. Moving it here makes
9
+ * the good behaviour the default instead of the reference app's private trick.
10
+ *
11
+ * Nothing in this file imports from `next`. The return type is structural and
12
+ * assignable to Next's `Metadata`, but the derivation itself is just data, so
13
+ * it stays usable from a non-Next renderer.
14
+ */
15
+ export const DEFAULT_SITE_DESCRIPTION = "Welcome to our site.";
16
+ /**
17
+ * Props scanned, in order, for a description when the page declares none.
18
+ *
19
+ * Ordered by how likely the value is to read as a summary rather than as a
20
+ * label: a `description` is written to be one, a `heading` is a last resort.
21
+ */
22
+ const CANDIDATE_PROP_KEYS = ["description", "subheading", "subtitle", "summary", "excerpt", "body", "text", "heading"];
23
+ /** Shortest block text accepted as a description. Below this it reads as a label, not a summary. */
24
+ const MIN_BLOCK_TEXT = 40;
25
+ /** Search engines truncate around here; writing longer just hides the tail. */
26
+ const MAX_DESCRIPTION = 160;
27
+ export function stripMarkdown(input) {
28
+ return input
29
+ .replace(/!\[[^\]]*]\([^)]*\)/g, " ")
30
+ // The group is load-bearing: without it `$1` has nothing to refer to and
31
+ // JavaScript inserts those two characters literally, so every description
32
+ // built from prose containing a link read "Book a $1 with our team".
33
+ .replace(/\[([^\]]+)]\([^)]*\)/g, "$1")
34
+ .replace(/[`*_>#~\-]+/g, " ")
35
+ .replace(/\s+/g, " ")
36
+ .trim();
37
+ }
38
+ export function truncateForMeta(input, maxLength = MAX_DESCRIPTION) {
39
+ if (input.length <= maxLength)
40
+ return input;
41
+ const truncated = input.slice(0, maxLength + 1);
42
+ const lastSpace = truncated.lastIndexOf(" ");
43
+ const base = (lastSpace > 80 ? truncated.slice(0, lastSpace) : truncated.slice(0, maxLength)).trim();
44
+ // A cut that lands just after a sentence already ends in punctuation, and
45
+ // appending to that produced ".." — trailing punctuation is trimmed first so
46
+ // the ellipsis-substitute is always exactly one character.
47
+ return `${base.replace(/[.,;:!?\s]+$/, "")}.`;
48
+ }
49
+ function pickBlockText(page) {
50
+ for (const block of page.blocks) {
51
+ for (const key of CANDIDATE_PROP_KEYS) {
52
+ const value = block.props[key];
53
+ if (typeof value !== "string")
54
+ continue;
55
+ const normalized = stripMarkdown(value);
56
+ if (normalized.length >= MIN_BLOCK_TEXT)
57
+ return normalized;
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+ /**
63
+ * The best description available for a page: its own, then the first block
64
+ * that reads like prose, then a generated fallback. Never returns empty — a
65
+ * missing description is worse than a generic one.
66
+ */
67
+ export function derivePageDescription(page) {
68
+ const explicit = page.meta?.description?.trim();
69
+ if (explicit)
70
+ return truncateForMeta(stripMarkdown(explicit));
71
+ const blockText = pickBlockText(page);
72
+ if (blockText)
73
+ return truncateForMeta(blockText);
74
+ return truncateForMeta(`${page.title}. ${DEFAULT_SITE_DESCRIPTION}`);
75
+ }
76
+ /** The page's own title, falling back to the document title. */
77
+ export function derivePageTitle(page) {
78
+ return page.meta?.title?.trim() || page.title;
79
+ }
80
+ /**
81
+ * Build the full metadata object for a page.
82
+ *
83
+ * A social card needs an image to render as anything but a text link, so the
84
+ * `summary_large_image` twitter card is only claimed when there is actually an
85
+ * image to put in it.
86
+ */
87
+ export function buildPageMetadata(page, options = {}) {
88
+ const title = derivePageTitle(page);
89
+ const description = derivePageDescription(page);
90
+ const image = page.meta?.ogImage?.trim();
91
+ const images = image ? [image] : undefined;
92
+ return {
93
+ title,
94
+ description,
95
+ openGraph: {
96
+ title,
97
+ description,
98
+ type: "website",
99
+ ...(images ? { images } : {}),
100
+ ...(options.siteName ? { siteName: options.siteName } : {}),
101
+ ...(options.canonical ? { url: options.canonical } : {}),
102
+ },
103
+ twitter: {
104
+ ...(images ? { card: "summary_large_image", images } : {}),
105
+ title,
106
+ description,
107
+ },
108
+ ...(options.canonical ? { alternates: { canonical: options.canonical } } : {}),
109
+ };
110
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,105 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { buildPageMetadata, derivePageDescription, derivePageTitle, stripMarkdown, truncateForMeta } from "./page-metadata.js";
4
+ const basePage = {
5
+ id: "p_test",
6
+ slug: "/test",
7
+ title: "Test Page",
8
+ updatedAt: "2026-03-13T00:00:00.000Z",
9
+ blocks: [],
10
+ };
11
+ test("derivePageDescription prefers an explicit description", () => {
12
+ const page = { ...basePage, meta: { description: "Explicit description for search snippets." } };
13
+ assert.equal(derivePageDescription(page), "Explicit description for search snippets.");
14
+ });
15
+ test("derivePageDescription falls back to the first block that reads like prose", () => {
16
+ const page = {
17
+ ...basePage,
18
+ blocks: [
19
+ { id: "b0", type: "Hero", props: { heading: "Avocados" } },
20
+ { id: "b1", type: "Hero", props: { subheading: "Learn how to pick, store, and prepare avocados quickly." } },
21
+ ],
22
+ };
23
+ assert.match(derivePageDescription(page), /pick, store, and prepare avocados quickly/i);
24
+ });
25
+ test("derivePageDescription skips block text too short to be a summary", () => {
26
+ const page = {
27
+ ...basePage,
28
+ blocks: [{ id: "b1", type: "CTA", props: { text: "Buy now" } }],
29
+ };
30
+ // "Buy now" is a label, not a description — the generated fallback wins.
31
+ assert.match(derivePageDescription(page), /^Test Page\./);
32
+ });
33
+ test("derivePageDescription strips markdown and caps length", () => {
34
+ const page = {
35
+ ...basePage,
36
+ blocks: [
37
+ {
38
+ id: "b2",
39
+ type: "RichText",
40
+ props: {
41
+ body: "# Title\n\nThis is **a long markdown description** with [a link](https://example.com) that should be normalized and capped to a search-friendly length without weird symbols lingering around.",
42
+ },
43
+ },
44
+ ],
45
+ };
46
+ const description = derivePageDescription(page);
47
+ assert.ok(description.length <= 161, `too long: ${description.length}`);
48
+ assert.equal(description.includes("**"), false);
49
+ assert.equal(description.includes("[a link]"), false);
50
+ /*
51
+ * The words have to survive, not just the brackets. Asserting only that
52
+ * "[a link]" is gone passed while the replacement wrote a literal `$1` over
53
+ * the link text, because the pattern it referred to had no capture group.
54
+ */
55
+ assert.ok(description.includes("a link"), `link text was dropped: ${description}`);
56
+ assert.equal(description.includes("$1"), false, `replacement token leaked: ${description}`);
57
+ });
58
+ test("stripMarkdown keeps link text and drops the target", () => {
59
+ assert.equal(stripMarkdown("Book a [guided tour](/tours) with our team."), "Book a guided tour with our team.");
60
+ // An image has no text worth keeping — alt text describes the picture, not
61
+ // the sentence — so it goes entirely rather than leaving its alt behind.
62
+ assert.equal(stripMarkdown("Before ![a photo](/p.png) after."), "Before after.");
63
+ });
64
+ test("truncateForMeta ends in exactly one period", () => {
65
+ const cutAfterASentence = `${"A".repeat(100)} end of the first sentence here. ${"B".repeat(80)}`;
66
+ const out = truncateForMeta(cutAfterASentence);
67
+ assert.equal(out.endsWith(".."), false, `doubled the period: ${JSON.stringify(out.slice(-20))}`);
68
+ assert.equal(out.endsWith("."), true);
69
+ assert.ok(out.length <= 161, `too long: ${out.length}`);
70
+ });
71
+ test("derivePageDescription never returns empty", () => {
72
+ assert.ok(derivePageDescription(basePage).length > 0);
73
+ });
74
+ test("derivePageTitle prefers meta.title over the document title", () => {
75
+ assert.equal(derivePageTitle(basePage), "Test Page");
76
+ assert.equal(derivePageTitle({ ...basePage, meta: { title: "SEO Title" } }), "SEO Title");
77
+ // An all-whitespace meta title is not a title.
78
+ assert.equal(derivePageTitle({ ...basePage, meta: { title: " " } }), "Test Page");
79
+ });
80
+ test("buildPageMetadata emits title, description and Open Graph", () => {
81
+ const meta = buildPageMetadata({ ...basePage, meta: { description: "A page about avocados and how to eat them." } });
82
+ assert.equal(meta.title, "Test Page");
83
+ assert.equal(meta.description, "A page about avocados and how to eat them.");
84
+ assert.equal(meta.openGraph?.title, "Test Page");
85
+ assert.equal(meta.openGraph?.description, "A page about avocados and how to eat them.");
86
+ assert.equal(meta.openGraph?.type, "website");
87
+ });
88
+ test("buildPageMetadata only claims a large image card when there is an image", () => {
89
+ const without = buildPageMetadata(basePage);
90
+ assert.equal(without.openGraph?.images, undefined);
91
+ assert.equal(without.twitter?.card, undefined);
92
+ const with_ = buildPageMetadata({ ...basePage, meta: { ogImage: "https://cdn.example.com/og.png" } });
93
+ assert.deepEqual(with_.openGraph?.images, ["https://cdn.example.com/og.png"]);
94
+ assert.equal(with_.twitter?.card, "summary_large_image");
95
+ assert.deepEqual(with_.twitter?.images, ["https://cdn.example.com/og.png"]);
96
+ });
97
+ test("buildPageMetadata carries siteName and canonical when given", () => {
98
+ const meta = buildPageMetadata(basePage, { siteName: "Avocado Co", canonical: "https://example.com/test" });
99
+ assert.equal(meta.openGraph?.siteName, "Avocado Co");
100
+ assert.equal(meta.openGraph?.url, "https://example.com/test");
101
+ assert.equal(meta.alternates?.canonical, "https://example.com/test");
102
+ const bare = buildPageMetadata(basePage);
103
+ assert.equal(bare.alternates, undefined);
104
+ assert.equal(bare.openGraph?.siteName, undefined);
105
+ });