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.
- package/dist/core/createApp.d.ts +19 -6
- package/dist/core/createApp.js +7 -2
- package/dist/core/createBlock.js +54 -43
- package/dist/core/createCollection.d.ts +25 -7
- package/dist/core/createCollection.js +28 -9
- package/dist/core/createLayout.d.ts +98 -71
- package/dist/core/createLayout.js +10 -16
- package/dist/core/derivedRoutes.js +1 -1
- package/dist/core/editing/BlockEditingRuntime.d.ts +3 -2
- package/dist/core/editing/CompleteBlockEditingRuntime.js +2 -1
- package/dist/core/editing/createEditableBlock.d.ts +59 -19
- package/dist/core/editing/createEditableBlock.js +162 -151
- package/dist/core/editing/useLatestRecord.js +7 -7
- package/dist/core/layoutContext.d.ts +54 -0
- package/dist/core/layoutContext.js +66 -0
- package/dist/core/lib/contentType.d.ts +91 -4
- package/dist/core/lib/contentType.js +84 -7
- package/dist/core/lib/recordScope.js +19 -17
- package/dist/core/lib/reference.d.ts +6 -2
- package/dist/core/recordItem.js +55 -2
- package/dist/core/requestContext.d.ts +18 -0
- package/dist/core/structuredData.d.ts +126 -0
- package/dist/core/structuredData.js +40 -0
- package/dist/features/canvas/canvasCamera.js +1 -1
- package/dist/features/canvas/sidebar/CanvasSidebarFrame.js +1 -1
- package/dist/features/content/ContentCollection.js +13 -7
- package/dist/features/content/collection-form.js +6 -2
- package/dist/features/content/components/MissingFromLocale.js +1 -1
- package/dist/features/og/pageImage.js +89 -0
- package/dist/features/page/CuratedPageContent.js +45 -32
- package/dist/features/page/DerivedPageContent.js +49 -22
- package/dist/features/page/SharedLayoutContent.js +26 -22
- package/dist/features/preview/CamoxPreview.js +40 -26
- package/dist/features/preview/components/AddBlockSheet.js +1 -0
- package/dist/features/preview/components/BlockActionsPopover.js +8 -6
- package/dist/features/preview/components/PageInfo.js +244 -264
- package/dist/features/preview/components/PageTree.js +63 -53
- package/dist/features/preview/components/RecordCard.js +50 -34
- package/dist/features/preview/components/RecordCombobox.js +12 -5
- package/dist/features/preview/components/ReferenceFieldEditor.js +4 -2
- package/dist/features/preview/components/ReferenceListFieldEditor.js +24 -19
- package/dist/features/preview/components/RepeatableItemsList.js +62 -46
- package/dist/features/preview/components/SelectionEditor.js +3 -2
- package/dist/features/preview/components/useFieldWriter.js +2 -4
- package/dist/features/preview/components/useRecordView.js +2 -2
- package/dist/features/preview/entityName.js +11 -0
- package/dist/features/preview/pageRecord.js +17 -18
- package/dist/features/routes/pageRuntime.d.ts +15 -1
- package/dist/features/routes/pageRuntime.js +35 -38
- package/dist/features/runtime/RuntimeChrome.js +1 -1
- package/dist/features/runtime/breadcrumbs.js +68 -0
- package/dist/features/runtime/livePages.js +3 -2
- package/dist/features/runtime/pageGraph.js +92 -0
- package/dist/features/runtime/projectLogo.js +110 -0
- package/dist/features/runtime/runtime.d.ts +11 -6
- package/dist/features/runtime/runtime.js +278 -55
- package/dist/features/runtime/runtimeRoutes.d.ts +1 -0
- package/dist/features/runtime/runtimeRoutes.js +1 -0
- package/dist/features/runtime/structuredData.js +213 -0
- package/dist/features/studio/components/ChatSidebar.js +5 -5
- package/dist/features/studio/components/ProjectMenu.js +127 -100
- package/dist/features/studio/components/SocialProfilesDialog.js +342 -0
- package/dist/features/studio/components/ThreadMessages.js +2 -2
- package/dist/features/vite/blockBoilerplate.js +3 -2
- package/dist/features/vite/layoutGeneration.js +3 -2
- package/dist/features/vite/vite.js +1 -0
- package/dist/lib/queries.d.ts +4 -0
- package/dist/lib/queries.js +1 -0
- package/dist/lib/studio-reads.js +3 -1
- package/dist/package.js +1 -1
- package/dist/studio.css +1 -1
- package/package.json +5 -4
- package/skills/camox/references/block-definitions.md +105 -25
- package/skills/camox/references/layout-definitions.md +53 -17
- 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
|
-
|
|
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.
|
|
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.
|
|
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?.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
loaded:
|
|
165
|
+
collection,
|
|
166
|
+
loaded: t0
|
|
168
167
|
};
|
|
169
|
-
$[0] =
|
|
170
|
-
$[1] =
|
|
171
|
-
$[2] =
|
|
172
|
-
$[3] =
|
|
173
|
-
} else
|
|
174
|
-
const record = useLatestRecord(
|
|
175
|
-
let
|
|
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
|
-
|
|
176
|
+
t2 = record ? {
|
|
178
177
|
collectionId,
|
|
179
178
|
record
|
|
180
179
|
} : null;
|
|
181
180
|
$[4] = collectionId;
|
|
182
181
|
$[5] = record;
|
|
183
|
-
$[6] =
|
|
184
|
-
} else
|
|
185
|
-
return
|
|
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 {
|
|
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
|
|
241
|
+
/** A page's title and description, built by its layout's `toTitle` and `toDescription`. */
|
|
239
242
|
function buildPageMeta(input) {
|
|
240
|
-
const context =
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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:
|
|
293
|
+
content: String(PAGE_IMAGE_SIZE.width)
|
|
305
294
|
}, {
|
|
306
295
|
property: "og:image:height",
|
|
307
|
-
content:
|
|
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(
|
|
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
|
-
...
|
|
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 =
|
|
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
|
|
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.
|
|
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
|