camox 0.47.0 → 0.51.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 (117) hide show
  1. package/dist/core/createApp.d.ts +19 -6
  2. package/dist/core/createApp.js +7 -2
  3. package/dist/core/createBlock.js +54 -43
  4. package/dist/core/createCollection.d.ts +25 -7
  5. package/dist/core/createCollection.js +28 -9
  6. package/dist/core/createLayout.d.ts +98 -71
  7. package/dist/core/createLayout.js +10 -16
  8. package/dist/core/derivedRoutes.js +1 -1
  9. package/dist/core/editing/BlockEditingRuntime.d.ts +3 -2
  10. package/dist/core/editing/CompleteBlockEditingRuntime.js +2 -1
  11. package/dist/core/editing/createEditableBlock.d.ts +59 -19
  12. package/dist/core/editing/createEditableBlock.js +162 -151
  13. package/dist/core/editing/useLatestRecord.js +7 -7
  14. package/dist/core/layoutContext.d.ts +55 -0
  15. package/dist/core/layoutContext.js +66 -0
  16. package/dist/core/lib/contentType.d.ts +91 -4
  17. package/dist/core/lib/contentType.js +84 -7
  18. package/dist/core/lib/recordScope.js +19 -17
  19. package/dist/core/lib/reference.d.ts +6 -2
  20. package/dist/core/recordItem.js +55 -2
  21. package/dist/core/requestContext.d.ts +18 -0
  22. package/dist/core/structuredData.d.ts +126 -0
  23. package/dist/core/structuredData.js +40 -0
  24. package/dist/features/canvas/canvasCamera.js +1 -1
  25. package/dist/features/canvas/sidebar/CanvasInspector.js +1 -1
  26. package/dist/features/canvas/sidebar/CanvasSidebarFrame.js +1 -1
  27. package/dist/features/content/ContentAssets.js +84 -88
  28. package/dist/features/content/ContentCollection.js +13 -7
  29. package/dist/features/content/collection-form.js +6 -2
  30. package/dist/features/content/components/MissingFromLocale.js +1 -1
  31. package/dist/features/og/pageImage.js +89 -0
  32. package/dist/features/page/CuratedPageContent.js +45 -32
  33. package/dist/features/page/DerivedPageContent.js +49 -22
  34. package/dist/features/page/SharedLayoutContent.js +26 -22
  35. package/dist/features/preview/CamoxPreview.js +41 -27
  36. package/dist/features/preview/EditablePageExperience.js +17 -9
  37. package/dist/features/preview/components/AddBlockSheet.js +1 -0
  38. package/dist/features/preview/components/BlockActionsPopover.js +19 -41
  39. package/dist/features/preview/components/FieldToolbar.js +1 -1
  40. package/dist/features/preview/components/MobilePreviewDrawer.js +6 -9
  41. package/dist/features/preview/components/PageInfo.js +244 -264
  42. package/dist/features/preview/components/PageTree.js +63 -53
  43. package/dist/features/preview/components/PreviewPanel.js +10 -64
  44. package/dist/features/preview/components/PreviewToolbar.js +1 -1
  45. package/dist/features/preview/components/RecordCard.js +50 -34
  46. package/dist/features/preview/components/RecordCombobox.js +12 -5
  47. package/dist/features/preview/components/ReferenceFieldEditor.js +4 -2
  48. package/dist/features/preview/components/ReferenceListFieldEditor.js +24 -19
  49. package/dist/features/preview/components/RepeatableItemsList.js +62 -46
  50. package/dist/features/preview/components/SelectionEditor.js +3 -2
  51. package/dist/features/preview/components/useFieldWriter.js +2 -4
  52. package/dist/features/preview/components/useRecordView.js +2 -2
  53. package/dist/features/preview/entityName.js +11 -0
  54. package/dist/features/preview/pageRecord.js +17 -18
  55. package/dist/features/preview/previewConstants.js +2 -8
  56. package/dist/features/preview/usePreviewViewportActions.js +1 -1
  57. package/dist/features/provider/AuthenticatedCamoxProvider.js +1 -1
  58. package/dist/features/provider/components/CommandPalette.js +2 -5
  59. package/dist/features/provider/signInShortcut.js +1 -2
  60. package/dist/features/provider/useAdminShortcuts.js +47 -53
  61. package/dist/features/routes/pageRuntime.d.ts +17 -2
  62. package/dist/features/routes/pageRuntime.js +61 -57
  63. package/dist/features/runtime/RuntimeChrome.js +1 -1
  64. package/dist/features/runtime/breadcrumbs.js +68 -0
  65. package/dist/features/runtime/livePages.js +3 -2
  66. package/dist/features/runtime/pageGraph.js +92 -0
  67. package/dist/features/runtime/projectLogo.js +110 -0
  68. package/dist/features/runtime/publicCache.js +83 -0
  69. package/dist/features/runtime/runtime.d.ts +24 -9
  70. package/dist/features/runtime/runtime.js +447 -130
  71. package/dist/features/runtime/runtimeRoutes.d.ts +1 -0
  72. package/dist/features/runtime/runtimeRoutes.js +1 -0
  73. package/dist/features/runtime/structuredData.js +213 -0
  74. package/dist/features/runtime/worker.d.ts +5 -1
  75. package/dist/features/runtime/worker.js +20 -3
  76. package/dist/features/settings/AnalyticsSettings.js +622 -0
  77. package/dist/features/settings/CamoxSettings.js +95 -0
  78. package/dist/features/settings/FaviconSettings.js +254 -0
  79. package/dist/features/settings/ProjectNameSettings.js +145 -0
  80. package/dist/features/settings/SettingsPart.js +73 -0
  81. package/dist/features/settings/SocialProfilesSettings.js +245 -0
  82. package/dist/features/settings/useProjectMutation.js +61 -0
  83. package/dist/features/studio/components/ChatSidebar.js +5 -5
  84. package/dist/features/studio/components/DraftsControl.js +136 -131
  85. package/dist/features/studio/components/LocalePicker.js +178 -174
  86. package/dist/features/studio/components/Navbar.js +107 -66
  87. package/dist/features/studio/components/ProjectMenu.js +85 -110
  88. package/dist/features/studio/components/ReviewDraftSheet.js +1046 -990
  89. package/dist/features/studio/components/ThreadMessages.js +2 -2
  90. package/dist/features/studio/components/UserButton.js +1 -1
  91. package/dist/features/studio/routes.js +3 -1
  92. package/dist/features/studio/showInCanvas.js +129 -0
  93. package/dist/features/vite/blockBoilerplate.js +3 -2
  94. package/dist/features/vite/definitionsSync.js +4 -5
  95. package/dist/features/vite/layoutGeneration.js +3 -2
  96. package/dist/features/vite/runtimeDev.js +32 -17
  97. package/dist/features/vite/staticAssetHeaders.js +52 -0
  98. package/dist/features/vite/vite.d.ts +1 -1
  99. package/dist/features/vite/vite.js +17 -4
  100. package/dist/lib/api-client-server.d.ts +1 -1
  101. package/dist/lib/api-client.d.ts +2 -1
  102. package/dist/lib/draft-scope.d.ts +1 -0
  103. package/dist/lib/locale-path.d.ts +3 -11
  104. package/dist/lib/locale-path.js +3 -27
  105. package/dist/lib/queries.d.ts +4 -0
  106. package/dist/lib/queries.js +8 -1
  107. package/dist/lib/shortcuts.js +29 -0
  108. package/dist/lib/studio-reads.d.ts +1 -0
  109. package/dist/lib/studio-reads.js +3 -1
  110. package/dist/lib/utils.js +1 -104
  111. package/dist/package.js +1 -1
  112. package/dist/studio.css +1 -1
  113. package/package.json +6 -4
  114. package/skills/camox/references/block-definitions.md +105 -25
  115. package/skills/camox/references/layout-definitions.md +53 -17
  116. package/skills/camox/references/routed-layouts.md +17 -14
  117. package/dist/features/studio/components/FaviconDialog.js +0 -318
