@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
@@ -0,0 +1,43 @@
1
+ declare const EDITOR_RENDER_HEADER = "x-avocado-editor-render";
2
+ export { EDITOR_RENDER_HEADER };
3
+ /**
4
+ * The pure half, so the decision is testable without a Next request.
5
+ *
6
+ * `getHeader` is whatever `headers()` gives you; `isDraftMode` is
7
+ * `draftMode().isEnabled`.
8
+ */
9
+ export declare function isEditorRenderFrom(getHeader: (name: string) => string | null | undefined, isDraftMode: boolean): boolean;
10
+ /**
11
+ * `true` when this render is being drawn inside the Avocado editor.
12
+ *
13
+ * Call it in the root layout and gate everything a preview should not carry:
14
+ *
15
+ * ```tsx
16
+ * export default async function RootLayout({ children }) {
17
+ * const inEditor = await isEditorRender()
18
+ * return (
19
+ * <html lang="de">
20
+ * <body>
21
+ * {children}
22
+ * {!inEditor && <CookieConsent />}
23
+ * {!inEditor && <Analytics />}
24
+ * </body>
25
+ * </html>
26
+ * )
27
+ * }
28
+ * ```
29
+ *
30
+ * **It answers a rendering question, not an authorization one.** What may see
31
+ * unpublished content is decided by `resolveEditorContext`, which requires draft
32
+ * mode or a valid secret; this only decides whether to mount third-party
33
+ * scripts. The header can be sent by anyone, and the worst a forged one does is
34
+ * opt that visitor out of the site's own analytics and consent banner — which
35
+ * is self-consistent, because the tracking those scripts set up does not happen
36
+ * either. Never gate content or credentials on it.
37
+ *
38
+ * Reading `headers()` opts the calling segment into dynamic rendering. That is
39
+ * already true of any layout that reads cookies for a session, and it is the
40
+ * price of a layout knowing anything about the request at all — but it is worth
41
+ * knowing before adding the call to a fully static layout.
42
+ */
43
+ export declare function isEditorRender(): Promise<boolean>;
@@ -0,0 +1,71 @@
1
+ /*
2
+ * Whether this render is the editor's preview, answerable from a layout.
3
+ *
4
+ * `resolveEditorContext()` already tells a *page* it is being previewed, and
5
+ * that is the wrong half of the tree for the thing sites keep getting wrong.
6
+ * Consent banners, analytics, tag managers and other visual editors' bridges
7
+ * are mounted in the root layout, and a Next layout receives no `searchParams`
8
+ * — so the natural implementation renders the real layout and brings all of
9
+ * them into the iframe.
10
+ *
11
+ * Measured on one integration, per preview render: three uncaught cross-origin
12
+ * errors from the consent platform reaching for `parent.location`, a cookie
13
+ * banner covering the page being edited, and a `page_view` written into the
14
+ * site's own analytics for every block an editor clicked through — twenty
15
+ * blocks, twenty pageviews, attributed to whoever was editing. Nobody would
16
+ * choose that, and nobody was asked.
17
+ *
18
+ * The signal is a request header the proxy sets when it rewrites to the preview
19
+ * route, because that is the one thing available to a layout regardless of
20
+ * where the site put its own state. Draft mode is checked too, for a site whose
21
+ * preview reaches the route some other way.
22
+ */
23
+ const EDITOR_RENDER_HEADER = "x-avocado-editor-render";
24
+ export { EDITOR_RENDER_HEADER };
25
+ /**
26
+ * The pure half, so the decision is testable without a Next request.
27
+ *
28
+ * `getHeader` is whatever `headers()` gives you; `isDraftMode` is
29
+ * `draftMode().isEnabled`.
30
+ */
31
+ export function isEditorRenderFrom(getHeader, isDraftMode) {
32
+ return getHeader(EDITOR_RENDER_HEADER) === "1" || isDraftMode;
33
+ }
34
+ /**
35
+ * `true` when this render is being drawn inside the Avocado editor.
36
+ *
37
+ * Call it in the root layout and gate everything a preview should not carry:
38
+ *
39
+ * ```tsx
40
+ * export default async function RootLayout({ children }) {
41
+ * const inEditor = await isEditorRender()
42
+ * return (
43
+ * <html lang="de">
44
+ * <body>
45
+ * {children}
46
+ * {!inEditor && <CookieConsent />}
47
+ * {!inEditor && <Analytics />}
48
+ * </body>
49
+ * </html>
50
+ * )
51
+ * }
52
+ * ```
53
+ *
54
+ * **It answers a rendering question, not an authorization one.** What may see
55
+ * unpublished content is decided by `resolveEditorContext`, which requires draft
56
+ * mode or a valid secret; this only decides whether to mount third-party
57
+ * scripts. The header can be sent by anyone, and the worst a forged one does is
58
+ * opt that visitor out of the site's own analytics and consent banner — which
59
+ * is self-consistent, because the tracking those scripts set up does not happen
60
+ * either. Never gate content or credentials on it.
61
+ *
62
+ * Reading `headers()` opts the calling segment into dynamic rendering. That is
63
+ * already true of any layout that reads cookies for a session, and it is the
64
+ * price of a layout knowing anything about the request at all — but it is worth
65
+ * knowing before adding the call to a fully static layout.
66
+ */
67
+ export async function isEditorRender() {
68
+ const { headers, draftMode } = await import("next/headers");
69
+ const [headerList, draft] = await Promise.all([headers(), draftMode()]);
70
+ return isEditorRenderFrom((name) => headerList.get(name), draft.isEnabled);
71
+ }
@@ -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
+ }
@@ -0,0 +1,48 @@
1
+ import type { FieldTable, LocaleLens, Primitives } from "./types.ts";
2
+ export type LensOptions<L extends string = string> = {
3
+ table: FieldTable;
4
+ locale: LocaleLens<L>;
5
+ primitives: Primitives;
6
+ };
7
+ /** Where a write was refused or degraded, and why. */
8
+ export type MergeWarning = {
9
+ /** `/preise > hero_section > title` — where it happened. */
10
+ where: string;
11
+ reason: string;
12
+ };
13
+ export type MergeResult<D> = {
14
+ doc: D;
15
+ changed: boolean;
16
+ warnings: MergeWarning[];
17
+ };
18
+ export type ProjectOptions = {
19
+ /**
20
+ * `true` when the document already came out of the CMS with a language
21
+ * applied, so the bare keys hold the right values and the localised slots
22
+ * must not be consulted.
23
+ *
24
+ * Delivery APIs resolve; management APIs do not. Getting this wrong is
25
+ * silent in one direction — a resolved document read as raw finds nothing in
26
+ * the suffixed keys and falls back to the same values it already had.
27
+ */
28
+ resolved?: boolean;
29
+ };
30
+ type Doc = Record<string, unknown>;
31
+ declare function humanise(key: string): string;
32
+ export declare function createLens<L extends string = string>(options: LensOptions<L>): {
33
+ project: (doc: Doc, type: string, lang: L, opts?: ProjectOptions) => Doc;
34
+ merge: (source: Doc, props: Doc, type: string, lang: L, where?: string, opts?: {
35
+ checkLang?: L;
36
+ }) => MergeResult<Doc>;
37
+ roundTrip: (doc: Doc, type: string, lang: L, opts?: ProjectOptions) => {
38
+ clean: boolean;
39
+ fields: string[];
40
+ warnings: MergeWarning[];
41
+ };
42
+ table: FieldTable;
43
+ locale: LocaleLens<L>;
44
+ primitives: Primitives;
45
+ label: typeof humanise;
46
+ };
47
+ export type Lens<L extends string = string> = ReturnType<typeof createLens<L>>;
48
+ export {};
@@ -0,0 +1,349 @@
1
+ /*
2
+ * The derivation: one field table, read as props and written back.
3
+ *
4
+ * `project` is the getter and `merge` is the setter, and the discipline of the
5
+ * whole thing is that they are inverses — a projection fed back through the
6
+ * merge must produce no change at all. That is not a property you reason out
7
+ * from the shapes; it is one you run against real content, which is why
8
+ * `roundTrip` below is part of the API rather than a script each integration
9
+ * writes for itself.
10
+ *
11
+ * Two rules govern every write, and both came out of running a projection
12
+ * through its own inverse over a real dataset rather than out of thinking about
13
+ * it:
14
+ *
15
+ * 1. **Unchanged means untouched.** A CMS with per-language fallback resolves
16
+ * a missing translation to the default language, which is correct on screen
17
+ * and a lie in storage. Merging a projection back wholesale materialises
18
+ * every one of those fallbacks as a real translation — dozens per publish,
19
+ * each identical to what the page already showed, so nothing looks wrong.
20
+ * Compare against the *projection* of the source, never against a rebuilt
21
+ * object: key order and synthetic row ids both make a string comparison
22
+ * report changes nobody made.
23
+ * 2. **Empty means absent.** Writing `""` into a slot that had no value for
24
+ * this language is a no-op on screen and a diff in the document.
25
+ *
26
+ * A field the table does not declare is invisible to Avocado and untouched by
27
+ * it: it does not reach the planner, does not appear in the panel, and survives
28
+ * every publish, because the merge patches the source document rather than
29
+ * replacing it. That is the lever for scope — declare what an editor should be
30
+ * able to change and leave the layout and behaviour switches out.
31
+ */
32
+ import { SCALAR_CODECS } from "./scalar-codecs.js";
33
+ function isRecord(v) {
34
+ return v != null && typeof v === "object" && !Array.isArray(v);
35
+ }
36
+ function humanise(key) {
37
+ return key
38
+ .replace(/[_-]+/g, " ")
39
+ .replace(/([a-z\d])([A-Z])/g, "$1 $2")
40
+ .replace(/^./, (c) => c.toUpperCase());
41
+ }
42
+ export function createLens(options) {
43
+ const { table, locale, primitives } = options;
44
+ const codecs = { ...SCALAR_CODECS, ...primitives.codecs };
45
+ function codecFor(spec) {
46
+ if (spec.kind === "list")
47
+ return listCodec;
48
+ const codec = codecs[spec.kind];
49
+ if (!codec) {
50
+ throw new Error(`No codec for field kind "${spec.kind}". The CMS primitive pack must supply one, ` +
51
+ `or the table should use a kind it does supply.`);
52
+ }
53
+ return codec;
54
+ }
55
+ function at(rec, path) {
56
+ if (path.length === 1)
57
+ return rec[path[0]];
58
+ const container = rec[path[0]];
59
+ return isRecord(container) ? container[path[1]] : undefined;
60
+ }
61
+ /**
62
+ * Where this language's value lives.
63
+ *
64
+ * Three cases, and the third is the one that cost a rewrite. A localised
65
+ * field is wherever `locale.path` says — for *every* language including the
66
+ * default, because a CMS that localises into an object under the key has no
67
+ * bare value at all, and short-circuiting the default to `doc[key]` reads
68
+ * that object back as `[object Object]`.
69
+ *
70
+ * A field with one value for every language is at the bare key, which is
71
+ * **not** the same as the default language's path: those coincide in a CMS
72
+ * that localises into a suffixed sibling and diverge in one that localises
73
+ * into an object. A field the CMS has stopped marking translatable is the
74
+ * same case — its old translations are still in the document and the delivery
75
+ * API ignores them, so this must too, or the publisher believes a field
76
+ * changed on every publish.
77
+ */
78
+ function slot(key, lang, spec, type) {
79
+ if (spec.localized === false)
80
+ return [key];
81
+ if (locale.translatable && !locale.translatable(type, key, lang))
82
+ return [key];
83
+ return locale.path(key, lang);
84
+ }
85
+ function isBlank(v) {
86
+ return v === undefined || v === null || v === "";
87
+ }
88
+ /**
89
+ * Read `key`'s value for `lang`, falling back to the default language.
90
+ *
91
+ * The default language is the fallback when the localised slot is absent *or*
92
+ * empty — an empty string in a translation slot is how a CMS spells "not
93
+ * translated yet", and reading it literally blanks the page.
94
+ */
95
+ function readField(rec, key, lang, spec, type) {
96
+ const target = slot(key, lang, spec, type);
97
+ const value = at(rec, target);
98
+ if (!isBlank(value))
99
+ return value;
100
+ const fallbackTarget = locale.path(key, locale.default);
101
+ if (target.length === fallbackTarget.length && target.every((seg, i) => seg === fallbackTarget[i]))
102
+ return value;
103
+ if (spec.localized === false)
104
+ return value;
105
+ const fallback = at(rec, fallbackTarget);
106
+ return isBlank(fallback) ? value : fallback;
107
+ }
108
+ function writeField(rec, key, lang, spec, type, value) {
109
+ const target = slot(key, lang, spec, type);
110
+ if (target.length === 1) {
111
+ rec[target[0]] = value;
112
+ return;
113
+ }
114
+ const container = isRecord(rec[target[0]]) ? { ...rec[target[0]] } : {};
115
+ container[target[1]] = value;
116
+ rec[target[0]] = container;
117
+ }
118
+ function specFor(type) {
119
+ return table[type];
120
+ }
121
+ /**
122
+ * A list of child rows, matched by identity rather than by position.
123
+ *
124
+ * Position is the wrong key and it fails quietly: reorder a list, or delete
125
+ * the second of five rows, and every row after the change is merged onto the
126
+ * wrong source — the edit lands, the page looks plausible, and four rows have
127
+ * quietly swapped their untouched fields. Matching on the CMS's own row id is
128
+ * what keeps a list edit a merge.
129
+ *
130
+ * **A row Avocado never saw stays where it was.** The editor's order is taken
131
+ * for the rows it knows; anything else is re-inserted at its original index.
132
+ * A list can hold types the table does not declare, and dropping them is how
133
+ * an integration deletes content it was never asked about.
134
+ *
135
+ * Built here rather than in a primitive pack because the walk is the same
136
+ * everywhere — what differs is the key a row carries its identity and type
137
+ * under, and both are already declared.
138
+ */
139
+ const listCodec = {
140
+ project(key, raw, ctx) {
141
+ const rows = Array.isArray(raw) ? raw : [];
142
+ const spec = ctx.spec;
143
+ if (spec.kind !== "list")
144
+ return { [key]: [] };
145
+ if ("itemFields" in spec) {
146
+ // Rows described inline: no type of their own, so project field by field.
147
+ const inline = spec.itemFields;
148
+ return {
149
+ [key]: rows.filter(isRecord).map((row) => {
150
+ const out = {};
151
+ if (primitives.rowIdKey in row)
152
+ out[primitives.rowIdKey] = row[primitives.rowIdKey];
153
+ for (const [itemKey, itemSpec] of Object.entries(inline)) {
154
+ const itemCtx = { spec: itemSpec, lang: ctx.lang, type: ctx.type, where: `${ctx.where}[] > ${itemKey}` };
155
+ // A row's fields are localised exactly as a block's are — the
156
+ // per-locale container sits on the row, not on the page.
157
+ const raw = readField(row, itemKey, ctx.lang, itemSpec, ctx.type);
158
+ Object.assign(out, codecFor(itemSpec).project(itemKey, raw, itemCtx));
159
+ }
160
+ return out;
161
+ })
162
+ };
163
+ }
164
+ const typeKey = primitives.rowTypeKey;
165
+ return {
166
+ [key]: rows
167
+ .filter((row) => isRecord(row) && Boolean(typeKey) && specFor(String(row[typeKey])) !== undefined)
168
+ .map((row) => ({
169
+ [typeKey]: row[typeKey],
170
+ [primitives.rowIdKey]: row[primitives.rowIdKey],
171
+ ...project(row, String(row[typeKey]), ctx.lang)
172
+ }))
173
+ };
174
+ },
175
+ merge(key, props, before, ctx) {
176
+ if (!(key in props))
177
+ return null;
178
+ const spec = ctx.spec;
179
+ if (spec.kind !== "list")
180
+ return null;
181
+ const items = Array.isArray(props[key]) ? props[key].filter(isRecord) : [];
182
+ const sourceRows = (Array.isArray(before) ? before : []).filter(isRecord);
183
+ const byId = new Map(sourceRows.map((row) => [row[primitives.rowIdKey], row]));
184
+ const seen = new Set();
185
+ const merged = [];
186
+ for (const item of items) {
187
+ const rowId = item[primitives.rowIdKey];
188
+ const source = rowId === undefined ? undefined : byId.get(rowId);
189
+ if (source)
190
+ seen.add(rowId);
191
+ if ("itemFields" in spec) {
192
+ const base = source ? { ...source } : { [primitives.rowIdKey]: primitives.newRowId() };
193
+ let rowChanged = !source;
194
+ for (const [itemKey, itemSpec] of Object.entries(spec.itemFields)) {
195
+ const itemCtx = { spec: itemSpec, lang: ctx.lang, type: ctx.type, where: `${ctx.where}[] > ${itemKey}` };
196
+ const before = source ? readField(source, itemKey, ctx.lang, itemSpec, ctx.type) : undefined;
197
+ const outcome = codecFor(itemSpec).merge(itemKey, item, before, itemCtx);
198
+ if (!outcome || "warning" in outcome)
199
+ continue;
200
+ writeField(base, itemKey, ctx.lang, itemSpec, ctx.type, outcome.value);
201
+ rowChanged = true;
202
+ }
203
+ void rowChanged;
204
+ merged.push(base);
205
+ continue;
206
+ }
207
+ const typeKey = primitives.rowTypeKey;
208
+ const rowType = String(item[typeKey] ?? (source ? source[typeKey] : ""));
209
+ if (!rowType || !specFor(rowType)) {
210
+ /*
211
+ * A row that names no type is a row no CMS storing rows as documents
212
+ * can construct, and guessing one writes content nobody asked for.
213
+ * The op validator rejects this at the moment of the mistake; a row
214
+ * that still reaches here is dropped from the merge rather than
215
+ * fabricated, and the source list keeps whatever it had.
216
+ */
217
+ if (source)
218
+ merged.push(source);
219
+ continue;
220
+ }
221
+ const result = merge(source ?? { [typeKey]: rowType, [primitives.rowIdKey]: primitives.newRowId() }, item, rowType, ctx.lang, `${ctx.where}[]`);
222
+ merged.push(result.doc);
223
+ }
224
+ // Rows the table does not describe keep their place rather than vanishing.
225
+ for (const [index, row] of sourceRows.entries()) {
226
+ const rowId = row[primitives.rowIdKey];
227
+ if (seen.has(rowId))
228
+ continue;
229
+ const typeKey = primitives.rowTypeKey;
230
+ const described = typeKey ? specFor(String(row[typeKey])) !== undefined : true;
231
+ if (described)
232
+ continue;
233
+ merged.splice(Math.min(index, merged.length), 0, row);
234
+ }
235
+ /*
236
+ * Compared structurally, and deliberately not by counting rows.
237
+ *
238
+ * The projection omits every row whose type the table does not describe,
239
+ * so a list of five that Avocado can edit two of arrives back with two —
240
+ * and a length check reads that as three deletions on a list nobody
241
+ * touched. Comparing the rebuilt list to the source catches a real
242
+ * reorder, add and delete, and says nothing about a list that only looks
243
+ * shorter from Avocado's side.
244
+ */
245
+ return JSON.stringify(merged) === JSON.stringify(sourceRows) ? null : { value: merged };
246
+ }
247
+ };
248
+ /**
249
+ * One CMS document as Avocado props, in one language.
250
+ *
251
+ * A type the table does not declare projects to nothing, rather than to a
252
+ * partial guess — an undeclared type is one nobody said Avocado may edit.
253
+ */
254
+ function project(doc, type, lang, opts) {
255
+ const blockSpec = specFor(type);
256
+ if (!blockSpec)
257
+ return {};
258
+ const props = {};
259
+ for (const [key, spec] of Object.entries(blockSpec.fields)) {
260
+ const raw = opts?.resolved ? doc[key] : readField(doc, key, lang, spec, type);
261
+ const ctx = { spec, lang, type, where: `${type} > ${key}` };
262
+ Object.assign(props, codecFor(spec).project(key, raw, ctx));
263
+ }
264
+ return props;
265
+ }
266
+ /**
267
+ * Edited props back onto their source document, for one language.
268
+ *
269
+ * The source is the live CMS document, not a snapshot Avocado holds, so every
270
+ * field the table does not declare survives untouched by construction — and
271
+ * that is the difference between a publish that patches and a publish that
272
+ * silently deletes forty fields this integration never learned about.
273
+ */
274
+ function merge(source, props, type, lang, where = type, opts) {
275
+ const blockSpec = specFor(type);
276
+ if (!blockSpec)
277
+ return { doc: source, changed: false, warnings: [] };
278
+ /*
279
+ * The language the *page* is in, which is not always the language being
280
+ * written: a live preview merges into the bare keys of an already-resolved
281
+ * document — writing as if it were the default language — while the page it
282
+ * draws may be another. Translatability is a question about the page;
283
+ * where to store the value is a question about the write. Conflating them
284
+ * lets the preview show an edit the publish then refuses, which is worse
285
+ * than refusing it in both places.
286
+ */
287
+ const checkLang = opts?.checkLang ?? lang;
288
+ const out = { ...source };
289
+ const warnings = [];
290
+ let changed = false;
291
+ for (const [key, spec] of Object.entries(blockSpec.fields)) {
292
+ const before = readField(source, key, lang, spec, type);
293
+ const ctx = { spec, lang, type, where: `${where} > ${key}` };
294
+ const outcome = codecFor(spec).merge(key, props, before, ctx);
295
+ if (!outcome)
296
+ continue;
297
+ if ("warning" in outcome) {
298
+ warnings.push({ where: ctx.where, reason: outcome.warning });
299
+ continue;
300
+ }
301
+ /*
302
+ * Asked here, about a real change, and deliberately not earlier. The
303
+ * first version of this re-projected the source and compared JSON before
304
+ * deciding — and reported fields nobody had touched as refused
305
+ * translations, because a rebuilt object's key order does not match the
306
+ * source's and because list rows carry a synthetic id. Both are
307
+ * invisible differences that a string comparison calls a change.
308
+ */
309
+ if (checkLang !== locale.default &&
310
+ spec.localized !== false &&
311
+ locale.translatable &&
312
+ !locale.translatable(type, key, checkLang)) {
313
+ warnings.push({
314
+ where: ctx.where,
315
+ reason: `"${key}" is not translatable on ${type} — it has one value for every language, ` +
316
+ `so this ${String(checkLang).toUpperCase()} edit cannot be stored. ` +
317
+ `Edit it on the ${String(locale.default).toUpperCase()} page.`
318
+ });
319
+ continue;
320
+ }
321
+ writeField(out, key, lang, spec, type, outcome.value);
322
+ changed = true;
323
+ }
324
+ return { doc: out, changed, warnings };
325
+ }
326
+ /**
327
+ * Project, merge the projection straight back, and report what moved.
328
+ *
329
+ * Nothing should. A non-empty result is the signal that a codec is not the
330
+ * inverse of itself for some value in this dataset — the class of defect that
331
+ * is invisible in the editor, harmless in the preview, and shows up as a
332
+ * publish wanting to rewrite documents nobody opened.
333
+ *
334
+ * Run it over real content, not fixtures. Every rule in this file exists
335
+ * because a fixture round-tripped and a dataset did not.
336
+ */
337
+ function roundTrip(doc, type, lang, opts) {
338
+ const props = project(doc, type, lang, opts);
339
+ const result = merge(doc, props, type, lang);
340
+ const fields = Object.keys(specFor(type)?.fields ?? {}).filter((key) => {
341
+ const spec = specFor(type).fields[key];
342
+ const before = readField(doc, key, lang, spec, type);
343
+ const after = readField(result.doc, key, lang, spec, type);
344
+ return JSON.stringify(before ?? null) !== JSON.stringify(after ?? null);
345
+ });
346
+ return { clean: !result.changed && fields.length === 0, fields, warnings: result.warnings };
347
+ }
348
+ return { project, merge, roundTrip, table, locale, primitives, label: humanise };
349
+ }
@@ -0,0 +1,7 @@
1
+ export { createLens } from "./create-lens.ts";
2
+ export type { Lens, LensOptions, MergeResult, MergeWarning, ProjectOptions } from "./create-lens.ts";
3
+ export { registerFieldTable, topLevelTypes } from "./register.ts";
4
+ export type { RegisterOptions } from "./register.ts";
5
+ export { suffixNaming } from "./types.ts";
6
+ export type { BlockSpec, CodecContext, FieldCodec, FieldSpec, FieldTable, ImageNaming, LocaleLens, MergeOutcome, Primitives } from "./types.ts";
7
+ export { changed } from "./scalar-codecs.ts";
@@ -0,0 +1,25 @@
1
+ /*
2
+ * The field table, and the four things derived from it.
3
+ *
4
+ * ```ts
5
+ * import { createLens, registerFieldTable } from "@avocadostudio-ai/site-sdk/lens"
6
+ * import { storyblokPrimitives } from "@avocadostudio-ai/site-sdk/lens/storyblok"
7
+ *
8
+ * const primitives = storyblokPrimitives()
9
+ *
10
+ * registerFieldTable(TABLE, { primitives }) // schemas + panel metadata
11
+ * export const lens = createLens({ // projection + merge
12
+ * table: TABLE,
13
+ * locale: { default: "de", languages: ["de", "en", "fr"], path: (k, l) => [`${k}__i18n__${l}`] },
14
+ * primitives,
15
+ * })
16
+ * ```
17
+ *
18
+ * The two are separate on purpose: registration writes to a global registry and
19
+ * `createLens` returns a value, so folding one into the other would make an
20
+ * object you cannot build twice and make the order of two imports matter.
21
+ */
22
+ export { createLens } from "./create-lens.js";
23
+ export { registerFieldTable, topLevelTypes } from "./register.js";
24
+ export { suffixNaming } from "./types.js";
25
+ export { changed } from "./scalar-codecs.js";