camox 0.46.0 → 0.48.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 (82) 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 +54 -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 +21 -6
  25. package/dist/features/canvas/sidebar/CanvasSidebarFrame.js +1 -1
  26. package/dist/features/canvas/useCanvasCamera.js +16 -11
  27. package/dist/features/content/ContentCollection.js +13 -7
  28. package/dist/features/content/collection-form.js +6 -2
  29. package/dist/features/content/components/MissingFromLocale.js +1 -1
  30. package/dist/features/og/pageImage.js +89 -0
  31. package/dist/features/page/CuratedPageContent.js +45 -32
  32. package/dist/features/page/DerivedPageContent.js +49 -22
  33. package/dist/features/page/SharedLayoutContent.js +26 -22
  34. package/dist/features/preview/CamoxPreview.js +176 -145
  35. package/dist/features/preview/components/AddBlockSheet.js +1 -0
  36. package/dist/features/preview/components/BlockActionsPopover.js +8 -6
  37. package/dist/features/preview/components/PageInfo.js +244 -264
  38. package/dist/features/preview/components/PageTree.js +63 -53
  39. package/dist/features/preview/components/PreviewToolbar.js +74 -85
  40. package/dist/features/preview/components/RecordCard.js +50 -34
  41. package/dist/features/preview/components/RecordCombobox.js +12 -5
  42. package/dist/features/preview/components/ReferenceFieldEditor.js +4 -2
  43. package/dist/features/preview/components/ReferenceListFieldEditor.js +24 -19
  44. package/dist/features/preview/components/RepeatableItemsList.js +62 -46
  45. package/dist/features/preview/components/SelectionEditor.js +3 -2
  46. package/dist/features/preview/components/useFieldWriter.js +2 -4
  47. package/dist/features/preview/components/useRecordView.js +2 -2
  48. package/dist/features/preview/entityName.js +11 -0
  49. package/dist/features/preview/pageRecord.js +17 -18
  50. package/dist/features/provider/CamoxProvider.js +8 -16
  51. package/dist/features/provider/CoreCamoxProvider.js +1 -6
  52. package/dist/features/provider/signInShortcut.js +60 -0
  53. package/dist/features/routes/pageRuntime.d.ts +15 -1
  54. package/dist/features/routes/pageRuntime.js +35 -38
  55. package/dist/features/runtime/RuntimeChrome.js +2 -2
  56. package/dist/features/runtime/breadcrumbs.js +68 -0
  57. package/dist/features/runtime/livePages.js +3 -2
  58. package/dist/features/runtime/pageApp.js +11 -39
  59. package/dist/features/runtime/pageGraph.js +92 -0
  60. package/dist/features/runtime/projectLogo.js +110 -0
  61. package/dist/features/runtime/runtime.d.ts +11 -6
  62. package/dist/features/runtime/runtime.js +278 -55
  63. package/dist/features/runtime/runtimeRoutes.d.ts +1 -0
  64. package/dist/features/runtime/runtimeRoutes.js +1 -0
  65. package/dist/features/runtime/structuredData.js +213 -0
  66. package/dist/features/studio/components/ChatSidebar.js +5 -5
  67. package/dist/features/studio/components/ProjectMenu.js +127 -100
  68. package/dist/features/studio/components/SocialProfilesDialog.js +342 -0
  69. package/dist/features/studio/components/ThreadMessages.js +2 -2
  70. package/dist/features/vite/blockBoilerplate.js +3 -2
  71. package/dist/features/vite/layoutGeneration.js +3 -2
  72. package/dist/features/vite/vite.js +1 -0
  73. package/dist/lib/queries.d.ts +4 -0
  74. package/dist/lib/queries.js +1 -0
  75. package/dist/lib/studio-reads.js +3 -1
  76. package/dist/package.js +1 -1
  77. package/dist/studio.css +1 -1
  78. package/package.json +5 -4
  79. package/skills/camox/references/block-definitions.md +105 -25
  80. package/skills/camox/references/layout-definitions.md +53 -17
  81. package/skills/camox/references/routed-layouts.md +17 -14
  82. package/dist/features/provider/LocalhostPreviewProvider.js +0 -158