@@ -1,7 +1,10 @@
1
- import { localeHeaders, localizedPath } from "../../lib/locale-path.js";
1
+ import { UNNAMED_DEFAULT_LOCALE, localeHeaders } from "../../lib/locale-path.js";
2
2
  import { createDraftScope, withoutDraft } from "../../lib/draft-scope.js";
3
3
  import { createStudioCache, reads } from "../../lib/studio-reads.js";
4
4
  import { linkFallbackLocale } from "../../lib/link-pages.js";
5
+ import { curatedLayoutContext } from "../../core/layoutContext.js";
6
+ import { PAGE_IMAGE_SIZE, pageImageUrl } from "../og/pageImage.js";
7
+ import { isArticle } from "../../core/structuredData.js";
5
8
  import { DRAFT_ENDED_ERROR_CODE, DRAFT_HEADER, NO_DEVELOPMENT_DRAFT_ERROR } from "@camox/api-contract/drafts";
6
9
  import { createORPCClient } from "@orpc/client";
7
10
  import { RPCLink } from "@orpc/client/fetch";
@@ -215,59 +218,52 @@ async function loadCamoxPageForRequest({ apiUrl, authCookieHeader, draftId, loca
215
218
  shouldClearAuthCookie: member.clearAuthCookie
216
219
  };
217
220
  }
