@avocadostudio-ai/site-sdk 0.7.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 (55) 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/editor-api-handler-core.d.ts +85 -0
  11. package/dist/editor-api-handler-core.js +101 -0
  12. package/dist/editor-api-handler.d.ts +7 -49
  13. package/dist/editor-api-handler.js +12 -66
  14. package/dist/editor-rewrite-core.d.ts +71 -0
  15. package/dist/editor-rewrite-core.js +37 -0
  16. package/dist/lens/register.d.ts +6 -8
  17. package/dist/lens/register.js +203 -29
  18. package/dist/lens/types.d.ts +36 -0
  19. package/dist/page-metadata.d.ts +21 -0
  20. package/dist/page-metadata.js +64 -0
  21. package/dist/proxy.d.ts +9 -30
  22. package/dist/proxy.js +16 -22
  23. package/dist/resolve-page-render.d.ts +94 -0
  24. package/dist/resolve-page-render.js +80 -0
  25. package/dist/routes-core.d.ts +12 -0
  26. package/dist/routes-core.js +24 -0
  27. package/package.json +20 -15
  28. package/dist/cli/register-notice.test.d.ts +0 -1
  29. package/dist/cli/register-notice.test.js +0 -21
  30. package/dist/draft-context-core.test.d.ts +0 -10
  31. package/dist/draft-context-core.test.js +0 -146
  32. package/dist/draft-fetch.test.d.ts +0 -1
  33. package/dist/draft-fetch.test.js +0 -153
  34. package/dist/editor-api-blocks-catalogue.test.d.ts +0 -1
  35. package/dist/editor-api-blocks-catalogue.test.js +0 -73
  36. package/dist/editor-cors.test.d.ts +0 -1
  37. package/dist/editor-cors.test.js +0 -85
  38. package/dist/editor-render.test.d.ts +0 -1
  39. package/dist/editor-render.test.js +0 -44
  40. package/dist/lens/lens.test.d.ts +0 -1
  41. package/dist/lens/lens.test.js +0 -221
  42. package/dist/lens/register.test.d.ts +0 -1
  43. package/dist/lens/register.test.js +0 -81
  44. package/dist/manifest-utils.test.d.ts +0 -1
  45. package/dist/manifest-utils.test.js +0 -72
  46. package/dist/markers.test.d.ts +0 -1
  47. package/dist/markers.test.js +0 -35
  48. package/dist/next-config.test.d.ts +0 -1
  49. package/dist/next-config.test.js +0 -393
  50. package/dist/page-metadata.test.d.ts +0 -1
  51. package/dist/page-metadata.test.js +0 -105
  52. package/dist/proxy.test.d.ts +0 -1
  53. package/dist/proxy.test.js +0 -182
  54. package/dist/publish/field-diff.test.d.ts +0 -1
  55. package/dist/publish/field-diff.test.js +0 -350
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Options shared by every host's editor rewrite.
3
+ *
4
+ * `EditorProxyOptions` in `./proxy.ts` extends this with `trailingSlash`, which
5
+ * exists only to re-issue a redirect Next was told to stop issuing.
6
+ */
7
+ export type EditorRewriteOptions = {
8
+ /**
9
+ * The internal route prefix that the dynamic editor/draft page lives under.
10
+ * @default "/preview-draft"
11
+ */
12
+ previewRoute?: string;
13
+ /**
14
+ * Query parameter that signals an editor iframe request.
15
+ * @default "__editor"
16
+ */
17
+ editorParam?: string;
18
+ /**
19
+ * Name of the cookie that keeps in-preview navigation in draft mode, or
20
+ * `false` to key the rewrite on {@link EditorRewriteOptions.editorParam}
21
+ * alone.
22
+ *
23
+ * A link clicked inside the editor iframe carries no `__editor=1`, so without
24
+ * a cookie the second page a user visits renders published content.
25
+ *
26
+ * The default is Next's own `__prerender_bypass`, which is not Avocado's to
27
+ * claim: anything else calling `draftMode().enable()` — Sanity's Presentation
28
+ * tool, Contentful's live preview — sets the same cookie, and every one of
29
+ * *their* preview requests then lands on Avocado's preview route.
30
+ *
31
+ * A host with no draft-mode convention of its own has no `__prerender_bypass`
32
+ * to inherit and must name and set a cookie itself; whatever it names goes
33
+ * here, so the rewrite triggers on the same cookie the host sets.
34
+ *
35
+ * @default "__prerender_bypass"
36
+ */
37
+ draftCookie?: string | false;
38
+ };
39
+ /** What the host should do with this request. */
40
+ export type EditorRewriteDecision =
41
+ /** Not an editor request. The caller's cue to run its own logic, not a decision to pass through. */
42
+ {
43
+ kind: "pass";
44
+ }
45
+ /** Serve the preview route instead, with these request headers added. */
46
+ | {
47
+ kind: "preview";
48
+ pathname: string;
49
+ headers: Record<string, string>;
50
+ };
51
+ export type EditorRewriteRequest = {
52
+ /**
53
+ * The path the host routes on. Next supplies `nextUrl.pathname`, which has
54
+ * `basePath` already stripped; a host without the concept passes the URL's
55
+ * own pathname. It matters because the preview prefix is prepended to this
56
+ * value, and prepending it to a path that still carries a basePath produces a
57
+ * route that exists nowhere.
58
+ */
59
+ pathname: string;
60
+ searchParams: URLSearchParams;
61
+ hasCookie: (name: string) => boolean;
62
+ };
63
+ /**
64
+ * Whether a request is the editor previewing the site, and where it should be
65
+ * served from if so.
66
+ *
67
+ * Pure, and the whole of the rewrite: `editorPreviewRewrite` in `./proxy.ts` is
68
+ * this plus `NextResponse.rewrite`, and any other host's middleware is this
69
+ * plus its own equivalent.
70
+ */
71
+ export declare function decideEditorRewrite(request: EditorRewriteRequest, options?: EditorRewriteOptions): EditorRewriteDecision;
@@ -0,0 +1,37 @@
1
+ import { DEFAULT_PREVIEW_ROUTE } from "./editor-matcher.js";
2
+ import { EDITOR_RENDER_HEADER } from "./editor-render.js";
3
+ /**
4
+ * Whether a request is the editor previewing the site, and where it should be
5
+ * served from if so.
6
+ *
7
+ * Pure, and the whole of the rewrite: `editorPreviewRewrite` in `./proxy.ts` is
8
+ * this plus `NextResponse.rewrite`, and any other host's middleware is this
9
+ * plus its own equivalent.
10
+ */
11
+ export function decideEditorRewrite(request, options) {
12
+ const previewRoute = options?.previewRoute ?? DEFAULT_PREVIEW_ROUTE;
13
+ const editorParam = options?.editorParam ?? "__editor";
14
+ const draftCookie = options?.draftCookie === undefined ? "__prerender_bypass" : options.draftCookie;
15
+ const isEditor = request.searchParams.get(editorParam) === "1";
16
+ const hasDraftCookie = draftCookie !== false && request.hasCookie(draftCookie);
17
+ if (!isEditor && !hasDraftCookie)
18
+ return { kind: "pass" };
19
+ /*
20
+ * The one fact a layout can get at.
21
+ *
22
+ * A page learns it is being previewed from `resolveEditorContext(searchParams)`.
23
+ * A layout receives no search params, and the root layout is exactly where a
24
+ * site mounts its consent banner, analytics and tag manager — so every
25
+ * integration loads all three into the editor iframe until somebody notices
26
+ * the pageviews. Stamping the rewritten request lets `isEditorRenderFrom()`
27
+ * answer in a layout without the site threading anything down to it.
28
+ *
29
+ * Set on the request, replacing any client-supplied header of the same name,
30
+ * so what a layout reads is this function's answer and not the caller's.
31
+ */
32
+ return {
33
+ kind: "preview",
34
+ pathname: `${previewRoute}${request.pathname}`,
35
+ headers: { [EDITOR_RENDER_HEADER]: "1" },
36
+ };
37
+ }
@@ -1,14 +1,18 @@
1
1
  import type { FieldTable, Primitives } from "./types.ts";