@@ -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";
@@ -235,39 +238,25 @@ async function createMarkdownResponse({ apiUrl, camoxApp, locale, pathname, proj
235
238
  return null;
236
239
  }
237
240
  }
238
- /** A page's title and description, built by its layout's callbacks. */
241
+ /** A page's title and description, built by its layout's `toTitle` and `toDescription`. */
239
242
  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;
243
+ const { layout, context } = input;
244
+ const title = layout ? layout.toTitle(context) : context.page.title;
245
+ const description = layout ? layout.toDescription(context) : context.page.description;
255
246
  return {
256
247
  title,
257
248
  ...description && { description }
258
249
  };
259
250
  }
260
- /** A curated page's meta input: its editor metadata and its layout. */
251
+ /** What a page's layout says the page is, from the page's context. */
252
+ function declarePage(input) {
253
+ return input.layout?.toStructuredData(input.context);
254
+ }
261
255
  /** A curated page's meta input. Its path keeps the prefix of the page's `locale`. */
262
- function curatedPageMetaInput(camoxApp, page, locale) {
256
+ function curatedPageMetaInput(camoxApp, page, locale = UNNAMED_DEFAULT_LOCALE) {
263
257
  return {
264
258
  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
259
+ context: curatedLayoutContext(page, locale)
271
260
  };
272
261
  }
273
262
  /** A Markdown Representation, opened by YAML front matter stating the page's title, description and URL. */
@@ -281,18 +270,18 @@ function withFrontMatter(markdown, meta) {
281
270
  /** The head every page gets, whatever its layout kind. */
282
271
  function buildPageHead(input) {
283
272
  const { title, description } = buildPageMeta(input);
273
+ const mainEntity = declarePage(input) ?? (input.recordType ? { "@type": input.recordType } : void 0);
284
274
  const meta = [{ title }];
285
275
  if (description) meta.push({
286
276
  name: "description",
287
277
  content: description
288
278
  });
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 }
279
+ const { page, project } = input.context;
280
+ const ogImageUrl = pageImageUrl({
281
+ origin: input.origin,
282
+ path: page.path,
283
+ customImageUrl: input.customOgImageUrl
294
284
  });
295
- const ogImageUrl = input.customOgImageUrl ?? `${input.origin}/og?${ogImageParams.toString()}`;
296
285
  meta.push({
297
286
  property: "og:title",
298
287
  content: title
@@ -301,15 +290,25 @@ function buildPageHead(input) {
301
290
  content: ogImageUrl
302
291
  }, {
303
292
  property: "og:image:width",
304
- content: "1200"
293
+ content: String(PAGE_IMAGE_SIZE.width)
305
294
  }, {
306
295
  property: "og:image:height",
307
- content: "630"
296
+ content: String(PAGE_IMAGE_SIZE.height)
308
297
  });
309
298
  if (description) meta.push({
310
299
  property: "og:description",
311
300
  content: description
312
301
  });
302
+ meta.push({
303
+ property: "og:type",
304
+ content: isArticle(mainEntity ?? { "@type": "" }) ? "article" : "website"
305
+ }, {
306
+ property: "og:site_name",
307
+ content: project.name
308
+ }, {
309
+ name: "twitter:card",
310
+ content: "summary_large_image"
311
+ });
313
312
  const links = [{
314
313
  rel: "icon",
315
314
  href: input.faviconUrl
@@ -317,7 +316,7 @@ function buildPageHead(input) {
317
316
  if (input.hasMarkdown) links.push({
318
317
  rel: "alternate",
319
318
  type: "text/markdown",
320
- href: `${input.origin}${getMarkdownPath(input.pageFullPath)}`
319
+ href: `${input.origin}${getMarkdownPath(page.path)}`
321
320
  });
322
321
  return {
323
322
  meta,
@@ -328,10 +327,8 @@ function buildPageHead(input) {
328
327
  function buildCamoxPageHead(camoxApp, loaderData, locale) {
329
328
  if (!loaderData) return {};
330
329
  const { page, origin, faviconUrl } = loaderData;
331
- const metaInput = curatedPageMetaInput(camoxApp, page, locale);
332
330
  return buildPageHead({
333
- ...metaInput,
334
- ogImageTitle: metaInput.pageMetaTitle,
331
+ ...curatedPageMetaInput(camoxApp, page, locale),
335
332
  customOgImageUrl: page.page.customOgImageUrl,
336
333
  origin,
337
334
  faviconUrl,
@@ -340,4 +337,4 @@ function buildCamoxPageHead(camoxApp, loaderData, locale) {
340
337
  }
341
338
 
342
339
  //#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 };
340
+ export { buildCamoxPageHead, buildCamoxPageLoaderData, buildDefaultFaviconUrl, buildFaviconUrl, buildPageHead, buildPageMeta, createMarkdownResponse, createServerApiClient, createServerStudioCache, curatedPageMetaInput, declarePage, getMarkdownPath, isAuthSessionError, isNotFoundError, loadCamoxPage, loadCamoxPageForRequest, parseMarkdownPath, parseQuality, prefersMarkdown, readDraftOrLive, readScopeLocales, renderedHref, resolveMemberProject, withFrontMatter };
@@ -1,8 +1,8 @@
1
1
  import { previewStore, selectIsCanvasShown } from "../preview/previewStore.js";
2
2
  import { shownRecordPage } from "../preview/pageRecord.js";
3
3
  import { Navbar } from "../studio/components/Navbar.js";
4
- import { PreviewToolbar } from "../preview/components/PreviewToolbar.js";
5
4
  import { EditingFallbackContext } from "./editingFallback.js";
5
+ import { PreviewToolbar } from "../preview/components/PreviewToolbar.js";
6
6
  import { StudioEditingFallback } from "../preview/components/StudioEditingFallback.js";
7
7
  import { ChatSidebar } from "../studio/components/ChatSidebar.js";
8
8
  import { FollowAgentProvider } from "../studio/FollowAgentProvider.js";
@@ -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
  }));
@@ -1,11 +1,12 @@
1
1
  import { useAuthState, useSignInRedirect } from "../../lib/auth.js";
2
2
  import { DraftScopeContext } from "../../lib/draft-scope.js";
3
3
  import { DerivedPageContent } from "../page/DerivedPageContent.js";
4
+ import { useSignInShortcut } from "../provider/signInShortcut.js";
4
5
  import { PreviewDocumentContext } from "./PreviewDocumentContext.js";
5
6
  import { PreviewActivation } from "../preview/components/PreviewActivation.js";
6
7
  import { Frame } from "../preview/components/Frame.js";
7
8
  import { PreviewSignInPage } from "../provider/PreviewSignInPage.js";
8
- import { CoreCamoxProvider, isLocalhostPreview } from "../provider/CoreCamoxProvider.js";
9
+ import { CoreCamoxProvider } from "../provider/CoreCamoxProvider.js";
9
10
  import { CuratedPageContent } from "../page/CuratedPageContent.js";
10
11
  import { EditingFallbackSlot } from "./editingFallback.js";
11
12
  import { PageNavigationProvider } from "./pageNavigation.js";
@@ -96,7 +97,6 @@ function PageApp(t0) {
96
97
  return t6;
97
98
  }
98
99
  const LazyEditablePageExperience = React.lazy(() => import("../preview/EditablePageExperience.js").then((module) => ({ default: module.EditablePageExperience })));
99
- const LazyLocalhostPreviewProvider = React.lazy(() => import("../provider/LocalhostPreviewProvider.js").then((module) => ({ default: module.LocalhostPreviewProvider })));
100
100
  /** Editing lists and adds any block, so it waits for every definition, not only the page's. */
101
101
  function AllBlocks({ camoxApp, children }) {
102
102
  React.use(camoxApp.loadAllBlocks());
@@ -116,7 +116,7 @@ var EditingActivationBoundary = class extends React.Component {
116
116
  };
117
117
  const subscribeToHydration = () => () => {};
118
118
  function PageExperience(t0) {
119
- const $ = c(46);
119
+ const $ = c(38);
120
120
  const { camoxApp, input, queryClient, studioDocument } = t0;
121
121
  const { isAuthenticated, isLoading } = useAuthState();
122
122
  const hasHydrated = React.useSyncExternalStore(subscribeToHydration, _temp, _temp2);
@@ -145,6 +145,7 @@ function PageExperience(t0) {
145
145
  t2 = $[5];
146
146
  }
147
147
  React.useEffect(t1, t2);
148
+ useSignInShortcut(hasHydrated && !studioOnly && !isAuthenticated && !isLoading);
148
149
  let t3;
149
150
  if ($[6] === Symbol.for("react.memo_cache_sentinel")) {
150
151
  t3 = /* @__PURE__ */ jsx("div", {
@@ -248,50 +249,21 @@ function PageExperience(t0) {
248
249
  return t11;
249
250
  }
250
251
  if (studioOnly) return null;
251
- if (hasHydrated && isLocalhostPreview()) {
252
- let t7;
253
- if ($[35] !== readOnlyPage) {
254
- t7 = /* @__PURE__ */ jsx(LazyLocalhostPreviewProvider, { children: readOnlyPage });
255
- $[35] = readOnlyPage;
256
- $[36] = t7;
257
- } else t7 = $[36];
258
- let t8;
259
- if ($[37] !== camoxApp || $[38] !== t7) {
260
- t8 = /* @__PURE__ */ jsx(AllBlocks, {
261
- camoxApp,
262
- children: t7
263
- });
264
- $[37] = camoxApp;
265
- $[38] = t7;
266
- $[39] = t8;
267
- } else t8 = $[39];
268
- let t9;
269
- if ($[40] !== readOnlyPage || $[41] !== t8) {
270
- t9 = /* @__PURE__ */ jsx(React.Suspense, {
271
- fallback: readOnlyPage,
272
- children: t8
273
- });
274
- $[40] = readOnlyPage;
275
- $[41] = t8;
276
- $[42] = t9;
277
- } else t9 = $[42];
278
- return t9;
279
- }
280
252
  if (studioDocument) {
281
253
  let t7;
282
- if ($[43] === Symbol.for("react.memo_cache_sentinel")) {
254
+ if ($[35] === Symbol.for("react.memo_cache_sentinel")) {
283
255
  t7 = { height: "100vh" };
284
- $[43] = t7;
285
- } else t7 = $[43];
256
+ $[35] = t7;
257
+ } else t7 = $[35];
286
258
  let t8;
287
- if ($[44] !== isolatedPage) {
259
+ if ($[36] !== isolatedPage) {
288
260
  t8 = /* @__PURE__ */ jsx("div", {
289
261
  style: t7,
290
262
  children: isolatedPage
291
263
  });
292
- $[44] = isolatedPage;
293
- $[45] = t8;
294
- } else t8 = $[45];
264
+ $[36] = isolatedPage;
265
+ $[37] = t8;
266
+ } else t8 = $[37];
295
267
  return t8;
296
268
  }
297
269
  return readOnlyPage;
@@ -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 };
@@ -1,11 +1,11 @@
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
11
  export type RuntimeRouteKind = RuntimeOwnedRouteKind | "canvas" | "page" | "studio" | "studio-content" | "studio-nested";
@@ -39,6 +39,14 @@ export interface PageRenderInput extends DraftRead {
39
39
  presentation?: "public" | "studio";
40
40
  derived?: {
41
41
  layoutId: string;
42
+ /** What the layout's context names, beside the loader's result. */
43
+ page?: {
44
+ /** The params the page's route matched. */
45
+ params: Record<string, string>;
46
+ /** The page's path, with its locale's prefix. */
47
+ path: string;
48
+ projectName: string;
49
+ };
42
50
  result?: AnyLayoutLoaderResult;
43
51
  layout: Awaited<ReturnType<ReturnType<typeof createServerApiClient>["layouts"]["get"]>>["layout"];
44
52
  };
@@ -89,9 +97,6 @@ export interface RuntimeOptions {
89
97
  }
90
98
  export declare function getRuntimePathname(url: string, runtimeBasePath?: string): string | null;
91
99
  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;
100
+ export declare function createPageHeadInput(head: PageHead): UseHeadInput;
96
101
  export declare function handleCamoxRequest(request: Request, options?: RuntimeOptions): Promise<Response | null>;
97
102
  //#endregion