camox 0.47.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 (75) 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 +1 -1
  25. package/dist/features/canvas/sidebar/CanvasSidebarFrame.js +1 -1
  26. package/dist/features/content/ContentCollection.js +13 -7
  27. package/dist/features/content/collection-form.js +6 -2
  28. package/dist/features/content/components/MissingFromLocale.js +1 -1
  29. package/dist/features/og/pageImage.js +89 -0
  30. package/dist/features/page/CuratedPageContent.js +45 -32
  31. package/dist/features/page/DerivedPageContent.js +49 -22
  32. package/dist/features/page/SharedLayoutContent.js +26 -22
  33. package/dist/features/preview/CamoxPreview.js +40 -26
  34. package/dist/features/preview/components/AddBlockSheet.js +1 -0
  35. package/dist/features/preview/components/BlockActionsPopover.js +8 -6
  36. package/dist/features/preview/components/PageInfo.js +244 -264
  37. package/dist/features/preview/components/PageTree.js +63 -53
  38. package/dist/features/preview/components/RecordCard.js +50 -34
  39. package/dist/features/preview/components/RecordCombobox.js +12 -5
  40. package/dist/features/preview/components/ReferenceFieldEditor.js +4 -2
  41. package/dist/features/preview/components/ReferenceListFieldEditor.js +24 -19
  42. package/dist/features/preview/components/RepeatableItemsList.js +62 -46
  43. package/dist/features/preview/components/SelectionEditor.js +3 -2
  44. package/dist/features/preview/components/useFieldWriter.js +2 -4
  45. package/dist/features/preview/components/useRecordView.js +2 -2
  46. package/dist/features/preview/entityName.js +11 -0
  47. package/dist/features/preview/pageRecord.js +17 -18
  48. package/dist/features/routes/pageRuntime.d.ts +15 -1
  49. package/dist/features/routes/pageRuntime.js +35 -38
  50. package/dist/features/runtime/RuntimeChrome.js +1 -1
  51. package/dist/features/runtime/breadcrumbs.js +68 -0
  52. package/dist/features/runtime/livePages.js +3 -2
  53. package/dist/features/runtime/pageGraph.js +92 -0
  54. package/dist/features/runtime/projectLogo.js +110 -0
  55. package/dist/features/runtime/runtime.d.ts +11 -6
  56. package/dist/features/runtime/runtime.js +278 -55
  57. package/dist/features/runtime/runtimeRoutes.d.ts +1 -0
  58. package/dist/features/runtime/runtimeRoutes.js +1 -0
  59. package/dist/features/runtime/structuredData.js +213 -0
  60. package/dist/features/studio/components/ChatSidebar.js +5 -5
  61. package/dist/features/studio/components/ProjectMenu.js +127 -100
  62. package/dist/features/studio/components/SocialProfilesDialog.js +342 -0
  63. package/dist/features/studio/components/ThreadMessages.js +2 -2
  64. package/dist/features/vite/blockBoilerplate.js +3 -2
  65. package/dist/features/vite/layoutGeneration.js +3 -2
  66. package/dist/features/vite/vite.js +1 -0
  67. package/dist/lib/queries.d.ts +4 -0
  68. package/dist/lib/queries.js +1 -0
  69. package/dist/lib/studio-reads.js +3 -1
  70. package/dist/package.js +1 -1
  71. package/dist/studio.css +1 -1
  72. package/package.json +5 -4
  73. package/skills/camox/references/block-definitions.md +105 -25
  74. package/skills/camox/references/layout-definitions.md +53 -17
  75. package/skills/camox/references/routed-layouts.md +17 -14
@@ -2,8 +2,8 @@ import { useProjectUuid } from "../../../lib/auth.js";
2
2
  import { useCamoxApp } from "../../provider/components/CamoxAppContext.js";
3
3
  import { referenceList } from "../../../lib/normalized-data.js";
4
4
  import { reads, useStudioCache } from "../../../lib/studio-reads.js";
5
- import { lexicalStateToPlainText } from "../../../core/lib/lexicalState.js";
6
5
  import { referenceListIds } from "../../../core/lib/reference.js";
6
+ import { recordText } from "../../../core/recordItem.js";
7
7
  import { blockMutations, collectionMutations, repeatableItemMutations } from "../../../lib/queries.js";
8
8
  import { invalidateCollectionRecordViews } from "../../../lib/collection-cache.js";
9
9
  import { serializeAssetField } from "../../content/collection-form.js";
@@ -134,13 +134,11 @@ function useFieldWriter(target, schema) {
134
134
  collectionId,
135
135
  id: recordId
136
136
  }));
137
- const labelField = camoxApp.getCollectionById(collectionId)?._internal.label;
138
- const label = labelField ? record_0.content[labelField] : void 0;
139
137
  return {
140
138
  id: record_0.id,
141
139
  collectionId,
142
140
  content: record_0.content,
143
- label: typeof label === "string" ? lexicalStateToPlainText(label) : ""
141
+ ...recordText(camoxApp.getCollectionById(collectionId), record_0.content)
144
142
  };