2
2
  export type RegisterOptions = {
3
3
  /**
4
- * The CMS pack, read only for its image prop naming.
4
+ * The CMS pack, read for its image prop naming and its row keys.
5
5
  *
6
6
  * Passing the same object `createLens` gets is what makes the panel and the
7
7
  * projection agree about what an image field's two props are called. Held
8
8
  * apart they are two declarations that must match with nothing checking that
9
9
  * they do — and the symptom is an asset picker that never appears.
10
+ *
11
+ * `rowIdKey` and `rowTypeKey` are the same argument one level down: the
12
+ * projection writes a row's identity and type under those keys, so the schema
13
+ * has to expect them there and the list has to discriminate on them.
10
14
  */
11
- primitives?: Pick<Primitives, "imageNaming">;
15
+ primitives?: Pick<Primitives, "imageNaming"> & Partial<Pick<Primitives, "rowIdKey" | "rowTypeKey">>;
12
16
  /**
13
17
  * `false` to leave Avocado's own built-in block types in the picker.
14
18
  *
@@ -18,12 +22,6 @@ export type RegisterOptions = {
18
22
  */
19
23
  narrowCatalogue?: boolean;
20
24
  };
21
- /**
22
- * Register every type in the table as an Avocado block type.
23
- *
24
- * Adding a field to a block becomes one line in one file: the schema, the
25
- * panel, the projection and the merge all read the same declaration.
26
- */
27
25
  export declare function registerFieldTable(table: FieldTable, options?: RegisterOptions): void;
