@avocadostudio-ai/site-sdk 0.1.0 → 0.2.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 +212 -2
- package/dist/cli/register.js +23 -1
- 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/draft-fetch.d.ts +9 -10
- package/dist/draft-fetch.js +71 -5
- package/dist/draft-fetch.test.d.ts +1 -0
- package/dist/draft-fetch.test.js +87 -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 +355 -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 +95 -0
- package/dist/proxy.js +76 -0
- package/dist/proxy.test.d.ts +1 -0
- package/dist/proxy.test.js +123 -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 +78 -0
- package/next-config.mjs +468 -0
- package/package.json +63 -19
|
@@ -0,0 +1,355 @@
|
|
|
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
|
+
/*
|
|
31
|
+
* The shape every `@avocadostudio-ai` package actually has on npm, and the
|
|
32
|
+
* `types` field is the whole point of the fixture: `dist/index.d.ts` ends in
|
|
33
|
+
* `.ts`, so a naive TypeScript test matches it and transpiles the published
|
|
34
|
+
* package — which drags `orchestrator-core` into the bundle and fails the
|
|
35
|
+
* build on an optional peer.
|
|
36
|
+
*
|
|
37
|
+
* This test existed before that was found, with a fixture carrying `main`
|
|
38
|
+
* alone. It passed, on a package shape that does not occur on a registry.
|
|
39
|
+
*/
|
|
40
|
+
const root = fixture({
|
|
41
|
+
"@avocadostudio-ai/shared": { main: "dist/index.js", types: "dist/index.d.ts" }
|
|
42
|
+
});
|
|
43
|
+
assert.deepEqual(linkedAvocadoPackages(root), []);
|
|
44
|
+
});
|
|
45
|
+
test("a declaration file in an exports map is not a reason to transpile either", () => {
|
|
46
|
+
// The same trap one level down: an `exports` map carries its own `types`.
|
|
47
|
+
const root = fixture({
|
|
48
|
+
"@avocadostudio-ai/richtext": {
|
|
49
|
+
main: "dist/index.js",
|
|
50
|
+
exports: {
|
|
51
|
+
".": { types: "./dist/index.d.ts", import: "./dist/index.js" },
|
|
52
|
+
"./package.json": "./package.json"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
assert.deepEqual(linkedAvocadoPackages(root), []);
|
|
57
|
+
});
|
|
58
|
+
test("a linked package is still caught when its types point at source", () => {
|
|
59
|
+
// A workspace checkout points both fields at `src/`; that must still count.
|
|
60
|
+
const root = fixture({
|
|
61
|
+
"@avocadostudio-ai/shared": { main: "src/index.ts", types: "src/index.ts" }
|
|
62
|
+
});
|
|
63
|
+
assert.deepEqual(linkedAvocadoPackages(root), ["@avocadostudio-ai/shared"]);
|
|
64
|
+
});
|
|
65
|
+
test("a TypeScript entry hidden in an exports map still counts", () => {
|
|
66
|
+
const root = fixture({
|
|
67
|
+
"@avocadostudio-ai/blocks": {
|
|
68
|
+
main: "dist/index.js",
|
|
69
|
+
exports: { ".": { import: "./src/index.ts" }, "./styles.css": "./src/styles.css" }
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
assert.deepEqual(linkedAvocadoPackages(root), ["@avocadostudio-ai/blocks"]);
|
|
73
|
+
});
|
|
74
|
+
test("both scopes are scanned", () => {
|
|
75
|
+
const root = fixture({
|
|
76
|
+
"@avocadostudio-ai/richtext": { main: "src/index.ts" },
|
|
77
|
+
"@ai-site-editor/immersive-widget": { main: "src/index.ts" },
|
|
78
|
+
"@unrelated/thing": { main: "src/index.ts" }
|
|
79
|
+
});
|
|
80
|
+
assert.deepEqual(linkedAvocadoPackages(root), [
|
|
81
|
+
"@ai-site-editor/immersive-widget",
|
|
82
|
+
"@avocadostudio-ai/richtext"
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
test("withAvocado adds what is missing and keeps what was declared", () => {
|
|
86
|
+
const root = fixture({
|
|
87
|
+
"@avocadostudio-ai/shared": { main: "src/index.ts" },
|
|
88
|
+
"@avocadostudio-ai/richtext": { main: "src/index.ts" }
|
|
89
|
+
});
|
|
90
|
+
const config = withAvocado({ transpilePackages: ["@avocadostudio-ai/shared", "some-other-package"], reactStrictMode: true }, { cwd: root, silent: true });
|
|
91
|
+
assert.deepEqual(config.transpilePackages, [
|
|
92
|
+
"@avocadostudio-ai/shared",
|
|
93
|
+
"some-other-package",
|
|
94
|
+
"@avocadostudio-ai/richtext"
|
|
95
|
+
]);
|
|
96
|
+
assert.equal(config.reactStrictMode, true, "the rest of the config is untouched");
|
|
97
|
+
});
|
|
98
|
+
test("a config that already declares everything is returned unchanged", () => {
|
|
99
|
+
/*
|
|
100
|
+
* The other two halves are switched off because they always have something to
|
|
101
|
+
* add — image hosts, and the server externals — and this test is about the
|
|
102
|
+
* transpile contract on its own: when there is nothing to add, the wrapper
|
|
103
|
+
* hands back the very object it was given rather than a copy.
|
|
104
|
+
*/
|
|
105
|
+
const root = fixture({ "@avocadostudio-ai/shared": { main: "src/index.ts" } });
|
|
106
|
+
const input = { transpilePackages: ["@avocadostudio-ai/shared"] };
|
|
107
|
+
assert.equal(withAvocado(input, { cwd: root, silent: true, images: false, serverExternals: false }), input);
|
|
108
|
+
});
|
|
109
|
+
test("merging image hosts does not disturb the transpile list", () => {
|
|
110
|
+
const root = fixture({ "@avocadostudio-ai/shared": { main: "src/index.ts" } });
|
|
111
|
+
const config = withAvocado({ transpilePackages: ["some-other-package"] }, { cwd: root, silent: true });
|
|
112
|
+
assert.deepEqual(config.transpilePackages, ["some-other-package", "@avocadostudio-ai/shared"]);
|
|
113
|
+
assert.ok(config.images.remotePatterns.length > 0);
|
|
114
|
+
});
|
|
115
|
+
test("a config with no transpilePackages at all gets the full list", () => {
|
|
116
|
+
const root = fixture({ "@avocadostudio-ai/shared": { main: "src/index.ts" } });
|
|
117
|
+
assert.deepEqual(withAvocado({}, { cwd: root, silent: true }).transpilePackages, ["@avocadostudio-ai/shared"]);
|
|
118
|
+
});
|
|
119
|
+
test("an unreadable manifest is skipped rather than thrown on", () => {
|
|
120
|
+
const root = mkdtempSync(join(tmpdir(), "avocado-next-config-"));
|
|
121
|
+
const dir = join(root, "node_modules", "@avocadostudio-ai", "broken");
|
|
122
|
+
mkdirSync(dir, { recursive: true });
|
|
123
|
+
writeFileSync(join(dir, "package.json"), "{ not json");
|
|
124
|
+
assert.deepEqual(linkedAvocadoPackages(root), []);
|
|
125
|
+
});
|
|
126
|
+
test("a directory that does not exist returns nothing instead of failing the build", () => {
|
|
127
|
+
// The whole point of the helper is that it cannot break `next.config`.
|
|
128
|
+
assert.deepEqual(linkedAvocadoPackages(join(tmpdir(), "definitely-not-here-9f3a")), []);
|
|
129
|
+
});
|
|
130
|
+
test("this workspace's own linked packages are found", () => {
|
|
131
|
+
const found = linkedAvocadoPackages(new URL("..", import.meta.url).pathname);
|
|
132
|
+
assert.ok(found.includes("@avocadostudio-ai/shared"), `shared missing from ${found.join(", ")}`);
|
|
133
|
+
assert.ok(found.includes("@avocadostudio-ai/blocks"), `blocks missing from ${found.join(", ")}`);
|
|
134
|
+
});
|
|
135
|
+
test("a real consumer picks up the package that broke it", () => {
|
|
136
|
+
/*
|
|
137
|
+
* pnpm links only declared dependencies, so this has to be asked from a real
|
|
138
|
+
* app rather than from this package: `site-sdk` does not depend on
|
|
139
|
+
* `richtext`, but the Sanity example does — transitively, through `shared`,
|
|
140
|
+
* which is exactly the shape that caught everyone out.
|
|
141
|
+
*/
|
|
142
|
+
const found = linkedAvocadoPackages(new URL("../../../examples/sanity-site", import.meta.url).pathname);
|
|
143
|
+
assert.ok(found.includes("@avocadostudio-ai/richtext"), `richtext missing from ${found.join(", ")}`);
|
|
144
|
+
});
|
|
145
|
+
// ── images.remotePatterns ────────────────────────────────────────────────
|
|
146
|
+
//
|
|
147
|
+
// Four apps in this repo carried the same hand-copied `remotePatterns` block,
|
|
148
|
+
// and the integration docs never mentioned it — so an integrator who followed
|
|
149
|
+
// them got a 500 the first time a user generated an image.
|
|
150
|
+
const NO_ENV = { NODE_ENV: "production" };
|
|
151
|
+
/** A tree with no linked Avocado packages, so these tests see only the image merge. */
|
|
152
|
+
const EMPTY_DIR = mkdtempSync(join(tmpdir(), "avocado-next-config-images-"));
|
|
153
|
+
test("Avocado's own image hosts are added to a config that declares none", () => {
|
|
154
|
+
const config = withAvocado({}, { cwd: EMPTY_DIR, env: NO_ENV });
|
|
155
|
+
const hosts = config.images.remotePatterns.map((p) => p.hostname);
|
|
156
|
+
assert.ok(hosts.includes("images.unsplash.com"), hosts.join(", "));
|
|
157
|
+
assert.ok(hosts.includes("oaidalleapiprodscus.blob.core.windows.net"), hosts.join(", "));
|
|
158
|
+
assert.ok(hosts.includes("placehold.co"), hosts.join(", "));
|
|
159
|
+
});
|
|
160
|
+
test("the app's own hosts are kept, first and unduplicated", () => {
|
|
161
|
+
const config = withAvocado({
|
|
162
|
+
images: {
|
|
163
|
+
formats: ["image/avif"],
|
|
164
|
+
remotePatterns: [
|
|
165
|
+
{ protocol: "https", hostname: "cdn.sanity.io" },
|
|
166
|
+
{ protocol: "https", hostname: "images.unsplash.com" },
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
}, { cwd: EMPTY_DIR, env: NO_ENV });
|
|
170
|
+
const hosts = config.images.remotePatterns.map((p) => p.hostname);
|
|
171
|
+
assert.equal(hosts[0], "cdn.sanity.io", "the app's own list must keep its order");
|
|
172
|
+
assert.equal(hosts.filter((h) => h === "images.unsplash.com").length, 1, "duplicated a host the app declared");
|
|
173
|
+
// Unrelated image settings survive.
|
|
174
|
+
assert.deepEqual(config.images.formats, ["image/avif"]);
|
|
175
|
+
});
|
|
176
|
+
test("the orchestrator's own origin is allowed when it is configured", () => {
|
|
177
|
+
const config = withAvocado({}, { cwd: EMPTY_DIR, env: { ORCHESTRATOR_URL: "https://orch.example.com" } });
|
|
178
|
+
const match = config.images.remotePatterns.find((p) => p.hostname === "orch.example.com");
|
|
179
|
+
assert.ok(match, "orchestrator host missing");
|
|
180
|
+
assert.equal(match.protocol, "https");
|
|
181
|
+
});
|
|
182
|
+
test("a port on the orchestrator origin is carried through", () => {
|
|
183
|
+
const config = withAvocado({}, { cwd: EMPTY_DIR, env: { ORCHESTRATOR_URL: "http://localhost:4200" } });
|
|
184
|
+
const match = config.images.remotePatterns.find((p) => p.hostname === "localhost");
|
|
185
|
+
assert.ok(match, "orchestrator host missing");
|
|
186
|
+
assert.equal(match.port, "4200");
|
|
187
|
+
});
|
|
188
|
+
test("localhost is assumed in development but never in production", () => {
|
|
189
|
+
const dev = withAvocado({}, { cwd: EMPTY_DIR, env: { NODE_ENV: "development" } });
|
|
190
|
+
assert.ok(dev.images.remotePatterns.some((p) => p.hostname === "localhost"), "a local build should reach a local orchestrator without extra config");
|
|
191
|
+
const prod = withAvocado({}, { cwd: EMPTY_DIR, env: NO_ENV });
|
|
192
|
+
assert.equal(prod.images.remotePatterns.some((p) => p.hostname === "localhost"), false, "a deployed site must not open its image optimizer to localhost");
|
|
193
|
+
});
|
|
194
|
+
test("a garbage orchestrator URL is ignored rather than thrown on", () => {
|
|
195
|
+
const config = withAvocado({}, { cwd: EMPTY_DIR, env: { ...NO_ENV, ORCHESTRATOR_URL: "not a url" } });
|
|
196
|
+
assert.ok(Array.isArray(config.images.remotePatterns));
|
|
197
|
+
});
|
|
198
|
+
test("images: false leaves the config's image settings untouched", () => {
|
|
199
|
+
const config = withAvocado({}, { cwd: EMPTY_DIR, images: false, env: NO_ENV });
|
|
200
|
+
assert.equal(config.images, undefined);
|
|
201
|
+
});
|
|
202
|
+
/*
|
|
203
|
+
* Server externals.
|
|
204
|
+
*
|
|
205
|
+
* `orchestrator-core` reaches native binaries and provider SDKs, and a bundler
|
|
206
|
+
* that swallows either produces a build that succeeds: `sharp` bundled crashes
|
|
207
|
+
* loading its `.node` file on the first request, and Turbopack — which resolves
|
|
208
|
+
* `await import(...)` statically — fails `Module not found` over an optional
|
|
209
|
+
* peer that was deliberately never installed.
|
|
210
|
+
*
|
|
211
|
+
* `serverExternalPackages` is the documented knob and is not sufficient alone:
|
|
212
|
+
* `transpilePackages`, which this same helper fills in, wins for a transitive
|
|
213
|
+
* dependency. Both halves are asserted here because shipping one is shipping a
|
|
214
|
+
* fix that does not hold.
|
|
215
|
+
*/
|
|
216
|
+
/** Run the merged `webpack` hook and read back what it externalised. */
|
|
217
|
+
function serverExternals(config) {
|
|
218
|
+
const result = config.webpack({}, { isServer: true });
|
|
219
|
+
const fns = (result.externals ?? []).filter((e) => typeof e === "function");
|
|
220
|
+
return (request) => {
|
|
221
|
+
for (const fn of fns) {
|
|
222
|
+
let answer;
|
|
223
|
+
fn({ request }, (_e, r) => { answer = r; });
|
|
224
|
+
if (answer)
|
|
225
|
+
return answer;
|
|
226
|
+
}
|
|
227
|
+
return undefined;
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
test("the native and provider dependencies are declared external", () => {
|
|
231
|
+
const config = withAvocado({}, { cwd: EMPTY_DIR, env: NO_ENV });
|
|
232
|
+
for (const name of ["better-sqlite3", "sharp", "@google/genai", "googleapis"]) {
|
|
233
|
+
assert.ok(config.serverExternalPackages.includes(name), `${name} must not be bundled into the server build`);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
test("an app's own server externals are kept, and not duplicated", () => {
|
|
237
|
+
const config = withAvocado({ serverExternalPackages: ["@sanity/client", "sharp"] }, { cwd: EMPTY_DIR, env: NO_ENV });
|
|
238
|
+
assert.equal(config.serverExternalPackages[0], "@sanity/client", "the app's own list keeps its order");
|
|
239
|
+
assert.equal(config.serverExternalPackages.filter((n) => n === "sharp").length, 1, "a package the app already listed must not appear twice");
|
|
240
|
+
});
|
|
241
|
+
test("the webpack hook externalises the same packages, because the array alone does not", () => {
|
|
242
|
+
// `transpilePackages` overrides `serverExternalPackages` for a transitive
|
|
243
|
+
// dependency — which is exactly how orchestrator-core reaches sharp.
|
|
244
|
+
const external = serverExternals(withAvocado({}, { cwd: EMPTY_DIR, env: NO_ENV }));
|
|
245
|
+
assert.equal(external("better-sqlite3"), "commonjs better-sqlite3");
|
|
246
|
+
// A deep import has to travel with the package root, or half of it is bundled.
|
|
247
|
+
assert.equal(external("sharp/lib/libvips.js"), "commonjs sharp/lib/libvips.js");
|
|
248
|
+
// A package that merely starts with the same letters is not a match.
|
|
249
|
+
assert.equal(external("sharpen-image"), undefined);
|
|
250
|
+
assert.equal(external("react"), undefined);
|
|
251
|
+
});
|
|
252
|
+
test("the client bundle is left alone", () => {
|
|
253
|
+
const config = withAvocado({}, { cwd: EMPTY_DIR, env: NO_ENV });
|
|
254
|
+
assert.equal(config.webpack({}, { isServer: false }).externals, undefined);
|
|
255
|
+
});
|
|
256
|
+
test("an app's own webpack hook still runs, and still wins", () => {
|
|
257
|
+
const calls = [];
|
|
258
|
+
const config = withAvocado({
|
|
259
|
+
webpack(webpackConfig, context) {
|
|
260
|
+
calls.push(`app:${context.isServer}`);
|
|
261
|
+
return { ...webpackConfig, resolve: { extensionAlias: { ".js": [".ts"] } } };
|
|
262
|
+
},
|
|
263
|
+
}, { cwd: EMPTY_DIR, env: NO_ENV });
|
|
264
|
+
const result = config.webpack({}, { isServer: true });
|
|
265
|
+
assert.deepEqual(calls, ["app:true"], "the app's hook must be called exactly once");
|
|
266
|
+
assert.deepEqual(result.resolve.extensionAlias, { ".js": [".ts"] }, "whatever the app's hook did survives");
|
|
267
|
+
assert.equal(serverExternals(config)("sharp"), "commonjs sharp");
|
|
268
|
+
});
|
|
269
|
+
test("serverExternals: false leaves both halves to the app", () => {
|
|
270
|
+
const config = withAvocado({}, { cwd: EMPTY_DIR, serverExternals: false, env: NO_ENV });
|
|
271
|
+
assert.equal(config.serverExternalPackages, undefined);
|
|
272
|
+
assert.equal(config.webpack, undefined, "no hook is attached at all");
|
|
273
|
+
assert.equal(config.turbopack, undefined, "and nothing is declared on its behalf");
|
|
274
|
+
});
|
|
275
|
+
/*
|
|
276
|
+
* On Next 16 Turbopack is the default, and a config with a `webpack` key and no
|
|
277
|
+
* `turbopack` key fails the build outright — measured, on 16.3.4, against a
|
|
278
|
+
* project that installed the SDK from a tarball:
|
|
279
|
+
*
|
|
280
|
+
* ERROR: This build is using Turbopack, with a `webpack` config and no
|
|
281
|
+
* `turbopack` config.
|
|
282
|
+
*
|
|
283
|
+
* Since the hook above is attached whether or not the app asked for one, the
|
|
284
|
+
* empty Turbopack config has to travel with it.
|
|
285
|
+
*/
|
|
286
|
+
test("attaching a webpack hook also declares the turbopack config Next 16 demands", () => {
|
|
287
|
+
const config = withAvocado({}, { cwd: EMPTY_DIR, env: NO_ENV });
|
|
288
|
+
assert.equal(typeof config.webpack, "function", "the hook is still attached");
|
|
289
|
+
assert.deepEqual(config.turbopack, {}, "an empty turbopack config must travel with it");
|
|
290
|
+
});
|
|
291
|
+
test("an app's own turbopack config is never overwritten", () => {
|
|
292
|
+
const config = withAvocado({ turbopack: { resolveAlias: { underscore: "lodash" } } }, { cwd: EMPTY_DIR, env: NO_ENV });
|
|
293
|
+
assert.deepEqual(config.turbopack, { resolveAlias: { underscore: "lodash" } });
|
|
294
|
+
});
|
|
295
|
+
test("externals are applied even when there is nothing to transpile", () => {
|
|
296
|
+
// The transpile derivation returns early twice — on a config that already
|
|
297
|
+
// lists every linked package, and on any filesystem error. Neither may skip
|
|
298
|
+
// the externals, which is why they are applied first.
|
|
299
|
+
const config = withAvocado({}, { cwd: "/nonexistent-path-for-this-test", env: NO_ENV });
|
|
300
|
+
assert.ok(config.serverExternalPackages.includes("better-sqlite3"));
|
|
301
|
+
});
|
|
302
|
+
/*
|
|
303
|
+
* Naming orchestrator-core's *dependencies* external does not stop Turbopack
|
|
304
|
+
* walking into orchestrator-core and resolving its `await import("googleapis")`
|
|
305
|
+
* statically. Measured on Next 16.3.4 against a real tarball install: the build
|
|
306
|
+
* fails on an optional peer the project never installed, with every provider
|
|
307
|
+
* SDK already in `serverExternalPackages`. Externalising the package itself is
|
|
308
|
+
* the fix, and it is only correct when the package arrived built.
|
|
309
|
+
*/
|
|
310
|
+
test("a registry-installed orchestrator-core is externalised, not walked", () => {
|
|
311
|
+
const root = fixture({
|
|
312
|
+
"@avocadostudio-ai/orchestrator-core": { main: "dist/index.js", types: "dist/index.d.ts" }
|
|
313
|
+
});
|
|
314
|
+
const config = withAvocado({}, { cwd: root, env: NO_ENV });
|
|
315
|
+
assert.ok(config.serverExternalPackages.includes("@avocadostudio-ai/orchestrator-core"), "a built orchestrator-core must be external, or Turbopack resolves its optional peers");
|
|
316
|
+
});
|
|
317
|
+
test("a linked orchestrator-core is transpiled instead, never externalised", () => {
|
|
318
|
+
// Externalising a package whose `main` is `src/index.ts` hands Node a
|
|
319
|
+
// TypeScript file to require — a build error traded for a runtime crash.
|
|
320
|
+
const root = fixture({ "@avocadostudio-ai/orchestrator-core": { main: "src/index.ts" } });
|
|
321
|
+
const config = withAvocado({}, { cwd: root, env: NO_ENV });
|
|
322
|
+
assert.deepEqual(config.transpilePackages, ["@avocadostudio-ai/orchestrator-core"]);
|
|
323
|
+
assert.equal(config.serverExternalPackages.includes("@avocadostudio-ai/orchestrator-core"), false, "a linked checkout must be compiled with the app, not handed to Node raw");
|
|
324
|
+
});
|
|
325
|
+
test("the exported list is what the helper actually applies", () => {
|
|
326
|
+
const config = withAvocado({}, { cwd: EMPTY_DIR, env: NO_ENV });
|
|
327
|
+
assert.deepEqual(config.serverExternalPackages, AVOCADO_SERVER_EXTERNALS);
|
|
328
|
+
});
|
|
329
|
+
/*
|
|
330
|
+
* `trailingSlash: true` is the setting that made the editor unreachable. Next
|
|
331
|
+
* applies its 308 to `/api/*` too, and a browser will not follow a redirect on
|
|
332
|
+
* a CORS preflight, so every editor API call failed before it was sent — with
|
|
333
|
+
* no error naming the config line responsible.
|
|
334
|
+
*/
|
|
335
|
+
test("a trailing-slash site stops redirecting, or the editor cannot reach it", () => {
|
|
336
|
+
const root = fixture({});
|
|
337
|
+
const config = withAvocado({ trailingSlash: true }, { cwd: root, silent: true });
|
|
338
|
+
assert.equal(config.skipTrailingSlashRedirect, true);
|
|
339
|
+
assert.equal(config.trailingSlash, true, "the app's own setting is not touched — only the redirect is");
|
|
340
|
+
});
|
|
341
|
+
test("a site without trailing slashes is left exactly as it was", () => {
|
|
342
|
+
const root = fixture({});
|
|
343
|
+
const config = withAvocado({}, { cwd: root, silent: true });
|
|
344
|
+
assert.equal(config.skipTrailingSlashRedirect, undefined, "the vast majority of sites never hit this, and must not inherit the workaround");
|
|
345
|
+
});
|
|
346
|
+
test("an app that already decided about the redirect keeps its decision", () => {
|
|
347
|
+
const root = fixture({});
|
|
348
|
+
const kept = withAvocado({ trailingSlash: true, skipTrailingSlashRedirect: false }, { cwd: root, silent: true });
|
|
349
|
+
assert.equal(kept.skipTrailingSlashRedirect, false, "a site that stated this has thought about it harder than a helper can");
|
|
350
|
+
});
|
|
351
|
+
test("trailingSlash: false leaves the whole thing to the app", () => {
|
|
352
|
+
const root = fixture({});
|
|
353
|
+
const config = withAvocado({ trailingSlash: true }, { cwd: root, silent: true, trailingSlash: false });
|
|
354
|
+
assert.equal(config.skipTrailingSlashRedirect, undefined);
|
|
355
|
+
});
|
|
@@ -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
|
+
});
|