@elytracms/studio 0.0.78 → 0.0.80

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 (85) hide show
  1. package/dist/components/app-views/default-modules.js +5 -1
  2. package/dist/components/app-views/default-modules.js.map +1 -1
  3. package/dist/components/assets/asset-thumb.js +12 -11
  4. package/dist/components/assets/asset-thumb.js.map +1 -1
  5. package/dist/components/builder/blocks-panel.d.ts +11 -5
  6. package/dist/components/builder/blocks-panel.js +18 -24
  7. package/dist/components/builder/blocks-panel.js.map +1 -1
  8. package/dist/components/builder/canvas-render-context.d.ts +22 -0
  9. package/dist/components/builder/canvas-render-context.js +65 -9
  10. package/dist/components/builder/canvas-render-context.js.map +1 -1
  11. package/dist/components/builder/reuse.d.ts +25 -0
  12. package/dist/components/builder/reuse.js +11 -2
  13. package/dist/components/builder/reuse.js.map +1 -1
  14. package/dist/components/cms/asset-field.js +52 -19
  15. package/dist/components/cms/asset-field.js.map +1 -1
  16. package/dist/components/cms/asset-picker-core.d.ts +40 -0
  17. package/dist/components/cms/asset-picker-core.js +21 -0
  18. package/dist/components/cms/asset-picker-core.js.map +1 -1
  19. package/dist/components/cms/asset-picker.d.ts +11 -2
  20. package/dist/components/cms/asset-picker.js +34 -13
  21. package/dist/components/cms/asset-picker.js.map +1 -1
  22. package/dist/components/cms/cell.js +2 -1
  23. package/dist/components/cms/cell.js.map +1 -1
  24. package/dist/components/cms/cms-store.d.ts +8 -1
  25. package/dist/components/cms/cms-store.js +8 -7
  26. package/dist/components/cms/cms-store.js.map +1 -1
  27. package/dist/components/cms/collection-table.js +7 -1
  28. package/dist/components/cms/collection-table.js.map +1 -1
  29. package/dist/components/cms/date-input-core.d.ts +85 -0
  30. package/dist/components/cms/date-input-core.js +98 -0
  31. package/dist/components/cms/date-input-core.js.map +1 -0
  32. package/dist/components/cms/date-input.d.ts +51 -0
  33. package/dist/components/cms/date-input.js +109 -0
  34. package/dist/components/cms/date-input.js.map +1 -0
  35. package/dist/components/cms/document-diff-view.js +2 -1
  36. package/dist/components/cms/document-diff-view.js.map +1 -1
  37. package/dist/components/cms/document-staging.d.ts +15 -4
  38. package/dist/components/cms/document-staging.js +26 -15
  39. package/dist/components/cms/document-staging.js.map +1 -1
  40. package/dist/components/cms/editable-cell.js +40 -5
  41. package/dist/components/cms/editable-cell.js.map +1 -1
  42. package/dist/components/cms/field-control.js +39 -7
  43. package/dist/components/cms/field-control.js.map +1 -1
  44. package/dist/components/cms/scalar-value-editor.js +5 -1
  45. package/dist/components/cms/scalar-value-editor.js.map +1 -1
  46. package/dist/components/cms/slug-auto-derive.d.ts +41 -0
  47. package/dist/components/cms/slug-auto-derive.js +14 -0
  48. package/dist/components/cms/slug-auto-derive.js.map +1 -0
  49. package/dist/components/components/transfer-dialog.js +3 -1
  50. package/dist/components/components/transfer-dialog.js.map +1 -1
  51. package/dist/components/project-session.d.ts +44 -22
  52. package/dist/components/project-session.js +72 -36
  53. package/dist/components/project-session.js.map +1 -1
  54. package/dist/lib/assets/asset-url.d.ts +20 -0
  55. package/dist/lib/assets/asset-url.js +25 -0
  56. package/dist/lib/assets/asset-url.js.map +1 -0
  57. package/dist/lib/assets/browser-blob-storage.d.ts +80 -0
  58. package/dist/lib/assets/browser-blob-storage.js +86 -0
  59. package/dist/lib/assets/browser-blob-storage.js.map +1 -0
  60. package/dist/lib/assets/index.d.ts +2 -0
  61. package/dist/lib/assets/index.js +2 -0
  62. package/dist/lib/assets/index.js.map +1 -1
  63. package/dist/lib/data/collection-content.d.ts +20 -1
  64. package/dist/lib/data/collection-content.js +51 -1
  65. package/dist/lib/data/collection-content.js.map +1 -1
  66. package/dist/lib/data/content-session-refresh.d.ts +74 -0
  67. package/dist/lib/data/content-session-refresh.js +138 -0
  68. package/dist/lib/data/content-session-refresh.js.map +1 -0
  69. package/dist/lib/data/index.d.ts +2 -0
  70. package/dist/lib/data/index.js +2 -0
  71. package/dist/lib/data/index.js.map +1 -1
  72. package/dist/lib/data/playground-unload-guard.d.ts +22 -0
  73. package/dist/lib/data/playground-unload-guard.js +73 -0
  74. package/dist/lib/data/playground-unload-guard.js.map +1 -0
  75. package/dist/lib/i18n/messages.de.js +3 -1
  76. package/dist/lib/i18n/messages.de.js.map +1 -1
  77. package/dist/lib/i18n/messages.en.d.ts +2 -0
  78. package/dist/lib/i18n/messages.en.js +5 -1
  79. package/dist/lib/i18n/messages.en.js.map +1 -1
  80. package/dist/lib/persistence/index.js +6 -1
  81. package/dist/lib/persistence/index.js.map +1 -1
  82. package/dist/ui/input.d.ts +8 -3
  83. package/dist/ui/input.js +6 -1
  84. package/dist/ui/input.js.map +1 -1
  85. package/package.json +2 -2
@@ -2,7 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect } from 'react';
3
3
  import { getStudioOperations, loadHydratedCms, loadProjectSession } from '@elytracms/studio/lib/operations';
4
4
  import { isPlaygroundMode } from '@elytracms/studio/lib/persistence';
5
- import { configureContentSession } from '@elytracms/studio/lib/data';
5
+ import { configureContentSession, isContentSessionWrite, markContentSessionStale, notePlaygroundWriteForUnloadGuard, registerContentSessionRefresher, } from '@elytracms/studio/lib/data';
6
6
  import { resolveDeploymentLocaleConfig, resolveDeploymentRoutes } from '@elytracms/studio/lib/workspace';
7
7
  import { createRequiredContext } from '@elytracms/studio/lib/required-context';
8
8
  import { HydrationBoundary, useHydrationPhase } from './use-hydration-phase.js';
@@ -31,23 +31,30 @@ export function contentSessionOf(cms, routes = resolveDeploymentRoutes(), locale
31
31
  }
32
32
  /**
33
33
  * Load the CURRENT persisted CMS state and point the preview content engine at
34
- * it — the framework-free core of `loadCms` (both modes run it; EC-361). The
35
- * playground return contract (EC-320) is pinned here: the GATE value is always
36
- * `{ ok: true, value: null }` in playground — CMS surfaces keep their
37
- * config/fixture baseline — while the content session above still hydrates
38
- * from the in-memory adapter (a load failure is loud, never a silent empty
39
- * preview).
34
+ * it — the framework-free core of `loadCms` (both modes run it; EC-361).
35
+ *
36
+ * EC-461 — the gate value is the REAL hydration result in BOTH modes now. The
37
+ * old EC-320 playground contract (`{ ok: true, value: null }` — CMS surfaces
38
+ * keep their static config/fixture baseline forever) was the root of the
39
+ * playground loss family: every cross-section navigation remounted
40
+ * `CmsProvider` from the static seed, so same-session edits vanished from the
41
+ * CMS surfaces even though the in-memory adapter still held them — and any
42
+ * write issued from that re-seeded session (e.g. an autosave) then clobbered
43
+ * the adapter's newer record with the stale baseline. The seeded in-memory
44
+ * adapter IS a persisted backend behind the operations seam (AD-17), so the
45
+ * playground hydrates from it per CMS mount exactly like connected mode
46
+ * hydrates from Convex; a fresh boot returns the deterministic seed, so
47
+ * fixture reproducibility (EC-138) is preserved in substance. A failure is
48
+ * loud twice over: logged here (for the fire-and-forget refresh callers) and
49
+ * rendered as the gate's retryable error state.
40
50
  */
