camox 0.76.0 → 0.76.1

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 (49) hide show
  1. package/dist/core/editing/createEditableBlock.js +1077 -1681
  2. package/dist/core/editing/fieldSlot.js +251 -0
  3. package/dist/core/editing/useFieldSlot.js +159 -0
  4. package/dist/core/layoutContext.d.ts +2 -0
  5. package/dist/core/layoutContext.js +27 -1
  6. package/dist/features/canvas/CanvasPageFrame.js +2 -2
  7. package/dist/features/canvas/CanvasPreviewPageSlot.js +1 -1
  8. package/dist/features/canvas/canvasCameraController.js +595 -0
  9. package/dist/features/canvas/useCanvasCamera.js +141 -587
  10. package/dist/features/canvas/useCanvasFrame.js +1 -1
  11. package/dist/features/page/DerivedPageContent.js +4 -8
  12. package/dist/features/preview/components/AssetLightbox.js +6 -65
  13. package/dist/features/preview/components/CanvasPreview.js +2 -2
  14. package/dist/features/preview/components/Frame.js +3 -3
  15. package/dist/features/preview/components/ItemFieldsEditor.js +376 -460
  16. package/dist/features/preview/components/Overlays.js +4 -9
  17. package/dist/features/preview/components/PreviewPanel.js +3 -3
  18. package/dist/features/preview/components/RemovedBlockPlaceholder.js +2 -2
  19. package/dist/features/preview/components/SelectionEditor.js +285 -339
  20. package/dist/features/preview/components/ZoomableImage.js +105 -0
  21. package/dist/features/preview/components/contentFieldSchema.js +3 -1
  22. package/dist/features/preview/components/useFieldWriter.js +30 -51
  23. package/dist/features/preview/components/useRecordView.js +3 -3
  24. package/dist/features/preview/overlayMessages.js +20 -58
  25. package/dist/features/preview/previewStore.js +6 -123
  26. package/dist/features/preview/repeatableItemPath.js +47 -0
  27. package/dist/features/renderer/RendererApp.js +6 -6
  28. package/dist/features/renderer/RendererFrames.js +2 -2
  29. package/dist/features/renderer/rendererHost.js +1 -1
  30. package/dist/features/routes/pageRuntime.d.ts +2 -8
  31. package/dist/features/routes/pageRuntime.js +21 -105
  32. package/dist/features/runtime/pageApp.js +1 -1
  33. package/dist/features/runtime/pageRead.js +105 -0
  34. package/dist/features/runtime/reader.d.ts +25 -0
  35. package/dist/features/runtime/reader.js +142 -0
  36. package/dist/features/runtime/runtime.d.ts +9 -3
  37. package/dist/features/runtime/runtime.js +608 -776
  38. package/dist/features/sdkBundle/buildSdkBundle.d.ts +5 -0
  39. package/dist/features/sdkBundle/buildSdkBundle.js +9 -3
  40. package/dist/features/studio/components/DraftsSheet.js +124 -65
  41. package/dist/features/studio/components/ThreadMessages.js +5 -9
  42. package/dist/features/studio/showInCanvas.js +9 -19
  43. package/dist/features/studio/useEditorSelection.js +8 -19
  44. package/dist/package.js +1 -1
  45. package/dist/studio.css +1 -1
  46. package/package.json +4 -4
  47. package/dist/core/hooks/useFieldSelection.js +0 -67
  48. package/dist/core/hooks/useIsEditable.js +0 -21
  49. package/dist/core/hooks/useOverlayMessage.js +0 -70
@@ -36,10 +36,16 @@ function parseMarkdownPath(pathname) {
36
36
  if (pathname === "/index.md") return "/";
37
37
  return pathname.slice(0, -3) || null;
38
38
  }