145
143
  };
146
144
  return (async () => {
@@ -302,7 +302,7 @@ function useRecordView(t0) {
302
302
  })
303
303
  }] : [], {
304
304
  key: "record",
305
- label: outerRecord.label,
305
+ label: outerRecord.title,
306
306
  isCurrent: !nested && !fieldHasOwnView,
307
307
  hoverTarget: outerTarget,
308
308
  onClick: nested || fieldHasOwnView ? () => previewStore.send({
@@ -332,7 +332,7 @@ function useRecordView(t0) {
332
332
  },
333
333
  {
334
334
  key: "nested-record",
335
- label: recordView.record.label,
335
+ label: recordView.record.title,
336
336
  isCurrent: !fieldHasOwnView,
337
337
  hoverTarget: nestedTarget,
338
338
  onClick: fieldHasOwnView ? () => previewStore.send({
@@ -0,0 +1,11 @@
1
+ //#region src/features/preview/entityName.ts
2
+ /**
3
+ * What the studio names a block or repeatable item by: its Summary, generated or hand-written,
4
+ * else its title, from its definition's `toTitle` on default-locale content, else `fallback`.
5
+ */
6
+ function entityName(entity, fallback) {
7
+ return entity.summary || entity.title || fallback;
8
+ }
9
+
10
+ //#endregion
11
+ export { entityName };
@@ -157,32 +157,31 @@ function usePageRecordPlacer() {
157
157
  const app = useCamoxApp();
158
158
  const collection = loaded ? app.getCollectionById(loaded.collectionId) : void 0;
159
159
  const collectionId = loaded?.collectionId ?? "";
160
- const t0 = collection?._internal.label ?? "";
161
- const t1 = collection ? loaded?.data : void 0;
162
- let t2;
163
- if ($[0] !== collectionId || $[1] !== t0 || $[2] !== t1) {
164
- t2 = {
160
+ const t0 = collection ? loaded?.data : void 0;
161
+ let t1;
162
+ if ($[0] !== collection || $[1] !== collectionId || $[2] !== t0) {
163
+ t1 = {
165
164
  collectionId,
166
- labelField: t0,
167
- loaded: t1
165
+ collection,
166
+ loaded: t0
168
167
  };
169
- $[0] = collectionId;
170
- $[1] = t0;
171
- $[2] = t1;
172
- $[3] = t2;
173
- } else t2 = $[3];
174
- const record = useLatestRecord(t2);
175
- let t3;
168
+ $[0] = collection;
169
+ $[1] = collectionId;
170
+ $[2] = t0;
171
+ $[3] = t1;
172
+ } else t1 = $[3];
173
+ const record = useLatestRecord(t1);
174
+ let t2;
176
175
  if ($[4] !== collectionId || $[5] !== record) {
177
- t3 = record ? {
176
+ t2 = record ? {
178
177
  collectionId,
179
178
  record
180
179
  } : null;
181
180
  $[4] = collectionId;
182
181
  $[5] = record;
183
- $[6] = t3;
184
- } else t3 = $[6];
185
- return t3;
182
+ $[6] = t2;
183
+ } else t2 = $[6];
184
+ return t2;
186
185
  }
187
186
 
188
187
  //#endregion
@@ -1,6 +1,7 @@
1
+ import { LocaleView } from "../../lib/locale-path.js";
2
+ import "../../core/structuredData.js";
1
3
  import "../../core/createLayout.js";
2
4
  import "../../core/createApp.js";
3
- import { LocaleView } from "../../lib/locale-path.js";
4
5
  import "../../lib/studio-reads.js";
5
6
  import { Router } from "@camox/api-contract";
6
7
  import { EndedDraft } from "@camox/api-contract/drafts";
@@ -14,6 +15,10 @@ export type PageStructure = {
14
15
  layout: PageWithBlocks["layout"];
15
16
  projectName: string;
16
17
  project: PageWithBlocks["project"];
18
+ /** Each block's Structured Data nodes, from a full read: a structure read has none. */
19
+ structuredData?: PageWithBlocks["structuredData"];
20
+ /** The nodes of the records its blocks reference, from a full read. */
21
+ recordStructuredData?: PageWithBlocks["recordStructuredData"];
17
22
  };
18
23
  /**
19
24
  * Why a member can't see the draft a URL names: it doesn't exist, it's someone else's
@@ -46,5 +51,14 @@ export declare function createServerApiClient(apiUrl: string, options?: {
46
51
  draftId?: string;
47
52
  locale?: LocaleView;
48
53
  }): RouterClient<Router>;
54
+ export type PageHead = {
55
+ meta?: Array<Record<string, string>>;
56
+ links?: Array<Record<string, string>>;
57
+ /** Scripts with their text, such as the page's Structured Data. */
58
+ scripts?: Array<{
59
+ type: string;
60
+ text: string;
61
+ }>;
62
+ };
49
63
  //#endregion
50
64
  export {};
@@ -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 };
@@ -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 };
@@ -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