camox 0.76.0 → 0.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) 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/core/lib/artifactTheme.js +43 -6
  7. package/dist/features/canvas/CanvasPageFrame.js +2 -2
  8. package/dist/features/canvas/CanvasPreviewPageSlot.js +1 -1
  9. package/dist/features/canvas/canvasCameraController.js +595 -0
  10. package/dist/features/canvas/useCanvasCamera.js +141 -587
  11. package/dist/features/canvas/useCanvasFrame.js +1 -1
  12. package/dist/features/page/DerivedPageContent.js +4 -8
  13. package/dist/features/preview/components/AssetLightbox.js +6 -65
  14. package/dist/features/preview/components/CanvasPreview.js +2 -2
  15. package/dist/features/preview/components/Frame.js +3 -3
  16. package/dist/features/preview/components/ItemFieldsEditor.js +376 -460
  17. package/dist/features/preview/components/Overlays.js +4 -9
  18. package/dist/features/preview/components/PreviewPanel.js +3 -3
  19. package/dist/features/preview/components/RemovedBlockPlaceholder.js +2 -2
  20. package/dist/features/preview/components/SelectionEditor.js +285 -339
  21. package/dist/features/preview/components/ZoomableImage.js +105 -0
  22. package/dist/features/preview/components/contentFieldSchema.js +3 -1
  23. package/dist/features/preview/components/useFieldWriter.js +30 -51
  24. package/dist/features/preview/components/useRecordView.js +3 -3
  25. package/dist/features/preview/overlayMessages.js +20 -58
  26. package/dist/features/preview/previewStore.js +6 -123
  27. package/dist/features/preview/repeatableItemPath.js +47 -0
  28. package/dist/features/renderer/RendererApp.js +6 -6
  29. package/dist/features/renderer/RendererFrames.js +2 -2
  30. package/dist/features/renderer/rendererHost.js +1 -1
  31. package/dist/features/routes/pageRuntime.d.ts +2 -8
  32. package/dist/features/routes/pageRuntime.js +21 -105
  33. package/dist/features/runtime/pageApp.js +1 -1
  34. package/dist/features/runtime/pageRead.js +105 -0
  35. package/dist/features/runtime/reader.d.ts +25 -0
  36. package/dist/features/runtime/reader.js +142 -0
  37. package/dist/features/runtime/runtime.d.ts +9 -3
  38. package/dist/features/runtime/runtime.js +608 -776
  39. package/dist/features/sdkBundle/buildSdkBundle.d.ts +5 -0
  40. package/dist/features/sdkBundle/buildSdkBundle.js +9 -3
  41. package/dist/features/studio/components/DraftsSheet.js +124 -65
  42. package/dist/features/studio/components/LocalePicker.js +518 -134
  43. package/dist/features/studio/components/ThreadMessages.js +5 -9
  44. package/dist/features/studio/showInCanvas.js +9 -19
  45. package/dist/features/studio/useEditorSelection.js +8 -19
  46. package/dist/lib/locale-path.js +2 -2
  47. package/dist/lib/queries.js +5 -1
  48. package/dist/package.js +1 -1
  49. package/dist/studio.css +1 -1
  50. package/package.json +4 -4
  51. package/skills/camox/references/artifacts.md +7 -0
  52. package/dist/core/hooks/useFieldSelection.js +0 -67
  53. package/dist/core/hooks/useIsEditable.js +0 -21
  54. package/dist/core/hooks/useOverlayMessage.js +0 -70
@@ -1,19 +1,19 @@
1
1
  import { serializeJsonForHtml } from "../../lib/html.js";
2
+ import { notFound } from "../../core/notFound.js";
2
3
  import { createRequestContext } from "../../core/requestContext.js";
3
4
  import { UNNAMED_DEFAULT_LOCALE, localizedPath, parseLocalePath } from "../../lib/locale-path.js";
4
- import { draftNamedBy } from "../../lib/draft-scope.js";
5
- import { buildClearServerAuthCookieHeader, buildServerAuthCookieHeader, getLookToken, getServerAuthCookieHeader } from "../../lib/server-auth-cookie.js";
5
+ import { buildServerAuthCookieHeader } from "../../lib/server-auth-cookie.js";
6
6
  import { getCookie, getSetCookie } from "../../lib/stored-auth-cookies.js";
7
7
  import { ARTIFACTS_PATH_PREFIX, RUNTIME_ROUTE_PATHS, matchRuntimeOwnedRoute } from "./runtimeRoutes.js";
8
8
  import { reads } from "../../lib/studio-reads.js";
9
- import { asCollectionItem, recordMarkdownTemplate, recordPageValues } from "../../core/recordItem.js";
9
+ import { recordMarkdownTemplate } from "../../core/recordItem.js";
10
10
  import { PLATFORM_SCRIPT } from "../../lib/platform.js";
11
11
  import { STUDIO_ASSETS_PATH, STUDIO_CANVAS_PATH, STUDIO_CONTENT_PATH, STUDIO_SETTINGS_PATH, matchCollectionContentPath } from "../studio/routes.js";
12
12
  import { STUDIO_THEME_SCRIPT } from "../studio/studioTheme.js";
13
13
  import { DefaultPageImage, renderPageImage } from "../og/pageImage.js";
14
- import { discoverLayoutPages, matchDerivedLayout } from "../../core/derivedRoutes.js";
15
- import { buildLayoutContext, curatedLayoutContext } from "../../core/layoutContext.js";
16
- import { buildCamoxPageHead, buildCamoxPageLoaderData, buildFaviconUrl, buildPageHead, buildPageMeta, createServerApiClient, createServerStudioCache, curatedMarkdownResponse, declarePage, isAuthSessionError, isNotFoundError, loadCamoxPage, loadCamoxPageForRequest, parseMarkdownPath, prefersMarkdown, readDraftOrLive, readPageMarkdown, readScopeLocales, renderedHref, resolveMemberProject, withFrontMatter } from "../routes/pageRuntime.js";
14
+ import { discoverLayoutPages } from "../../core/derivedRoutes.js";
15
+ import { curatedLayoutContext } from "../../core/layoutContext.js";
16
+ import { buildCamoxPageHead, buildCamoxPageLoaderData, buildFaviconUrl, buildPageHead, buildPageMeta, createServerStudioCache, curatedMarkdownResponse, declarePage, isAuthSessionError, isNotFoundError, loadCamoxPage, parseMarkdownPath, prefersMarkdown, readPageMarkdown, renderedHref, withFrontMatter } from "../routes/pageRuntime.js";
17
17
  import { assetPrefix } from "./assetPrefix.js";
18
18
  import { documentGoogleFonts } from "../og/documentFonts.js";
19
19
  import { alternatesByPage, findAlternates, hreflangLinks } from "./alternates.js";
@@ -26,7 +26,9 @@ import { formatLlmsTxt } from "./llmsTxt.js";
26
26
  import { getPageBlockTypes } from "./pageBlockTypes.js";
27
27
  import { withPageUrls } from "./structuredData.js";
28
28
  import { curatedPageDescription, pageRecords, placedBlockNodes, withStructuredData } from "./pageGraph.js";
29
+ import { mayShowCuratedPage, readPage } from "./pageRead.js";
29
30
  import { createProjectLogoResponse, projectLogo } from "./projectLogo.js";
31
+ import { isPrivateRequest, lookReader, resolveReader, visitorReader } from "./reader.js";
30
32
  import { DRAFT_SEARCH_PARAM, LOCALE_HEADER, LOOK_BLOCKS_PARAM, LOOK_BLOCK_DEFINITION_PARAM, LOOK_CONTENT_PARAM, LOOK_LAYOUT_DEFINITION_PARAM, LOOK_LOCALE_PARAM, LOOK_PATH_PARAM, LOOK_PLACEHOLDER_HEIGHTS, LOOK_PLACEHOLDER_PARAM, LOOK_SETTINGS_PARAM, UNPLACED_BLOCK_ID } from "@camox/api-contract/drafts";
31
33
  import { QueryClient, dehydrate } from "@tanstack/react-query";
32
34
  import { createHead, renderSSRHead } from "@unhead/react/server";
@@ -53,16 +55,19 @@ function getRuntimePathname(url, runtimeBasePath = DEFAULT_RUNTIME_BASE_PATH) {
53
55
  if (!pathname.startsWith(`${basePath}/`)) return null;
54
56
  return pathname.slice(basePath.length) || "/";
55
57
  }
56
- /** The draft the request's URL names, if any. Sitemaps and Markdown ignore it: they read Live. */
57
- function getRequestDraftId(request) {
58
- return draftNamedBy(request.url) ?? void 0;
59
- }
58
+ /** What a response says when no cache may keep it. */
59
+ const PRIVATE_HEADERS = { "Cache-Control": "private, no-store" };
60
60
  /**
61
- * Whether a response may show what only this visitor sees: a session, or a draft named in the
62
- * URL. Shared caches must never keep it.
61
+ * The headers of what a request reads as `reader`: private when it may show what only this
62
+ * reader sees (or `alwaysPrivate`), varying by the session's cookie, which a stale session's
63
+ * response clears.
63
64
  */