39
- function createServerApiClient(apiUrl, options) {
40
- const headers = localeHeaders(options?.locale);
41
- if (options?.lookToken) headers[LOOK_TOKEN_HEADER] = options.lookToken;
42
- if (options?.draftId && (options.authCookieHeader || options.lookToken)) headers[DRAFT_HEADER] = options.draftId;
39
+ /**
40
+ * The API's client for one kind of read: with a credential, in a draft or Live, in a locale. Only
41
+ * a credential can read a draft, so a draft is only ever named with one; a Live read names the
42
+ * draft this Worker publishes, if any.
43
+ */
44
+ function createServerApiClient(apiUrl, { credential, draftId, locale } = {}) {
45
+ const headers = localeHeaders(locale);
46
+ const session = credential && "session" in credential ? credential.session : void 0;
47
+ if (credential && "lookToken" in credential) headers[LOOK_TOKEN_HEADER] = credential.lookToken;
48
+ if (draftId && credential) headers[DRAFT_HEADER] = draftId;
43
49
  else {
44
50
  const publishing = publishingDraft();
45
51
  if (publishing) headers[PUBLISHING_DRAFT_HEADER] = publishing;
@@ -48,7 +54,7 @@ function createServerApiClient(apiUrl, options) {
48
54
  url: `${apiUrl}/rpc`,
49
55
  headers,
50
56
  fetch: (request, init) => {
51
- if (options?.authCookieHeader && request instanceof Request) request.headers.set("Better-Auth-Cookie", options.authCookieHeader);
57
+ if (session && request instanceof Request) request.headers.set("Better-Auth-Cookie", session);
52
58
  return fetch(request, init);
53
59
  }
54
60
  }));
@@ -92,42 +98,16 @@ function draftRefusalNotice(error, draftId) {
92
98
  if (code === "FORBIDDEN" || status === 403) return { reason: "forbidden" };
93
99
  if (code === "UNAUTHORIZED" || status === 401) return null;
94
100
  }
95
- /**
96
- * Reads with the draft a URL names, or Live (ADR 0011). Only a member's request names the draft;
97
- * anyone else reads Live. When the API refuses the draft, the same response reads Live instead.
98
- */
99
- async function readDraftOrLive(draftId, member, read) {
100
- if (!draftId) return { value: await read(void 0) };
101
- if (!member) return {
102
- value: await read(void 0),
103
- refusedDraft: { notice: null }
104
- };
105
- try {
106
- return {
107
- value: await read(draftId),
108
- draftId
109
- };
110
- } catch (error) {
111
- const notice = draftRefusalNotice(error, draftId);
112
- if (notice === void 0) throw error;
113
- return {
114
- value: await read(void 0),
115
- refusedDraft: { notice }
116
- };
117
- }
118
- }
119
101
  /** The URL a render shows: without the draft param when the draft couldn't be read. */
120
102
  function renderedHref(url, read) {
121
103
  return read.refusedDraft ? withoutDraft(url) : url;
122
104
  }
123
- async function loadCamoxPage({ apiUrl, authCookieHeader, lookToken, draftId, locale, pathname, projectUuid, queryClient }) {
105
+ async function loadCamoxPage({ reader, draftId, locale, pathname, projectUuid, queryClient }) {
124
106
  const studioCache = createServerStudioCache(queryClient, {
125
- apiUrl,
126
- authCookieHeader,
127
- lookToken,
107
+ reader,
128
108
  locale
129
109
  });
130
- const inDraft = { in: draftId && (authCookieHeader || lookToken) ? draftId : null };
110
+ const inDraft = { in: draftId ?? null };
131
111
  const fallback = locale && linkFallbackLocale(locale);
132
112
  const [page] = await Promise.all([
133
113
  studioCache.ensure(reads.pageStructure({
@@ -143,16 +123,14 @@ async function loadCamoxPage({ apiUrl, authCookieHeader, lookToken, draftId, loc
143
123
  return page;
144
124
  }
145
125
  /**
146
- * The studio's reads for one server request, read with its session. Each operation names the
147
- * draft it acts in, once the request settled which one it reads.
126
+ * The studio's reads for one server request, read as its reader. Each operation names the draft
127
+ * it acts in, once the request settled which one it reads.
148
128
  */
149
- function createServerStudioCache(queryClient, { apiUrl, authCookieHeader, lookToken, locale }) {
129
+ function createServerStudioCache(queryClient, { reader, locale }) {
150
130
  return createStudioCache({
151
131
  queryClient,
152
132
  scope: createDraftScope({ queryClient }),
153
- api: (draft, inLocale) => createServerApiClient(apiUrl, {
154
- authCookieHeader,
155
- lookToken,
133
+ api: (draft, inLocale) => reader.api({
156
134
  draftId: draft ?? void 0,
157
135
  locale: inLocale
158
136
  }),
@@ -160,45 +138,6 @@ function createServerStudioCache(queryClient, { apiUrl, authCookieHeader, lookTo
160
138
  });
161
139
  }
162
140
  /**
163
- * The locales of the scope a request reads: the URL's draft's for a session naming one, else
164
- * Live's. A draft the API refuses reads Live's, as the request's other reads then do. Live's read
165
- * keeps the session: a member's studio routes the locales Live doesn't serve yet, which visitors
166
- * never see (ADR 0043), and a render with a session is private.
167
- */
168
- async function readScopeLocales({ apiUrl, authCookieHeader, draftId, projectUuid }) {
169
- const list = (session, readDraftId) => createServerApiClient(apiUrl, {
170
- authCookieHeader: session,
171
- draftId: readDraftId
172
- }).locales.list({ projectUuid });
173
- const { value } = await readDraftOrLive(draftId, !!authCookieHeader, async (readDraftId) => {
174
- if (readDraftId || !authCookieHeader) return list(authCookieHeader, readDraftId);
175
- try {
176
- return await list(authCookieHeader, void 0);
177
- } catch (error) {
178
- if (!isAuthSessionError(error)) throw error;
179
- return list(void 0, void 0);
180
- }
181
- });
182
- return value;
183
- }
184
- /**
185
- * The project, when the auth cookie is a valid session of one of its members. A successful
186
- * protected project lookup validates both session and project access: cookie presence alone
187
- * is not proof of authentication. An invalid session asks for the cookie to be cleared.
188
- */
189
- async function resolveMemberProject({ apiUrl, authCookieHeader, projectUuid }) {
190
- if (!authCookieHeader) return { clearAuthCookie: false };
191
- try {
192
- return {
193
- project: await createServerApiClient(apiUrl, { authCookieHeader }).projects.getByUuid({ projectUuid }),
194
- clearAuthCookie: false
195
- };
196
- } catch (error) {
197
- if (!isAuthSessionError(error)) throw error;
198
- return { clearAuthCookie: true };
199
- }
200
- }
201
- /**
202
141
  * The project's favicon, which falls back to the default favicon when none was uploaded.
203
142
  * Pass `withDefault: false` to get a 404 instead, to learn whether there is one.
204
143
  */
@@ -217,36 +156,13 @@ function buildCamoxPageLoaderData(apiUrl, page, origin) {
217
156
  faviconUrl: buildFaviconUrl(apiUrl, page.project)
218
157
  };
219
158
  }
220
- async function loadCamoxPageForRequest({ apiUrl, authCookieHeader, draftId, locale, origin, pathname, projectUuid, queryClient }) {
221
- const member = await resolveMemberProject({
222
- apiUrl,
223
- authCookieHeader,
224
- projectUuid
225
- });
226
- if (member.clearAuthCookie) console.warn("[camox] Ignoring stale camox_auth_cookie.");
227
- const { value: page, ...read } = await readDraftOrLive(draftId, !!member.project, (readDraftId) => loadCamoxPage({
228
- apiUrl,
229
- authCookieHeader: member.project ? authCookieHeader : void 0,
230
- draftId: readDraftId,
231
- locale,
232
- pathname,
233
- projectUuid,
234
- queryClient
235
- }));
236
- return {
237
- ...read,
238
- data: buildCamoxPageLoaderData(apiUrl, page, origin),
239
- project: member.project,
240
- shouldClearAuthCookie: member.clearAuthCookie
241
- };
242
- }
243
159
  /**
244
160
  * Reads the Markdown Representation of the page at a runtime path, with its locale's prefix, in
245
161
  * one API read: the API finds the locale the prefix names, the page there, its Markdown and what
246
162
  * its front matter is built from. Markdown is a public output, so it reads Live whoever asks.
247
163
  */
248
- function readPageMarkdown({ apiUrl, path, projectUuid }) {
249
- return createServerApiClient(apiUrl).pages.getMarkdown({
164
+ function readPageMarkdown({ reader, path, projectUuid }) {
165
+ return reader.api().pages.getMarkdown({
250
166
  path,
251
167
  projectUuid
252
168
  });
@@ -366,4 +282,4 @@ function buildCamoxPageHead(camoxApp, loaderData, locale) {
366
282
  }
367
283
 
368
284
  //#endregion
369
- export { buildCamoxPageHead, buildCamoxPageLoaderData, buildDefaultFaviconUrl, buildFaviconUrl, buildPageHead, buildPageMeta, createServerApiClient, createServerStudioCache, curatedMarkdownResponse, curatedPageMetaInput, declarePage, getMarkdownPath, isAuthSessionError, isNotFoundError, loadCamoxPage, loadCamoxPageForRequest, parseMarkdownPath, parseQuality, prefersMarkdown, readDraftOrLive, readPageMarkdown, readScopeLocales, renderedHref, resolveMemberProject, withFrontMatter };
285
+ export { buildCamoxPageHead, buildCamoxPageLoaderData, buildDefaultFaviconUrl, buildFaviconUrl, buildPageHead, buildPageMeta, createServerApiClient, createServerStudioCache, curatedMarkdownResponse, curatedPageMetaInput, declarePage, draftRefusalNotice, getMarkdownPath, isAuthSessionError, isNotFoundError, loadCamoxPage, parseMarkdownPath, parseQuality, prefersMarkdown, readPageMarkdown, renderedHref, withFrontMatter };
@@ -2,8 +2,8 @@ import { DraftScopeContext } from "../../lib/draft-scope.js";
2
2
  import { useAuthState, useSignInRedirect } from "../../lib/auth.js";
3
3
  import { ArtifactCaptureContext } from "../../core/hooks/useArtifactHost.js";
4
4
  import { useSignInShortcut } from "../provider/signInShortcut.js";
5
- import { PreviewActivation } from "../preview/components/PreviewActivation.js";
6
5
  import { PreviewDocumentContext } from "./PreviewDocumentContext.js";
6
+ import { PreviewActivation } from "../preview/components/PreviewActivation.js";
7
7
  import { Frame } from "../preview/components/Frame.js";
8
8
  import { PreviewSignInPage } from "../provider/PreviewSignInPage.js";
9
9
  import { CoreCamoxProvider } from "../provider/CoreCamoxProvider.js";
@@ -0,0 +1,105 @@
1
+ import { createRequestContext } from "../../core/requestContext.js";
2
+ import { asCollectionItem } from "../../core/recordItem.js";
3
+ import { matchDerivedLayout } from "../../core/derivedRoutes.js";
4
+ import { layoutPageContext } from "../../core/layoutContext.js";
5
+ import { isNotFoundError } from "../routes/pageRuntime.js";
6
+
7
+ //#region src/features/runtime/pageRead.ts
8
+ /**
9
+ * What a path shows, in the Locale the path's prefix named: a Singleton Layout's page first, then
10
+ * the curated page at the path, then a Derived Layout's page. Each output reads the curated page
11
+ * and the layout's blocks as it needs them; Page Read decides which it shows and assembles a
12
+ * Layout Page. A Layout Page's loader runs in the Draft its blocks are read in: alongside them
13
+ * when the reader knows the Draft, after them when their read settles it.
14
+ */
15
+ async function readPage({ app, path, pathname, reader, curated, layout }) {
16
+ const match = app && matchDerivedLayout(app.getLayouts(), path);
17
+ const layoutPage = () => readLayoutPage({
18
+ app,
19
+ match,
20
+ pathname,
21
+ reader,
22
+ readBlocks: layout
23
+ });
24
+ if (!mayShowCuratedPage(app, path)) return layoutPage();
25
+ try {
26
+ const { value: page, ...read } = await curated();
27
+ if (page !== null) return {
28
+ kind: "curated",
29
+ page,
30
+ ...read
31
+ };
32
+ } catch (error) {
33
+ if (!isNotFoundError(error)) throw error;
34
+ }
35
+ return match ? layoutPage() : { kind: "none" };
36
+ }
37
+ /**
38
+ * Whether a path may show a curated page, so reading one there may be worth starting early: not
39
+ * a Singleton Layout's route.
40
+ */
41
+ function mayShowCuratedPage(app, path) {
42
+ return (app && matchDerivedLayout(app.getLayouts(), path))?.layout._internal.kind !== "singleton";
43
+ }
44
+ async function readLayoutPage({ app, match, pathname, reader, readBlocks }) {
45
+ const layout = match.layout._internal;
46
+ const blocksInScope = () => reader.inScope((draftId) => readBlocks(draftId, layout.id));
47
+ const load = (draftId) => loadLayoutResult(app, match, createRequestContext({ records: reader.records(draftId) }), reader.locale);
48
+ let result;
49
+ let read;
50
+ try {
51
+ if (reader.draft) [result, read] = await Promise.all([load(reader.draft.id), blocksInScope()]);
52
+ else {
53
+ read = await blocksInScope();
54
+ result = await load(read.draftId);
55
+ }
56
+ } catch (error) {
57
+ if (isNotFoundError(error)) return {
58
+ kind: "none",
59
+ layout: match.layout
60
+ };
61
+ throw error;
62
+ }
63
+ const { value: blocks, ...draftRead } = read;
64
+ return {
65
+ kind: "layout",
66
+ layout: match.layout,
67
+ params: match.params,
68
+ blocks,
69
+ result,
70
+ ...layoutPageContext(app, layout, {
71
+ path: pathname,
72
+ locale: reader.locale.tag,
73
+ projectName: blocks.projectName,
74
+ params: match.params,
75
+ result
76
+ }),
77
+ ...draftRead
78
+ };
79
+ }
80
+ const RECORD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
81
+ /** An envelope names its record; it doesn't authorize anything, the API does. */
82
+ function isValidCollectionItem(app, result) {
83
+ const data = result.data;
84
+ return typeof result.collectionId === "string" && !!app.getCollectionById(result.collectionId) && typeof data?.id === "string" && RECORD_ID.test(data.id) && typeof data.content === "object" && data.content !== null;
85
+ }
86
+ /**
87
+ * Runs a Layout Page's loader in the page's locale. The envelope survives SSR, navigation and
88
+ * hydration, and its data renders as returned: Camox never translates it.
89
+ */
90
+ async function loadLayoutResult(app, match, context, locale) {
91
+ const { loader, id } = match.layout._internal;
92
+ if (!loader) return void 0;
93
+ const result = await loader({
94
+ params: match.params,
95
+ context,
96
+ locale: locale.tag
97
+ });
98
+ const serializedResult = result === void 0 ? void 0 : JSON.parse(JSON.stringify(result));
99
+ const record = asCollectionItem(serializedResult);
100
+ if (!(record ? isValidCollectionItem(app, record) : serializedResult?.kind === "data" && Object.hasOwn(serializedResult, "data"))) throw new Error(`Layout "${id}" loader must return { kind: "data", data } with JSON-serializable data, or a collection-item envelope naming a registered collection and a record id`);
101
+ return serializedResult;
102
+ }
103
+
104
+ //#endregion
105
+ export { mayShowCuratedPage, readPage };
@@ -0,0 +1,25 @@
1
+ import { LocaleView } from "../../lib/locale-path.js";
2
+ import "../routes/pageRuntime.js";
3
+ import { Router } from "@camox/api-contract/router";
4
+ import { RouterClient } from "@orpc/server";
5
+ //#region src/features/runtime/reader.d.ts
6
+ /** The API's procedures, as the content port answers them. */
7
+ export type ContentClient = RouterClient<Router>;
8
+ /** What proves a read may see more than Live: a Member's session, or a look token (ADR 0032). */
9
+ export type Credential = {
10
+ session: string;
11
+ } | {
12
+ lookToken: string;
13
+ };
14
+ /** Which reads a client makes: with which credential, in which Draft (Live without), in which Locale. */
15
+ export interface ContentAccess {
16
+ credential?: Credential;
17
+ draftId?: string;
18
+ locale?: LocaleView;
19
+ }
20
+ /**
21
+ * The content port: the API's reads, as one credential sees them, in Live or a Draft, in a
22
+ * Locale. Production reads the API (`apiContent`); tests read an in-memory one.
23
+ */
24
+ export type Content = (access: ContentAccess) => ContentClient;
25
+ //#endregion
@@ -0,0 +1,142 @@
1
+ import { draftNamedBy } from "../../lib/draft-scope.js";
2
+ import { buildClearServerAuthCookieHeader, getLookToken, getServerAuthCookieHeader } from "../../lib/server-auth-cookie.js";
3
+ import { createServerApiClient, draftRefusalNotice, isAuthSessionError } from "../routes/pageRuntime.js";
4
+ import { DRAFT_SEARCH_PARAM } from "@camox/api-contract/drafts";
5
+
6
+ //#region src/features/runtime/reader.ts
7
+ /** The content port's production adapter: the API at `apiUrl`, over oRPC. */
8
+ function apiContent(apiUrl) {
9
+ return (access) => createServerApiClient(apiUrl, access);
10
+ }
11
+ function contentOf(options) {
12
+ return options.content ?? (options.apiUrl ? apiContent(options.apiUrl) : void 0);
13
+ }
14
+ /**
15
+ * Whether a request may see what only it sees, so no shared cache keeps its response (ADR 0022).
16
+ * Known before any read, so a cache can decide before the runtime answers.
17
+ */
18
+ function isPrivateRequest(request) {
19
+ return !!getServerAuthCookieHeader(request.headers) || draftNamedBy(request.url) !== null;
20
+ }
21
+ /**
22
+ * The request's Reader: a Member when the API confirms its session for the project, which it
23
+ * asks once, else a Visitor. A session the API refuses is cleared. Without a project or a way to
24
+ * read content, nobody is a Member.
25
+ */
26
+ async function resolveReader(request, options) {
27
+ const content = contentOf(options);
28
+ const session = getServerAuthCookieHeader(request.headers);
29
+ const named = draftNamedBy(request.url) ?? void 0;
30
+ const isPrivate = isPrivateRequest(request);
31
+ if (!content || !options.projectUuid || !session) return visitor(content, options, {
32
+ named,
33
+ private: isPrivate
34
+ });
35
+ let member;
36
+ try {
37
+ member = await content({ credential: { session } }).projects.getByUuid({ projectUuid: options.projectUuid });
38
+ } catch (error) {
39
+ if (!isAuthSessionError(error)) throw error;
40
+ console.warn("[camox] Ignoring stale camox_auth_cookie.");
41
+ return visitor(content, options, {
42
+ named,
43
+ private: isPrivate,
44
+ setCookie: buildClearServerAuthCookieHeader()
45
+ });
46
+ }
47
+ const credential = { session };
48
+ const api = ({ draftId, locale } = {}) => content({
49
+ credential,
50
+ draftId,
51
+ locale
52
+ });
53
+ return withLocales(options, {
54
+ member,
55
+ draftId: named,
56
+ private: isPrivate,
57
+ setCookie: void 0,
58
+ api,
59
+ read: async (read) => {
60
+ if (!named) return { value: await read(void 0) };
61
+ try {
62
+ return {
63
+ value: await read(named),
64
+ draftId: named
65
+ };
66
+ } catch (error) {
67
+ const notice = draftRefusalNotice(error, named);
68
+ if (notice === void 0) throw error;
69
+ return {
70
+ value: await read(void 0),
71
+ refusedDraft: { notice }
72
+ };
73
+ }
74
+ }
75
+ });
76
+ }
77
+ /**
78
+ * A Visitor's Reader, for what reads Live whoever asks (sitemaps, llms.txt, Markdown): no
79
+ * session check, no credential.
80
+ */
81
+ function visitorReader(options) {
82
+ return visitor(contentOf(options), options, {
83
+ named: void 0,
84
+ private: false
85
+ });
86
+ }
87
+ /**
88
+ * The look document's Reader (ADR 0032): the look token the request's cookie carries, for the
89
+ * Draft the URL names. Null without both.
90
+ */
91
+ function lookReader(request, options) {
92
+ const content = contentOf(options);
93
+ const lookToken = getLookToken(request.headers);
94
+ const draftId = new URL(request.url).searchParams.get(DRAFT_SEARCH_PARAM) || void 0;
95
+ if (!content || !lookToken || !draftId) return null;
96
+ const credential = { lookToken };
97
+ return withLocales(options, {
98
+ member: void 0,
99
+ draftId,
100
+ private: true,
101
+ setCookie: void 0,
102
+ api: ({ locale } = {}) => content({
103
+ credential,
104
+ draftId,
105
+ locale
106
+ }),
107
+ read: async (read) => ({
108
+ value: await read(draftId),
109
+ draftId
110
+ })
111
+ });
112
+ }
113
+ function visitor(content, options, { named, private: isPrivate, setCookie }) {
114
+ const api = ({ locale } = {}) => {
115
+ if (!content) throw new Error("Camox has no content to read: the runtime names no API.");
116
+ return content({ locale });
117
+ };
118
+ return withLocales(options, {
119
+ member: void 0,
120
+ draftId: void 0,
121
+ private: isPrivate,
122
+ setCookie,
123
+ api,
124
+ read: async (read) => ({
125
+ value: await read(void 0),
126
+ ...named && { refusedDraft: { notice: null } }
127
+ })
128
+ });
129
+ }
130
+ function withLocales(options, reader) {
131
+ return {
132
+ ...reader,
133
+ locales: async () => {
134
+ const projectUuid = options.projectUuid;
135
+ const { value } = await reader.read((draftId) => reader.api({ draftId }).locales.list({ projectUuid }));
136
+ return value;
137
+ }
138
+ };
139
+ }
140
+
141
+ //#endregion
142
+ export { apiContent, isPrivateRequest, lookReader, resolveReader, visitorReader };
@@ -3,9 +3,10 @@ import { AlternatePath, LocaleView } from "../../lib/locale-path.js";
3
3
  import { AnyLayoutLoaderResult } from "../../core/createLayout.js";
4
4
  import { CamoxApp } from "../../core/createApp.js";
5
5
  import { AnalyticsProviders } from "./analytics.js";
6
+ import { Content, ContentClient } from "./reader.js";
6
7
  import { RuntimeOwnedRouteKind } from "./runtimeRoutes.js";
7
8
  import { StudioRenderInput } from "./studioApp.js";
8
- import { DraftRead, MemberProject, PageHead, createServerApiClient } from "../routes/pageRuntime.js";
9
+ import { DraftRead, MemberProject, PageHead } from "../routes/pageRuntime.js";
9
10
  import { LookPlaceholderHeight } from "@camox/api-contract/drafts";
10
11
  import { UseHeadInput } from "unhead/types";
11
12
  //#region src/features/runtime/runtime.d.ts
@@ -47,7 +48,7 @@ export interface PageRenderInput extends DraftRead {
47
48
  */
48
49
  lookLayout?: {
49
50
  layoutId: string;
50
- layout: Awaited<ReturnType<ReturnType<typeof createServerApiClient>["layouts"]["getUnplaced"]>>["layout"];
51
+ layout: Awaited<ReturnType<ContentClient["layouts"]["getUnplaced"]>>["layout"];
51
52
  placeholder: LookPlaceholderHeight;
52
53
  path: string;
53
54
  projectName: string;
@@ -74,7 +75,7 @@ export interface PageRenderInput extends DraftRead {
74
75
  projectName: string;
75
76
  };
76
77
  result?: AnyLayoutLoaderResult;
77
- layout: Awaited<ReturnType<ReturnType<typeof createServerApiClient>["layouts"]["get"]>>["layout"];
78
+ layout: Awaited<ReturnType<ContentClient["layouts"]["get"]>>["layout"];
78
79
  };
79
80
  apiUrl: string;
80
81
  authenticationUrl: string;
@@ -97,6 +98,11 @@ export interface PageRenderInput extends DraftRead {
97
98
  }
98
99
  export interface RuntimeOptions {
99
100
  apiUrl?: string;
101
+ /**
102
+ * Where the runtime reads content, as each request's Reader sees it. Without it, the API at
103
+ * `apiUrl`. Tests read an in-memory one.
104
+ */
105
+ content?: Content;
100
106
  authenticationUrl?: string;
101
107
  /** Where the browser loads each block from, so a page can preload the blocks it renders. */
102
108
  blockPreloads?: {