@avocadostudio-ai/site-sdk 0.5.1 → 0.7.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 (41) hide show
  1. package/dist/cli/register-notice.d.ts +20 -0
  2. package/dist/cli/register-notice.js +34 -0
  3. package/dist/cli/register-notice.test.d.ts +1 -0
  4. package/dist/cli/register-notice.test.js +21 -0
  5. package/dist/cli/register.js +7 -2
  6. package/dist/draft.d.ts +1 -0
  7. package/dist/draft.js +3 -0
  8. package/dist/editor-render.d.ts +43 -0
  9. package/dist/editor-render.js +71 -0
  10. package/dist/editor-render.test.d.ts +1 -0
  11. package/dist/editor-render.test.js +44 -0
  12. package/dist/editor.d.ts +1 -1
  13. package/dist/editor.js +1 -1
  14. package/dist/lens/create-lens.d.ts +48 -0
  15. package/dist/lens/create-lens.js +349 -0
  16. package/dist/lens/index.d.ts +7 -0
  17. package/dist/lens/index.js +25 -0
  18. package/dist/lens/lens.test.d.ts +1 -0
  19. package/dist/lens/lens.test.js +221 -0
  20. package/dist/lens/register.d.ts +34 -0
  21. package/dist/lens/register.js +148 -0
  22. package/dist/lens/register.test.d.ts +1 -0
  23. package/dist/lens/register.test.js +81 -0
  24. package/dist/lens/sanity.d.ts +28 -0
  25. package/dist/lens/sanity.js +187 -0
  26. package/dist/lens/scalar-codecs.d.ts +26 -0
  27. package/dist/lens/scalar-codecs.js +92 -0
  28. package/dist/lens/storyblok.d.ts +28 -0
  29. package/dist/lens/storyblok.js +232 -0
  30. package/dist/lens/types.d.ts +243 -0
  31. package/dist/lens/types.js +28 -0
  32. package/dist/markers.d.ts +68 -0
  33. package/dist/markers.js +62 -0
  34. package/dist/markers.test.d.ts +1 -0
  35. package/dist/markers.test.js +35 -0
  36. package/dist/middleware.d.ts +1 -0
  37. package/dist/middleware.js +6 -0
  38. package/dist/proxy.d.ts +26 -0
  39. package/dist/proxy.js +88 -26
  40. package/dist/proxy.test.js +61 -2
  41. package/package.json +21 -5