218
- async function createMarkdownResponse({ apiUrl, camoxApp, locale, pathname, projectUuid, request, url, onNotFound }) {
219
- const api = createServerApiClient(apiUrl, { locale });
220
- const requestPathname = pathname ?? new URL(request.url).pathname;
221
- try {
222
- const page = await api.pages.getByPath({
223
- path: requestPathname,
224
- projectUuid
225
- });
226
- const { markdown } = await api.blocks.getPageMarkdown({ pageId: page.page.id });
227
- if (!markdown) return null;
228
- const meta = buildPageMeta(curatedPageMetaInput(camoxApp, page, locale));
229
- return new Response(withFrontMatter(markdown, {
230
- ...meta,
231
- url
232
- }), { headers: { "Content-Type": "text/markdown; charset=utf-8" } });
233
- } catch (error) {
234
- if (onNotFound && isNotFoundError(error)) return onNotFound();
235
- return null;
236
- }
221
+ /**
222
+ * Reads the Markdown Representation of the page at a runtime path, with its locale's prefix, in
223
+ * one API read: the API finds the locale the prefix names, the page there, its Markdown and what
224
+ * its front matter is built from. Markdown is a public output, so it reads Live whoever asks.
225
+ */
226
+ function readPageMarkdown({ apiUrl, path, projectUuid }) {
227
+ return createServerApiClient(apiUrl).pages.getMarkdown({
228
+ path,
229
+ projectUuid
230
+ });
231
+ }
232
+ /**
233
+ * A curated page's Markdown Representation, opened by front matter stating its title,
234
+ * description and absolute `url`. Null when the path has no curated page, or the page has no
235
+ * Markdown.
236
+ */
237
+ function curatedMarkdownResponse(camoxApp, read, url) {
238
+ if (!read.page || !read.markdown) return null;
239
+ const meta = buildPageMeta(curatedPageMetaInput(camoxApp, {
240
+ ...read,
241
+ page: read.page
242
+ }, read.locale));
243
+ return new Response(withFrontMatter(read.markdown, {
244
+ ...meta,
245
+ url
246
+ }), { headers: { "Content-Type": "text/markdown; charset=utf-8" } });
237
247
  }
238
- /** A page's title and description, built by its layout's callbacks. */
248
+ /** A page's title and description, built by its layout's `toTitle` and `toDescription`. */
239
249
  function buildPageMeta(input) {
240
- const context = {
241
- data: input.data,
242
- ...input.item && { item: input.item },
243
- params: input.params,
244
- projectName: input.projectName,
245
- pageFullPath: input.pageFullPath
246
- };
247
- const title = input.layout?.buildMetaTitle({
248
- ...context,
249
- pageMetaTitle: input.pageMetaTitle
250
- }) ?? input.pageMetaTitle;
251
- const description = input.layout?.buildMetaDescription ? input.layout.buildMetaDescription({
252
- ...context,
253
- pageMetaDescription: input.pageMetaDescription
254
- }) : input.pageMetaDescription;
250
+ const { layout, context } = input;
251
+ const title = layout ? layout.toTitle(context) : context.page.title;
252
+ const description = layout ? layout.toDescription(context) : context.page.description;
255
253
  return {
256
254
  title,
257
255
  ...description && { description }
258
256
  };
259
257
  }
260
- /** A curated page's meta input: its editor metadata and its layout. */
258
+ /** What a page's layout says the page is, from the page's context. */
259
+ function declarePage(input) {
260
+ return input.layout?.toStructuredData(input.context);
261
+ }
261
262
  /** A curated page's meta input. Its path keeps the prefix of the page's `locale`. */
262
- function curatedPageMetaInput(camoxApp, page, locale) {
263
+ function curatedPageMetaInput(camoxApp, page, locale = UNNAMED_DEFAULT_LOCALE) {
263
264
  return {
264
265
  layout: page.layout ? camoxApp?.getLayoutById(page.layout.layoutId)?._internal : void 0,
265
- pageMetaTitle: page.page.metaTitle ?? page.page.pathSegment,
266
- pageMetaDescription: page.page.metaDescription ?? void 0,
267
- data: void 0,
268
- params: {},
269
- projectName: page.projectName,
270
- pageFullPath: locale ? localizedPath(page.page.fullPath, locale) : page.page.fullPath
266
+ context: curatedLayoutContext(page, locale)
271
267
  };
272
268
  }
273
269
  /** A Markdown Representation, opened by YAML front matter stating the page's title, description and URL. */
@@ -281,18 +277,18 @@ function withFrontMatter(markdown, meta) {
281
277
  /** The head every page gets, whatever its layout kind. */
282
278
  function buildPageHead(input) {
283
279
  const { title, description } = buildPageMeta(input);
280
+ const mainEntity = declarePage(input) ?? (input.recordType ? { "@type": input.recordType } : void 0);
284
281
  const meta = [{ title }];
285
282
  if (description) meta.push({
286
283
  name: "description",
287
284
  content: description
288
285
  });
289
- const ogImageParams = new URLSearchParams({
290
- ...input.layout && { layoutId: input.layout.id },
291
- title: input.ogImageTitle ?? title,
292
- ...description && { description },
293
- ...input.projectName && { projectName: input.projectName }
286
+ const { page, project } = input.context;
287
+ const ogImageUrl = pageImageUrl({
288
+ origin: input.origin,
289
+ path: page.path,
290
+ customImageUrl: input.customOgImageUrl
294
291
  });
295
- const ogImageUrl = input.customOgImageUrl ?? `${input.origin}/og?${ogImageParams.toString()}`;
296
292
  meta.push({
297
293
  property: "og:title",
298
294
  content: title
@@ -301,15 +297,25 @@ function buildPageHead(input) {
301
297
  content: ogImageUrl
302
298
  }, {
303
299
  property: "og:image:width",
304
- content: "1200"
300
+ content: String(PAGE_IMAGE_SIZE.width)
305
301
  }, {
306
302
  property: "og:image:height",
307
- content: "630"
303
+ content: String(PAGE_IMAGE_SIZE.height)
308
304
  });
309
305
  if (description) meta.push({
310
306
  property: "og:description",
311
307
  content: description
312
308
  });
309
+ meta.push({
310
+ property: "og:type",
311
+ content: isArticle(mainEntity ?? { "@type": "" }) ? "article" : "website"
312
+ }, {
313
+ property: "og:site_name",
314
+ content: project.name
315
+ }, {
316
+ name: "twitter:card",
317
+ content: "summary_large_image"
318
+ });
313
319
  const links = [{
314
320
  rel: "icon",
315
321
  href: input.faviconUrl
@@ -317,7 +323,7 @@ function buildPageHead(input) {
317
323
  if (input.hasMarkdown) links.push({
318
324
  rel: "alternate",
319
325
  type: "text/markdown",
320
- href: `${input.origin}${getMarkdownPath(input.pageFullPath)}`
326
+ href: `${input.origin}${getMarkdownPath(page.path)}`
321
327
  });
322
328
  return {
323
329
  meta,
@@ -328,10 +334,8 @@ function buildPageHead(input) {
328
334
  function buildCamoxPageHead(camoxApp, loaderData, locale) {
329
335
  if (!loaderData) return {};
330
336
  const { page, origin, faviconUrl } = loaderData;
331
- const metaInput = curatedPageMetaInput(camoxApp, page, locale);
332
337
  return buildPageHead({
333
- ...metaInput,
334
- ogImageTitle: metaInput.pageMetaTitle,
338
+ ...curatedPageMetaInput(camoxApp, page, locale),
335
339
  customOgImageUrl: page.page.customOgImageUrl,
336
340
  origin,
337
341
  faviconUrl,
@@ -340,4 +344,4 @@ function buildCamoxPageHead(camoxApp, loaderData, locale) {
340
344
  }
341
345
 
342
346
  //#endregion
343
- export { buildCamoxPageHead, buildCamoxPageLoaderData, buildDefaultFaviconUrl, buildFaviconUrl, buildPageHead, buildPageMeta, createMarkdownResponse, createServerApiClient, createServerStudioCache, getMarkdownPath, isAuthSessionError, isNotFoundError, loadCamoxPage, loadCamoxPageForRequest, parseMarkdownPath, parseQuality, prefersMarkdown, readDraftOrLive, readScopeLocales, renderedHref, resolveMemberProject, withFrontMatter };
347
+ 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 };
@@ -22,7 +22,7 @@ function RuntimeChrome(t0) {
22
22
  const website = !input.routeKind;
23
23
  const hidden = useSelector(previewStore, _temp);
24
24
  const isCanvasShown = useSelector(previewStore, selectIsCanvasShown);
25
- const showChat = !website || isCanvasShown;
25
+ const showChat = website && isCanvasShown;
26
26
  const t1 = input.runtimeBasePath;
27
27
  let t2;
28
28
  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
@@ -0,0 +1,68 @@
1
+ import { recordPageValues } from "../../core/recordItem.js";
2
+ import { curatedPageTitle } from "../../core/layoutContext.js";
3
+ import { getPageDestinations } from "../../core/pageDestinations.js";
4
+ import { matchDerivedLayout } from "../../core/derivedRoutes.js";
5
+
6
+ //#region src/features/runtime/breadcrumbs.ts
7
+ /** The paths above a page's, from the home page down: none for the home page itself. */
8
+ function ancestorPaths(path) {
9
+ const segments = path.split("/").filter(Boolean);
10
+ if (segments.length === 0) return [];
11
+ return ["/", ...segments.slice(1).map((_, index) => `/${segments.slice(0, index + 1).join("/")}`)];
12
+ }
13
+ /**
14
+ * A page's crumbs, ending with the page itself, which `title` names. The home page has none. An
15
+ * ancestor that isn't a page, or is a derived page other than a record page, is left out.
16
+ */
17
+ async function readBreadcrumb({ path, title, app, reads }) {
18
+ const ancestors = ancestorPaths(path);
19
+ if (ancestors.length === 0) return [];
20
+ const layouts = app?.getLayouts() ?? [];
21
+ const pages = await reads.pages();
22
+ const destinations = getPageDestinations(pages, layouts);
23
+ const pagesByPath = new Map(pages.map((page) => [page.fullPath, page]));
24
+ return [...(await Promise.all(ancestors.map(async (ancestor) => {
25
+ const destination = destinations.find((d) => d.kind === "singleton" && d.fullPath === ancestor) ?? destinations.find((d) => d.kind === "curated" && d.fullPath === ancestor);
26
+ if (destination?.kind === "singleton") return {
27
+ name: destination.title,
28
+ path: ancestor
29
+ };
30
+ if (destination?.kind === "curated") {
31
+ const page = pagesByPath.get(ancestor);
32
+ return {
33
+ name: curatedPageTitle(page),
34
+ path: ancestor
35
+ };
36
+ }
37
+ const name = await recordPageTitle(app, ancestor, reads.records);
38
+ return name === void 0 ? void 0 : {
39
+ name,
40
+ path: ancestor
41
+ };
42
+ }))).filter((crumb) => crumb !== void 0), {
43
+ name: title,
44
+ path
45
+ }];
46
+ }
47
+ /**
48
+ * The title of the record a record page at `path` renders, found by its route param as the
49
+ * page's own read finds it. Undefined when no record page is there.
50
+ */
51
+ async function recordPageTitle(app, path, records) {
52
+ const match = app && matchDerivedLayout(app.getLayouts(), path);
53
+ const recordPages = match?.layout._internal.recordPages;
54
+ if (!match || !recordPages || !records) return void 0;
55
+ const value = recordPages.param ? match.params[recordPages.param] : Object.values(match.params).length === 1 ? Object.values(match.params)[0] : void 0;
56
+ const collection = app.getCollectionById(recordPages.collectionId);
57
+ if (!value || !collection) return void 0;
58
+ const found = await records.findByField({
59
+ collectionId: recordPages.collectionId,
60
+ field: recordPages.field,
61
+ value
62
+ });
63
+ if (found.length !== 1) return void 0;
64
+ return recordPageValues(collection, { data: found[0] }).title;
65
+ }
66
+
67
+ //#endregion
68
+ export { readBreadcrumb };
@@ -1,4 +1,5 @@
1
1
  import { createRequestContext } from "../../core/requestContext.js";
2
+ import { curatedPageTitle } from "../../core/layoutContext.js";
2
3
  import { getPageDestinations } from "../../core/pageDestinations.js";
3
4
  import { discoverLayoutPages } from "../../core/derivedRoutes.js";
4
5
 
@@ -27,7 +28,7 @@ async function listLivePages({ pages, app, records }) {
27
28
  key: destination.key,
28
29
  fullPath: page.fullPath,
29
30
  layoutId: page.layoutId,
30
- title: page.metaTitle || page.nickname,
31
+ title: curatedPageTitle(page),
31
32
  ...page.metaDescription && { description: page.metaDescription },
32
33
  hasMarkdown: true,
33
34
  updatedAt: page.updatedAt
@@ -41,7 +42,7 @@ async function listLivePages({ pages, app, records }) {
41
42
  key: page.recordId ? `record:${layout._internal.id}:${page.recordId}` : `derived:${page.path}`,
42
43
  fullPath: page.path,
43
44
  layoutId: layout._internal.id,
44
- title: page.label || page.path,
45
+ title: page.title || page.path,
45
46
  hasMarkdown: servesMarkdown(layout)
46
47
  }));
47
48
  }));
@@ -0,0 +1,92 @@
1
+ import { curatedPageTitle } from "../../core/layoutContext.js";
2
+ import { curatedPageMetaInput, declarePage } from "../routes/pageRuntime.js";
3
+ import { buildPageGraph, serializeStructuredData } from "./structuredData.js";
4
+
5
+ //#region src/features/runtime/pageGraph.ts
6
+ /**
7
+ * The records a page's graph can hold: those its blocks reference, and on a record page, the
8
+ * page's record and those it references.
9
+ */
10
+ function pageRecords(blocks, page) {
11
+ const own = page?.structuredData;
12
+ return {
13
+ nodes: {
14
+ ...blocks?.nodes,
15
+ ...own?.records.nodes,
16
+ ...page && own?.node && { [page.id]: own.node }
17
+ },
18
+ referenced: [.../* @__PURE__ */ new Set([...blocks?.referenced ?? [], ...own?.records.referenced ?? []])],
19
+ ...page?.titles && { titles: page.titles },
20
+ ...page && { main: page.id }
21
+ };
22
+ }
23
+ /** Each placed block's nodes, its layout's before and after the page's own. */
24
+ function placedBlockNodes(layout, pageBlockIds, structuredData) {
25
+ if (!structuredData) return [];
26
+ return [
27
+ ...layout?.beforeBlockIds ?? [],
28
+ ...pageBlockIds,
29
+ ...layout?.afterBlockIds ?? []
30
+ ].flatMap((id) => {
31
+ const nodes = structuredData?.[id];
32
+ return nodes?.length ? [{
33
+ id,
34
+ nodes
35
+ }] : [];
36
+ });
37
+ }
38
+ /** What a curated page's Structured Data says beyond its head. */
39
+ function curatedPageDescription(camoxApp, structure, locale) {
40
+ const { page, project, projectName } = structure;
41
+ return {
42
+ project: {
43
+ ...project,
44
+ name: projectName
45
+ },
46
+ title: curatedPageTitle(page),
47
+ published: {
48
+ first: page.firstPublishedAt,
49
+ last: page.publishedAt
50
+ },
51
+ declared: declarePage(curatedPageMetaInput(camoxApp, structure, locale)),
52
+ blocks: placedBlockNodes(structure.layout, page.blockIds, structure.structuredData),
53
+ records: pageRecords(structure.recordStructuredData)
54
+ };
55
+ }
56
+ /** The value of a head's meta tag, by its name or property. */
57
+ function headMeta(head, key) {
58
+ return head.meta?.find((entry) => entry.name === key || entry.property === key)?.content;
59
+ }
60
+ /**
61
+ * A page's head with its Structured Data, which names and describes the page as the head does,
62
+ * with the head's page image.
63
+ */
64
+ function withStructuredData(head, place, site, { published, declared, breadcrumb, blocks, records }) {
65
+ const name = head.meta?.find((entry) => entry.title)?.title ?? "";
66
+ const graph = buildPageGraph({
67
+ site,
68
+ page: {
69
+ url: place.url,
70
+ name,
71
+ description: headMeta(head, "description"),
72
+ inLanguage: place.locale,
73
+ image: headMeta(head, "og:image"),
74
+ ...published?.first && { datePublished: new Date(published.first) },
75
+ ...published?.last && { dateModified: new Date(published.last) },
76
+ breadcrumb
77
+ },
78
+ declared,
79
+ blocks,
80
+ records
81
+ });
82
+ return {
83
+ ...head,
84
+ scripts: [...head.scripts ?? [], {
85
+ type: "application/ld+json",
86
+ text: serializeStructuredData(graph)
87
+ }]
88
+ };
89
+ }
90
+
91
+ //#endregion
92
+ export { curatedPageDescription, pageRecords, placedBlockNodes, withStructuredData };
@@ -0,0 +1,110 @@
1
+ import { RUNTIME_ROUTE_PATHS } from "./runtimeRoutes.js";
2
+ import { buildDefaultFaviconUrl, buildFaviconUrl, createServerApiClient, isNotFoundError } from "../routes/pageRuntime.js";
3
+ import { jsx } from "react/jsx-runtime";
4
+
5
+ //#region src/features/runtime/projectLogo.tsx
6
+ /**
7
+ * The project's logo in its Structured Data: its favicon, rendered as a PNG by the runtime.
8
+ * Search engines want a raster logo at least 112px wide, and favicons are often SVGs or tiny.
9
+ */
10
+ /** The size the logo is served at: square, and wider than search engines' minimum. */
11
+ const LOGO_SIZE = 512;
12
+ /**
13
+ * Where the runtime serves its project's logo. `url` makes a runtime path absolute. The project's
14
+ * version only busts caches: the route always serves the project's current logo.
15
+ */
16
+ function projectLogo(url, project) {
17
+ const search = new URLSearchParams({ v: String(project.updatedAt) });
18
+ return {
19
+ url: `${url(RUNTIME_ROUTE_PATHS.logo)}?${search}`,
20
+ width: 512,
21
+ height: 512
22
+ };
23
+ }
24
+ /** An image fetched as a data URL, which the renderer reads without fetching it again. */
25
+ async function asDataUrl(url) {
26
+ const response = await fetch(new Request(url));
27
+ if (!response.ok) return null;
28
+ const type = response.headers.get("Content-Type") ?? "application/octet-stream";
29
+ const bytes = new Uint8Array(await response.arrayBuffer());
30
+ let binary = "";
31
+ for (const byte of bytes) binary += String.fromCharCode(byte);
32
+ return `data:${type};base64,${btoa(binary)}`;
33
+ }
34
+ async function renderLogo(source) {
35
+ const { ImageResponse } = await import("camox/_internal/imageResponse");
36
+ return new ImageResponse(/* @__PURE__ */ jsx("div", {
37
+ style: {
38
+ display: "flex",
39
+ width: "100%",
40
+ height: "100%"
41
+ },
42
+ children: /* @__PURE__ */ jsx("img", {
43
+ src: source,
44
+ style: {
45
+ width: "100%",
46
+ height: "100%",
47
+ objectFit: "contain"
48
+ }
49
+ })
50
+ }), {
51
+ width: 512,
52
+ height: 512,
53
+ format: "png"
54
+ }).arrayBuffer();
55
+ }
56
+ /** The project's favicon as a PNG. A favicon the renderer can't read gives way to the default. */
57
+ async function renderProjectLogo(apiUrl, project) {
58
+ for (const url of [buildFaviconUrl(apiUrl, project), buildDefaultFaviconUrl(apiUrl)]) {
59
+ const source = await asDataUrl(url);
60
+ if (!source) continue;
61
+ try {
62
+ return await renderLogo(source);
63
+ } catch {}
64
+ }
65
+ return null;
66
+ }
67
+ /**
68
+ * The last logo rendered, with the version of the project it was rendered for, by project. A
69
+ * runtime serves one project, so it keeps one logo, and renders again only once the project
70
+ * changed.
71
+ */
72
+ const renderedLogos = /* @__PURE__ */ new Map();
73
+ /**
74
+ * The website's own project's logo, as a PNG. The request names no project, and its `v` only
75
+ * busts caches: whatever it says, the logo is the project's current one, rendered once per
76
+ * version of the project.
77
+ */
78
+ async function createProjectLogoResponse(request, { apiUrl, projectUuid }) {
79
+ if (!apiUrl || !projectUuid) return new Response("Not found", { status: 404 });
80
+ let project;
81
+ try {
82
+ project = await createServerApiClient(apiUrl).projects.getSummary({ projectUuid });
83
+ } catch (error) {
84
+ if (isNotFoundError(error)) return new Response("Not found", { status: 404 });
85
+ throw error;
86
+ }
87
+ const version = String(project.updatedAt);
88
+ const key = `${apiUrl} ${projectUuid}`;
89
+ let rendered = renderedLogos.get(key);
90
+ if (rendered?.version !== version) {
91
+ rendered = {
92
+ version,
93
+ png: renderProjectLogo(apiUrl, project)
94
+ };
95
+ renderedLogos.set(key, rendered);
96
+ }
97
+ const png = await rendered.png;
98
+ if (!png) {
99
+ if (renderedLogos.get(key) === rendered) renderedLogos.delete(key);
100
+ return new Response("Not found", { status: 404 });
101
+ }
102
+ const current = new URL(request.url).searchParams.get("v") === version;
103
+ return new Response(png, { headers: {
104
+ "Content-Type": "image/png",
105
+ "Cache-Control": current ? "public, max-age=86400" : "public, no-cache"
106
+ } });
107
+ }
108
+
109
+ //#endregion
110
+ export { createProjectLogoResponse, projectLogo };
@@ -0,0 +1,83 @@
1
+ import { prefersMarkdown } from "../routes/pageRuntime.js";
2
+
3
+ //#region src/features/runtime/publicCache.ts
4
+ /** How long the edge keeps a render. Live changing replaces its key long before. */
5
+ const EDGE_MAX_AGE = 86400;
6
+ /** Browsers can't be purged, so they ask again each time; the edge answers them. */
7
+ const VISITOR_CACHE_CONTROL = "public, max-age=0, must-revalidate";
8
+ /** The search parameter the cache key adds to a request's URL. */
9
+ const CACHE_KEY_PARAM = "__camox_cache";
10
+ /** The project's Live cache key, as the API serves it from its edge cache. */
11
+ async function readLiveCacheKey(apiUrl, projectUuid) {
12
+ try {
13
+ const response = await fetch(`${apiUrl}/live-cache-keys/${encodeURIComponent(projectUuid)}`);
14
+ return response.ok ? await response.text() || null : null;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+ /**
20
+ * Answers a public request from the cache, or renders it and keeps the render when it may be
21
+ * shared. Without a Live cache key, it renders without the cache: nothing tells when Live changed.
22
+ */
23
+ async function respondThroughPublicCache(request, publicCache, render) {
24
+ const liveCacheKey = await publicCache.liveCacheKey();
25
+ if (!liveCacheKey) return render();
26
+ const key = cacheKeyOf(request, `${liveCacheKey}.${publicCache.build}`);
27
+ const cached = await publicCache.cache.match(withValidators(key, request));
28
+ if (cached) return forVisitors(cached, request);
29
+ const response = await render();
30
+ if (!response || !mayBeShared(response)) return response;
31
+ publicCache.waitUntil(publicCache.cache.put(key, forEdge(response.clone())));
32
+ return forVisitors(response, request);
33
+ }
34
+ /**
35
+ * What a request's render is kept under: its URL, the representation it asks for, and the
36
+ * version of Live and of the website it renders.
37
+ */
38
+ function cacheKeyOf(request, version) {
39
+ const url = new URL(request.url);
40
+ const representation = prefersMarkdown(request.headers.get("Accept") ?? "") ? "md" : "any";
41
+ url.searchParams.set(CACHE_KEY_PARAM, `${version}.${representation}`);
42
+ return new Request(url.toString(), { method: "GET" });
43
+ }
44
+ /** The cache key, asking for the part of the render the request does. */
45
+ function withValidators(key, request) {
46
+ const headers = new Headers();
47
+ for (const name of [
48
+ "Range",
49
+ "If-None-Match",
50
+ "If-Modified-Since"
51
+ ]) {
52
+ const value = request.headers.get(name);
53
+ if (value !== null) headers.set(name, value);
54
+ }
55
+ return new Request(key, { headers });
56
+ }
57
+ /** Whether a response is the same for every visitor: what a private render never is. */
58
+ function mayBeShared(response) {
59
+ if (response.status !== 200 || response.headers.has("Set-Cookie")) return false;
60
+ const cacheControl = response.headers.get("Cache-Control") ?? "";
61
+ return !/\b(private|no-store)\b/i.test(cacheControl);
62
+ }
63
+ /** How long browsers may keep a render: what it asked for, if it's versioned, else not at all. */
64
+ function visitorCacheControl(response) {
65
+ const cacheControl = response.headers.get("Cache-Control") ?? "";
66
+ return /\bmax-age=[1-9]/.test(cacheControl) ? cacheControl : VISITOR_CACHE_CONTROL;
67
+ }
68
+ /** The render as the edge keeps it: as visitors get it, for as long as the edge keeps renders. */
69
+ function forEdge(response) {
70
+ const kept = new Response(response.body, response);
71
+ kept.headers.set("Cache-Control", `${visitorCacheControl(response)}, s-maxage=${EDGE_MAX_AGE}`);
72
+ return kept;
73
+ }
74
+ /** The render as visitors get it, without the edge's lifetime, which no cache downstream gets. */
75
+ function forVisitors(response, request) {
76
+ const cacheControl = visitorCacheControl(response).replace(/,\s*s-maxage=\d+/, "");
77
+ const answered = new Response(request.method === "HEAD" ? null : response.body, response);
78
+ answered.headers.set("Cache-Control", cacheControl);
79
+ return answered;
80
+ }
81
+
82
+ //#endregion
83
+ export { readLiveCacheKey, respondThroughPublicCache };
@@ -1,14 +1,17 @@
1
1
  import { CamoxDocument } from "../../core/defineDocument.js";
2
+ import { AlternatePath, LocaleView } from "../../lib/locale-path.js";
2
3
  import { AnyLayoutLoaderResult } from "../../core/createLayout.js";
3
4
  import { CamoxApp } from "../../core/createApp.js";
4
- import { AlternatePath, LocaleView } from "../../lib/locale-path.js";
5
5
  import { AnalyticsProviders } from "./analytics.js";
6
6
  import { RuntimeOwnedRouteKind } from "./runtimeRoutes.js";
7
7
  import { StudioRenderInput } from "./studioApp.js";
8
- import { DraftRead, MemberProject, createServerApiClient } from "../routes/pageRuntime.js";
8
+ import { DraftRead, MemberProject, PageHead, createServerApiClient } from "../routes/pageRuntime.js";
9
9
  import { UseHeadInput } from "unhead/types";
10
10
  //#region src/features/runtime/runtime.d.ts
11
- export type RuntimeRouteKind = RuntimeOwnedRouteKind | "canvas" | "page" | "studio" | "studio-content" | "studio-nested";
11
+ /** The studio's own routes, rather than a page or the Canvas. */
12
+ declare const STUDIO_ROUTE_KINDS: readonly ["studio", "studio-content", "studio-settings", "studio-nested"];
13
+ export type StudioRouteKind = (typeof STUDIO_ROUTE_KINDS)[number];
14
+ export type RuntimeRouteKind = RuntimeOwnedRouteKind | "canvas" | "page" | StudioRouteKind;
12
15
  export interface RuntimeRouteMatch {
13
16
  kind: RuntimeRouteKind;
14
17
  pathname: string;
@@ -32,13 +35,21 @@ export interface PageRenderInput extends DraftRead {
32
35
  /** Authenticate before rendering, to show the Studio around the page. */
33
36
  previewHandoff?: boolean;
34
37
  previewDocument?: string;
35
- routeKind?: "studio" | "studio-content" | "studio-nested";
38
+ routeKind?: StudioRouteKind;
36
39
  /** Whether the location is the Canvas with nothing selected, which only the Canvas shows. */
37
40
  canvasOnly?: boolean;
38
41
  project?: MemberProject;
39
42
  presentation?: "public" | "studio";
40
43
  derived?: {
41
44
  layoutId: string;
45
+ /** What the layout's context names, beside the loader's result. */
46
+ page?: {
47
+ /** The params the page's route matched. */
48
+ params: Record<string, string>;
49
+ /** The page's path, with its locale's prefix. */
50
+ path: string;
51
+ projectName: string;
52
+ };
42
53
  result?: AnyLayoutLoaderResult;
43
54
  layout: Awaited<ReturnType<ReturnType<typeof createServerApiClient>["layouts"]["get"]>>["layout"];
44
55
  };
@@ -88,10 +99,14 @@ export interface RuntimeOptions {
88
99
  studioStylesheetUrl?: string;
89
100
  }
90
101
  export declare function getRuntimePathname(url: string, runtimeBasePath?: string): string | null;
102
+ /**
103
+ * Whether a request reads what every visitor sees, so a shared cache may answer it: a GET or
104
+ * HEAD of a page or a public output, with no session, no draft named and no preview handoff. A
105
+ * response that comes out private all the same, such as the Studio's, still says so.
106
+ */
107
+ export declare function isPublicRequest(request: Request, options?: RuntimeOptions): boolean;
91
108
  export declare function matchRuntimeRoute(pathname: string): RuntimeRouteMatch;
92
- export declare function createPageHeadInput(head: {
93
- meta?: Array<Record<string, string>>;
94
- links?: Array<Record<string, string>>;
95
- }): UseHeadInput;
109
+ export declare function createPageHeadInput(head: PageHead): UseHeadInput;
96
110
  export declare function handleCamoxRequest(request: Request, options?: RuntimeOptions): Promise<Response | null>;
97
- //#endregion
111
+ //#endregion
112
+ export {};