28
26
  /**
29
27
  * The types a page body may hold, which is not every type in the table.
@@ -12,7 +12,7 @@
12
12
  * `createLens` returns a value; folding a side effect into a constructor makes
13
13
  * an object you cannot build twice, and makes the order of two imports matter.
14
14
  */
15
- import { registerBlock, declareBlockCatalogue, z } from "@avocadostudio-ai/shared";
15
+ import { registerBlock, declareBlockCatalogue, z, FIELD_KINDS } from "@avocadostudio-ai/shared";
16
16
  import { suffixNaming } from "./types.js";
17
17
  /**
18
18
  * A ProseMirror document, declared so the panel recognises it as one.
@@ -32,10 +32,70 @@ function humanise(key) {
32
32
  .replace(/([a-z\d])([A-Z])/g, "$1 $2")
33
33
  .replace(/^./, (c) => c.toUpperCase());
34
34
  }
35
- function schemaForField(spec) {
35
+ /**
36
+ * The row types a `list: { of }` can actually be given a shape for.
37
+ *
38
+ * A named type the table does not describe is skipped rather than emitted as an
39
+ * empty branch: Avocado cannot project or merge a row of it — `createLens`
40
+ * filters those rows out — so offering it in the panel would be an Add control
41
+ * for something no publish could store.
42
+ */
43
+ function rowTypesFor(of, ctx) {
44
+ if (!ctx.rowTypeKey)
45
+ return [];
46
+ return [...new Set(of)].filter((type) => ctx.table[type] !== undefined && !ctx.visiting.has(type));
47
+ }
48
+ /**
49
+ * One list row as an object schema, and `loose` is the load-bearing word.
50
+ *
51
+ * A row is a CMS document: it carries `component`, `_uid`, and every field the
52
+ * table chose not to declare. `z.object` strips what it does not know, and the
53
+ * ops engine's passthrough only covers a block's *top-level* props — so a
54
+ * strict row schema would silently delete the undeclared half of every row an
55
+ * edit touched.
56
+ */
57
+ function rowSchema(fields, ctx, pinned) {
58
+ const shape = { ...pinned };
59
+ // Declared, not merely tolerated: an undeclared prop present in the content is
60
+ // an `orphan_prop` to the coverage checkers, and a row id is one per row.
61
+ if (ctx.rowIdKey)
62
+ shape[ctx.rowIdKey] = z.string().optional();
63
+ Object.assign(shape, shapeForFields(fields, ctx));
64
+ return z.object(shape).loose();
65
+ }
66
+ /**
67
+ * A list's schema, in the one shape the manifest can resolve.
68
+ *
69
+ * It used to be `z.array(z.record(z.string(), z.any()))` for both forms, which
70
+ * reaches the manifest as an array of objects with no properties — so the
71
+ * derivation produced no item fields at all, `resolveManifestFieldMeta` dropped
72
+ * the list, and the panel drew neither rows nor an Add control. The metadata
73
+ * registered beside it was correct the whole time; the schema is what the panel
74
+ * sees, and it said nothing.
75
+ */
76
+ function listSchemaFor(spec, ctx) {
77
+ if ("itemFields" in spec)
78
+ return z.array(rowSchema(spec.itemFields, ctx, {})).optional();
79
+ const typeKey = ctx.rowTypeKey;
80
+ const types = rowTypesFor(spec.of, ctx);
81
+ // Nothing describable: back to the opaque array, which at least stores the
82
+ // rows untouched rather than rejecting them.
83
+ if (types.length === 0)
84
+ return z.array(z.record(z.string(), z.any())).optional();
85
+ const branches = types.map((type) => rowSchema(ctx.table[type].fields, { ...ctx, visiting: new Set([...ctx.visiting, type]) }, {
86
+ [typeKey]: z.literal(type)
87
+ }));
88
+ return z.array(z.discriminatedUnion(typeKey, branches)).optional();
89
+ }
90
+ function schemaForField(spec, ctx) {
36
91
  switch (spec.kind) {
37
92
  case "richtext":
38
93
  return richTextSchema().optional();
94
+ // Stored as the string the template renders. The document only exists
95
+ // while the panel has it open; `richTextSchema` would also admit a doc,
96
+ // and a doc written back is markup the site cannot render.
97
+ case "html":
98
+ return z.string().optional();
39
99
  case "boolean":
40
100
  return z.boolean().optional();
41
101
  case "number":
@@ -51,70 +111,184 @@ function schemaForField(spec) {
51
111
  case "imageList":
52
112
  return z.array(z.record(z.string(), z.any())).optional();
53
113
  case "list":
54
- return z.array(z.record(z.string(), z.any())).optional();
114
+ return listSchemaFor(spec, ctx);
55
115
  default:
56
116
  return z.string().optional();
57
117
  }
58
118
  }
119
+ /**
120
+ * A declared field set as an object shape — a block's props, or a row's.
121
+ *
122
+ * An image is two props and `key` may be neither of them: a pack that names
123
+ * them `heroUrl`/`heroAlt` must not also leave a bare `hero` in the schema, or
124
+ * the panel draws a third control that writes nowhere.
125
+ */
126
+ function shapeForFields(fields, ctx) {
127
+ const shape = {};
128
+ for (const [key, spec] of Object.entries(fields)) {
129
+ if (spec.kind === "image") {
130
+ shape[ctx.naming.url(key)] = z.string().optional();
131
+ shape[ctx.naming.alt(key)] = z.string().optional();
132
+ continue;
133
+ }
134
+ shape[key] = schemaForField(spec, ctx);
135
+ }
136
+ return shape;
137
+ }
59
138
  function metaForField(key, spec) {
60
139
  const meta = {
61
- kind: spec.kind === "stringList" || spec.kind === "imageList" || spec.kind === "list" ? "text" : spec.kind,
140
+ /*
141
+ * `list` alone is still `text` here, and it is not a loss: a list travels
142
+ * the separate `listFields` channel, which carries its row shape, and the
143
+ * kind on this side is never read. `stringList` and `imageList` have no
144
+ * such channel — flattening them to `text` was the whole of AW-01, because
145
+ * the panel then discarded the metadata as contradicting an array schema
146
+ * and drew nothing.
147
+ */
148
+ kind: spec.kind === "list" ? "text" : spec.kind,
62
149
  label: spec.label ?? humanise(key)
63
150
  };
64
151
  if (spec.internal)
65
152
  meta.internal = true;
153
+ if (spec.panelOnly)
154
+ meta.panelOnly = true;
66
155
  if (spec.kind === "text" && spec.multiline)
67
156
  meta.multiline = true;
68
157
  if (spec.kind === "text" && spec.inlineEditable !== undefined)
69
158
  meta.inlineEditable = spec.inlineEditable;
70
159
  if (spec.kind === "richtext" && spec.inline)
71
160
  meta.inline = true;
161
+ if (spec.kind === "html" && spec.inline)
162
+ meta.inline = true;
163
+ /*
164
+ * Not editable on the page unless the site says so.
165
+ *
166
+ * Every other kind defaults to inline-editable, and for this one that
167
+ * default corrupts: the overlay edits an element's text, and the value here
168
+ * is markup wrapped around that text. A person fixing a typo in the hero
169
+ * headline would write the typo back without the spans. The panel converts
170
+ * properly, so it is the whole of the UI until a site opts in.
171
+ */
172
+ if (spec.kind === "html")
173
+ meta.inlineEditable = spec.inlineEditable ?? false;
72
174
  if (spec.kind === "enum")
73
175
  meta.options = [...spec.options];
74
176
  if (spec.kind === "image" && spec.imageSpec)
75
177
  meta.imageSpec = spec.imageSpec;
76
178
  return meta;
77
179
  }
180
+ /** The same expansion as `shapeForFields`, as panel metadata rather than schema. */
181
+ function metaForFields(fields, ctx) {
182
+ const out = {};
183
+ if (ctx.rowIdKey)
184
+ out[ctx.rowIdKey] = { kind: "text", label: humanise(ctx.rowIdKey), internal: true };
185
+ for (const [key, spec] of Object.entries(fields)) {
186
+ if (spec.kind === "image") {
187
+ out[ctx.naming.url(key)] = metaForField(key, spec);
188
+ out[ctx.naming.alt(key)] = { kind: "imageAlt", label: `${spec.label ?? humanise(key)} alt` };
189
+ continue;
190
+ }
191
+ out[key] = metaForField(key, spec);
192
+ }
193
+ return out;
194
+ }
195
+ /**
196
+ * A list's panel metadata.
197
+ *
198
+ * Declared rather than left to the derivation because the derivation cannot
199
+ * know all of it: an enum's options never reach the JSON schema (the schema is
200
+ * a plain string on purpose, see above), and neither does a label the table
201
+ * chose. For the `of` form this carries the per-type field sets and the
202
+ * discriminator the panel narrows a row by — and that the ops engine reads to
203
+ * refuse a new row that names no type.
204
+ */
205
+ function listMetaFor(key, spec, ctx) {
206
+ const label = spec.label ?? humanise(key);
207
+ if ("itemFields" in spec)
208
+ return { label, itemFields: metaForFields(spec.itemFields, ctx) };
209
+ const types = rowTypesFor(spec.of, ctx);
210
+ if (types.length === 0)
211
+ return { label, itemFields: {} };
212
+ const itemFieldsByType = {};
213
+ const merged = {};
214
+ for (const type of types) {
215
+ const fields = metaForFields(ctx.table[type].fields, {
216
+ ...ctx,
217
+ visiting: new Set([...ctx.visiting, type])
218
+ });
219
+ itemFieldsByType[type] = fields;
220
+ for (const [fieldKey, meta] of Object.entries(fields))
221
+ if (!(fieldKey in merged))
222
+ merged[fieldKey] = meta;
223
+ }
224
+ return { label, itemFields: merged, discriminator: ctx.rowTypeKey, itemFieldsByType };
225
+ }
78
226
  /**
79
227
  * Register every type in the table as an Avocado block type.
80
228
  *
81
229
  * Adding a field to a block becomes one line in one file: the schema, the
82
230
  * panel, the projection and the merge all read the same declaration.
83
231
  */
232
+ /**
233
+ * Refuse a field this table cannot draw, rather than emitting the nearest kind.
234
+ *
235
+ * Every gap this file has produced was found by a person looking at a property
236
+ * panel: a control drawn for something it cannot edit, or — worse — no control
237
+ * at all, with the field valid in the schema and present in the codec. Nothing
238
+ * failed, so nothing was reported, and the integration looked finished.
239
+ *
240
+ * TypeScript already rejects an unknown `kind` for a table written in
241
+ * TypeScript. This is for the tables that are not: `registerFieldTable` is a
242
+ * published entry point, adopters call it from plain JavaScript and from JSON
243
+ * they generated, and a misspelled kind there is currently a field that quietly
244
+ * becomes a text input over a value a text input destroys.
245
+ *
246
+ * It checks the kind only. It cannot tell whether the panel's control for a
247
+ * kind is the *right* control — that is what `FIELD_KINDS` and the editor's own
248
+ * exhaustiveness check are for.
249
+ */
250
+ function assertDrawable(type, key, spec) {
251
+ const kind = spec.kind;
252
+ if (typeof kind === "string" && KNOWN_KINDS.includes(kind))
253
+ return;
254
+ throw new Error(`registerFieldTable: ${type}.${key} declares kind ${JSON.stringify(kind)}, which is not a field kind. `
255
+ + `Known kinds: ${KNOWN_KINDS.join(", ")}.`);
256
+ }
257
+ /**
258
+ * The kinds a table may declare.
259
+ *
260
+ * `FIELD_KINDS` is the panel's vocabulary; `list` is this layer's own, because
261
+ * a list is not a field — it travels the `listFields` channel instead and has
262
+ * no `FieldKind` spelling.
263
+ */
264
+ const KNOWN_KINDS = [...FIELD_KINDS, "list"];
84
265
  export function registerFieldTable(table, options) {
85
266
  const naming = options?.primitives?.imageNaming ?? suffixNaming("", "_alt");
267
+ const ctx = {
268
+ naming,
269
+ table,
270
+ rowIdKey: options?.primitives?.rowIdKey,
271
+ rowTypeKey: options?.primitives?.rowTypeKey,
272
+ visiting: new Set()
273
+ };
86
274
  for (const [type, blockSpec] of Object.entries(table)) {
87
- const shape = {};
275
+ const shape = shapeForFields(blockSpec.fields, { ...ctx, visiting: new Set([type]) });
88
276
  const fields = {};
89
277
  const listFields = {};
90
278
  for (const [key, spec] of Object.entries(blockSpec.fields)) {
91
- shape[key] = schemaForField(spec);
92
- if (spec.kind === "list" || spec.kind === "imageList") {
93
- const itemFields = {};
94
- if (spec.kind === "imageList") {
95
- itemFields.image = { kind: "image", label: "Image" };
96
- itemFields.alt = { kind: "imageAlt", label: "Alt text" };
97
- }
98
- else if ("itemFields" in spec) {
99
- for (const [itemKey, itemSpec] of Object.entries(spec.itemFields)) {
100
- if (itemSpec.kind === "image") {
101
- itemFields[naming.url(itemKey)] = metaForField(itemKey, itemSpec);
102
- itemFields[naming.alt(itemKey)] = { kind: "imageAlt", label: `${itemSpec.label ?? humanise(itemKey)} alt` };
103
- continue;
104
- }
105
- itemFields[itemKey] = metaForField(itemKey, itemSpec);
106
- }
107
- }
108
- listFields[key] = { label: spec.label ?? humanise(key), itemFields };
279
+ assertDrawable(type, key, spec);
280
+ if (spec.kind === "imageList") {
281
+ listFields[key] = {
282
+ label: spec.label ?? humanise(key),
283
+ itemFields: { image: { kind: "image", label: "Image" }, alt: { kind: "imageAlt", label: "Alt text" } }
284
+ };
285
+ continue;
286
+ }
287
+ if (spec.kind === "list") {
288
+ listFields[key] = listMetaFor(key, spec, { ...ctx, visiting: new Set([type]) });
109
289
  continue;
110
290
  }
111
291
  if (spec.kind === "image") {
112
- // An image is two props, and `key` may not be either of them: a pack
113
- // that names them `heroUrl`/`heroAlt` must not also leave a bare `hero`
114
- // in the schema, or the panel draws a third control that writes nowhere.
115
- delete shape[key];
116
- shape[naming.url(key)] = z.string().optional();
117
- shape[naming.alt(key)] = z.string().optional();
118
292
  fields[naming.url(key)] = metaForField(key, spec);
119
293
  fields[naming.alt(key)] = { kind: "imageAlt", label: `${spec.label ?? humanise(key)} alt` };
120
294
  continue;
@@ -11,6 +11,20 @@ type FieldCommon = {
11
11
  * keeping it out of the panel and out of the planner's view.
12
12
  */
13
13
  internal?: boolean;
14
+ /**
15
+ * A person edits it, but never on the page.
16
+ *
17
+ * A section anchor, a video poster, a layout switch: real content with no
18
+ * element that could carry an editable marker, so the property panel is the
19
+ * whole of its UI. `editableCoverage` reads this as "no marker expected",
20
+ * which is what makes the number reachable — without it a correct
21
+ * integration reports 88% and the integrator gating on coverage has to guess
22
+ * a threshold, at which point a real regression hides behind the guess.
23
+ *
24
+ * The weaker neighbour of `internal`: that one says the field has no
25
+ * audience, this one says it has no element.
26
+ */
27
+ panelOnly?: boolean;
14
28
  /**
15
29
  * `false` for a field with one value for every language.
16
30
  *
@@ -52,6 +66,22 @@ export type FieldSpec =
52
66
  kind: "richtext";
53
67
  inline?: boolean;
54
68
  })
69
+ /**
70
+ * A string of HTML — a prop the template renders with `set:html`,
71
+ * `dangerouslySetInnerHTML` or `v-html`.
72
+ *
73
+ * Stored as the string it is, edited as a document. Declaring one of these
74
+ * `richtext` is the mistake it exists to stop: `richtext` means a document,
75
+ * so the panel renders the markup literally and a person editing it writes
76
+ * broken markup back into the site's own source file. Tags outside the
77
+ * converter's vocabulary are preserved rather than dropped — see
78
+ * `@avocadostudio-ai/richtext`'s `fromHtml`.
79
+ */
80
+ | (FieldCommon & {
81
+ kind: "html";
82
+ inline?: boolean;
83
+ inlineEditable?: boolean;
84
+ })
55
85
  /** An image. Projects to a URL string plus a companion alt prop. */
56
86
  | (FieldCommon & {
57
87
  kind: "image";
@@ -99,6 +129,12 @@ export type FieldSpec =
99
129
  * `of` names the child types admitted, for a CMS where each row is its own
100
130
  * document and carries its own type; `itemFields` describes the row inline,
101
131
  * for a CMS where the rows are plain objects. Exactly one of the two.
132
+ *
133
+ * Every type named in `of` must itself be in the table, and the pack must
134
+ * supply a `rowTypeKey`: the list's schema is a discriminated union over that
135
+ * key, with one branch per named type. A type the table does not describe is
136
+ * left out of the union rather than admitted as an empty shape, because
137
+ * `createLens` cannot project or merge a row of it either.
102
138
  */
103
139
  | (FieldCommon & {
104
140
  kind: "list";
@@ -64,3 +64,24 @@ export type BuildPageMetadataOptions = {
64
64
  * image to put in it.
65
65
  */
66
66
  export declare function buildPageMetadata(page: Pick<PageDoc, "title" | "meta" | "blocks">, options?: BuildPageMetadataOptions): PageMetadata;
67
+ /**
68
+ * Render {@link PageMetadata} as the head tags it describes.
69
+ *
70
+ * Next consumes the object directly — a route exports `metadata` and the
71
+ * framework emits the tags — so nothing in this repo needed this until now.
72
+ * Every other host writes its own `<head>`, and an integrator left to do that
73
+ * by hand reproduces a subset: the title and description usually, Open Graph
74
+ * sometimes, `twitter:card` almost never, and the canonical link not at all.
75
+ * That is how `test:build` came to exist — three of those omissions had already
76
+ * shipped from the one route that built its head by hand.
77
+ *
78
+ * Returns a string because the alternative is a shape per templating language.
79
+ * The values are attribute-escaped; the caller inserts it as raw HTML
80
+ * (`set:html` in Astro, `v-html` in Vue, `{@html}` in Svelte).
81
+ *
82
+ * `og:image` and `twitter:image` are emitted exactly as given. A relative path
83
+ * is legal in the document but most crawlers will not resolve it, so pass
84
+ * absolute URLs — the same rule that applies to `buildPageMetadata`'s
85
+ * `canonical`.
86
+ */
87
+ export declare function renderPageMetadata(metadata: PageMetadata): string;
@@ -108,3 +108,67 @@ export function buildPageMetadata(page, options = {}) {
108
108
  ...(options.canonical ? { alternates: { canonical: options.canonical } } : {}),
109
109
  };
110
110
  }
111
+ /** `&`, `<`, `>` and `"` inside an attribute value, which a title routinely contains. */
112
+ function escapeAttribute(value) {
113
+ return value
114
+ .replace(/&/g, "&amp;")
115
+ .replace(/</g, "&lt;")
116
+ .replace(/>/g, "&gt;")
117
+ .replace(/"/g, "&quot;");
118
+ }
119
+ /**
120
+ * Render {@link PageMetadata} as the head tags it describes.
121
+ *
122
+ * Next consumes the object directly — a route exports `metadata` and the
123
+ * framework emits the tags — so nothing in this repo needed this until now.
124
+ * Every other host writes its own `<head>`, and an integrator left to do that
125
+ * by hand reproduces a subset: the title and description usually, Open Graph
126
+ * sometimes, `twitter:card` almost never, and the canonical link not at all.
127
+ * That is how `test:build` came to exist — three of those omissions had already
128
+ * shipped from the one route that built its head by hand.
129
+ *
130
+ * Returns a string because the alternative is a shape per templating language.
131
+ * The values are attribute-escaped; the caller inserts it as raw HTML
132
+ * (`set:html` in Astro, `v-html` in Vue, `{@html}` in Svelte).
133
+ *
134
+ * `og:image` and `twitter:image` are emitted exactly as given. A relative path
135
+ * is legal in the document but most crawlers will not resolve it, so pass
136
+ * absolute URLs — the same rule that applies to `buildPageMetadata`'s
137
+ * `canonical`.
138
+ */
139
+ export function renderPageMetadata(metadata) {
140
+ const tags = [];
141
+ const meta = (attr, key, value) => {
142
+ if (value)
143
+ tags.push(`<meta ${attr}="${escapeAttribute(key)}" content="${escapeAttribute(value)}" />`);
144
+ };
145
+ if (metadata.title)
146
+ tags.push(`<title>${escapeAttribute(metadata.title)}</title>`);
147
+ meta("name", "description", metadata.description);
148
+ if (metadata.alternates?.canonical) {
149
+ tags.push(`<link rel="canonical" href="${escapeAttribute(metadata.alternates.canonical)}" />`);
150
+ }
151
+ const og = metadata.openGraph;
152
+ if (og) {
153
+ meta("property", "og:title", og.title);
154
+ meta("property", "og:description", og.description);
155
+ meta("property", "og:type", og.type);
156
+ meta("property", "og:site_name", og.siteName);
157
+ meta("property", "og:url", og.url);
158
+ for (const image of og.images ?? [])
159
+ meta("property", "og:image", image);
160
+ }
161
+ const twitter = metadata.twitter;
162
+ if (twitter) {
163
+ meta("name", "twitter:card", twitter.card);
164
+ meta("name", "twitter:title", twitter.title);
165
+ meta("name", "twitter:description", twitter.description);
166
+ for (const image of twitter.images ?? [])
167
+ meta("name", "twitter:image", image);
168
+ }
169
+ if (metadata.robots) {
170
+ const directives = [metadata.robots.index ? "index" : "noindex", metadata.robots.follow ? "follow" : "nofollow"];
171
+ meta("name", "robots", directives.join(", "));
172
+ }
173
+ return tags.join("\n");
174
+ }
package/dist/proxy.d.ts CHANGED
@@ -1,37 +1,16 @@
1
1
  import { NextResponse, type NextRequest } from "next/server";
2
+ import { type EditorRewriteOptions } from "./editor-rewrite-core.ts";
2
3
  export { DEFAULT_PREVIEW_ROUTE, buildEditorMatcher } from "./editor-matcher.ts";
4
+ export { decideEditorRewrite } from "./editor-rewrite-core.ts";
5
+ export type { EditorRewriteDecision, EditorRewriteOptions } from "./editor-rewrite-core.ts";
3
6
  /**
4
7
  * Options for the editor proxy factory.
8
+ *
9
+ * `previewRoute`, `editorParam` and `draftCookie` are documented on
10
+ * {@link EditorRewriteOptions}, which every host shares; `trailingSlash` below
11
+ * is Next's alone.
5
12
  */
6
- export type EditorProxyOptions = {
7
- /**
8
- * The internal route prefix that the dynamic editor/draft page lives under.
9
- * @default "/preview-draft"
10
- */
11
- previewRoute?: string;
12
- /**
13
- * Query parameter that signals an editor iframe request.
14
- * @default "__editor"
15
- */
16
- editorParam?: string;
17
- /**
18
- * Name of the Next.js draft-mode bypass cookie, or `false` to ignore cookies
19
- * entirely and key the rewrite on {@link EditorProxyOptions.editorParam} alone.
20
- *
21
- * The cookie is how navigation *inside* the editor iframe stays in draft mode:
22
- * a link click carries no `__editor=1`, so without it the second page a user
23
- * visits renders published content.
24
- *
25
- * But the cookie is Next's own, and it is not Avocado's to claim. Any other
26
- * feature that calls `draftMode().enable()` sets the same
27
- * `__prerender_bypass` — Sanity's Presentation tool and Contentful's live
28
- * preview both do — and every one of *their* preview requests then lands on
29
- * Avocado's preview route. On a site that already had Draft Mode before it had
30
- * Avocado, pass `draftCookie: false` and the two stop fighting over it.
31
- *
32
- * @default "__prerender_bypass"
33
- */
34
- draftCookie?: string | false;
13
+ export type EditorProxyOptions = EditorRewriteOptions & {
35
14
  /**
36
15
  * Set this to `true` on a site whose `next.config` sets `trailingSlash: true`.
37
16
  *
@@ -118,4 +97,4 @@ export declare function trailingSlashRedirect(request: NextRequest): NextRespons
118
97
  * The half of `createEditorProxy` a site with its own middleware needs; see
119
98
  * {@link trailingSlashRedirect} for the composition.
120
99
  */
121
- export declare function editorPreviewRewrite(request: NextRequest, options?: Pick<EditorProxyOptions, "previewRoute" | "editorParam" | "draftCookie">): NextResponse | null;
100
+ export declare function editorPreviewRewrite(request: NextRequest, options?: EditorRewriteOptions): NextResponse | null;