@@ -0,0 +1,20 @@
1
+ /** Where the standalone orchestrator listens, and what the editor assumes. */
2
+ export declare const DEFAULT_ORCHESTRATOR = "http://localhost:4200";
3
+ /**
4
+ * The warning that makes "the site should appear in the dashboard" honest, or
5
+ * `null` when it already is.
6
+ *
7
+ * Registration went to whichever orchestrator `--orchestrator` named. The
8
+ * editor reads exactly one, `VITE_ORCHESTRATOR_URL`, baked in at build time,
9
+ * and it defaults to :4200. A library-mode site registers with its own handler
10
+ * at, say, `:3002/api/avocado`, whose registry holds a completely different
11
+ * set of sites — so "the site should appear in the dashboard" is followed by a
12
+ * dashboard listing somebody else's sites, with nothing reporting a problem.
13
+ *
14
+ * Nothing *is* wrong. There are two registries and this command cannot know
15
+ * which one the editor was built against. It can say which one it just wrote
16
+ * to, and it says so only when the two visibly disagree: printing an
17
+ * environment variable after every default-target registration would be noise
18
+ * on the path almost everyone is on.
19
+ */
20
+ export declare function orchestratorMismatchNotice(orchestrator: string): string | null;
@@ -0,0 +1,34 @@
1
+ /*
2
+ * Lives apart from `register.ts` because `register.ts` calls `main()` at module
3
+ * scope — importing it to reach one helper *registers a site*, which is a poor
4
+ * thing for a test to do and a worse thing for it to do by accident.
5
+ */
6
+ /** Where the standalone orchestrator listens, and what the editor assumes. */
7
+ export const DEFAULT_ORCHESTRATOR = "http://localhost:4200";
8
+ /**
9
+ * The warning that makes "the site should appear in the dashboard" honest, or
10
+ * `null` when it already is.
11
+ *
12
+ * Registration went to whichever orchestrator `--orchestrator` named. The
13
+ * editor reads exactly one, `VITE_ORCHESTRATOR_URL`, baked in at build time,
14
+ * and it defaults to :4200. A library-mode site registers with its own handler
15
+ * at, say, `:3002/api/avocado`, whose registry holds a completely different
16
+ * set of sites — so "the site should appear in the dashboard" is followed by a
17
+ * dashboard listing somebody else's sites, with nothing reporting a problem.
18
+ *
19
+ * Nothing *is* wrong. There are two registries and this command cannot know
20
+ * which one the editor was built against. It can say which one it just wrote
21
+ * to, and it says so only when the two visibly disagree: printing an
22
+ * environment variable after every default-target registration would be noise
23
+ * on the path almost everyone is on.
24
+ */
25
+ export function orchestratorMismatchNotice(orchestrator) {
26
+ if (orchestrator.replace(/\/+$/, "") === DEFAULT_ORCHESTRATOR)
27
+ return null;
28
+ return (`\nThis is not the default orchestrator (${DEFAULT_ORCHESTRATOR}), and the editor\n` +
29
+ `reads one URL baked in at build time. For the site to show up there, the\n` +
30
+ `editor has to be built against this one:\n\n` +
31
+ ` VITE_ORCHESTRATOR_URL=${orchestrator}\n\n` +
32
+ `Set it in the editor's .env and restart it. A dashboard listing other\n` +
33
+ `sites, or none, is the symptom of it pointing somewhere else.\n`);
34
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { orchestratorMismatchNotice } from "./register-notice.js";
4
+ /*
5
+ * `avocado-register` ends with "the site should appear in the dashboard", and
6
+ * for a library-mode site that is a promise it cannot keep: the registration
7
+ * went to the site's own handler, and the editor reads one
8
+ * `VITE_ORCHESTRATOR_URL` baked in at build time. The dashboard then opens on
9
+ * a different registry's sites and reports no problem, because there is none —
10
+ * there are simply two registries.
11
+ */
12
+ test("the default target says nothing — that is the path almost everyone is on", () => {
13
+ assert.equal(orchestratorMismatchNotice("http://localhost:4200"), null);
14
+ assert.equal(orchestratorMismatchNotice("http://localhost:4200/"), null, "a trailing slash is the same URL");
15
+ });
16
+ test("a library-mode target names the variable the editor needs", () => {
17
+ const notice = orchestratorMismatchNotice("http://localhost:3002/api/avocado");
18
+ assert.ok(notice);
19
+ assert.match(notice, /VITE_ORCHESTRATOR_URL=http:\/\/localhost:3002\/api\/avocado/);
20
+ assert.match(notice, /sites, or none/, "and names the symptom, which is what the reader is looking at");
21
+ });
@@ -33,6 +33,7 @@
33
33
  import { randomBytes } from "node:crypto";
34
34
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
35
35
  import { join, resolve } from "node:path";
36
+ import { DEFAULT_ORCHESTRATOR, orchestratorMismatchNotice } from "./register-notice.js";
36
37
  function parseArgs(argv) {
37
38
  const out = {};
38
39
  for (let i = 0; i < argv.length; i++) {
@@ -224,7 +225,7 @@ async function main() {
224
225
  // Resolve port
225
226
  const port = args.port ?? detectPortFromPackageJson(pkg) ?? 3000;
226
227
  // Resolve orchestrator URL
227
- const orchestrator = (args.orchestrator ?? process.env.ORCHESTRATOR_URL ?? "http://localhost:4200").replace(/\/+$/, "");
228
+ const orchestrator = (args.orchestrator ?? process.env.ORCHESTRATOR_URL ?? DEFAULT_ORCHESTRATOR).replace(/\/+$/, "");
228
229
  /*
229
230
  * A credentialed orchestrator refuses `/sites/register` like any other route,
230
231
  * and this CLI had no way to present a token — so the documented path for
@@ -323,7 +324,11 @@ async function main() {
323
324
  process.stdout.write(`\nNext steps:\n`);
324
325
  process.stdout.write(` 1. Start your site: pnpm dev (in this directory)\n`);
325
326
  process.stdout.write(` 2. Open the editor: http://localhost:4100\n`);
326
- process.stdout.write(` 3. The site should appear in the dashboard. If not, refresh the page.\n\n`);
327
+ process.stdout.write(` 3. The site should appear in the dashboard. If not, refresh the page.\n`);
328
+ const mismatch = orchestratorMismatchNotice(orchestrator);
329
+ if (mismatch)
330
+ process.stdout.write(mismatch);
331
+ process.stdout.write(`\n`);
327
332
  }
328
333
  function humanize(s) {
329
334
  return s
package/dist/draft.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { resolveEditorContext, single } from "./draft-context.ts";
2
+ export { isEditorRender, isEditorRenderFrom, EDITOR_RENDER_HEADER } from "./editor-render.ts";
2
3
  export { getOrchestratorUrl, fetchEditorPage, fetchEditorSlugs, fetchEditorSiteConfig } from "./draft-fetch.ts";
3
4
  export { DRAFT_SESSION_COOKIE, DRAFT_SITE_COOKIE, EDITOR_ORIGIN_COOKIE } from "./draft-common.ts";
package/dist/draft.js CHANGED
@@ -1,5 +1,8 @@
1
1
  // Editor context resolution
2
2
  export { resolveEditorContext, single } from "./draft-context.js";
3
+ // Whether this render is the editor's preview — answerable from a layout,
4
+ // where `resolveEditorContext` cannot reach because layouts get no searchParams.
5
+ export { isEditorRender, isEditorRenderFrom, EDITOR_RENDER_HEADER } from "./editor-render.js";
3
6
  // Editor content fetching
4
7
  export { getOrchestratorUrl, fetchEditorPage, fetchEditorSlugs, fetchEditorSiteConfig } from "./draft-fetch.js";
5
8
  // Draft cookie constants
@@ -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 @@
1
+ export {};
@@ -0,0 +1,44 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { test } from "node:test";
3
+ import { EDITOR_RENDER_HEADER, isEditorRenderFrom } from "./editor-render.js";
4
+ import { editorPreviewRewrite } from "./proxy.js";
5
+ import { NextRequest } from "next/server";
6
+ const noHeaders = () => null;
7
+ test("a layout with neither the header nor draft mode is rendering the public page", () => {
8
+ assert.equal(isEditorRenderFrom(noHeaders, false), false);
9
+ });
10
+ test("the header the proxy stamps is what a layout reads", () => {
11
+ const get = (name) => (name === EDITOR_RENDER_HEADER ? "1" : null);
12
+ assert.equal(isEditorRenderFrom(get, false), true);
13
+ });
14
+ /*
15
+ * The editor renders the site in a cross-origin iframe, where the draft cookie
16
+ * is frequently blocked outright — which is why the header exists at all. Draft
17
+ * mode still counts, for a site whose preview reaches the route some other way.
18
+ */
19
+ test("draft mode alone is enough, for a preview that arrives without the proxy", () => {
20
+ assert.equal(isEditorRenderFrom(noHeaders, true), true);
21
+ });
22
+ test("only the exact value counts, so a stray header does not blank the site's analytics", () => {
23
+ assert.equal(isEditorRenderFrom(() => "true", false), false);
24
+ assert.equal(isEditorRenderFrom(() => "", false), false);
25
+ });
26
+ /*
27
+ * The two halves have to agree: a header the proxy does not set is a layout
28
+ * that never learns, and the failure is silent — the preview simply keeps
29
+ * loading the consent banner.
30
+ */
31
+ test("the preview rewrite stamps the header isEditorRender reads", () => {
32
+ const response = editorPreviewRewrite(new NextRequest("https://site.test/about?__editor=1"));
33
+ assert.ok(response, "an editor request must be rewritten");
34
+ assert.equal(response.headers.get(`x-middleware-request-${EDITOR_RENDER_HEADER}`), "1");
35
+ });
36
+ test("a published request is not rewritten and carries no header", () => {
37
+ assert.equal(editorPreviewRewrite(new NextRequest("https://site.test/about")), null);
38
+ });
39
+ test("a header the client sent is replaced by the proxy's own answer", () => {
40
+ const response = editorPreviewRewrite(new NextRequest("https://site.test/about?__editor=1", {
41
+ headers: { [EDITOR_RENDER_HEADER]: "0" }
42
+ }));
43
+ assert.equal(response?.headers.get(`x-middleware-request-${EDITOR_RENDER_HEADER}`), "1");
44
+ });
package/dist/editor.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { EditorOverlay } from "./editor-overlay.tsx";
2
2
  export { buildEditorQuerySuffix } from "./editor-query.ts";
3
- export { getPreviewWrapperProps, editableProps } from "./markers.ts";
3
+ export { getPreviewWrapperProps, editableProps, editableScopeProps } from "./markers.ts";
4
4
  export { renderBlocks } from "./render-blocks.tsx";
5
5
  export { RenderedBlocks, PreviewBlock } from "./live-preview-blocks.tsx";
6
6
  export { LivePreviewProvider, useLivePreviewBlocks } from "@avocadostudio-ai/preview-adapter";
package/dist/editor.js CHANGED
@@ -11,7 +11,7 @@ export { buildEditorQuerySuffix } from "./editor-query.js";
11
11
  * for the two-line attribute helper from here drags the whole editor into the
12
12
  * public bundle: +66 kB First Load JS, for identical markup.
13
13
  */
14
- export { getPreviewWrapperProps, editableProps } from "./markers.js";
14
+ export { getPreviewWrapperProps, editableProps, editableScopeProps } from "./markers.js";
15
15
  // Block rendering helper
16
16
  export { renderBlocks } from "./render-blocks.js";
17
17
  // Live-preview store renderer (streams field drafts through React)
@@ -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 {};