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