@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.
- package/README.md +212 -2
- package/dist/create-site-page.d.ts +38 -8
- package/dist/create-site-page.js +59 -8
- package/dist/draft-common.d.ts +32 -0
- package/dist/draft-common.js +58 -0
- package/dist/draft-context-core.js +39 -6
- package/dist/draft-context-core.test.d.ts +10 -0
- package/dist/draft-context-core.test.js +146 -0
- package/dist/editor-cors.d.ts +12 -0
- package/dist/editor-cors.js +31 -6
- package/dist/editor-cors.test.d.ts +1 -0
- package/dist/editor-cors.test.js +66 -0
- package/dist/editor-manifest.d.ts +2 -3
- package/dist/editor-manifest.js +12 -64
- package/dist/editor-matcher.d.ts +27 -0
- package/dist/editor-matcher.js +34 -0
- package/dist/editor-query.js +7 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/integration-check.js +11 -1
- package/dist/manifest-utils.d.ts +13 -0
- package/dist/manifest-utils.js +30 -3
- package/dist/manifest-utils.test.d.ts +1 -0
- package/dist/manifest-utils.test.js +72 -0
- package/dist/middleware.d.ts +21 -19
- package/dist/middleware.js +19 -22
- package/dist/next-config.test.d.ts +1 -0
- package/dist/next-config.test.js +253 -0
- package/dist/page-metadata.d.ts +66 -0
- package/dist/page-metadata.js +110 -0
- package/dist/page-metadata.test.d.ts +1 -0
- package/dist/page-metadata.test.js +105 -0
- package/dist/proxy.d.ts +58 -0
- package/dist/proxy.js +50 -0
- package/dist/proxy.test.d.ts +1 -0
- package/dist/proxy.test.js +72 -0
- package/dist/publish/field-diff.d.ts +191 -0
- package/dist/publish/field-diff.js +252 -0
- package/dist/publish/field-diff.test.d.ts +1 -0
- package/dist/publish/field-diff.test.js +286 -0
- package/dist/server/orchestrator.d.ts +1 -117
- package/dist/server/orchestrator.js +14 -733
- package/next-config.d.ts +68 -0
- package/next-config.mjs +358 -0
- package/package.json +63 -19
package/dist/middleware.d.ts
CHANGED
|
@@ -1,24 +1,10 @@
|
|
|
1
|
-
import {
|
|
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
|
};
|
package/dist/middleware.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
14
|
-
|
|
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  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
|
+
});
|