41
- export async function loadCmsIntoSession(operations, projectId, playground) {
51
+ export async function loadCmsIntoSession(operations, projectId) {
42
52
  const result = await loadHydratedCms(operations, projectId);
43
53
  // Keep the preview engine in sync with the freshest persisted content.
44
54
  if (result.ok && result.value !== null)
45
55
  configureContentSession(contentSessionOf(result.value));
46
- if (playground) {
47
- if (!result.ok)
48
- console.error('[elytra] playground content-session load failed', result.error);
49
- return { ok: true, value: null };
50
- }
56
+ if (!result.ok)
57
+ console.error('[elytra] content-session load failed', result.error);
51
58
  return result;
52
59
  }
53
60
  export function ProjectSessionProvider({ projectId, children, }) {
@@ -71,15 +78,45 @@ export function ProjectSessionProvider({ projectId, children, }) {
71
78
  // Leaving the project resets the preview engine to fixtures; the next
72
79
  // project's gate reconfigures it before rendering its own children.
73
80
  useEffect(() => () => configureContentSession(null), []);
74
- // EC-361: the playground runs the SAME load (over the seeded in-memory
75
- // adapter) for the content-session side effect, but the GATE value stays
76
- // `null` — CMS surfaces keep their config/fixture baseline (EC-320's
77
- // static-baseline rule), matching the pre-EC-361 playground contract. The
78
- // pure core (`loadCmsIntoSession`, above) carries + pins that contract.
81
+ // EC-361/EC-461: both modes run the SAME load — the content-session side
82
+ // effect plus the REAL hydration value for the CMS gates. (EC-320's
83
+ // playground null-mask is retired: it was the loss family's root — see
84
+ // `loadCmsIntoSession`.)
79
85
  const loadCms = useCallback(async () => {
80
86
  const { operations } = await getStudioOperations();
81
- return loadCmsIntoSession(operations, projectId, playground);
82
- }, [playground, projectId]);
87
+ return loadCmsIntoSession(operations, projectId);
88
+ }, [projectId]);
89
+ // EC-460: keep the preview content session in sync with WRITES. Every
90
+ // succeeded content-bearing command (documents/schema/assets) on the shared
91
+ // operations event bus marks the session stale; the registered refresher —
92
+ // this provider's own `loadCms`, i.e. the identical hydration path both
93
+ // modes already run — re-points it (debounced, and only while a preview
94
+ // consumer is mounted; see `content-session-refresh.ts`). Before this, the
95
+ // session refreshed only at boot + CMS-surface mounts, so a canvas opened
96
+ // after authoring/publishing resolved listings and relation picks against a
97
+ // stale snapshot: the EC-276 empty-listing / 1-of-3-relations family.
98
+ useEffect(() => {
99
+ const unregister = registerContentSessionRefresher(() => loadCms());
100
+ let unsubscribe;
101
+ let cancelled = false;
102
+ void getStudioOperations().then(({ operations }) => {
103
+ if (cancelled)
104
+ return;
105
+ unsubscribe = operations.events.subscribe((event) => {
106
+ if (isContentSessionWrite(event))
107
+ markContentSessionStale();
108
+ // EC-461: the first authored playground write arms the beforeunload
109
+ // guard for the rest of the page's life (module-level — surviving this
110
+ // provider's unmount, because the in-memory world does too).
111
+ notePlaygroundWriteForUnloadGuard(event);
112
+ });
113
+ });
114
+ return () => {
115
+ cancelled = true;
116
+ unregister();
117
+ unsubscribe?.();
118
+ };
119
+ }, [loadCms]);
83
120
  // EC-361: playground boot hydration for the content preview engine — the same
84
121
  // loader connected mode gates on, fired WITHOUT gating so the synchronous
85
122
  // playground first render (EC-138) is preserved. `loadCms` owns the
@@ -94,28 +131,27 @@ export function ProjectSessionProvider({ projectId, children, }) {
94
131
  }
95
132
  export { useProjectSession };
96
133
  /**
97
- * Gate one CMS surface mount on a FRESH persisted CMS load (Convex mode).
134
+ * Gate one CMS surface mount on a FRESH persisted CMS load — BOTH modes.
98
135
  * Render-prop: hands the hydrated content (or `null` → fixture fallback,
99
- * first-run) to the caller once it resolved. Playground renders synchronously
100
- * with `null`. Unlike `ProjectSessionProvider`, this gate owns no
101
- * `configureContentSession` side effect of its own — `loadCms` (from
102
- * `useProjectSession`) already calls it internally when it resolves — so no
103
- * `onResolved`/`enabled` is passed here.
136
+ * first-run / un-baselined kit) to the caller once it resolved. Unlike
137
+ * `ProjectSessionProvider`, this gate owns no `configureContentSession` side
138
+ * effect of its own — `loadCms` (from `useProjectSession`) already calls it
139
+ * internally when it resolves — so no `onResolved`/`enabled` is passed here.
104
140
  *
105
- * EC-361: in playground the gate stays synchronous (fixture baseline, EC-138),
106
- * but `loadCms` is still fired per mount for its content-session side effect —
107
- * the preview engine refreshes from the in-memory adapter on the same
108
- * per-CMS-mount rhythm connected mode gets from the gated load.
141
+ * EC-461: the playground gates on the SAME async hydration as connected mode
142
+ * (over the seeded in-memory adapter, resolved in microtasks). The old
143
+ * synchronous `null` fast-path (EC-320/EC-361) made every remounted CMS
144
+ * session re-seed the STATIC baseline, which both hid and then destroyed
145
+ * same-session edits — the playground loss family. A fresh playground boot
146
+ * hydrates the deterministic seed, so fixture reproducibility is unchanged in
147
+ * substance; only a microtask-long loading frame replaced the synchronous
148
+ * first render HERE (`ProjectSessionProvider`'s EC-138 playground fast-path is
149
+ * untouched).
109
150
  */
110
151
  export function CmsSessionGate({ children, }) {
111
152
  const { loadCms } = useProjectSession();
112
- const playground = isPlaygroundMode();
113
- useEffect(() => {
114
- if (playground)
115
- void loadCms();
116
- }, [playground, loadCms]);
117
153
  const { phase, retry } = useHydrationPhase(loadCms, {
118
- playground,
154
+ playground: false,
119
155
  playgroundValue: null,
120
156
  });
121
157
  return (_jsx(HydrationBoundary, { phase: phase, retry: retry, children: children }));
@@ -1 +1 @@
1
- {"version":3,"file":"project-session.js","sourceRoot":"","sources":["../../src/components/project-session.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useCallback, useEffect } from 'react'\nimport type { ReactNode } from 'react'\nimport type { LocaleConfig, RouteRecord } from '@elytracms/core/cms-core'\nimport { getStudioOperations, loadHydratedCms, loadProjectSession } from '@elytracms/studio/lib/operations'\nimport type {\n HydratedCms,\n HydratedProjectSession,\n OperationResult,\n OperationsClient,\n} from '@elytracms/studio/lib/operations'\nimport { isPlaygroundMode } from '@elytracms/studio/lib/persistence'\nimport { configureContentSession } from '@elytracms/studio/lib/data'\nimport type { ContentSessionState } from '@elytracms/studio/lib/data'\nimport { resolveDeploymentLocaleConfig, resolveDeploymentRoutes } from '@elytracms/studio/lib/workspace'\nimport { createRequiredContext } from '@elytracms/studio/lib/required-context'\nimport { HydrationBoundary, useHydrationPhase } from './use-hydration-phase'\n\n/**\n * Project-session hydration gate (Milestone B sign-off fix, EC-177 gap 1).\n *\n * In Convex mode every project session must BOOT from persistence: the\n * builder from the latest persisted graph revision, the CMS from the saved\n * schema + documents, publishing from the persisted environment states.\n * This provider runs the `@elytracms/core/operations` hydration queries\n * (`lib/operations/session-hydration`) and — like `DeploymentSetupGate` — gates its\n * children on a deterministic loading state, so fixture content never\n * flashes (and is never editable) before the persisted state arrives.\n *\n * Playground mode renders children synchronously with `session: null`\n * (fixtures), byte-identical to the pre-hydration behavior — the EC-138\n * determinism contract.\n *\n * Side effect on hydration: the content preview engine\n * (`lib/data/collection-content`) is pointed at the hydrated documents\n * BEFORE children render, so \"Preview with\" lists and section previews show\n * live entries, not fixture titles.\n *\n * EC-361 — the playground gets the SAME side effect, without the render gate:\n * the seeded in-memory adapter IS a persisted backend behind the operations\n * seam (AD-17), so the content session hydrates from it through the identical\n * `loadHydratedCms` loader connected mode uses — fire-and-forget after the\n * synchronous first render (EC-138 stays intact), refreshed per CMS surface\n * mount exactly like connected's `CmsSessionGate` → `loadCms` rhythm. Canvas\n * listing/relation population therefore resolves from in-memory session\n * documents with connected-mode semantics (published-ness = the version\n * pointer; the Draft view resolves live rows) instead of the blanket-empty\n * session the playground used to run with. The React `session`/`loadCms`\n * VALUES stay `null` in playground — the CMS surfaces keep their fixture\n * baseline (EC-320's static-baseline rule); only the preview content engine\n * reads persisted state.\n *\n * EC-308/AD-17 note — this gate deliberately does NOT read through the\n * `useOperationQuery` cache seam. It is boot/session orchestration, not a\n * cacheable server read: (1) the synchronous playground first-render is the\n * EC-138 determinism contract (a pull-query is async even when disabled);\n * (2) `configureContentSession` must run synchronously BEFORE children mount,\n * which a post-render query effect cannot guarantee; (3) `CmsSessionGate` is\n * intentionally fresh-per-visit (it remounts to show edits persisted since\n * project open) — the opposite of a cached read. It still reads through the\n * operations client (no transport bypass), and uses a user-driven retry epoch,\n * not freshness machinery — so it is outside the patchwork AD-17 retires.\n */\n\nexport interface ProjectSessionStore {\n /** Hydrated persisted state (Convex mode); `null` in playground (fixtures). */\n session: HydratedProjectSession | null\n /**\n * Load the CURRENT persisted CMS state (fresh query — the CMS section\n * remounts per visit and must show edits persisted since project open).\n * Playground resolves `null` immediately (fixtures).\n */\n loadCms(): Promise<OperationResult<HydratedCms | null>>\n}\n\nconst { Context: ProjectSessionContext, useRequired: useProjectSession } =\n createRequiredContext<ProjectSessionStore>('ProjectSession')\n\n/**\n * Compose the preview content-session state from a hydrated CMS load plus the\n * config-owned deployment seams — the ONE place hydrated content meets config:\n *\n * - `routes` (EC-291): the config-sourced routes, so the canvas `resolveLink`\n * derives a rich-text link's URL exactly as delivery does.\n * - `localeConfig` (EC-385): the active config project's declared locales\n * (AD-11 — locales are structure, structure is code), so canvas content\n * clients evaluate locale-shaped values (EC-018 fallback, localized\n * filter/sort) over the PROJECT's locale set, never the sample `en/de`\n * fixture. Without a config project (env-only wiring) the key stays absent\n * and the preview engine keeps its sample-config default, mirroring the CMS\n * surface ladder (`resolveCmsInitialLocaleConfig`).\n *\n * The parameters exist purely as a unit-test seam (house pattern, same as\n * `resolveCmsInitialCollections`); app code always uses the defaults.\n */\nexport function contentSessionOf(\n cms: HydratedCms | null,\n routes: readonly RouteRecord[] = resolveDeploymentRoutes(),\n localeConfig: LocaleConfig | undefined = resolveDeploymentLocaleConfig(),\n): ContentSessionState | null {\n if (!cms) return null\n return { ...cms, routes, ...(localeConfig ? { localeConfig } : {}) }\n}\n\n/**\n * Load the CURRENT persisted CMS state and point the preview content engine at\n * it — the framework-free core of `loadCms` (both modes run it; EC-361). The\n * playground return contract (EC-320) is pinned here: the GATE value is always\n * `{ ok: true, value: null }` in playground — CMS surfaces keep their\n * config/fixture baseline — while the content session above still hydrates\n * from the in-memory adapter (a load failure is loud, never a silent empty\n * preview).\n */\nexport async function loadCmsIntoSession(\n operations: OperationsClient,\n projectId: string,\n playground: boolean,\n): Promise<OperationResult<HydratedCms | null>> {\n const result = await loadHydratedCms(operations, projectId)\n // Keep the preview engine in sync with the freshest persisted content.\n if (result.ok && result.value !== null) configureContentSession(contentSessionOf(result.value))\n if (playground) {\n if (!result.ok) console.error('[elytra] playground content-session load failed', result.error)\n return { ok: true, value: null }\n }\n return result\n}\n\nexport function ProjectSessionProvider({\n projectId,\n children,\n}: {\n projectId: string\n children: ReactNode\n}) {\n const playground = isPlaygroundMode()\n\n const loader = useCallback(\n () => getStudioOperations().then(({ operations }) => loadProjectSession(operations, projectId)),\n [projectId],\n )\n // Point the preview content engine at the hydrated documents BEFORE any\n // child renders (panels read it module-level, synchronously), composed with\n // the config-owned routes (EC-291) + locale config (EC-385) via\n // `contentSessionOf`. `enabled: true` below runs this in the playground\n // fast-path too (with `session: null`) — the one divergence from\n // `CmsSessionGate`, which owns no such side effect.\n const onResolved = useCallback((session: HydratedProjectSession | null) => {\n configureContentSession(contentSessionOf(session?.cms ?? null))\n }, [])\n const { phase, retry } = useHydrationPhase<HydratedProjectSession | null>(loader, {\n playground,\n playgroundValue: null,\n enabled: true,\n onResolved,\n })\n\n // Leaving the project resets the preview engine to fixtures; the next\n // project's gate reconfigures it before rendering its own children.\n useEffect(() => () => configureContentSession(null), [])\n\n // EC-361: the playground runs the SAME load (over the seeded in-memory\n // adapter) for the content-session side effect, but the GATE value stays\n // `null` — CMS surfaces keep their config/fixture baseline (EC-320's\n // static-baseline rule), matching the pre-EC-361 playground contract. The\n // pure core (`loadCmsIntoSession`, above) carries + pins that contract.\n const loadCms = useCallback(async (): Promise<OperationResult<HydratedCms | null>> => {\n const { operations } = await getStudioOperations()\n return loadCmsIntoSession(operations, projectId, playground)\n }, [playground, projectId])\n\n // EC-361: playground boot hydration for the content preview engine — the same\n // loader connected mode gates on, fired WITHOUT gating so the synchronous\n // playground first render (EC-138) is preserved. `loadCms` owns the\n // `configureContentSession` side effect; the resolved value is discarded here.\n // Runs after `useHydrationPhase`'s fast-path effect (registered above) has\n // reset the session, so the hydrated state always lands last.\n useEffect(() => {\n if (playground) void loadCms()\n }, [playground, loadCms])\n\n return (\n <HydrationBoundary phase={phase} retry={retry}>\n {(session) => (\n <ProjectSessionContext.Provider value={{ session, loadCms }}>\n {children}\n </ProjectSessionContext.Provider>\n )}\n </HydrationBoundary>\n )\n}\n\nexport { useProjectSession }\n\n/**\n * Gate one CMS surface mount on a FRESH persisted CMS load (Convex mode).\n * Render-prop: hands the hydrated content (or `null` → fixture fallback,\n * first-run) to the caller once it resolved. Playground renders synchronously\n * with `null`. Unlike `ProjectSessionProvider`, this gate owns no\n * `configureContentSession` side effect of its own — `loadCms` (from\n * `useProjectSession`) already calls it internally when it resolves — so no\n * `onResolved`/`enabled` is passed here.\n *\n * EC-361: in playground the gate stays synchronous (fixture baseline, EC-138),\n * but `loadCms` is still fired per mount for its content-session side effect —\n * the preview engine refreshes from the in-memory adapter on the same\n * per-CMS-mount rhythm connected mode gets from the gated load.\n */\nexport function CmsSessionGate({\n children,\n}: {\n children: (cms: HydratedCms | null) => ReactNode\n}) {\n const { loadCms } = useProjectSession()\n const playground = isPlaygroundMode()\n useEffect(() => {\n if (playground) void loadCms()\n }, [playground, loadCms])\n const { phase, retry } = useHydrationPhase<HydratedCms | null>(loadCms, {\n playground,\n playgroundValue: null,\n })\n return (\n <HydrationBoundary phase={phase} retry={retry}>\n {children}\n </HydrationBoundary>\n )\n}\n"]}
1
+ {"version":3,"file":"project-session.js","sourceRoot":"","sources":["../../src/components/project-session.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useCallback, useEffect } from 'react'\nimport type { ReactNode } from 'react'\nimport type { LocaleConfig, RouteRecord } from '@elytracms/core/cms-core'\nimport { getStudioOperations, loadHydratedCms, loadProjectSession } from '@elytracms/studio/lib/operations'\nimport type {\n HydratedCms,\n HydratedProjectSession,\n OperationResult,\n OperationsClient,\n} from '@elytracms/studio/lib/operations'\nimport { isPlaygroundMode } from '@elytracms/studio/lib/persistence'\nimport {\n configureContentSession,\n isContentSessionWrite,\n markContentSessionStale,\n notePlaygroundWriteForUnloadGuard,\n registerContentSessionRefresher,\n} from '@elytracms/studio/lib/data'\nimport type { ContentSessionState } from '@elytracms/studio/lib/data'\nimport { resolveDeploymentLocaleConfig, resolveDeploymentRoutes } from '@elytracms/studio/lib/workspace'\nimport { createRequiredContext } from '@elytracms/studio/lib/required-context'\nimport { HydrationBoundary, useHydrationPhase } from './use-hydration-phase'\n\n/**\n * Project-session hydration gate (Milestone B sign-off fix, EC-177 gap 1).\n *\n * In Convex mode every project session must BOOT from persistence: the\n * builder from the latest persisted graph revision, the CMS from the saved\n * schema + documents, publishing from the persisted environment states.\n * This provider runs the `@elytracms/core/operations` hydration queries\n * (`lib/operations/session-hydration`) and — like `DeploymentSetupGate` — gates its\n * children on a deterministic loading state, so fixture content never\n * flashes (and is never editable) before the persisted state arrives.\n *\n * Playground mode renders children synchronously with `session: null`\n * (fixtures), byte-identical to the pre-hydration behavior — the EC-138\n * determinism contract.\n *\n * Side effect on hydration: the content preview engine\n * (`lib/data/collection-content`) is pointed at the hydrated documents\n * BEFORE children render, so \"Preview with\" lists and section previews show\n * live entries, not fixture titles.\n *\n * EC-361 — the playground gets the SAME side effect, without the render gate:\n * the seeded in-memory adapter IS a persisted backend behind the operations\n * seam (AD-17), so the content session hydrates from it through the identical\n * `loadHydratedCms` loader connected mode uses — fire-and-forget after the\n * synchronous first render (EC-138 stays intact), refreshed per CMS surface\n * mount exactly like connected's `CmsSessionGate` → `loadCms` rhythm. Canvas\n * listing/relation population therefore resolves from in-memory session\n * documents with connected-mode semantics (published-ness = the version\n * pointer; the Draft view resolves live rows) instead of the blanket-empty\n * session the playground used to run with.\n *\n * EC-461 — `CmsSessionGate`/`loadCms` hydrate the CMS surfaces from the\n * adapter in the playground too now. The old EC-320 \"static-baseline rule\"\n * (gate value pinned `null`; CMS sessions re-seeded the fixture baseline on\n * every mount) is retired: it made every cross-section navigation silently\n * revert the visible world to the seed, and a subsequent autosave from that\n * re-seeded session destroyed the adapter's newer record — the EC-276/EC-461\n * playground loss family. Only this provider's OWN `session` value keeps the\n * playground `null` fast-path (boot determinism, EC-138 below).\n *\n * EC-308/AD-17 note — this gate deliberately does NOT read through the\n * `useOperationQuery` cache seam. It is boot/session orchestration, not a\n * cacheable server read: (1) the synchronous playground first-render is the\n * EC-138 determinism contract (a pull-query is async even when disabled);\n * (2) `configureContentSession` must run synchronously BEFORE children mount,\n * which a post-render query effect cannot guarantee; (3) `CmsSessionGate` is\n * intentionally fresh-per-visit (it remounts to show edits persisted since\n * project open) — the opposite of a cached read. It still reads through the\n * operations client (no transport bypass), and uses a user-driven retry epoch,\n * not freshness machinery — so it is outside the patchwork AD-17 retires.\n */\n\nexport interface ProjectSessionStore {\n /** Hydrated persisted state (Convex mode); `null` in playground (fixtures). */\n session: HydratedProjectSession | null\n /**\n * Load the CURRENT persisted CMS state (fresh query — the CMS section\n * remounts per visit and must show edits persisted since project open).\n * EC-461: the playground resolves the seeded in-memory adapter's state the\n * same way (`null` only while nothing is persisted — e.g. an un-baselined\n * kit project), so remounted CMS sessions carry same-session edits.\n */\n loadCms(): Promise<OperationResult<HydratedCms | null>>\n}\n\nconst { Context: ProjectSessionContext, useRequired: useProjectSession } =\n createRequiredContext<ProjectSessionStore>('ProjectSession')\n\n/**\n * Compose the preview content-session state from a hydrated CMS load plus the\n * config-owned deployment seams — the ONE place hydrated content meets config:\n *\n * - `routes` (EC-291): the config-sourced routes, so the canvas `resolveLink`\n * derives a rich-text link's URL exactly as delivery does.\n * - `localeConfig` (EC-385): the active config project's declared locales\n * (AD-11 — locales are structure, structure is code), so canvas content\n * clients evaluate locale-shaped values (EC-018 fallback, localized\n * filter/sort) over the PROJECT's locale set, never the sample `en/de`\n * fixture. Without a config project (env-only wiring) the key stays absent\n * and the preview engine keeps its sample-config default, mirroring the CMS\n * surface ladder (`resolveCmsInitialLocaleConfig`).\n *\n * The parameters exist purely as a unit-test seam (house pattern, same as\n * `resolveCmsInitialCollections`); app code always uses the defaults.\n */\nexport function contentSessionOf(\n cms: HydratedCms | null,\n routes: readonly RouteRecord[] = resolveDeploymentRoutes(),\n localeConfig: LocaleConfig | undefined = resolveDeploymentLocaleConfig(),\n): ContentSessionState | null {\n if (!cms) return null\n return { ...cms, routes, ...(localeConfig ? { localeConfig } : {}) }\n}\n\n/**\n * Load the CURRENT persisted CMS state and point the preview content engine at\n * it — the framework-free core of `loadCms` (both modes run it; EC-361).\n *\n * EC-461 — the gate value is the REAL hydration result in BOTH modes now. The\n * old EC-320 playground contract (`{ ok: true, value: null }` — CMS surfaces\n * keep their static config/fixture baseline forever) was the root of the\n * playground loss family: every cross-section navigation remounted\n * `CmsProvider` from the static seed, so same-session edits vanished from the\n * CMS surfaces even though the in-memory adapter still held them — and any\n * write issued from that re-seeded session (e.g. an autosave) then clobbered\n * the adapter's newer record with the stale baseline. The seeded in-memory\n * adapter IS a persisted backend behind the operations seam (AD-17), so the\n * playground hydrates from it per CMS mount exactly like connected mode\n * hydrates from Convex; a fresh boot returns the deterministic seed, so\n * fixture reproducibility (EC-138) is preserved in substance. A failure is\n * loud twice over: logged here (for the fire-and-forget refresh callers) and\n * rendered as the gate's retryable error state.\n */\nexport async function loadCmsIntoSession(\n operations: OperationsClient,\n projectId: string,\n): Promise<OperationResult<HydratedCms | null>> {\n const result = await loadHydratedCms(operations, projectId)\n // Keep the preview engine in sync with the freshest persisted content.\n if (result.ok && result.value !== null) configureContentSession(contentSessionOf(result.value))\n if (!result.ok) console.error('[elytra] content-session load failed', result.error)\n return result\n}\n\nexport function ProjectSessionProvider({\n projectId,\n children,\n}: {\n projectId: string\n children: ReactNode\n}) {\n const playground = isPlaygroundMode()\n\n const loader = useCallback(\n () => getStudioOperations().then(({ operations }) => loadProjectSession(operations, projectId)),\n [projectId],\n )\n // Point the preview content engine at the hydrated documents BEFORE any\n // child renders (panels read it module-level, synchronously), composed with\n // the config-owned routes (EC-291) + locale config (EC-385) via\n // `contentSessionOf`. `enabled: true` below runs this in the playground\n // fast-path too (with `session: null`) — the one divergence from\n // `CmsSessionGate`, which owns no such side effect.\n const onResolved = useCallback((session: HydratedProjectSession | null) => {\n configureContentSession(contentSessionOf(session?.cms ?? null))\n }, [])\n const { phase, retry } = useHydrationPhase<HydratedProjectSession | null>(loader, {\n playground,\n playgroundValue: null,\n enabled: true,\n onResolved,\n })\n\n // Leaving the project resets the preview engine to fixtures; the next\n // project's gate reconfigures it before rendering its own children.\n useEffect(() => () => configureContentSession(null), [])\n\n // EC-361/EC-461: both modes run the SAME load — the content-session side\n // effect plus the REAL hydration value for the CMS gates. (EC-320's\n // playground null-mask is retired: it was the loss family's root — see\n // `loadCmsIntoSession`.)\n const loadCms = useCallback(async (): Promise<OperationResult<HydratedCms | null>> => {\n const { operations } = await getStudioOperations()\n return loadCmsIntoSession(operations, projectId)\n }, [projectId])\n\n // EC-460: keep the preview content session in sync with WRITES. Every\n // succeeded content-bearing command (documents/schema/assets) on the shared\n // operations event bus marks the session stale; the registered refresher —\n // this provider's own `loadCms`, i.e. the identical hydration path both\n // modes already run — re-points it (debounced, and only while a preview\n // consumer is mounted; see `content-session-refresh.ts`). Before this, the\n // session refreshed only at boot + CMS-surface mounts, so a canvas opened\n // after authoring/publishing resolved listings and relation picks against a\n // stale snapshot: the EC-276 empty-listing / 1-of-3-relations family.\n useEffect(() => {\n const unregister = registerContentSessionRefresher(() => loadCms())\n let unsubscribe: (() => void) | undefined\n let cancelled = false\n void getStudioOperations().then(({ operations }) => {\n if (cancelled) return\n unsubscribe = operations.events.subscribe((event) => {\n if (isContentSessionWrite(event)) markContentSessionStale()\n // EC-461: the first authored playground write arms the beforeunload\n // guard for the rest of the page's life (module-level — surviving this\n // provider's unmount, because the in-memory world does too).\n notePlaygroundWriteForUnloadGuard(event)\n })\n })\n return () => {\n cancelled = true\n unregister()\n unsubscribe?.()\n }\n }, [loadCms])\n\n // EC-361: playground boot hydration for the content preview engine — the same\n // loader connected mode gates on, fired WITHOUT gating so the synchronous\n // playground first render (EC-138) is preserved. `loadCms` owns the\n // `configureContentSession` side effect; the resolved value is discarded here.\n // Runs after `useHydrationPhase`'s fast-path effect (registered above) has\n // reset the session, so the hydrated state always lands last.\n useEffect(() => {\n if (playground) void loadCms()\n }, [playground, loadCms])\n\n return (\n <HydrationBoundary phase={phase} retry={retry}>\n {(session) => (\n <ProjectSessionContext.Provider value={{ session, loadCms }}>\n {children}\n </ProjectSessionContext.Provider>\n )}\n </HydrationBoundary>\n )\n}\n\nexport { useProjectSession }\n\n/**\n * Gate one CMS surface mount on a FRESH persisted CMS load — BOTH modes.\n * Render-prop: hands the hydrated content (or `null` → fixture fallback,\n * first-run / un-baselined kit) to the caller once it resolved. Unlike\n * `ProjectSessionProvider`, this gate owns no `configureContentSession` side\n * effect of its own — `loadCms` (from `useProjectSession`) already calls it\n * internally when it resolves — so no `onResolved`/`enabled` is passed here.\n *\n * EC-461: the playground gates on the SAME async hydration as connected mode\n * (over the seeded in-memory adapter, resolved in microtasks). The old\n * synchronous `null` fast-path (EC-320/EC-361) made every remounted CMS\n * session re-seed the STATIC baseline, which both hid and then destroyed\n * same-session edits — the playground loss family. A fresh playground boot\n * hydrates the deterministic seed, so fixture reproducibility is unchanged in\n * substance; only a microtask-long loading frame replaced the synchronous\n * first render HERE (`ProjectSessionProvider`'s EC-138 playground fast-path is\n * untouched).\n */\nexport function CmsSessionGate({\n children,\n}: {\n children: (cms: HydratedCms | null) => ReactNode\n}) {\n const { loadCms } = useProjectSession()\n const { phase, retry } = useHydrationPhase<HydratedCms | null>(loadCms, {\n playground: false,\n playgroundValue: null,\n })\n return (\n <HydrationBoundary phase={phase} retry={retry}>\n {children}\n </HydrationBoundary>\n )\n}\n"]}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Asset URL scheme classification (EC-459). `InMemoryBlobStorage` (playground's
3
+ * default blob backend, `@elytracms/core/persistence`) mints an opaque
4
+ * `memory://blobs/<key>` locator for stored bytes — correct for Node tests and
5
+ * inspection, but not a resource a browser can ever fetch. Every studio surface
6
+ * that puts an asset's `url` in an `<img src>` must gate on "is this actually
7
+ * displayable", not "is this specifically `http(s)`" — the fix wiring
8
+ * (`browser-blob-storage.ts`) replaces `memory://` with a `blob:` object URL,
9
+ * which the OLD `url.startsWith('http')` gates here would have kept rejecting.
10
+ *
11
+ * Pure and dependency-free so it is trivially Node-testable; every surface that
12
+ * decides "show the `<img>` or the placeholder" imports this one predicate so
13
+ * the decision can never drift between call sites again.
14
+ */
15
+ /**
16
+ * True when `url` is a scheme a browser can fetch/render (`http(s):`, `blob:`,
17
+ * `data:`) — false for `undefined`, empty, and opaque backend-internal locators
18
+ * like `memory://blobs/…` that must never reach an `<img>`/`<a href>`.
19
+ */
20
+ export declare function isDisplayableAssetUrl(url: string | undefined | null): url is string;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Asset URL scheme classification (EC-459). `InMemoryBlobStorage` (playground's
3
+ * default blob backend, `@elytracms/core/persistence`) mints an opaque
4
+ * `memory://blobs/<key>` locator for stored bytes — correct for Node tests and
5
+ * inspection, but not a resource a browser can ever fetch. Every studio surface
6
+ * that puts an asset's `url` in an `<img src>` must gate on "is this actually
7
+ * displayable", not "is this specifically `http(s)`" — the fix wiring
8
+ * (`browser-blob-storage.ts`) replaces `memory://` with a `blob:` object URL,
9
+ * which the OLD `url.startsWith('http')` gates here would have kept rejecting.
10
+ *
11
+ * Pure and dependency-free so it is trivially Node-testable; every surface that
12
+ * decides "show the `<img>` or the placeholder" imports this one predicate so
13
+ * the decision can never drift between call sites again.
14
+ */
15
+ /** Schemes a browser can actually load pixels from in an `<img src>`. */
16
+ const DISPLAYABLE_URL_PATTERN = /^(https?:|blob:|data:)/i;
17
+ /**
18
+ * True when `url` is a scheme a browser can fetch/render (`http(s):`, `blob:`,
19
+ * `data:`) — false for `undefined`, empty, and opaque backend-internal locators
20
+ * like `memory://blobs/…` that must never reach an `<img>`/`<a href>`.
21
+ */
22
+ export function isDisplayableAssetUrl(url) {
23
+ return typeof url === 'string' && DISPLAYABLE_URL_PATTERN.test(url);
24
+ }
25
+ //# sourceMappingURL=asset-url.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"asset-url.js","sourceRoot":"","sources":["../../../src/lib/assets/asset-url.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,yEAAyE;AACzE,MAAM,uBAAuB,GAAG,yBAAyB,CAAA;AAEzD;;;;GAIG;AACH,MAAM,UAAU,qBAAqB,CAAC,GAA8B;IAClE,OAAO,OAAO,GAAG,KAAK,QAAQ,IAAI,uBAAuB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;AACrE,CAAC","sourcesContent":["/**\n * Asset URL scheme classification (EC-459). `InMemoryBlobStorage` (playground's\n * default blob backend, `@elytracms/core/persistence`) mints an opaque\n * `memory://blobs/<key>` locator for stored bytes — correct for Node tests and\n * inspection, but not a resource a browser can ever fetch. Every studio surface\n * that puts an asset's `url` in an `<img src>` must gate on \"is this actually\n * displayable\", not \"is this specifically `http(s)`\" — the fix wiring\n * (`browser-blob-storage.ts`) replaces `memory://` with a `blob:` object URL,\n * which the OLD `url.startsWith('http')` gates here would have kept rejecting.\n *\n * Pure and dependency-free so it is trivially Node-testable; every surface that\n * decides \"show the `<img>` or the placeholder\" imports this one predicate so\n * the decision can never drift between call sites again.\n */\n\n/** Schemes a browser can actually load pixels from in an `<img src>`. */\nconst DISPLAYABLE_URL_PATTERN = /^(https?:|blob:|data:)/i\n\n/**\n * True when `url` is a scheme a browser can fetch/render (`http(s):`, `blob:`,\n * `data:`) — false for `undefined`, empty, and opaque backend-internal locators\n * like `memory://blobs/…` that must never reach an `<img>`/`<a href>`.\n */\nexport function isDisplayableAssetUrl(url: string | undefined | null): url is string {\n return typeof url === 'string' && DISPLAYABLE_URL_PATTERN.test(url)\n}\n"]}
@@ -0,0 +1,80 @@
1
+ import type { BlobStorageAdapter } from '@elytracms/core/persistence';
2
+ /**
3
+ * Studio-side (playground-only) blob storage wrapper (EC-459).
4
+ *
5
+ * Root cause this fixes: `InMemoryBlobStorage` (`@elytracms/core/persistence`)
6
+ * mints `url(key)` as an opaque `memory://blobs/<key>` locator. That is correct
7
+ * for its job — Node tests and programmatic inspection never need a real
8
+ * browser resource — but the studio's upload flow (`upload.ts`) bakes
9
+ * `blobs.resolveUrl?.(storageKey) ?? blobs.url(storageKey)` straight into the
10
+ * persisted `AssetRecord.url` (and the EC-400 thumb variant's `url`). Every
11
+ * surface that then puts that value in an `<img src>` — the asset library grid,
12
+ * the compact field preview, the focal-point editor, and (worst of all, no
13
+ * gating at all) the canvas iframe's resolved-asset renderer — hands the
14
+ * browser a URL it can never fetch. Pixels never render; the canvas iframe
15
+ * shows an actual broken-image icon (`naturalWidth === 0`).
16
+ *
17
+ * The fix: wrap the byte store, not replace it. `put`/`store` still delegate
18
+ * unchanged to `inner` (an `InMemoryBlobStorage` by default, or any adapter a
19
+ * test wants), so bytes live exactly where they always did. The moment bytes
20
+ * are known, THIS wrapper additionally mints a real browser-displayable URL
21
+ * (an object URL by default) and serves THAT from `url()`/`resolveUrl()` —
22
+ * so the value the upload flow bakes into the record is something an `<img>`
23
+ * can actually load. `@elytracms/core`'s `InMemoryBlobStorage` is untouched and
24
+ * stays platform-neutral (it still runs fine under Node tests); the one
25
+ * DOM-touching function (`objectUrlMinter`, using the real `Blob`/`URL` globals)
26
+ * is injected, so this whole orchestrator — caching, delegation, revoke-on-
27
+ * delete/overwrite — is unit-testable in Node with a fake minter.
28
+ *
29
+ * Playground-only by construction: nothing calls this in the Convex/connected
30
+ * path, and the `BlobStorageAdapter` interface is unchanged, so the delivery
31
+ * wire and the connected-mode adapter are untouched.
32
+ */
33
+ /** Mints a browser-displayable URL from raw bytes. Injected for Node testability. */
34
+ export type UrlMinter = (data: Uint8Array, contentType: string) => string;
35
+ /** Releases a previously-minted URL's resources (e.g. `URL.revokeObjectURL`). */
36
+ export type UrlRevoker = (url: string) => void;
37
+ /**
38
+ * The real browser minter: an object URL backed by a same-process `Blob`.
39
+ * Deliberately thin and untested in Node (mirrors `canvasVariantEncoder` in
40
+ * `thumbnail.ts` — a DOM shell around a pure/injectable orchestrator).
41
+ */
42
+ export declare const objectUrlMinter: UrlMinter;
43
+ /** The real browser revoker: releases an object URL's underlying resources. */
44
+ export declare const objectUrlRevoker: UrlRevoker;
45
+ export interface BrowserBlobStorageOptions {
46
+ /** The wrapped adapter that actually holds the bytes. Defaults to a fresh `InMemoryBlobStorage`. */
47
+ inner?: BlobStorageAdapter;
48
+ /** Defaults to {@link objectUrlMinter}; tests inject a deterministic fake. */
49
+ mint?: UrlMinter;
50
+ /** Defaults to {@link objectUrlRevoker}; tests inject a fake to assert revocation. */
51
+ revoke?: UrlRevoker;
52
+ }
53
+ /**
54
+ * Wraps a `BlobStorageAdapter` so `url()`/`resolveUrl()` return a browser-
55
+ * displayable URL instead of the inner adapter's opaque locator (EC-459). See
56
+ * the module doc above for the full rationale.
57
+ */
58
+ export declare class BrowserBlobStorage implements BlobStorageAdapter {
59
+ private readonly inner;
60
+ private readonly mint;
61
+ private readonly revoke;
62
+ /** storageKey → minted display URL, populated at the moment bytes are known. */
63
+ private readonly displayUrls;
64
+ constructor(options?: BrowserBlobStorageOptions);
65
+ /**
66
+ * Mint (or re-mint) the display URL for `key`. A re-put under the same key
67
+ * (re-upload / overwrite) revokes the stale URL first, so no object URL leaks
68
+ * and no surface can ever show stale bytes under a reused key.
69
+ */
70
+ private remember;
71
+ put(key: string, data: Uint8Array, contentType?: string): Promise<void>;
72
+ get(key: string): Promise<Uint8Array | undefined>;
73
+ url(key: string): string;
74
+ has(key: string): Promise<boolean>;
75
+ delete(key: string): Promise<void>;
76
+ store(data: Uint8Array, contentType?: string): Promise<string>;
77
+ resolveUrl(key: string): Promise<string | undefined>;
78
+ }
79
+ /** Convenience factory — `createBrowserBlobStorage()` wraps a fresh `InMemoryBlobStorage`. */
80
+ export declare function createBrowserBlobStorage(options?: BrowserBlobStorageOptions): BlobStorageAdapter;
@@ -0,0 +1,86 @@
1
+ import { InMemoryBlobStorage } from '@elytracms/core/persistence';
2
+ /**
3
+ * The real browser minter: an object URL backed by a same-process `Blob`.
4
+ * Deliberately thin and untested in Node (mirrors `canvasVariantEncoder` in
5
+ * `thumbnail.ts` — a DOM shell around a pure/injectable orchestrator).
6
+ */
7
+ export const objectUrlMinter = (data, contentType) => URL.createObjectURL(new Blob([data.slice()], { type: contentType || 'application/octet-stream' }));
8
+ /** The real browser revoker: releases an object URL's underlying resources. */
9
+ export const objectUrlRevoker = (url) => {
10
+ URL.revokeObjectURL(url);
11
+ };
12
+ /**
13
+ * Wraps a `BlobStorageAdapter` so `url()`/`resolveUrl()` return a browser-
14
+ * displayable URL instead of the inner adapter's opaque locator (EC-459). See
15
+ * the module doc above for the full rationale.
16
+ */
17
+ export class BrowserBlobStorage {
18
+ inner;
19
+ mint;
20
+ revoke;
21
+ /** storageKey → minted display URL, populated at the moment bytes are known. */
22
+ displayUrls = new Map();
23
+ constructor(options = {}) {
24
+ this.inner = options.inner ?? new InMemoryBlobStorage();
25
+ this.mint = options.mint ?? objectUrlMinter;
26
+ this.revoke = options.revoke ?? objectUrlRevoker;
27
+ }
28
+ /**
29
+ * Mint (or re-mint) the display URL for `key`. A re-put under the same key
30
+ * (re-upload / overwrite) revokes the stale URL first, so no object URL leaks
31
+ * and no surface can ever show stale bytes under a reused key.
32
+ */
33
+ remember(key, data, contentType) {
34
+ const previous = this.displayUrls.get(key);
35
+ if (previous)
36
+ this.revoke(previous);
37
+ this.displayUrls.set(key, this.mint(data, contentType ?? 'application/octet-stream'));
38
+ }
39
+ async put(key, data, contentType) {
40
+ await this.inner.put(key, data, contentType);
41
+ this.remember(key, data, contentType);
42
+ }
43
+ async get(key) {
44
+ return this.inner.get(key);
45
+ }
46
+ url(key) {
47
+ // Fall back to the inner adapter's own locator only when this wrapper never
48
+ // saw the bytes (e.g. a seed record whose metadata carries no stored blob at
49
+ // all) — the same "no bytes, no pretending" behaviour the inner adapter has
50
+ // always had; nothing here silently invents a broken image.
51
+ return this.displayUrls.get(key) ?? this.inner.url(key);
52
+ }
53
+ async has(key) {
54
+ return this.inner.has(key);
55
+ }
56
+ async delete(key) {
57
+ await this.inner.delete(key);
58
+ const cached = this.displayUrls.get(key);
59
+ if (cached) {
60
+ this.revoke(cached);
61
+ this.displayUrls.delete(key);
62
+ }
63
+ }
64
+ async store(data, contentType) {
65
+ if (!this.inner.store) {
66
+ throw new Error('BrowserBlobStorage requires an inner adapter that implements store().');
67
+ }
68
+ const key = await this.inner.store(data, contentType);
69
+ this.remember(key, data, contentType);
70
+ return key;
71
+ }
72
+ async resolveUrl(key) {
73
+ const cached = this.displayUrls.get(key);
74
+ if (cached)
75
+ return cached;
76
+ // Not minted through THIS wrapper — fall back to whatever the inner adapter
77
+ // can resolve (still `undefined` when genuinely no bytes exist: scope §5,
78
+ // an explicit missing state, never a silently broken image).
79
+ return this.inner.resolveUrl ? this.inner.resolveUrl(key) : undefined;
80
+ }
81
+ }
82
+ /** Convenience factory — `createBrowserBlobStorage()` wraps a fresh `InMemoryBlobStorage`. */
83
+ export function createBrowserBlobStorage(options) {
84
+ return new BrowserBlobStorage(options);
85
+ }
86
+ //# sourceMappingURL=browser-blob-storage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser-blob-storage.js","sourceRoot":"","sources":["../../../src/lib/assets/browser-blob-storage.ts"],"names":[],"mappings":"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","sourcesContent":["import { InMemoryBlobStorage } from '@elytracms/core/persistence'\nimport type { BlobStorageAdapter } from '@elytracms/core/persistence'\n\n/**\n * Studio-side (playground-only) blob storage wrapper (EC-459).\n *\n * Root cause this fixes: `InMemoryBlobStorage` (`@elytracms/core/persistence`)\n * mints `url(key)` as an opaque `memory://blobs/<key>` locator. That is correct\n * for its job — Node tests and programmatic inspection never need a real\n * browser resource — but the studio's upload flow (`upload.ts`) bakes\n * `blobs.resolveUrl?.(storageKey) ?? blobs.url(storageKey)` straight into the\n * persisted `AssetRecord.url` (and the EC-400 thumb variant's `url`). Every\n * surface that then puts that value in an `<img src>` — the asset library grid,\n * the compact field preview, the focal-point editor, and (worst of all, no\n * gating at all) the canvas iframe's resolved-asset renderer — hands the\n * browser a URL it can never fetch. Pixels never render; the canvas iframe\n * shows an actual broken-image icon (`naturalWidth === 0`).\n *\n * The fix: wrap the byte store, not replace it. `put`/`store` still delegate\n * unchanged to `inner` (an `InMemoryBlobStorage` by default, or any adapter a\n * test wants), so bytes live exactly where they always did. The moment bytes\n * are known, THIS wrapper additionally mints a real browser-displayable URL\n * (an object URL by default) and serves THAT from `url()`/`resolveUrl()` —\n * so the value the upload flow bakes into the record is something an `<img>`\n * can actually load. `@elytracms/core`'s `InMemoryBlobStorage` is untouched and\n * stays platform-neutral (it still runs fine under Node tests); the one\n * DOM-touching function (`objectUrlMinter`, using the real `Blob`/`URL` globals)\n * is injected, so this whole orchestrator — caching, delegation, revoke-on-\n * delete/overwrite — is unit-testable in Node with a fake minter.\n *\n * Playground-only by construction: nothing calls this in the Convex/connected\n * path, and the `BlobStorageAdapter` interface is unchanged, so the delivery\n * wire and the connected-mode adapter are untouched.\n */\n\n/** Mints a browser-displayable URL from raw bytes. Injected for Node testability. */\nexport type UrlMinter = (data: Uint8Array, contentType: string) => string\n\n/** Releases a previously-minted URL's resources (e.g. `URL.revokeObjectURL`). */\nexport type UrlRevoker = (url: string) => void\n\n/**\n * The real browser minter: an object URL backed by a same-process `Blob`.\n * Deliberately thin and untested in Node (mirrors `canvasVariantEncoder` in\n * `thumbnail.ts` — a DOM shell around a pure/injectable orchestrator).\n */\nexport const objectUrlMinter: UrlMinter = (data, contentType) =>\n URL.createObjectURL(new Blob([data.slice() as BlobPart], { type: contentType || 'application/octet-stream' }))\n\n/** The real browser revoker: releases an object URL's underlying resources. */\nexport const objectUrlRevoker: UrlRevoker = (url) => {\n URL.revokeObjectURL(url)\n}\n\nexport interface BrowserBlobStorageOptions {\n /** The wrapped adapter that actually holds the bytes. Defaults to a fresh `InMemoryBlobStorage`. */\n inner?: BlobStorageAdapter\n /** Defaults to {@link objectUrlMinter}; tests inject a deterministic fake. */\n mint?: UrlMinter\n /** Defaults to {@link objectUrlRevoker}; tests inject a fake to assert revocation. */\n revoke?: UrlRevoker\n}\n\n/**\n * Wraps a `BlobStorageAdapter` so `url()`/`resolveUrl()` return a browser-\n * displayable URL instead of the inner adapter's opaque locator (EC-459). See\n * the module doc above for the full rationale.\n */\nexport class BrowserBlobStorage implements BlobStorageAdapter {\n private readonly inner: BlobStorageAdapter\n private readonly mint: UrlMinter\n private readonly revoke: UrlRevoker\n /** storageKey → minted display URL, populated at the moment bytes are known. */\n private readonly displayUrls = new Map<string, string>()\n\n constructor(options: BrowserBlobStorageOptions = {}) {\n this.inner = options.inner ?? new InMemoryBlobStorage()\n this.mint = options.mint ?? objectUrlMinter\n this.revoke = options.revoke ?? objectUrlRevoker\n }\n\n /**\n * Mint (or re-mint) the display URL for `key`. A re-put under the same key\n * (re-upload / overwrite) revokes the stale URL first, so no object URL leaks\n * and no surface can ever show stale bytes under a reused key.\n */\n private remember(key: string, data: Uint8Array, contentType: string | undefined): void {\n const previous = this.displayUrls.get(key)\n if (previous) this.revoke(previous)\n this.displayUrls.set(key, this.mint(data, contentType ?? 'application/octet-stream'))\n }\n\n async put(key: string, data: Uint8Array, contentType?: string): Promise<void> {\n await this.inner.put(key, data, contentType)\n this.remember(key, data, contentType)\n }\n\n async get(key: string): Promise<Uint8Array | undefined> {\n return this.inner.get(key)\n }\n\n url(key: string): string {\n // Fall back to the inner adapter's own locator only when this wrapper never\n // saw the bytes (e.g. a seed record whose metadata carries no stored blob at\n // all) — the same \"no bytes, no pretending\" behaviour the inner adapter has\n // always had; nothing here silently invents a broken image.\n return this.displayUrls.get(key) ?? this.inner.url(key)\n }\n\n async has(key: string): Promise<boolean> {\n return this.inner.has(key)\n }\n\n async delete(key: string): Promise<void> {\n await this.inner.delete(key)\n const cached = this.displayUrls.get(key)\n if (cached) {\n this.revoke(cached)\n this.displayUrls.delete(key)\n }\n }\n\n async store(data: Uint8Array, contentType?: string): Promise<string> {\n if (!this.inner.store) {\n throw new Error('BrowserBlobStorage requires an inner adapter that implements store().')\n }\n const key = await this.inner.store(data, contentType)\n this.remember(key, data, contentType)\n return key\n }\n\n async resolveUrl(key: string): Promise<string | undefined> {\n const cached = this.displayUrls.get(key)\n if (cached) return cached\n // Not minted through THIS wrapper — fall back to whatever the inner adapter\n // can resolve (still `undefined` when genuinely no bytes exist: scope §5,\n // an explicit missing state, never a silently broken image).\n return this.inner.resolveUrl ? this.inner.resolveUrl(key) : undefined\n }\n}\n\n/** Convenience factory — `createBrowserBlobStorage()` wraps a fresh `InMemoryBlobStorage`. */\nexport function createBrowserBlobStorage(options?: BrowserBlobStorageOptions): BlobStorageAdapter {\n return new BrowserBlobStorage(options)\n}\n"]}
@@ -9,3 +9,5 @@ export * from './thumbnail.js';
9
9
  export * from './backfill-thumbs.js';
10
10
  export * from './upload.js';
11
11
  export * from './delete-asset.js';
12
+ export * from './asset-url.js';
13
+ export * from './browser-blob-storage.js';
@@ -9,4 +9,6 @@ export * from './thumbnail.js';
9
9
  export * from './backfill-thumbs.js';
10
10
  export * from './upload.js';
11
11
  export * from './delete-asset.js';
12
+ export * from './asset-url.js';
13
+ export * from './browser-blob-storage.js';
12
14
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/lib/assets/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,cAAc,gBAAgB,CAAA;AAC9B,cAAc,oBAAoB,CAAA;AAClC,cAAc,aAAa,CAAA;AAC3B,cAAc,mBAAmB,CAAA;AACjC,cAAc,UAAU,CAAA;AACxB,cAAc,gBAAgB,CAAA","sourcesContent":["/**\n * Asset upload, deletion, and usage logic (EC-151). Pure/injectable modules:\n * the studio UI consumes these; storage is reached only through the\n * `BlobStorageAdapter` boundary.\n */\nexport * from './upload-state'\nexport * from './image-dimensions'\nexport * from './thumbnail'\nexport * from './backfill-thumbs'\nexport * from './upload'\nexport * from './delete-asset'\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/lib/assets/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,cAAc,gBAAgB,CAAA;AAC9B,cAAc,oBAAoB,CAAA;AAClC,cAAc,aAAa,CAAA;AAC3B,cAAc,mBAAmB,CAAA;AACjC,cAAc,UAAU,CAAA;AACxB,cAAc,gBAAgB,CAAA;AAC9B,cAAc,aAAa,CAAA;AAC3B,cAAc,wBAAwB,CAAA","sourcesContent":["/**\n * Asset upload, deletion, and usage logic (EC-151). Pure/injectable modules:\n * the studio UI consumes these; storage is reached only through the\n * `BlobStorageAdapter` boundary.\n */\nexport * from './upload-state'\nexport * from './image-dimensions'\nexport * from './thumbnail'\nexport * from './backfill-thumbs'\nexport * from './upload'\nexport * from './delete-asset'\nexport * from './asset-url'\nexport * from './browser-blob-storage'\n"]}
@@ -1,4 +1,4 @@
1
- import type { GetDocumentResult, ListDocumentsQuery, ListDocumentsResult, ResolvedDocument } from '@elytracms/core/content';
1
+ import type { ContentQueryError, GetDocumentResult, ListDocumentsQuery, ListDocumentsResult, ResolvedDocument } from '@elytracms/core/content';
2
2
  import type { CmsDocument, CollectionDef, DocumentRef, Locale, LocaleConfig, RouteRecord } from '@elytracms/core/cms-core';
3
3
  import type { AssetRecord } from '@elytracms/core/persistence';
4
4
  import type { ListingQuery } from '@elytracms/core/runtime-renderer';
@@ -72,6 +72,14 @@ export declare const playgroundCollections: readonly CollectionDef[];
72
72
  * published-only — this does not weaken `live-is-published-only`.
73
73
  */
74
74
  export type CanvasPerspective = 'draft' | 'published';
75
+ /** The current content-session epoch — bumps on every `configureContentSession`. */
76
+ export declare function contentSessionEpoch(): number;
77
+ /**
78
+ * Subscribe to content-session swaps (EC-460). The listener fires after every
79
+ * `configureContentSession`; returns the unsubscribe. Shaped for
80
+ * `useSyncExternalStore(subscribeContentSession, contentSessionEpoch)`.
81
+ */
82
+ export declare function subscribeContentSession(listener: () => void): () => void;
75
83
  /**
76
84
  * Point the preview engine at a project's hydrated content — Convex hydration
77
85
  * or the playground's in-memory persisted state (EC-361), both through the same
@@ -118,6 +126,17 @@ export declare function resolveCanvasRelation(ref: DocumentRef, locale?: Locale,
118
126
  * state. Reuses the same `listDocuments` path as `runCollectionListQuery`.
119
127
  */
120
128
  export declare function resolveCanvasListing(query: ListingQuery, locale?: Locale, perspective?: CanvasPerspective): ResolvedDocument[];
129
+ /**
130
+ * Statically validate a listing block's query against the ACTIVE session schema
131
+ * (EC-460, the never-silent rule): the exact `validateWhere`/`validateSort`/
132
+ * `validateLimit` checks `listDocuments` runs, WITHOUT executing the query — so
133
+ * the canvas can surface a config-invalid listing (a filter/sort field the
134
+ * collection doesn't declare `filterable`, an unknown collection) as a visible
135
+ * notice instead of the block's indistinguishable empty state. With no content
136
+ * session configured (boot instants, fixtures) there is no schema to judge
137
+ * against, so it reports nothing rather than false-flagging every listing.
138
+ */
139
+ export declare function validateCanvasListingQuery(query: ListingQuery): ContentQueryError[];
121
140
  /**
122
141
  * Resolve a rich-text internal link's target document to its URL path for the canvas
123
142
  * (EC-291) — the studio analog of the host's baked links map. Derives the URL through
@@ -1,4 +1,4 @@
1
- import { createContentClient, createContentContext, } from '@elytracms/core/content';
1
+ import { createContentClient, createContentContext, queryError, validateLimit, validateSort, validateWhere, } from '@elytracms/core/content';
2
2
  import { documentKey, resolveDocumentTitle, sampleLocaleConfig } from '@elytracms/core/cms-core';
3
3
  import { getStudioLocale } from '../locale/locale-store.js';
4
4
  /**
@@ -14,6 +14,26 @@ let contentSession = null;
14
14
  let lookup = createLookup(playgroundCollections, [], []);
15
15
  // Keyed by `${locale}:${perspective}` (EC-300) — one deterministic client per pair.
16
16
  const clients = new Map();
17
+ // EC-460: a monotonically increasing session epoch + listener set, so a REACT
18
+ // surface (the canvas render context) can re-render when the session is
19
+ // re-pointed at fresher content. The session itself stays a module global (the
20
+ // EC-361 design); this only makes "it changed" observable — `configureContentSession`
21
+ // remains the single setter.
22
+ let sessionEpoch = 0;
23
+ const sessionListeners = new Set();
24
+ /** The current content-session epoch — bumps on every `configureContentSession`. */
25
+ export function contentSessionEpoch() {
26
+ return sessionEpoch;
27
+ }
28
+ /**
29
+ * Subscribe to content-session swaps (EC-460). The listener fires after every
30
+ * `configureContentSession`; returns the unsubscribe. Shaped for
31
+ * `useSyncExternalStore(subscribeContentSession, contentSessionEpoch)`.
32
+ */
33
+ export function subscribeContentSession(listener) {
34
+ sessionListeners.add(listener);
35
+ return () => sessionListeners.delete(listener);
36
+ }
17
37
  /**
18
38
  * Point the preview engine at a project's hydrated content — Convex hydration
19
39
  * or the playground's in-memory persisted state (EC-361), both through the same
@@ -25,6 +45,9 @@ export function configureContentSession(next) {
25
45
  ? createLookup(next.collections, next.documents, next.publishedKeys, next.assets)
26
46
  : createLookup(playgroundCollections, [], []);
27
47
  clients.clear();
48
+ sessionEpoch += 1;
49
+ for (const listener of [...sessionListeners])
50
+ listener();
28
51
  }
29
52
  /** Whether a hydrated content session is configured (Convex mode). */
30
53
  export function hasContentSession() {
@@ -191,6 +214,33 @@ export function resolveCanvasListing(query, locale = getStudioLocale(), perspect
191
214
  });
192
215
  return result.ok ? result.documents : [];
193
216
  }
217
+ /**
218
+ * Statically validate a listing block's query against the ACTIVE session schema
219
+ * (EC-460, the never-silent rule): the exact `validateWhere`/`validateSort`/
220
+ * `validateLimit` checks `listDocuments` runs, WITHOUT executing the query — so
221
+ * the canvas can surface a config-invalid listing (a filter/sort field the
222
+ * collection doesn't declare `filterable`, an unknown collection) as a visible
223
+ * notice instead of the block's indistinguishable empty state. With no content
224
+ * session configured (boot instants, fixtures) there is no schema to judge
225
+ * against, so it reports nothing rather than false-flagging every listing.
226
+ */
227
+ export function validateCanvasListingQuery(query) {
228
+ if (contentSession === null)
229
+ return [];
230
+ const collection = playgroundCollection(query.collection);
231
+ if (!collection) {
232
+ return [
233
+ queryError('unknown-collection', `Collection "${query.collection}" is not defined.`, [
234
+ 'collection',
235
+ ]),
236
+ ];
237
+ }
238
+ return [
239
+ ...validateWhere(collection, query.where),
240
+ ...(query.sort ? validateSort(collection, query.sort) : []),
241
+ ...(query.limit !== undefined ? validateLimit(query.limit) : []),
242
+ ];
243
+ }
194
244
  /**
195
245
  * Resolve a rich-text internal link's target document to its URL path for the canvas
196
246
  * (EC-291) — the studio analog of the host's baked links map. Derives the URL through