64
- function isPrivateRender(request) {
65
- return !!getServerAuthCookieHeader(request.headers) || getRequestDraftId(request) !== void 0;
65
+ function readerHeaders(reader, { alwaysPrivate = false } = {}) {
66
+ return {
67
+ ...(alwaysPrivate || reader.private) && PRIVATE_HEADERS,
68
+ Vary: "Cookie",
69
+ ...reader.setCookie && { "Set-Cookie": reader.setCookie }
70
+ };
66
71
  }
67
72
  /** The routes whose response is the same for every visitor, unless the request is private. */
68
73
  const PUBLIC_ROUTE_KINDS = /* @__PURE__ */ new Set([
@@ -80,7 +85,7 @@ const PUBLIC_ROUTE_KINDS = /* @__PURE__ */ new Set([
80
85
  */
81
86
  function isPublicRequest(request, options = {}) {
82
87
  if (request.method !== "GET" && request.method !== "HEAD") return false;
83
- if (isPrivateRender(request) || new URL(request.url).searchParams.has("ott")) return false;
88
+ if (isPrivateRequest(request) || new URL(request.url).searchParams.has("ott")) return false;
84
89
  const pathname = getRuntimePathname(request.url, options.runtimeBasePath);
85
90
  return pathname !== null && PUBLIC_ROUTE_KINDS.has(matchRuntimeRoute(pathname).kind);
86
91
  }
@@ -147,9 +152,10 @@ async function respondWithLiveWebsite(options, name, respond, { everyLocale = fa
147
152
  const { apiUrl, projectUuid } = options;
148
153
  if (!apiUrl || !projectUuid) return Response.json({ message: `Camox ${name} is not configured.` }, { status: 500 });
149
154
  const app = await options.getCamoxApp?.();
155
+ const reader = visitorReader(options);
150
156
  const readLocale = async (locale) => {
151
- const { projectName, pages } = await createServerApiClient(apiUrl, { locale }).pages.listBySlug({ projectUuid });
152
- const records = createRecordLookup(options, "live", { locale });
157
+ const { projectName, pages } = await reader.api({ locale }).pages.listBySlug({ projectUuid });
158
+ const records = createRecordLookup(options, reader, { locale });
153
159
  return {
154
160
  locale,
155
161
  projectName,
@@ -163,10 +169,7 @@ async function respondWithLiveWebsite(options, name, respond, { everyLocale = fa
163
169
  let locales;
164
170
  let localizations;
165
171
  try {
166
- locales = everyLocale ? [...await readScopeLocales({
167
- apiUrl,
168
- projectUuid
169
- })].sort((a, b) => Number(b.isDefault) - Number(a.isDefault)) : [UNNAMED_DEFAULT_LOCALE];
172
+ locales = everyLocale ? [...await reader.locales()].sort((a, b) => Number(b.isDefault) - Number(a.isDefault)) : [UNNAMED_DEFAULT_LOCALE];
170
173
  localizations = await Promise.all(locales.map(readLocale));
171
174
  } catch (error) {
172
175
  const discovery = error instanceof RouteDiscoveryError;
@@ -242,15 +245,6 @@ ${entries}
242
245
  </urlset>`, { headers: { "Content-Type": "application/xml; charset=utf-8" } });
243
246
  }, { everyLocale: true });
244
247
  }
245
- /** The request's member project, if its session is valid. Without an API, nobody is a member. */
246
- async function resolveRequestMember(request, options) {
247
- if (!options.apiUrl || !options.projectUuid) return { clearAuthCookie: false };
248
- return resolveMemberProject({
249
- apiUrl: options.apiUrl,
250
- authCookieHeader: getServerAuthCookieHeader(request.headers),
251
- projectUuid: options.projectUuid
252
- });
253
- }
254
248
  /**
255
249
  * The first discovered page of a derived layout, which the Canvas previews by default. With
256
250
  * `recordId`, the page rendering that record, which the Studio follows when its slug changes.
@@ -265,21 +259,17 @@ async function createRoutesResponse(request, options) {
265
259
  const recordId = searchParams.get("recordId");
266
260
  const layout = camoxApp.getLayoutById(layoutId);
267
261
  if (layout?._internal.kind !== "derived") return new Response("Not found", { status: 404 });
268
- const { project, clearAuthCookie } = await resolveRequestMember(request, options);
262
+ const reader = await resolveReader(request, options);
269
263
  const headers = {
270
- "Cache-Control": "private, no-store",
271
- Vary: `Cookie, ${LOCALE_HEADER}`,
272
- ...clearAuthCookie ? { "Set-Cookie": buildClearServerAuthCookieHeader() } : {}
264
+ ...readerHeaders(reader, { alwaysPrivate: true }),
265
+ Vary: `Cookie, ${LOCALE_HEADER}`
273
266
  };
274
267
  const tag = request.headers.get(LOCALE_HEADER);
275
268
  try {
276
- const pages = await discoverLayoutPages(layout, createLayoutRequestContext(options, project ? "draft" : "live", {
277
- authCookieHeader: getServerAuthCookieHeader(request.headers),
278
- locale: tag ? {
279
- tag,
280
- isDefault: false
281
- } : void 0
282
- }));
269
+ const pages = await discoverLayoutPages(layout, createLayoutRequestContext(options, reader, { locale: tag ? {
270
+ tag,
271
+ isDefault: false
272
+ } : void 0 }));
283
273
  const page = recordId ? pages.find((entry) => entry.recordId === recordId) : pages[0];
284
274
  return Response.json({ path: page?.path ?? null }, { headers });
285
275
  } catch (error) {
@@ -313,7 +303,7 @@ async function createOgResponse(request, options) {
313
303
  source.context,
314
304
  style
315
305
  ])),
316
- "Cache-Control": isPrivateRender(request) ? "private, no-store" : "public, no-cache"
306
+ "Cache-Control": isPrivateRequest(request) ? PRIVATE_HEADERS["Cache-Control"] : "public, no-cache"
317
307
  };
318
308
  if (namesEtag(request, headers.ETag)) return new Response(null, {
319
309
  status: 304,
@@ -329,66 +319,38 @@ async function readPageImageSource(request, options, app) {
329
319
  const { apiUrl, projectUuid } = options;
330
320
  const path = new URL(request.url).searchParams.get("path");
331
321
  if (!apiUrl || !projectUuid || !path?.startsWith("/")) return null;
332
- const located = await locateRequest(request, options, path);
322
+ const requestReader = await resolveReader(request, options);
323
+ const located = await locateRequest(requestReader, options, path);
333
324
  if (matchLocalizedRoute(located.path)?.kind !== "page") return null;
334
- const { project } = await resolveRequestMember(request, options);
335
- const member = project !== void 0;
336
- const authCookieHeader = member ? getServerAuthCookieHeader(request.headers) : void 0;
337
- const requestedDraft = getRequestDraftId(request);
338
- const api = (draftId) => createServerApiClient(apiUrl, {
339
- authCookieHeader,
325
+ const reader = pageReaderOf(options, requestReader, located.locale);
326
+ const api = (draftId) => requestReader.api({
340
327
  draftId,
341
328
  locale: located.locale
342
329
  });
343
- const match = matchDerivedLayout(app.getLayouts(), located.path);
344
- /** A code-owned page: its loader runs in the draft the layout read settled on. */
345
- const codeOwned = async (match) => {
346
- const layout = match.layout._internal;
347
- const { value: shared, draftId } = await readDraftOrLive(requestedDraft, member, (draftId) => api(draftId).layouts.get({
330
+ const shown = await readPage({
331
+ app,
332
+ path: located.path,
333
+ pathname: path,
334
+ reader,
335
+ curated: () => reader.inScope((draftId) => api(draftId).pages.getByPath({
336
+ path: located.path,
337
+ projectUuid
338
+ })),
339
+ layout: (draftId, layoutId) => api(draftId).layouts.get({
348
340
  projectUuid,
349
- layoutId: layout.id
350
- }));
351
- const result = await loadLayoutResult(app, match, createLayoutRequestContext(options, member ? "draft" : "live", {
352
- authCookieHeader,
353
- draftId,
354
- locale: located.locale
355
- }), located.locale);
356
- const record = asCollectionItem(result);
357
- const collection = record && app.getCollectionById(record.collectionId);
358
- return {
359
- layout,
360
- context: buildLayoutContext(layout, {
361
- path,
362
- locale: located.locale.tag,
363
- projectName: shared.projectName,
364
- data: result?.data,
365
- params: match.params,
366
- record: record && collection ? recordPageValues(collection, record) : void 0
367
- })
368
- };
341
+ layoutId
342
+ })
343
+ });
344
+ if (shown.kind === "none") return null;
345
+ if (shown.kind === "layout") return {
346
+ layout: shown.layout._internal,
347
+ context: shown.context
369
348
  };
370
- /** A curated page, or else a derived layout's page, as the page's HTML falls back. */
371
- const curated = async () => {
372
- try {
373
- const { value: page } = await readDraftOrLive(requestedDraft, member, (draftId) => api(draftId).pages.getByPath({
374
- path: located.path,
375
- projectUuid
376
- }));
377
- return {
378
- layout: page.layout ? app.getLayoutById(page.layout.layoutId)?._internal : void 0,
379
- context: curatedLayoutContext(page, located.locale)
380
- };
381
- } catch (error) {
382
- if (isNotFoundError(error) && match) return codeOwned(match);
383
- throw error;
384
- }
349
+ const { page } = shown;
350
+ return {
351
+ layout: page.layout ? app.getLayoutById(page.layout.layoutId)?._internal : void 0,
352
+ context: curatedLayoutContext(page, located.locale)
385
353
  };
386
- try {
387
- return match?.layout._internal.kind === "singleton" ? await codeOwned(match) : await curated();
388
- } catch (error) {
389
- if (isNotFoundError(error)) return null;
390
- throw error;
391
- }
392
354
  }
393
355
  function createDefaultHeadInput(stylesheetUrl) {
394
356
  return {
@@ -491,17 +453,19 @@ function renderBlockPreloads(options, dehydratedState) {
491
453
  * is only ever the studio's, so no shared cache keeps it.
492
454
  */
493
455
  function createRendererDocumentResponse(options) {
494
- const input = {
495
- apiUrl: options.apiUrl,
496
- authenticationUrl: options.authenticationUrl,
497
- projectUuid: options.projectUuid,
498
- runtimeBasePath: normalizeRuntimeBasePath(options.runtimeBasePath)
499
- };
500
- return new Response(`<!doctype html><html><head><meta charset="utf-8"><meta name="robots" content="noindex"><title>Camox renderer</title><script id="__CAMOX_DATA__" type="application/json">${serializeJsonForHtml(input)}<\/script></head><body>${renderClientEntryScript(options.rendererClientEntryUrl)}</body></html>`, { headers: {
501
- "Content-Type": "text/html; charset=utf-8",
502
- "Cache-Control": "private, no-store",
456
+ return htmlResponse(htmlDocument({
457
+ before: `<meta charset="utf-8"><meta name="robots" content="noindex"><title>Camox renderer</title>`,
458
+ data: {
459
+ apiUrl: options.apiUrl,
460
+ authenticationUrl: options.authenticationUrl,
461
+ projectUuid: options.projectUuid,
462
+ runtimeBasePath: normalizeRuntimeBasePath(options.runtimeBasePath)
463
+ },
464
+ entryUrl: options.rendererClientEntryUrl
465
+ }), {
466
+ ...PRIVATE_HEADERS,
503
467
  "X-Robots-Tag": "noindex"
504
- } });
468
+ });
505
469
  }
506
470
  /**
507
471
  * Marks the look document ready (`data-camox-ready` on its root element) for the browser to wait
@@ -515,7 +479,7 @@ function createRendererDocumentResponse(options) {
515
479
  const LOOK_READY_SCRIPT = `(()=>{const loaded=new WeakSet();let hydrated=false,marked=false;const mark=()=>{if(marked||!hydrated||document.readyState!=="complete")return;const frames=[...document.querySelectorAll("iframe")];for(const frame of frames)if(frame.getAttribute("loading")==="lazy")frame.setAttribute("loading","eager");if(!frames.every((frame)=>loaded.has(frame)))return;marked=true;requestAnimationFrame(()=>requestAnimationFrame(()=>document.documentElement.setAttribute("data-camox-ready","")))};const settle=(event)=>{if(event.target&&event.target.tagName==="IFRAME")loaded.add(event.target);mark()};document.addEventListener("load",settle,true);document.addEventListener("error",settle,true);addEventListener("load",mark,{once:true});addEventListener("camox:hydrated",()=>{hydrated=true;mark()},{once:true})})()`;
516
480
  /** The headers every look document answer carries: private, never cached, never indexed. */
517
481
  const LOOK_HEADERS = {
518
- "Cache-Control": "private, no-store",
482
+ ...PRIVATE_HEADERS,
519
483
  "X-Robots-Tag": "noindex"
520
484
  };
521
485
  /** What the look document answers when it shows nothing: no token, or one the API refused. */
@@ -566,9 +530,9 @@ function overrideParam(searchParams, name) {
566
530
  async function createLookDocumentResponse(request, options) {
567
531
  const { apiUrl, authenticationUrl, projectUuid, renderPage } = options;
568
532
  const { searchParams } = new URL(request.url);
569
- const lookToken = getLookToken(request.headers);
570
- const draftId = searchParams.get(DRAFT_SEARCH_PARAM) ?? void 0;
571
- if (!lookToken || !draftId || !apiUrl || !authenticationUrl || !projectUuid || !renderPage) return lookNotFound();
533
+ const reader = lookReader(request, options);
534
+ if (!reader || !apiUrl || !authenticationUrl || !projectUuid || !renderPage) return lookNotFound();
535
+ const draftId = reader.draftId;
572
536
  const tag = searchParams.get(LOOK_LOCALE_PARAM)?.toLowerCase();
573
537
  const blockDefinition = searchParams.get(LOOK_BLOCK_DEFINITION_PARAM);
574
538
  const layoutDefinition = searchParams.get(LOOK_LAYOUT_DEFINITION_PARAM);
@@ -577,15 +541,12 @@ async function createLookDocumentResponse(request, options) {
577
541
  let locale;
578
542
  let shown;
579
543
  try {
580
- locales = await createServerApiClient(apiUrl, {
581
- lookToken,
582
- draftId
583
- }).locales.list({ projectUuid });
544
+ locales = await reader.locales();
584
545
  locale = locales.find((each) => tag ? each.tag.toLowerCase() === tag : each.isDefault);
585
546
  if (!locale) return lookNotFound();
586
547
  const reading = {
587
548
  apiUrl,
588
- lookToken,
549
+ reader,
589
550
  draftId,
590
551
  locale,
591
552
  projectUuid,
@@ -613,27 +574,16 @@ async function createLookDocumentResponse(request, options) {
613
574
  locales
614
575
  };
615
576
  const appHtml = await renderPage(input);
616
- const renderedHead = renderHead(document, createPageHeadInput(shown.head), options.stylesheetUrl, void 0, locale);
617
- return new Response(`<!doctype html>
618
- <html${renderedHead.htmlAttrs}>
619
- <head>
620
- ${renderedHead.headTags}
621
- <meta name="robots" content="noindex">
622
- <script>${LOOK_READY_SCRIPT}<\/script>
623
- ${renderBlockPreloads(options, dehydratedState)}
624
- <script id="__CAMOX_DATA__" type="application/json">${serializeJsonForHtml(hydrationInputFor(input, appHtml))}<\/script>
625
- </head>
626
- <body${renderedHead.bodyAttrs}>
627
- ${renderedHead.bodyTagsOpen}
628
- <div id="root">${appHtml}</div>
629
- ${renderedHead.bodyTags}
630
- ${renderClientEntryScript(options.pageClientEntryUrl)}
631
- </body>
632
- </html>`, { headers: {
577
+ return htmlResponse(htmlDocument({
578
+ head: renderHead(document, createPageHeadInput(shown.head), options.stylesheetUrl, void 0, locale),
579
+ after: `<meta name="robots" content="noindex"><script>${LOOK_READY_SCRIPT}<\/script>${renderBlockPreloads(options, dehydratedState)}`,
580
+ data: hydrationInputFor(input, appHtml),
581
+ root: appHtml,
582
+ entryUrl: options.pageClientEntryUrl
583
+ }), {
633
584
  ...LOOK_HEADERS,
634
- "Content-Type": "text/html; charset=utf-8",
635
585
  "Referrer-Policy": "no-referrer"
636
- } });
586
+ });
637
587
  }
638
588
  /** Where the look document's client believes it is: `path` of the draft, in its Locale. */
639
589
  function lookHref(request, options, pathname, draftId) {
@@ -642,65 +592,38 @@ function lookHref(request, options, pathname, draftId) {
642
592
  return href;
643
593
  }
644
594
  /**
645
- * The look document's page at `path`, with its head, as a visitor would see it. A path is
646
- * matched as the website serves it: a singleton layout's route first, then a curated page, then a
647
- * derived layout's route.
595
+ * The look document's page at `path`, with its head, as a visitor would see it: whatever Page
596
+ * Read finds there, read with the look token in its draft. A Layout's route whose loader finds
597
+ * nothing answers why.
648
598
  */
649
599
  async function lookAtPage(request, options, reading) {
650
- const { apiUrl, draftId, locale } = reading;
600
+ const { apiUrl, draftId, locale, projectUuid, queryClient } = reading;
651
601
  const path = normalizePagePath(new URL(request.url).searchParams.get(LOOK_PATH_PARAM));
652
- const camoxApp = await options.getCamoxApp?.();
653
- const match = camoxApp && matchDerivedLayout(camoxApp.getLayouts(), path);
654
- if (match?.layout._internal.kind === "singleton") return lookAtLayoutPage(request, options, reading, path, match);
655
- let page;
656
- try {
657
- page = await loadCamoxPage({
658
- ...reading,
659
- pathname: path
660
- });
661
- } catch (error) {
662
- if (match && isNotFoundError(error)) return lookAtLayoutPage(request, options, reading, path, match);
663
- throw error;
664
- }
665
- const pathname = localizedPath(path, locale);
666
- const href = lookHref(request, options, pathname, draftId);
667
- const loaderData = buildCamoxPageLoaderData(apiUrl, page, href.origin);
668
- return {
669
- head: camoxApp ? buildCamoxPageHead(camoxApp, loaderData, locale) : {},
670
- href: href.href,
671
- layoutIdentity: getLayoutIdentity(page.layout),
672
- loaderData,
673
- pathname
674
- };
675
- }
676
- /**
677
- * The look document's page of a singleton or derived layout at `path`: the layout's blocks, read
678
- * with the look token, around what its loader returns, its records read from the draft. A loader
679
- * that finds nothing answers why.
680
- */
681
- async function lookAtLayoutPage(request, options, reading, path, match) {
682
- const { apiUrl, lookToken, draftId, locale, projectUuid, queryClient } = reading;
683
602
  const app = await options.getCamoxApp?.();
684
- const layout = match.layout._internal;
685
603
  const pathname = localizedPath(path, locale);
686
- let result;
687
- let shared;
688
- try {
689
- [result, shared] = await Promise.all([loadLayoutResult(app, match, createLayoutRequestContext(options, "draft", {
690
- lookToken,
691
- draftId,
692
- locale
693
- }), locale), createServerApiClient(apiUrl, {
694
- lookToken,
695
- draftId,
696
- locale
697
- }).layouts.get({
604
+ const api = (inDraft) => reading.reader.api({
605
+ draftId: inDraft,
606
+ locale
607
+ });
608
+ const reader = pageReaderOf(options, reading.reader, locale);
609
+ const shown = await readPage({
610
+ app,
611
+ path,
612
+ pathname,
613
+ reader,
614
+ curated: () => reader.inScope((inDraft) => loadCamoxPage({
615
+ ...reading,
616
+ draftId: inDraft,
617
+ pathname: path
618
+ })),
619
+ layout: (inDraft, layoutId) => api(inDraft).layouts.get({
698
620
  projectUuid,
699
- layoutId: layout.id
700
- })]);
701
- } catch (error) {
702
- if (!isNotFoundError(error)) throw error;
703
- return new Response(`The draft has no page at ${path}: the ${layout.id} layout's loader found nothing to show.`, {
621
+ layoutId
622
+ })
623
+ });
624
+ if (shown.kind === "none") {
625
+ if (!shown.layout) return lookNotFound();
626
+ return new Response(`The draft has no page at ${path}: the ${shown.layout._internal.id} layout's loader found nothing to show.`, {
704
627
  status: 404,
705
628
  headers: {
706
629
  ...LOOK_HEADERS,
@@ -708,58 +631,84 @@ async function lookAtLayoutPage(request, options, reading, path, match) {
708
631
  }
709
632
  });
710
633
  }
711
- const studioCache = createServerStudioCache(queryClient, {
634
+ const href = lookHref(request, options, pathname, draftId);
635
+ if (shown.kind === "curated") return curatedPageShown({
636
+ app,
712
637
  apiUrl,
713
- lookToken,
714
- locale
638
+ page: shown.page,
639
+ locale,
640
+ href: href.href,
641
+ pathname
715
642
  });
716
- const inDraft = { in: draftId };
717
- const runtimeBasePath = normalizeRuntimeBasePath(options.runtimeBasePath);
718
- studioCache.land(reads.layoutStructure({
719
- projectUuid,
720
- layoutId: layout.id
721
- }), shared, inDraft);
722
- studioCache.set(reads.derivedPageData({
643
+ landLayoutPage(createServerStudioCache(queryClient, {
644
+ reader: reading.reader,
645
+ locale
646
+ }), shown, {
723
647
  projectUuid,
724
- runtimeBasePath,
648
+ options,
649
+ pathname
650
+ }, { in: draftId });
651
+ return layoutPageShown({
652
+ page: shown,
653
+ apiUrl,
654
+ href: href.href,
725
655
  pathname
726
- }), result ?? null, inDraft);
727
- const href = lookHref(request, options, pathname, draftId);
728
- const record = asCollectionItem(result);
729
- const collection = record && app?.getCollectionById(record.collectionId);
730
- const context = buildLayoutContext(layout, {
731
- path: pathname,
732
- locale: locale.tag,
733
- projectName: shared.projectName,
734
- data: result?.data,
735
- params: match.params,
736
- record: record && collection ? recordPageValues(collection, record) : void 0
737
656
  });
657
+ }
658
+ /** What a render shows of a curated page. */
659
+ function curatedPageShown({ app, apiUrl, page, locale, href, pathname }) {
660
+ const loaderData = buildCamoxPageLoaderData(apiUrl, page, new URL(href).origin);
661
+ return {
662
+ head: app ? buildCamoxPageHead(app, loaderData, locale) : {},
663
+ href,
664
+ layoutIdentity: getLayoutIdentity(page.layout),
665
+ loaderData,
666
+ pathname
667
+ };
668
+ }
669
+ /**
670
+ * What a render shows of a Layout Page: its head from its layout's options, and the layout, its
671
+ * loader's result and its route's params, which its client renders it from.
672
+ */
673
+ function layoutPageShown({ page, apiUrl, href, pathname }) {
674
+ const layout = page.layout._internal;
738
675
  return {
739
676
  head: buildPageHead({
740
677
  layout,
741
- context,
742
- origin: href.origin,
743
- faviconUrl: buildFaviconUrl(apiUrl, shared.project),
678
+ context: page.context,
679
+ origin: new URL(href).origin,
680
+ faviconUrl: buildFaviconUrl(apiUrl, page.blocks.project),
744
681
  hasMarkdown: layout.toMarkdown !== void 0,
745
- recordType: record?.structuredData?.node?.["@type"]
682
+ recordType: page.record?.item.structuredData?.node?.["@type"]
746
683
  }),
747
- href: href.href,
748
- layoutIdentity: getLayoutIdentity(shared.layout),
684
+ href,
685
+ layoutIdentity: getLayoutIdentity(page.blocks.layout),
749
686
  loaderData: null,
750
687
  pathname,
751
688
  derived: {
752
689
  layoutId: layout.id,
753
690
  page: {
754
- params: match.params,
691
+ params: page.params,
755
692
  path: pathname,
756
- projectName: shared.projectName
693
+ projectName: page.blocks.projectName
757
694
  },
758
- result,
759
- layout: shared.layout
695
+ result: page.result,
696
+ layout: page.blocks.layout
760
697
  }
761
698
  };
762
699
  }
700
+ /** Lands a Layout Page's reads in the studio's cache, which its client reads them from. */
701
+ function landLayoutPage(studioCache, page, { projectUuid, options, pathname }, inDraft) {
702
+ studioCache.land(reads.layoutStructure({
703
+ projectUuid,
704
+ layoutId: page.layout._internal.id
705
+ }), page.blocks, inDraft);
706
+ studioCache.set(reads.derivedPageData({
707
+ projectUuid,
708
+ runtimeBasePath: normalizeRuntimeBasePath(options.runtimeBasePath),
709
+ pathname
710
+ }), page.result ?? null, inDraft);
711
+ }
763
712
  /**
764
713
  * The look document's Block Definition mode: the block of the Block Definition `type` as the
765
714
  * draft would show it placed, its content and Block Settings empty but for the overrides the URL
@@ -768,22 +717,20 @@ async function lookAtLayoutPage(request, options, reading, path, match) {
768
717
  * as a page's block does; its page is the Locale's home, which links resolve against.
769
718
  */
770
719
  async function lookAtBlockDefinition(request, options, reading, type) {
771
- const { apiUrl, lookToken, draftId, locale, projectUuid, queryClient } = reading;
720
+ const { reader, draftId, locale, projectUuid, queryClient } = reading;
772
721
  const { searchParams } = new URL(request.url);
773
722
  const content = overrideParam(searchParams, LOOK_CONTENT_PARAM);
774
723
  const settings = overrideParam(searchParams, LOOK_SETTINGS_PARAM);
775
724
  if (!content.ok) return lookRefused(content.error);
776
725
  if (!settings.ok) return lookRefused(settings.error);
777
726
  const studioCache = createServerStudioCache(queryClient, {
778
- apiUrl,
779
- lookToken,
727
+ reader,
780
728
  locale
781
729
  });
782
730
  const inDraft = { in: draftId };
783
731
  let bundle;
784
732
  try {
785
- [bundle] = await Promise.all([createServerApiClient(apiUrl, {
786
- lookToken,
733
+ [bundle] = await Promise.all([reader.api({
787
734
  draftId,
788
735
  locale
789
736
  }).blocks.getUnplaced({
@@ -815,22 +762,20 @@ async function lookAtBlockDefinition(request, options, reading, type) {
815
762
  * says. Its path is the Locale's home, which links resolve against.
816
763
  */
817
764
  async function lookAtLayoutDefinition(request, options, reading, layoutId) {
818
- const { apiUrl, lookToken, draftId, locale, projectUuid, queryClient } = reading;
765
+ const { reader, draftId, locale, projectUuid, queryClient } = reading;
819
766
  const { searchParams } = new URL(request.url);
820
767
  const blocks = overrideParam(searchParams, LOOK_BLOCKS_PARAM);
821
768
  if (!blocks.ok) return lookRefused(blocks.error);
822
769
  const placeholder = searchParams.get(LOOK_PLACEHOLDER_PARAM) ?? LOOK_PLACEHOLDER_HEIGHTS[0];
823
770
  if (!LOOK_PLACEHOLDER_HEIGHTS.includes(placeholder)) return lookRefused(`The placeholder is ${LOOK_PLACEHOLDER_HEIGHTS.join(" or ")}.`);
824
771
  const studioCache = createServerStudioCache(queryClient, {
825
- apiUrl,
826
- lookToken,
772
+ reader,
827
773
  locale
828
774
  });
829
775
  const inDraft = { in: draftId };
830
776
  let shared;
831
777
  try {
832
- [shared] = await Promise.all([createServerApiClient(apiUrl, {
833
- lookToken,
778
+ [shared] = await Promise.all([reader.api({
834
779
  draftId,
835
780
  locale
836
781
  }).layouts.getUnplaced({
@@ -871,6 +816,38 @@ function renderClientEntryScript(src) {
871
816
  if (!src) return "";
872
817
  return `<script type="module" src="${escapeXml(src)}"><\/script>`;
873
818
  }
819
+ const NO_HEAD = {
820
+ htmlAttrs: "",
821
+ headTags: "",
822
+ bodyAttrs: "",
823
+ bodyTagsOpen: "",
824
+ bodyTags: ""
825
+ };
826
+ /**
827
+ * Every document the runtime serves: its head, between what comes `before` and `after` it, the
828
+ * render input its client starts from (`__CAMOX_DATA__`), the app's HTML in its root, if it has
829
+ * one, and the client's entry script.
830
+ */
831
+ function htmlDocument({ head = NO_HEAD, before = "", after = "", data, root, entryUrl }) {
832
+ return `<!doctype html>
833
+ <html${head.htmlAttrs}>
834
+ <head>
835
+ ${before}${head.headTags}${after}
836
+ <script id="__CAMOX_DATA__" type="application/json">${serializeJsonForHtml(data)}<\/script>
837
+ </head>
838
+ <body${head.bodyAttrs}>
839
+ ${head.bodyTagsOpen}${root === void 0 ? "" : `<div id="root">${root}</div>`}
840
+ ${head.bodyTags}
841
+ ${renderClientEntryScript(entryUrl)}
842
+ </body>
843
+ </html>`;
844
+ }
845
+ /** An HTML document's response. */
846
+ function htmlResponse(html, headers) {
847
+ const response = new Response(html, { headers });
848
+ response.headers.set("Content-Type", "text/html; charset=utf-8");
849
+ return response;
850
+ }
874
851
  /**
875
852
  * The site document the studio's frames load: the site's head and an empty root, which the
876
853
  * renderer frame renders the page into. The studio never renders the site's code itself.
@@ -981,7 +958,7 @@ async function createSignInResponse(request, apiUrl) {
981
958
  status: 302,
982
959
  headers: {
983
960
  Location: `${url.pathname}${url.search}`,
984
- "Cache-Control": "private, no-store",
961
+ ...PRIVATE_HEADERS,
985
962
  "Referrer-Policy": "no-referrer",
986
963
  "Set-Cookie": buildServerAuthCookieHeader(session, { secure: url.protocol === "https:" })
987
964
  }
@@ -1063,15 +1040,10 @@ function withLocatedTags(head, { url, locale, alternates }) {
1063
1040
  }]
1064
1041
  };
1065
1042
  }
1066
- function requestLocaleReads(options, { authCookieHeader, draftId, queryClient }) {
1043
+ function requestLocaleReads(options, { reader, draftId, queryClient }) {
1067
1044
  const { apiUrl, projectUuid } = options;
1068
- const reading = {
1069
- authCookieHeader: draftId ? authCookieHeader : void 0,
1070
- draftId
1071
- };
1072
- const source = draftId ? "draft" : "live";
1073
1045
  const pagesByLocale = /* @__PURE__ */ new Map();
1074
- const studioCache = apiUrl && queryClient ? createServerStudioCache(queryClient, { apiUrl }) : void 0;
1046
+ const studioCache = queryClient ? createServerStudioCache(queryClient, { reader }) : void 0;
1075
1047
  return {
1076
1048
  pages: (locale) => {
1077
1049
  if (!apiUrl || !projectUuid) return Promise.resolve([]);
@@ -1080,19 +1052,19 @@ function requestLocaleReads(options, { authCookieHeader, draftId, queryClient })
1080
1052
  locale
1081
1053
  });
1082
1054
  if (loaded) return Promise.resolve(loaded);
1083
- const read = pagesByLocale.get(locale.tag) ?? createServerApiClient(apiUrl, {
1084
- ...reading,
1055
+ const read = pagesByLocale.get(locale.tag) ?? reader.api({
1056
+ draftId,
1085
1057
  locale
1086
1058
  }).pages.listBySlug({ projectUuid }).then(({ pages }) => pages);
1087
1059
  pagesByLocale.set(locale.tag, read);
1088
1060
  return read;
1089
1061
  },
1090
- context: (locale) => createLayoutRequestContext(options, source, {
1091
- ...reading,
1062
+ context: (locale) => createLayoutRequestContext(options, reader, {
1063
+ draftId,
1092
1064
  locale
1093
1065
  }),
1094
- records: (locale) => createRecordLookup(options, source, {
1095
- ...reading,
1066
+ records: (locale) => createRecordLookup(options, reader, {
1067
+ draftId,
1096
1068
  locale
1097
1069
  })
1098
1070
  };
@@ -1167,71 +1139,116 @@ async function createPreviewHandoffResponse({ options, pathname, request }) {
1167
1139
  layoutIdentity: null,
1168
1140
  dehydratedState: dehydrate(new QueryClient())
1169
1141
  };
1170
- const html = await options.renderPage(input);
1171
- return new Response(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="referrer" content="no-referrer"><title>Camox draft preview</title><script id="__CAMOX_DATA__" type="application/json">${serializeJsonForHtml(input)}<\/script></head><body><div id="root">${html}</div>${renderClientEntryScript(options.pageClientEntryUrl)}</body></html>`, { headers: {
1172
- "Content-Type": "text/html; charset=utf-8",
1173
- "Cache-Control": "private, no-store",
1142
+ return htmlResponse(htmlDocument({
1143
+ before: `<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="referrer" content="no-referrer"><title>Camox draft preview</title>`,
1144
+ data: input,
1145
+ root: await options.renderPage(input),
1146
+ entryUrl: options.pageClientEntryUrl
1147
+ }), {
1148
+ ...PRIVATE_HEADERS,
1174
1149
  "Referrer-Policy": "no-referrer"
1175
- } });
1150
+ });
1176
1151
  }
1177
- /** Starts reading the curated page at a locale-relative path, as the request reads it. */
1178
- function readCuratedPage(options, request, { locale, path }) {
1152
+ /**
1153
+ * What Page Read needs of the request's Reader, in one Locale. Only a Member's Draft can be
1154
+ * refused, so only then does the first read settle the Draft; anyone else reads one scope
1155
+ * throughout: a Visitor Live, a look token its own Draft.
1156
+ */
1157
+ function pageReaderOf(options, reader, locale) {
1158
+ return {
1159
+ locale,
1160
+ inScope: (read) => reader.read(read),
1161
+ ...!(reader.member && reader.draftId) && { draft: { id: reader.draftId } },
1162
+ records: (draftId) => createRecordLookup(options, reader, {
1163
+ draftId,
1164
+ locale
1165
+ })
1166
+ };
1167
+ }
1168
+ /** Starts reading the curated page at a locale-relative path, as the request's Reader reads it. */
1169
+ function readCuratedPage(options, reader, { locale, path }) {
1179
1170
  const queryClient = new QueryClient();
1180
1171
  return {
1181
1172
  queryClient,
1182
- result: loadCamoxPageForRequest({
1183
- apiUrl: options.apiUrl,
1184
- authCookieHeader: getServerAuthCookieHeader(request.headers),
1185
- draftId: getRequestDraftId(request),
1173
+ result: reader.read((draftId) => loadCamoxPage({
1174
+ reader,
1175
+ draftId,
1186
1176
  locale,
1187
- origin: new URL(request.url).origin,
1188
1177
  pathname: path,
1189
1178
  projectUuid: options.projectUuid,
1190
1179
  queryClient
1191
- })
1180
+ }))
1192
1181
  };
1193
1182
  }
1194
1183
  /** A first path segment that could be a locale's prefix: a language tag, in lowercase. */
1195
- const LOCALE_PREFIX_LIKE = /^\/[a-z]{2,3}(?:-[a-z0-9]{1,8})*(?:\/|$)/;
1184
+ const LOCALE_PREFIX_LIKE = /^\/([a-z]{2,3}(?:-[a-z0-9]{1,8})*)(?:\/|$)/;
1196
1185
  /**
1197
- * The read of the curated page a path names in the default locale, started while the request's
1198
- * locales are read, or none where it couldn't be the request's page. A default-locale page sits
1199
- * at its own path, so its read needn't wait for the locales: once read, they only confirm the path
1200
- * names no other locale. A path whose first segment looks like a locale's prefix waits for them
1201
- * instead, rather than reading a page it likely doesn't name, and a singleton's path reads no
1202
- * curated page.
1186
+ * The read of the curated page a path most likely names, started while the request's locales are
1187
+ * read, so the page's read needn't wait for them: when its first segment names a language, as a
1188
+ * locale's prefix does, in that language's locale, at the rest of the path; otherwise in the
1189
+ * default locale, at the path. A locale's prefix is its tag in lowercase. Once read, the locales
1190
+ * confirm the guess, or the read is left unused. A path that never shows a curated page reads
1191
+ * none.
1203
1192
  */
1204
- async function readDefaultLocalePageEarly(options, request, pathname) {
1193
+ async function readPageEarly(options, reader, pathname) {
1205
1194
  const { apiUrl, projectUuid } = options;
1206
1195
  if (!apiUrl || !projectUuid) return void 0;
1207
- if (matchLocalizedRoute(pathname)?.kind !== "page" || LOCALE_PREFIX_LIKE.test(pathname)) return;
1196
+ const prefixed = LOCALE_PREFIX_LIKE.exec(pathname);
1197
+ const language = prefixed && languageTagOf(prefixed[1]);
1198
+ const guessed = language ? {
1199
+ locale: {
1200
+ tag: language,
1201
+ isDefault: false
1202
+ },
1203
+ path: pathname.slice(prefixed[0].replace(/\/$/, "").length) || "/"
1204
+ } : {
1205
+ locale: UNNAMED_DEFAULT_LOCALE,
1206
+ path: pathname
1207
+ };
1208
+ if (matchLocalizedRoute(guessed.path)?.kind !== "page") return void 0;
1208
1209
  const app = await options.getCamoxApp?.().catch(() => void 0);
1209
- if (app && matchDerivedLayout(app.getLayouts(), pathname)?.layout._internal.kind === "singleton") return void 0;
1210
+ if (!mayShowCuratedPage(app, guessed.path)) return void 0;
1210
1211
  const read = readCuratedPage({
1211
1212
  ...options,
1212
- apiUrl,
1213
1213
  projectUuid
1214
- }, request, {
1215
- locale: UNNAMED_DEFAULT_LOCALE,
1216
- path: pathname
1217
- });
1214
+ }, reader, guessed);
1218
1215
  read.result.catch(() => void 0);
1219
- return read;
1216
+ return {
1217
+ locale: guessed.locale,
1218
+ read
1219
+ };
1220
+ }
1221
+ const languageNames = new Intl.DisplayNames(["en"], {
1222
+ type: "language",
1223
+ fallback: "none"
1224
+ });
1225
+ /** The canonical tag of the language a locale's prefix names, or null when it names none. */
1226
+ function languageTagOf(prefix) {
1227
+ try {
1228
+ const [tag] = Intl.getCanonicalLocales(prefix);
1229
+ return tag && languageNames.of(tag) ? tag : null;
1230
+ } catch {
1231
+ return null;
1232
+ }
1233
+ }
1234
+ /** The early read, when it's the request's page's: in the locale the request's path names. */
1235
+ function earlyReadIn(early, { locale }) {
1236
+ if (!early) return void 0;
1237
+ return (locale.isDefault ? early.locale.isDefault : early.locale.tag === locale.tag) ? early.read : void 0;
1220
1238
  }
1221
1239
  /**
1222
1240
  * The reads of each locale the head of the request's curated page makes, and those it can make
1223
- * while the page is read. When the request reads Live, whatever the page's read finds about its
1224
- * draft, the pages of the locales the page's read doesn't load start loading now. Otherwise they
1225
- * wait for the page's read to settle the draft.
1241
+ * while the page is read. When the Reader has no Draft to read, the request reads Live, so the
1242
+ * pages of the locales the page's read doesn't load start loading now. Otherwise they wait for
1243
+ * the page's read to settle the Draft.
1226
1244
  */
1227
- function curatedPageLocaleReads(options, request, located, queryClient) {
1228
- const authCookieHeader = getServerAuthCookieHeader(request.headers);
1245
+ function curatedPageLocaleReads(options, reader, located, queryClient) {
1229
1246
  const forDraft = (draftId) => requestLocaleReads(options, {
1230
- authCookieHeader,
1247
+ reader,
1231
1248
  draftId,
1232
1249
  queryClient
1233
1250
  });
1234
- if (!(!getRequestDraftId(request) || !authCookieHeader)) return forDraft;
1251
+ if (reader.draftId) return forDraft;
1235
1252
  const live = forDraft(void 0);
1236
1253
  if (located.locales.length > 1) for (const locale of located.locales) {
1237
1254
  if (locale.tag === located.locale.tag || locale.isDefault) continue;
@@ -1239,119 +1256,189 @@ function curatedPageLocaleReads(options, request, located, queryClient) {
1239
1256
  }
1240
1257
  return () => live;
1241
1258
  }
1242
- async function createPageHtmlResponse({ options, pathname, located, request, pageRead }) {
1243
- if (!options.apiUrl || !options.authenticationUrl || !options.projectUuid || !options.renderPage) return createPageDataResponse({
1244
- options,
1259
+ /**
1260
+ * A page's HTML, or with `dataOnly` its render input as JSON, which client navigation loads:
1261
+ * whatever Page Read finds at the path, as the request reads it. A runtime that can't render
1262
+ * pages answers their data.
1263
+ */
1264
+ async function createPageResponse({ options, pathname, located, request, reader: requestReader, pageRead, dataOnly: dataRequested }) {
1265
+ const { apiUrl, projectUuid, renderPage } = options;
1266
+ if (!apiUrl || !projectUuid) return Response.json({ message: "Camox page loader is not configured." }, { status: 500 });
1267
+ const dataOnly = dataRequested || !options.authenticationUrl || !renderPage;
1268
+ const app = await options.getCamoxApp?.();
1269
+ const reader = pageReaderOf(options, requestReader, located.locale);
1270
+ const api = (draftId) => requestReader.api({
1271
+ draftId,
1272
+ locale: located.locale
1273
+ });
1274
+ const curatedRead = pageRead ?? {
1275
+ queryClient: new QueryClient(),
1276
+ result: void 0
1277
+ };
1278
+ let localeReads;
1279
+ const shown = await readPage({
1280
+ app,
1281
+ path: located.path,
1245
1282
  pathname,
1246
- located,
1247
- request,
1248
- pageRead
1283
+ reader,
1284
+ curated: () => {
1285
+ localeReads = curatedPageLocaleReads(options, requestReader, located, curatedRead.queryClient);
1286
+ return curatedRead.result ?? reader.inScope((draftId) => loadCamoxPage({
1287
+ reader: requestReader,
1288
+ draftId,
1289
+ locale: located.locale,
1290
+ pathname: located.path,
1291
+ projectUuid,
1292
+ queryClient: curatedRead.queryClient
1293
+ }));
1294
+ },
1295
+ layout: (draftId, layoutId) => api(draftId).layouts.get({
1296
+ projectUuid,
1297
+ layoutId
1298
+ })
1249
1299
  });
1250
- const singleton = await createDerivedResponse(options, pathname, located, request, { singletonOnly: true });
1251
- if (singleton) return singleton;
1252
- const { queryClient, result: reading } = pageRead ?? readCuratedPage({
1253
- ...options,
1254
- apiUrl: options.apiUrl,
1255
- projectUuid: options.projectUuid
1256
- }, request, located);
1257
- const localeReads = curatedPageLocaleReads(options, request, located, queryClient);
1258
- try {
1259
- const result = await reading;
1260
- const headers = new Headers({
1261
- "Content-Type": "text/html; charset=utf-8",
1262
- Vary: "Cookie",
1263
- Link: llmsTxtLinkHeader(options)
1300
+ if (shown.kind === "none") {
1301
+ if (shown.layout) return new Response("Page not found", { status: 404 });
1302
+ return dataOnly ? Response.json({
1303
+ kind: "page",
1304
+ message: "No Camox page on this path",
1305
+ pathname
1306
+ }, { status: 404 }) : new Response("No Camox page on this path", { status: 404 });
1307
+ }
1308
+ const href = renderedHref(request.url, shown);
1309
+ const queryClient = shown.kind === "curated" ? curatedRead.queryClient : new QueryClient();
1310
+ let page;
1311
+ let readAlternatesOf;
1312
+ let site;
1313
+ if (shown.kind === "curated") {
1314
+ page = curatedPageShown({
1315
+ app,
1316
+ apiUrl,
1317
+ page: shown.page,
1318
+ locale: located.locale,
1319
+ href,
1320
+ pathname
1264
1321
  });
1265
- if (isPrivateRender(request)) headers.set("Cache-Control", "private, no-store");
1266
- if (result.shouldClearAuthCookie) headers.append("Set-Cookie", buildClearServerAuthCookieHeader());
1267
- const camoxApp = await options.getCamoxApp?.();
1268
- const document = await options.getDocument?.() ?? {};
1269
- if (isHiddenFromSearchEngines(result.data.page.project)) headers.set("X-Robots-Tag", "noindex");
1270
- const { alternates, head } = await readAlternates(request, options, {
1271
- located,
1322
+ site = shown.page.project;
1323
+ readAlternatesOf = {
1272
1324
  identity: {
1273
1325
  kind: "curated",
1274
- pageId: result.data.page.page.id
1326
+ pageId: shown.page.page.id
1275
1327
  },
1276
- head: withSearchEngineMark(camoxApp ? buildCamoxPageHead(camoxApp, result.data, located.locale) : {}, result.data.page.project),
1277
- described: curatedPageDescription(camoxApp, result.data.page, located.locale),
1278
- localeReads: localeReads(result.draftId)
1328
+ described: curatedPageDescription(app, shown.page, located.locale),
1329
+ localeReads: localeReads(shown.draftId)
1330
+ };
1331
+ } else {
1332
+ landLayoutPage(createServerStudioCache(queryClient, {
1333
+ reader: requestReader,
1334
+ locale: located.locale
1335
+ }), shown, {
1336
+ projectUuid,
1337
+ options,
1338
+ pathname
1339
+ }, { in: shown.draftId ?? null });
1340
+ page = layoutPageShown({
1341
+ page: shown,
1342
+ apiUrl,
1343
+ href,
1344
+ pathname
1279
1345
  });
1280
- const dehydratedState = dehydrate(queryClient);
1281
- const { project } = result;
1282
- const studio = project !== void 0;
1283
- const analytics = visitorAnalytics({
1284
- studio,
1285
- draftId: result.draftId
1286
- }, result.data.page.project);
1287
- const pageRenderInput = await withArtifactsByUrl({
1288
- alternates,
1289
- project,
1290
- presentation: studio ? "studio" : "public",
1291
- ...analytics && { analytics },
1292
- apiUrl: options.apiUrl,
1293
- authenticationUrl: options.authenticationUrl,
1294
- dehydratedState,
1295
- draftId: result.draftId,
1296
- refusedDraft: result.refusedDraft,
1297
- head,
1298
- href: renderedHref(request.url, result),
1299
- layoutIdentity: getLayoutIdentity(result.data.page.layout),
1300
- loaderData: result.data,
1301
- pathname,
1302
- projectUuid: options.projectUuid,
1303
- runtimeBasePath: normalizeRuntimeBasePath(options.runtimeBasePath),
1304
- locale: located.locale,
1305
- locales: located.locales
1306
- }, options);
1307
- preparePreviewDocument(pageRenderInput, options, document);
1308
- const appHtml = await options.renderPage(pageRenderInput);
1309
- const renderedHead = renderHead(studio ? {} : document, createPageHeadInput(head), studio ? void 0 : options.stylesheetUrl, studio ? options.studioStylesheetUrl : void 0, studio ? void 0 : located.locale);
1310
- return new Response(`<!doctype html>
1311
- <html${renderedHead.htmlAttrs}>
1312
- <head>
1313
- ${renderStudioThemeScript(studio)}
1314
- ${renderedHead.headTags}
1315
- ${renderAnalyticsScripts(analytics)}
1316
- ${studio ? "" : renderBlockPreloads(options, dehydratedState)}
1317
- <script id="__CAMOX_DATA__" type="application/json">${serializeJsonForHtml(hydrationInputFor(pageRenderInput, appHtml))}<\/script>
1318
- </head>
1319
- <body${renderedHead.bodyAttrs}>
1320
- ${renderedHead.bodyTagsOpen}
1321
- <div id="root">${appHtml}</div>
1322
- ${renderedHead.bodyTags}
1323
- ${renderClientEntryScript(options.pageClientEntryUrl)}
1324
- </body>
1325
- </html>`, { headers });
1326
- } catch (error) {
1327
- if (isNotFoundError(error)) {
1328
- const derived = await createDerivedResponse(options, pathname, located, request);
1329
- if (derived) return derived;
1330
- return new Response("No Camox page on this path", { status: 404 });
1331
- }
1332
- throw error;
1346
+ site = shown.blocks.project;
1347
+ const { blocks, record } = shown;
1348
+ readAlternatesOf = {
1349
+ identity: record && shown.layout._internal.kind === "derived" ? {
1350
+ kind: "record",
1351
+ layout: shown.layout,
1352
+ recordId: record.item.data.id
1353
+ } : { kind: "code-owned" },
1354
+ described: {
1355
+ project: {
1356
+ ...blocks.project,
1357
+ name: blocks.projectName
1358
+ },
1359
+ title: shown.context.page.title,
1360
+ declared: declarePage({
1361
+ layout: shown.layout._internal,
1362
+ context: shown.context
1363
+ }),
1364
+ blocks: placedBlockNodes(blocks.layout, [], blocks.structuredData),
1365
+ ...record?.item.published && { published: record.item.published },
1366
+ records: pageRecords(blocks.recordStructuredData, record && {
1367
+ id: record.item.data.id,
1368
+ structuredData: record.item.structuredData,
1369
+ titles: record.values.titles
1370
+ })
1371
+ },
1372
+ localeReads: requestLocaleReads(options, {
1373
+ reader: requestReader,
1374
+ draftId: shown.draftId
1375
+ })
1376
+ };
1333
1377
  }
1378
+ const { alternates, head } = await readAlternates(request, options, {
1379
+ ...readAlternatesOf,
1380
+ located,
1381
+ head: withSearchEngineMark(page.head, site)
1382
+ });
1383
+ const studio = requestReader.member !== void 0;
1384
+ const analytics = dataOnly ? void 0 : visitorAnalytics({
1385
+ studio,
1386
+ draftId: shown.draftId
1387
+ }, site);
1388
+ const dehydratedState = dehydrate(queryClient);
1389
+ const input = await withArtifactsByUrl({
1390
+ ...page,
1391
+ head,
1392
+ alternates,
1393
+ project: requestReader.member,
1394
+ presentation: studio ? "studio" : "public",
1395
+ ...analytics && { analytics },
1396
+ apiUrl,
1397
+ authenticationUrl: options.authenticationUrl ?? "",
1398
+ projectUuid,
1399
+ dehydratedState,
1400
+ draftId: shown.draftId,
1401
+ refusedDraft: shown.refusedDraft,
1402
+ runtimeBasePath: normalizeRuntimeBasePath(options.runtimeBasePath),
1403
+ locale: located.locale,
1404
+ locales: located.locales
1405
+ }, options);
1406
+ const document = await options.getDocument?.() ?? {};
1407
+ preparePreviewDocument(input, options, document);
1408
+ const headers = readerHeaders(requestReader);
1409
+ if (dataOnly) return Response.json(input, { headers: {
1410
+ ...headers,
1411
+ "Content-Type": "application/json; charset=utf-8"
1412
+ } });
1413
+ const appHtml = await renderPage(input);
1414
+ return htmlResponse(htmlDocument({
1415
+ head: renderHead(studio ? {} : document, createPageHeadInput(head), studio ? void 0 : options.stylesheetUrl, studio ? options.studioStylesheetUrl : void 0, studio ? void 0 : located.locale),
1416
+ before: renderStudioThemeScript(studio),
1417
+ after: `${renderAnalyticsScripts(analytics)}${studio ? "" : renderBlockPreloads(options, dehydratedState)}`,
1418
+ data: hydrationInputFor(input, appHtml),
1419
+ root: appHtml,
1420
+ entryUrl: options.pageClientEntryUrl
1421
+ }), {
1422
+ ...headers,
1423
+ Link: llmsTxtLinkHeader(options),
1424
+ ...isHiddenFromSearchEngines(site) && { "X-Robots-Tag": "noindex" }
1425
+ });
1334
1426
  }
1335
1427
  /**
1336
- * The context `loader` and `routes` get. Its record lookup reads each record's current content;
1337
- * only draft reads carry the editor's session, and `draftId` names the draft they read.
1428
+ * The context `loader` and `routes` get. Its record lookup reads each record's current content as
1429
+ * the request's Reader, in the draft the request settled on.
1338
1430
  */
1339
- function createLayoutRequestContext(options, source, reading) {
1340
- return createRequestContext({ records: createRecordLookup(options, source, reading) });
1431
+ function createLayoutRequestContext(options, reader, scope) {
1432
+ return createRequestContext({ records: createRecordLookup(options, reader, scope) });
1341
1433
  }
1342
1434
  /**
1343
- * Reads the records `loader` and `routes` ask for, in `locale` or the default locale; none
1344
- * without an API.
1435
+ * Reads the records `loader` and `routes` ask for as the request's Reader, in `locale` or the
1436
+ * default locale; none without an API.
1345
1437
  */
1346
- function createRecordLookup(options, source, { authCookieHeader, lookToken, draftId, locale } = {}) {
1438
+ function createRecordLookup(options, reader, scope = {}) {
1347
1439
  const { apiUrl, projectUuid } = options;
1348
1440
  if (!apiUrl || !projectUuid) return void 0;
1349
- const api = createServerApiClient(apiUrl, {
1350
- authCookieHeader: source === "draft" ? authCookieHeader : void 0,
1351
- lookToken: source === "draft" ? lookToken : void 0,
1352
- draftId,
1353
- locale
1354
- });
1441
+ const api = reader.api(scope);
1355
1442
  return {
1356
1443
  findByField: (input) => api.collectionDefinitions.findRecordsByField({
1357
1444
  ...input,
@@ -1363,155 +1450,116 @@ function createRecordLookup(options, source, { authCookieHeader, lookToken, draf
1363
1450
  })
1364
1451
  };
1365
1452
  }
1366
- const RECORD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1367
- /** An envelope names its record; it doesn't authorize anything, the API does. */
1368
- function isValidCollectionItem(app, result) {
1369
- const data = result.data;
1370
- 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;
1371
- }
1372
- /**
1373
- * Runs a code-owned page's loader in the page's locale. The envelope survives SSR, navigation and
1374
- * hydration, and its data renders as returned: Camox never translates it.
1375
- */
1376
- async function loadLayoutResult(app, match, context, locale) {
1377
- const { loader, id } = match.layout._internal;
1378
- if (!loader) return void 0;
1379
- const result = await loader({
1380
- params: match.params,
1381
- context,
1382
- locale: locale.tag
1383
- });
1384
- const serializedResult = result === void 0 ? void 0 : JSON.parse(JSON.stringify(result));
1385
- const record = asCollectionItem(serializedResult);
1386
- 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`);
1387
- return serializedResult;
1453
+ /** Whether an API error refuses the Draft a read named, rather than finding nothing in it. */
1454
+ function refusesDraft(error, draftId) {
1455
+ const data = error?.data;
1456
+ return draftId !== void 0 && data?.draftId === draftId;
1388
1457
  }
1389
1458
  /**
1390
- * The Markdown of a singleton or derived page: front matter, its before blocks, the layout's
1391
- * toMarkdown output, then its after blocks. Null without toMarkdown: the page is then HTML only.
1392
- * The layout's blocks settle which draft it reads, as they do for the page's HTML.
1459
+ * What a page's Markdown is, at a path: whatever Page Read finds there, read as `reader` reads
1460
+ * it. A curated page's comes from `curated`, a Layout Page's from its layout's blocks and its
1461
+ * `toMarkdown`. Null when the page has none: it's then HTML only.
1393
1462
  */
1394
- async function buildLayoutMarkdown(options, match, locale, page, reader = { member: false }) {
1395
- const { toMarkdown, id } = match.layout._internal;
1396
- const { apiUrl, projectUuid } = options;
1397
- if (!toMarkdown || !apiUrl || !projectUuid) return null;
1398
- const authCookieHeader = reader.member ? reader.authCookieHeader : void 0;
1399
- const api = (draftId) => createServerApiClient(apiUrl, {
1400
- authCookieHeader,
1463
+ async function readPageMarkdownShown(options, { app, path, page, reader, requestReader, curated }) {
1464
+ const { projectUuid } = options;
1465
+ const api = (draftId) => requestReader.api({
1401
1466
  draftId,
1402
- locale
1467
+ locale: reader.locale
1403
1468
  });
1404
- const { value: slots, draftId } = await readDraftOrLive(reader.requestedDraft, reader.member, (draftId) => api(draftId).blocks.getLayoutMarkdown({
1405
- projectUuid,
1406
- layoutId: id
1407
- })).catch((error) => {
1408
- if (!isNotFoundError(error)) throw error;
1409
- return {
1410
- value: {
1469
+ const shown = await readPage({
1470
+ app,
1471
+ path,
1472
+ pathname: page.pathname,
1473
+ reader,
1474
+ curated: async () => ({ value: curated?.page ? curated : null }),
1475
+ layout: (draftId, layoutId) => api(draftId).blocks.getLayoutMarkdown({
1476
+ projectUuid,
1477
+ layoutId
1478
+ }).catch((error) => {
1479
+ if (!isNotFoundError(error) || refusesDraft(error, draftId)) throw error;
1480
+ return {
1411
1481
  before: "",
1412
1482
  after: "",
1413
1483
  projectName: ""
1414
- },
1415
- draftId: reader.member ? reader.requestedDraft : void 0
1416
- };
1484
+ };
1485
+ })
1417
1486
  });
1418
- const app = await options.getCamoxApp?.();
1419
- /** The layout's own Markdown, between its before and after blocks, and what built it. */
1420
- const layoutMarkdown = async () => {
1421
- const result = await loadLayoutResult(app, match, createLayoutRequestContext(options, reader.member ? "draft" : "live", {
1422
- authCookieHeader,
1423
- draftId,
1424
- locale
1425
- }), locale);
1426
- const record = asCollectionItem(result);
1427
- const collection = record && app?.getCollectionById(record.collectionId);
1428
- if (!record || !collection) return {
1429
- body: toMarkdown({
1430
- data: result?.data,
1431
- params: match.params
1432
- }),
1433
- data: result?.data
1434
- };
1435
- const { data } = record;
1436
- const template = recordMarkdownTemplate(collection, (item) => toMarkdown({
1487
+ if (shown.kind === "none") {
1488
+ if (shown.layout) throw notFound();
1489
+ return null;
1490
+ }
1491
+ if (shown.kind === "curated") return {
1492
+ kind: "curated",
1493
+ read: shown.page
1494
+ };
1495
+ const layout = shown.layout._internal;
1496
+ const { toMarkdown } = layout;
1497
+ if (!toMarkdown) return {
1498
+ kind: "layout",
1499
+ markdown: null
1500
+ };
1501
+ const { record, params, blocks } = shown;
1502
+ const data = shown.result?.data;
1503
+ const body = record ? [await api(shown.draftId).collectionDefinitions.recordMarkdown({
1504
+ projectUuid,
1505
+ collectionId: record.item.collectionId,
1506
+ id: record.item.data.id,
1507
+ toMarkdown: recordMarkdownTemplate(record.collection, (item) => toMarkdown({
1437
1508
  data,
1438
- params: match.params,
1509
+ params,
1439
1510
  item
1440
- }));
1441
- return {
1442
- body: [await api(draftId).collectionDefinitions.recordMarkdown({
1443
- projectUuid,
1444
- collectionId: record.collectionId,
1445
- id: data.id,
1446
- toMarkdown: template
1447
- })],
1448
- data,
1449
- record: recordPageValues(collection, record)
1450
- };
1451
- };
1452
- const rendered = await layoutMarkdown();
1511
+ }))
1512
+ })] : toMarkdown({
1513
+ data,
1514
+ params
1515
+ });
1453
1516
  const meta = buildPageMeta({
1454
- layout: match.layout._internal,
1455
- context: buildLayoutContext(match.layout._internal, {
1456
- path: page.pathname,
1457
- locale: locale.tag,
1458
- projectName: slots.projectName,
1459
- data: rendered.data,
1460
- params: match.params,
1461
- record: "record" in rendered ? rendered.record : void 0
1462
- })
1517
+ layout,
1518
+ context: shown.context
1463
1519
  });
1464
1520
  const markdown = [
1465
- slots.before,
1466
- ...rendered.body,
1467
- slots.after
1521
+ blocks.before,
1522
+ ...body,
1523
+ blocks.after
1468
1524
  ].filter(Boolean).join("\n\n");
1469
- return withFrontMatter(markdown, {
1470
- ...meta,
1471
- url: page.url
1472
- });
1473
- }
1474
- /**
1475
- * A singleton or derived page's public Markdown Representation. Markdown is a public output: it
1476
- * reads published records and blocks whoever asks.
1477
- */
1478
- async function createLayoutMarkdownResponse(options, match, locale, page) {
1479
- let markdown;
1480
- try {
1481
- markdown = await buildLayoutMarkdown(options, match, locale, page);
1482
- } catch (error) {
1483
- if (isNotFoundError(error)) return new Response("Page not found", { status: 404 });
1484
- throw error;
1485
- }
1486
- if (markdown === null) return null;
1487
- return new Response(markdown, { headers: { "Content-Type": "text/markdown; charset=utf-8" } });
1525
+ return {
1526
+ kind: "layout",
1527
+ markdown: withFrontMatter(markdown, {
1528
+ ...meta,
1529
+ url: page.url
1530
+ })
1531
+ };
1488
1532
  }
1489
1533
  /**
1490
- * A singleton or derived page's Markdown Representation as JSON (`/_camox/data?markdown`), read
1491
- * like the page's data: in the draft the URL names, for a member. The studio shows it, while the
1492
- * page's `.md` URL always reads Live. `markdown` is null when the layout declares none.
1534
+ * A Layout Page's Markdown Representation as JSON (`/_camox/data?markdown`), read like the page's
1535
+ * data: in the draft the URL names, for a member. The studio shows it, while the page's `.md` URL
1536
+ * always reads Live. `markdown` is null when the layout declares none. A curated page's Markdown
1537
+ * is the API's to read.
1493
1538
  */
1494
1539
  async function createLayoutMarkdownDataResponse(options, request, pathname) {
1495
- const located = await locateRequest(request, options, pathname);
1496
- const app = await options.getCamoxApp?.();
1497
- const match = app && matchDerivedLayout(app.getLayouts(), located.path);
1498
- if (!match || matchLocalizedRoute(located.path)?.kind !== "page") return new Response("Not found", { status: 404 });
1499
- const { project, clearAuthCookie } = await resolveRequestMember(request, options);
1500
- const headers = {
1501
- ...isPrivateRender(request) ? { "Cache-Control": "private, no-store" } : {},
1502
- Vary: "Cookie",
1503
- ...clearAuthCookie ? { "Set-Cookie": buildClearServerAuthCookieHeader() } : {}
1504
- };
1540
+ const { apiUrl, projectUuid } = options;
1541
+ const requestReader = await resolveReader(request, options);
1542
+ const located = await locateRequest(requestReader, options, pathname);
1543
+ if (!apiUrl || !projectUuid || matchLocalizedRoute(located.path)?.kind !== "page") return new Response("Not found", { status: 404 });
1544
+ const headers = readerHeaders(requestReader);
1545
+ const reader = pageReaderOf(options, requestReader, located.locale);
1546
+ let shown;
1505
1547
  try {
1506
- const markdown = await buildLayoutMarkdown(options, match, located.locale, {
1507
- pathname,
1508
- url: absoluteUrl(request, options)(pathname)
1548
+ shown = await readPageMarkdownShown({
1549
+ ...options,
1550
+ apiUrl,
1551
+ projectUuid
1509
1552
  }, {
1510
- member: project !== void 0,
1511
- authCookieHeader: getServerAuthCookieHeader(request.headers),
1512
- requestedDraft: getRequestDraftId(request)
1553
+ app: await options.getCamoxApp?.(),
1554
+ path: located.path,
1555
+ page: {
1556
+ pathname,
1557
+ url: absoluteUrl(request, options)(pathname)
1558
+ },
1559
+ reader,
1560
+ requestReader,
1561
+ curated: null
1513
1562
  });
1514
- return Response.json({ markdown }, { headers });
1515
1563
  } catch (error) {
1516
1564
  if (isNotFoundError(error)) return new Response("Not found", {
1517
1565
  status: 404,
@@ -1519,160 +1567,16 @@ async function createLayoutMarkdownDataResponse(options, request, pathname) {
1519
1567
  });
1520
1568
  throw error;
1521
1569
  }
1570
+ if (shown?.kind !== "layout") return new Response("Not found", { status: 404 });
1571
+ return Response.json({ markdown: shown.markdown }, { headers });
1522
1572
  }
1523
- async function createDerivedResponse(options, pathname, located, request, { dataOnly = false, singletonOnly = false } = {}) {
1524
- const app = await options.getCamoxApp?.();
1525
- const match = app && matchDerivedLayout(app.getLayouts(), located.path);
1526
- if (!match || !dataOnly && !options.renderPage) return null;
1527
- if (singletonOnly && match.layout._internal.kind !== "singleton") return null;
1528
- try {
1529
- const queryClient = new QueryClient();
1530
- const { project, clearAuthCookie } = await resolveRequestMember(request, options);
1531
- const studio = project !== void 0;
1532
- const authCookieHeader = studio ? getServerAuthCookieHeader(request.headers) : void 0;
1533
- const requestedDraft = getRequestDraftId(request);
1534
- const readLayout = () => readDraftOrLive(requestedDraft, studio, (draftId) => createServerApiClient(options.apiUrl, {
1535
- authCookieHeader,
1536
- draftId,
1537
- locale: located.locale
1538
- }).layouts.get({
1539
- projectUuid: options.projectUuid,
1540
- layoutId: match.layout._internal.id
1541
- }));
1542
- const runLoader = (draftId) => loadLayoutResult(app, match, createLayoutRequestContext(options, studio ? "draft" : "live", {
1543
- authCookieHeader,
1544
- draftId,
1545
- locale: located.locale
1546
- }), located.locale);
1547
- const [serializedResult, { value: shared, ...read }] = studio && requestedDraft ? await readLayout().then(async (read) => [await runLoader(read.draftId), read]) : await Promise.all([runLoader(), readLayout()]);
1548
- const studioCache = createServerStudioCache(queryClient, {
1549
- apiUrl: options.apiUrl,
1550
- authCookieHeader,
1551
- locale: located.locale
1552
- });
1553
- const projectUuid = options.projectUuid;
1554
- const shownIn = { in: read.draftId ?? null };
1555
- studioCache.land(reads.layoutStructure({
1556
- projectUuid,
1557
- layoutId: match.layout._internal.id
1558
- }), shared, shownIn);
1559
- studioCache.set(reads.derivedPageData({
1560
- projectUuid,
1561
- runtimeBasePath: normalizeRuntimeBasePath(options.runtimeBasePath),
1562
- pathname
1563
- }), serializedResult ?? null, shownIn);
1564
- const record = asCollectionItem(serializedResult);
1565
- const recordCollection = record && app.getCollectionById(record.collectionId);
1566
- const recordValues = record && recordCollection ? recordPageValues(recordCollection, record) : void 0;
1567
- const context = buildLayoutContext(match.layout._internal, {
1568
- path: pathname,
1569
- locale: located.locale.tag,
1570
- projectName: shared.projectName,
1571
- data: serializedResult?.data,
1572
- params: match.params,
1573
- record: recordValues
1574
- });
1575
- const { alternates, head: pageHead } = await readAlternates(request, options, {
1576
- located,
1577
- identity: record && match.layout._internal.kind === "derived" ? {
1578
- kind: "record",
1579
- layout: match.layout,
1580
- recordId: record.data.id
1581
- } : { kind: "code-owned" },
1582
- head: withSearchEngineMark(buildPageHead({
1583
- layout: match.layout._internal,
1584
- context,
1585
- origin: new URL(request.url).origin,
1586
- faviconUrl: buildFaviconUrl(options.apiUrl, shared.project),
1587
- hasMarkdown: match.layout._internal.toMarkdown !== void 0,
1588
- recordType: record?.structuredData?.node?.["@type"]
1589
- }), shared.project),
1590
- described: {
1591
- project: {
1592
- ...shared.project,
1593
- name: shared.projectName
1594
- },
1595
- title: context.page.title,
1596
- declared: declarePage({
1597
- layout: match.layout._internal,
1598
- context
1599
- }),
1600
- blocks: placedBlockNodes(shared.layout, [], shared.structuredData),
1601
- ...record?.published && { published: record.published },
1602
- records: pageRecords(shared.recordStructuredData, record && {
1603
- id: record.data.id,
1604
- structuredData: record.structuredData,
1605
- titles: recordValues?.titles
1606
- })
1607
- },
1608
- localeReads: requestLocaleReads(options, {
1609
- authCookieHeader,
1610
- draftId: read.draftId
1611
- })
1612
- });
1613
- const analytics = dataOnly ? void 0 : visitorAnalytics({
1614
- studio,
1615
- draftId: read.draftId
1616
- }, shared.project);
1617
- const input = await withArtifactsByUrl({
1618
- alternates,
1619
- project,
1620
- presentation: studio ? "studio" : "public",
1621
- ...analytics && { analytics },
1622
- apiUrl: options.apiUrl,
1623
- authenticationUrl: options.authenticationUrl,
1624
- projectUuid: options.projectUuid,
1625
- dehydratedState: dehydrate(queryClient),
1626
- draftId: read.draftId,
1627
- refusedDraft: read.refusedDraft,
1628
- head: pageHead,
1629
- href: renderedHref(request.url, read),
1630
- layoutIdentity: getLayoutIdentity(shared.layout),
1631
- loaderData: null,
1632
- pathname,
1633
- runtimeBasePath: normalizeRuntimeBasePath(options.runtimeBasePath),
1634
- locale: located.locale,
1635
- locales: located.locales,
1636
- derived: {
1637
- layoutId: match.layout._internal.id,
1638
- page: {
1639
- params: match.params,
1640
- path: pathname,
1641
- projectName: shared.projectName
1642
- },
1643
- result: serializedResult,
1644
- layout: shared.layout
1645
- }
1646
- }, options);
1647
- const document = await options.getDocument?.() ?? {};
1648
- preparePreviewDocument(input, options, document);
1649
- if (dataOnly) return Response.json(input, { headers: {
1650
- ...isPrivateRender(request) ? { "Cache-Control": "private, no-store" } : {},
1651
- Vary: "Cookie",
1652
- ...clearAuthCookie ? { "Set-Cookie": buildClearServerAuthCookieHeader() } : {}
1653
- } });
1654
- const html = await options.renderPage(input);
1655
- const head = renderHead(studio ? {} : document, createPageHeadInput(pageHead), studio ? void 0 : options.stylesheetUrl, studio ? options.studioStylesheetUrl : void 0, studio ? void 0 : located.locale);
1656
- return new Response(`<!doctype html><html${head.htmlAttrs}><head>${renderStudioThemeScript(studio)}${head.headTags}${renderAnalyticsScripts(analytics)}${studio ? "" : renderBlockPreloads(options, input.dehydratedState)}<script id="__CAMOX_DATA__" type="application/json">${serializeJsonForHtml(hydrationInputFor(input, html))}<\/script></head><body${head.bodyAttrs}>${head.bodyTagsOpen}<div id="root">${html}</div>${head.bodyTags}${renderClientEntryScript(options.pageClientEntryUrl)}</body></html>`, { headers: {
1657
- "Content-Type": "text/html; charset=utf-8",
1658
- Vary: "Cookie",
1659
- Link: llmsTxtLinkHeader(options),
1660
- ...isPrivateRender(request) ? { "Cache-Control": "private, no-store" } : {},
1661
- ...clearAuthCookie ? { "Set-Cookie": buildClearServerAuthCookieHeader() } : {},
1662
- ...isHiddenFromSearchEngines(shared.project) && { "X-Robots-Tag": "noindex" }
1663
- } });
1664
- } catch (error) {
1665
- if (isNotFoundError(error)) return new Response("Page not found", { status: 404 });
1666
- throw error;
1667
- }
1668
- }
1669
- async function createStudioHtmlResponse({ match, options, pathname, located, request, dataOnly = false }) {
1573
+ async function createStudioHtmlResponse({ match, options, pathname, located, request, reader, dataOnly = false }) {
1670
1574
  if (!options.apiUrl || !options.authenticationUrl || !options.projectUuid || !dataOnly && !options.renderStudio) return Response.json({
1671
1575
  message: "Camox Studio renderer is not configured.",
1672
1576
  ...match
1673
1577
  }, { status: 500 });
1674
1578
  const queryClient = new QueryClient();
1675
- const { project, clearAuthCookie } = await resolveRequestMember(request, options);
1579
+ const project = reader.member;
1676
1580
  const presentation = project ? "studio" : "public";
1677
1581
  const studioRenderInput = {
1678
1582
  project,
@@ -1690,46 +1594,30 @@ async function createStudioHtmlResponse({ match, options, pathname, located, req
1690
1594
  runtimeBasePath: normalizeRuntimeBasePath(options.runtimeBasePath),
1691
1595
  locale: located.locale,
1692
1596
  locales: located.locales,
1693
- draftId: project ? getRequestDraftId(request) : void 0
1597
+ draftId: reader.draftId
1694
1598
  };
1695
- if (dataOnly) return Response.json(studioRenderInput, { headers: {
1696
- "Cache-Control": "private, no-store",
1697
- Vary: "Cookie",
1698
- ...clearAuthCookie ? { "Set-Cookie": buildClearServerAuthCookieHeader() } : {}
1699
- } });
1599
+ const headers = readerHeaders(reader, { alwaysPrivate: true });
1600
+ if (dataOnly) return Response.json(studioRenderInput, { headers });
1700
1601
  const appHtml = await options.renderStudio(studioRenderInput);
1701
- const renderedHead = renderHead({}, { title: "Camox Studio" }, void 0, presentation === "studio" ? options.studioStylesheetUrl : void 0);
1702
- return new Response(`<!doctype html>
1703
- <html${renderedHead.htmlAttrs}>
1704
- <head>
1705
- ${renderStudioThemeScript(presentation === "studio")}
1706
- ${renderedHead.headTags}
1707
- <script id="__CAMOX_DATA__" type="application/json">${serializeJsonForHtml(studioRenderInput)}<\/script>
1708
- </head>
1709
- <body${renderedHead.bodyAttrs}>
1710
- ${renderedHead.bodyTagsOpen}
1711
- <div id="root">${appHtml}</div>
1712
- ${renderedHead.bodyTags}
1713
- ${renderClientEntryScript(options.studioClientEntryUrl)}
1714
- </body>
1715
- </html>`, { headers: {
1716
- "Content-Type": "text/html; charset=utf-8",
1717
- "Cache-Control": "private, no-store",
1718
- Vary: "Cookie",
1719
- ...clearAuthCookie ? { "Set-Cookie": buildClearServerAuthCookieHeader() } : {}
1720
- } });
1602
+ return htmlResponse(htmlDocument({
1603
+ head: renderHead({}, { title: "Camox Studio" }, void 0, presentation === "studio" ? options.studioStylesheetUrl : void 0),
1604
+ before: renderStudioThemeScript(presentation === "studio"),
1605
+ data: studioRenderInput,
1606
+ root: appHtml,
1607
+ entryUrl: options.studioClientEntryUrl
1608
+ }), headers);
1721
1609
  }
1722
1610
  /**
1723
1611
  * The Canvas with nothing selected renders the page app, like a Page, so
1724
1612
  * selecting it keeps the Canvas mounted.
1725
1613
  */
1726
- async function createCanvasOnlyResponse({ options, pathname, located, request, dataOnly = false }) {
1614
+ async function createCanvasOnlyResponse({ options, pathname, located, request, reader, dataOnly = false }) {
1727
1615
  if (!options.apiUrl || !options.authenticationUrl || !options.projectUuid || !dataOnly && !options.renderPage) return Response.json({
1728
1616
  message: "Camox page renderer is not configured.",
1729
1617
  kind: "canvas",
1730
1618
  pathname
1731
1619
  }, { status: 500 });
1732
- const { project, clearAuthCookie } = await resolveRequestMember(request, options);
1620
+ const project = reader.member;
1733
1621
  const studio = project !== void 0;
1734
1622
  const title = "Canvas";
1735
1623
  const input = {
@@ -1748,87 +1636,18 @@ async function createCanvasOnlyResponse({ options, pathname, located, request, d
1748
1636
  loaderData: null,
1749
1637
  dehydratedState: dehydrate(new QueryClient()),
1750
1638
  canvasOnly: true,
1751
- draftId: studio ? getRequestDraftId(request) : void 0
1752
- };
1753
- const headers = {
1754
- "Cache-Control": "private, no-store",
1755
- Vary: "Cookie",
1756
- ...clearAuthCookie ? { "Set-Cookie": buildClearServerAuthCookieHeader() } : {}
1639
+ draftId: reader.draftId
1757
1640
  };
1641
+ const headers = readerHeaders(reader, { alwaysPrivate: true });
1758
1642
  if (dataOnly) return Response.json(input, { headers });
1759
1643
  const html = await options.renderPage(input);
1760
- const head = renderHead({}, { title }, void 0, studio ? options.studioStylesheetUrl : void 0);
1761
- return new Response(`<!doctype html><html${head.htmlAttrs}><head>${renderStudioThemeScript(studio)}${head.headTags}<script id="__CAMOX_DATA__" type="application/json">${serializeJsonForHtml(input)}<\/script></head><body${head.bodyAttrs}>${head.bodyTagsOpen}<div id="root">${html}</div>${head.bodyTags}${renderClientEntryScript(options.pageClientEntryUrl)}</body></html>`, { headers: {
1762
- "Content-Type": "text/html; charset=utf-8",
1763
- ...headers
1764
- } });
1765
- }
1766
- async function createPageDataResponse({ options, pathname, located, request, pageRead }) {
1767
- if (!options.apiUrl || !options.projectUuid) return Response.json({ message: "Camox page loader is not configured." }, { status: 500 });
1768
- const singleton = await createDerivedResponse(options, pathname, located, request, {
1769
- dataOnly: true,
1770
- singletonOnly: true
1771
- });
1772
- if (singleton) return singleton;
1773
- const { queryClient, result: reading } = pageRead ?? readCuratedPage({
1774
- ...options,
1775
- apiUrl: options.apiUrl,
1776
- projectUuid: options.projectUuid
1777
- }, request, located);
1778
- const localeReads = curatedPageLocaleReads(options, request, located, queryClient);
1779
- try {
1780
- const result = await reading;
1781
- const headers = new Headers({
1782
- "Content-Type": "application/json; charset=utf-8",
1783
- Vary: "Cookie"
1784
- });
1785
- if (isPrivateRender(request)) headers.set("Cache-Control", "private, no-store");
1786
- if (result.shouldClearAuthCookie) headers.append("Set-Cookie", buildClearServerAuthCookieHeader());
1787
- const camoxApp = await options.getCamoxApp?.();
1788
- const { alternates, head } = await readAlternates(request, options, {
1789
- located,
1790
- identity: {
1791
- kind: "curated",
1792
- pageId: result.data.page.page.id
1793
- },
1794
- head: withSearchEngineMark(camoxApp ? buildCamoxPageHead(camoxApp, result.data, located.locale) : null, result.data.page.project),
1795
- described: curatedPageDescription(camoxApp, result.data.page, located.locale),
1796
- localeReads: localeReads(result.draftId)
1797
- });
1798
- const dehydratedState = dehydrate(queryClient);
1799
- const navigationInput = await withArtifactsByUrl({
1800
- alternates,
1801
- apiUrl: options.apiUrl,
1802
- authenticationUrl: options.authenticationUrl ?? "",
1803
- projectUuid: options.projectUuid,
1804
- runtimeBasePath: normalizeRuntimeBasePath(options.runtimeBasePath),
1805
- locale: located.locale,
1806
- locales: located.locales,
1807
- href: renderedHref(request.url, result),
1808
- draftId: result.draftId,
1809
- refusedDraft: result.refusedDraft,
1810
- presentation: result.project ? "studio" : "public",
1811
- pathname,
1812
- layoutIdentity: getLayoutIdentity(result.data.page.layout),
1813
- project: result.project,
1814
- loaderData: result.data,
1815
- head,
1816
- dehydratedState
1817
- }, options);
1818
- preparePreviewDocument(navigationInput, options, await options.getDocument?.() ?? {});
1819
- return Response.json(navigationInput, { headers });
1820
- } catch (error) {
1821
- if (isNotFoundError(error)) {
1822
- const derived = await createDerivedResponse(options, pathname, located, request, { dataOnly: true });
1823
- if (derived) return derived;
1824
- return Response.json({
1825
- kind: "page",
1826
- message: "No Camox page on this path",
1827
- pathname
1828
- }, { status: 404 });
1829
- }
1830
- throw error;
1831
- }
1644
+ return htmlResponse(htmlDocument({
1645
+ head: renderHead({}, { title }, void 0, studio ? options.studioStylesheetUrl : void 0),
1646
+ before: renderStudioThemeScript(studio),
1647
+ data: input,
1648
+ root: html,
1649
+ entryUrl: options.pageClientEntryUrl
1650
+ }), headers);
1832
1651
  }
1833
1652
  async function handleCamoxRequest(request, options = {}) {
1834
1653
  const pathname = getRuntimePathname(request.url, options.runtimeBasePath);
@@ -1889,14 +1708,16 @@ async function handleCamoxRequest(request, options = {}) {
1889
1708
  * With `dataOnly`, as the JSON client navigation loads.
1890
1709
  */
1891
1710
  async function respondInLocale(options, request, pathname, { dataOnly = false } = {}) {
1892
- const [early, located] = await Promise.all([readDefaultLocalePageEarly(options, request, pathname), locateRequest(request, options, pathname)]);
1711
+ const reader = await resolveReader(request, options);
1712
+ const [early, located] = await Promise.all([readPageEarly(options, reader, pathname), locateRequest(reader, options, pathname)]);
1893
1713
  const localized = matchLocalizedRoute(located.path);
1894
1714
  if (!localized) return new Response("Not found", { status: 404 });
1895
1715
  const target = {
1896
1716
  options,
1897
1717
  pathname,
1898
1718
  located,
1899
- request
1719
+ request,
1720
+ reader
1900
1721
  };
1901
1722
  if (localized.kind === "canvas") return createCanvasOnlyResponse({
1902
1723
  ...target,
@@ -1907,11 +1728,11 @@ async function respondInLocale(options, request, pathname, { dataOnly = false }
1907
1728
  match: localized,
1908
1729
  dataOnly
1909
1730
  });
1910
- const page = {
1731
+ return createPageResponse({
1911
1732
  ...target,
1912
- pageRead: located.locale.isDefault ? early : void 0
1913
- };
1914
- return dataOnly ? createPageDataResponse(page) : createPageHtmlResponse(page);
1733
+ pageRead: earlyReadIn(early, located),
1734
+ dataOnly
1735
+ });
1915
1736
  }
1916
1737
  /**
1917
1738
  * A page's Markdown Representation in its locale, with the same precedence as its HTML:
@@ -1923,6 +1744,7 @@ async function createPageMarkdownResponse(options, { pathname, request, pagesOnl
1923
1744
  try {
1924
1745
  read = await readPageMarkdown({
1925
1746
  ...options,
1747
+ reader: visitorReader(options),
1926
1748
  path: pathname
1927
1749
  });
1928
1750
  } catch {
@@ -1935,10 +1757,25 @@ async function createPageMarkdownResponse(options, { pathname, request, pagesOnl
1935
1757
  url: absoluteUrl(request, options)(pathname)
1936
1758
  };
1937
1759
  const app = await options.getCamoxApp?.();
1938
- const layoutMatch = app && matchDerivedLayout(app.getLayouts(), path);
1939
- if (layoutMatch?.layout._internal.kind === "singleton") return createLayoutMarkdownResponse(options, layoutMatch, locale, page);
1940
- if (read.page) return curatedMarkdownResponse(app, read, page.url);
1941
- return layoutMatch ? createLayoutMarkdownResponse(options, layoutMatch, locale, page) : null;
1760
+ const requestReader = visitorReader(options);
1761
+ const reader = pageReaderOf(options, requestReader, locale);
1762
+ let shown;
1763
+ try {
1764
+ shown = await readPageMarkdownShown(options, {
1765
+ app,
1766
+ path,
1767
+ page,
1768
+ reader,
1769
+ requestReader,
1770
+ curated: read
1771
+ });
1772
+ } catch (error) {
1773
+ if (isNotFoundError(error)) return new Response("Page not found", { status: 404 });
1774
+ throw error;
1775
+ }
1776
+ if (shown?.kind === "curated") return curatedMarkdownResponse(app, shown.read, page.url);
1777
+ if (!shown?.markdown) return null;
1778
+ return new Response(shown.markdown, { headers: { "Content-Type": "text/markdown; charset=utf-8" } });
1942
1779
  }
1943
1780
  /** Whether a route is one of the studio's own, rather than a page or the Canvas. */
1944
1781
  function isStudioRoute(match) {
@@ -1953,18 +1790,13 @@ function matchLocalizedRoute(path) {
1953
1790
  return match.kind === "page" || match.kind === "canvas" || isStudioRoute(match) ? match : null;
1954
1791
  }
1955
1792
  /**
1956
- * The locale a request's path names, among the locales of the scope it reads: the URL's draft's
1957
- * for a session naming one, else Live's. Without an API there are none to read: every path is the
1958
- * default locale's, which then says it can't render.
1793
+ * The locale a request's path names, among the locales of the scope its Reader reads: its
1794
+ * draft's, else Live's. Without an API there are none to read: every path is the default
1795
+ * locale's, which then says it can't render.
1959
1796
  */
1960
- async function locateRequest(request, options, pathname) {
1797
+ async function locateRequest(reader, options, pathname) {
1961
1798
  const { apiUrl, projectUuid } = options;
1962
- const locales = apiUrl && projectUuid ? await readScopeLocales({
1963
- apiUrl,
1964
- projectUuid,
1965
- authCookieHeader: getServerAuthCookieHeader(request.headers),
1966
- draftId: getRequestDraftId(request)
1967
- }) : [UNNAMED_DEFAULT_LOCALE];
1799
+ const locales = apiUrl && projectUuid ? await reader.locales() : [UNNAMED_DEFAULT_LOCALE];
1968
1800
  return {
1969
1801
  ...parseLocalePath(pathname, locales),
1970
1802
  locales