camox 0.47.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +55 -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/CanvasInspector.js +1 -1
- package/dist/features/canvas/sidebar/CanvasSidebarFrame.js +1 -1
- package/dist/features/content/ContentAssets.js +84 -88
- 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 +41 -27
- package/dist/features/preview/EditablePageExperience.js +17 -9
- package/dist/features/preview/components/AddBlockSheet.js +1 -0
- package/dist/features/preview/components/BlockActionsPopover.js +19 -41
- package/dist/features/preview/components/FieldToolbar.js +1 -1
- package/dist/features/preview/components/MobilePreviewDrawer.js +6 -9
- package/dist/features/preview/components/PageInfo.js +244 -264
- package/dist/features/preview/components/PageTree.js +63 -53
- package/dist/features/preview/components/PreviewPanel.js +10 -64
- package/dist/features/preview/components/PreviewToolbar.js +1 -1
- 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/preview/previewConstants.js +2 -8
- package/dist/features/preview/usePreviewViewportActions.js +1 -1
- package/dist/features/provider/AuthenticatedCamoxProvider.js +1 -1
- package/dist/features/provider/components/CommandPalette.js +2 -5
- package/dist/features/provider/signInShortcut.js +1 -2
- package/dist/features/provider/useAdminShortcuts.js +47 -53
- package/dist/features/routes/pageRuntime.d.ts +17 -2
- package/dist/features/routes/pageRuntime.js +61 -57
- 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/publicCache.js +83 -0
- package/dist/features/runtime/runtime.d.ts +24 -9
- package/dist/features/runtime/runtime.js +447 -130
- 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/runtime/worker.d.ts +5 -1
- package/dist/features/runtime/worker.js +20 -3
- package/dist/features/settings/AnalyticsSettings.js +622 -0
- package/dist/features/settings/CamoxSettings.js +95 -0
- package/dist/features/settings/FaviconSettings.js +254 -0
- package/dist/features/settings/ProjectNameSettings.js +145 -0
- package/dist/features/settings/SettingsPart.js +73 -0
- package/dist/features/settings/SocialProfilesSettings.js +245 -0
- package/dist/features/settings/useProjectMutation.js +61 -0
- package/dist/features/studio/components/ChatSidebar.js +5 -5
- package/dist/features/studio/components/DraftsControl.js +136 -131
- package/dist/features/studio/components/LocalePicker.js +178 -174
- package/dist/features/studio/components/Navbar.js +107 -66
- package/dist/features/studio/components/ProjectMenu.js +85 -110
- package/dist/features/studio/components/ReviewDraftSheet.js +1046 -990
- package/dist/features/studio/components/ThreadMessages.js +2 -2
- package/dist/features/studio/components/UserButton.js +1 -1
- package/dist/features/studio/routes.js +3 -1
- package/dist/features/studio/showInCanvas.js +129 -0
- package/dist/features/vite/blockBoilerplate.js +3 -2
- package/dist/features/vite/definitionsSync.js +4 -5
- package/dist/features/vite/layoutGeneration.js +3 -2
- package/dist/features/vite/runtimeDev.js +32 -17
- package/dist/features/vite/staticAssetHeaders.js +52 -0
- package/dist/features/vite/vite.d.ts +1 -1
- package/dist/features/vite/vite.js +17 -4
- package/dist/lib/api-client-server.d.ts +1 -1
- package/dist/lib/api-client.d.ts +2 -1
- package/dist/lib/draft-scope.d.ts +1 -0
- package/dist/lib/locale-path.d.ts +3 -11
- package/dist/lib/locale-path.js +3 -27
- package/dist/lib/queries.d.ts +4 -0
- package/dist/lib/queries.js +8 -1
- package/dist/lib/shortcuts.js +29 -0
- package/dist/lib/studio-reads.d.ts +1 -0
- package/dist/lib/studio-reads.js +3 -1
- package/dist/lib/utils.js +1 -104
- package/dist/package.js +1 -1
- package/dist/studio.css +1 -1
- package/package.json +6 -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
- package/dist/features/studio/components/FaviconDialog.js +0 -318
|
@@ -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";
|
|
@@ -215,59 +218,52 @@ async function loadCamoxPageForRequest({ apiUrl, authCookieHeader, draftId, loca
|
|
|
215
218
|
shouldClearAuthCookie: member.clearAuthCookie
|
|
216
219
|
};
|
|
217
220
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
221
|
+
/**
|
|
222
|
+
* Reads the Markdown Representation of the page at a runtime path, with its locale's prefix, in
|
|
223
|
+
* one API read: the API finds the locale the prefix names, the page there, its Markdown and what
|
|
224
|
+
* its front matter is built from. Markdown is a public output, so it reads Live whoever asks.
|
|
225
|
+
*/
|
|
226
|
+
function readPageMarkdown({ apiUrl, path, projectUuid }) {
|
|
227
|
+
return createServerApiClient(apiUrl).pages.getMarkdown({
|
|
228
|
+
path,
|
|
229
|
+
projectUuid
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* A curated page's Markdown Representation, opened by front matter stating its title,
|
|
234
|
+
* description and absolute `url`. Null when the path has no curated page, or the page has no
|
|
235
|
+
* Markdown.
|
|
236
|
+
*/
|
|
237
|
+
function curatedMarkdownResponse(camoxApp, read, url) {
|
|
238
|
+
if (!read.page || !read.markdown) return null;
|
|
239
|
+
const meta = buildPageMeta(curatedPageMetaInput(camoxApp, {
|
|
240
|
+
...read,
|
|
241
|
+
page: read.page
|
|
242
|
+
}, read.locale));
|
|
243
|
+
return new Response(withFrontMatter(read.markdown, {
|
|
244
|
+
...meta,
|
|
245
|
+
url
|
|
246
|
+
}), { headers: { "Content-Type": "text/markdown; charset=utf-8" } });
|
|
237
247
|
}
|
|
238
|
-
/** A page's title and description, built by its layout's
|
|
248
|
+
/** A page's title and description, built by its layout's `toTitle` and `toDescription`. */
|
|
239
249
|
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;
|
|
250
|
+
const { layout, context } = input;
|
|
251
|
+
const title = layout ? layout.toTitle(context) : context.page.title;
|
|
252
|
+
const description = layout ? layout.toDescription(context) : context.page.description;
|
|
255
253
|
return {
|
|
256
254
|
title,
|
|
257
255
|
...description && { description }
|
|
258
256
|
};
|
|
259
257
|
}
|
|
260
|
-
/**
|
|
258
|
+
/** What a page's layout says the page is, from the page's context. */
|
|
259
|
+
function declarePage(input) {
|
|
260
|
+
return input.layout?.toStructuredData(input.context);
|
|
261
|
+
}
|
|
261
262
|
/** A curated page's meta input. Its path keeps the prefix of the page's `locale`. */
|
|
262
|
-
function curatedPageMetaInput(camoxApp, page, locale) {
|
|
263
|
+
function curatedPageMetaInput(camoxApp, page, locale = UNNAMED_DEFAULT_LOCALE) {
|
|
263
264
|
return {
|
|
264
265
|
layout: page.layout ? camoxApp?.getLayoutById(page.layout.layoutId)?._internal : void 0,
|
|
265
|
-
|
|
266
|
-
pageMetaDescription: page.page.metaDescription ?? void 0,
|
|
267
|
-
data: void 0,
|
|
268
|
-
params: {},
|
|
269
|
-
projectName: page.projectName,
|
|
270
|
-
pageFullPath: locale ? localizedPath(page.page.fullPath, locale) : page.page.fullPath
|
|
266
|
+
context: curatedLayoutContext(page, locale)
|
|
271
267
|
};
|
|
272
268
|
}
|
|
273
269
|
/** A Markdown Representation, opened by YAML front matter stating the page's title, description and URL. */
|
|
@@ -281,18 +277,18 @@ function withFrontMatter(markdown, meta) {
|
|
|
281
277
|
/** The head every page gets, whatever its layout kind. */
|
|
282
278
|
function buildPageHead(input) {
|
|
283
279
|
const { title, description } = buildPageMeta(input);
|
|
280
|
+
const mainEntity = declarePage(input) ?? (input.recordType ? { "@type": input.recordType } : void 0);
|
|
284
281
|
const meta = [{ title }];
|
|
285
282
|
if (description) meta.push({
|
|
286
283
|
name: "description",
|
|
287
284
|
content: description
|
|
288
285
|
});
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
286
|
+
const { page, project } = input.context;
|
|
287
|
+
const ogImageUrl = pageImageUrl({
|
|
288
|
+
origin: input.origin,
|
|
289
|
+
path: page.path,
|
|
290
|
+
customImageUrl: input.customOgImageUrl
|
|
294
291
|
});
|
|
295
|
-
const ogImageUrl = input.customOgImageUrl ?? `${input.origin}/og?${ogImageParams.toString()}`;
|
|
296
292
|
meta.push({
|
|
297
293
|
property: "og:title",
|
|
298
294
|
content: title
|
|
@@ -301,15 +297,25 @@ function buildPageHead(input) {
|
|
|
301
297
|
content: ogImageUrl
|
|
302
298
|
}, {
|
|
303
299
|
property: "og:image:width",
|
|
304
|
-
content:
|
|
300
|
+
content: String(PAGE_IMAGE_SIZE.width)
|
|
305
301
|
}, {
|
|
306
302
|
property: "og:image:height",
|
|
307
|
-
content:
|
|
303
|
+
content: String(PAGE_IMAGE_SIZE.height)
|
|
308
304
|
});
|
|
309
305
|
if (description) meta.push({
|
|
310
306
|
property: "og:description",
|
|
311
307
|
content: description
|
|
312
308
|
});
|
|
309
|
+
meta.push({
|
|
310
|
+
property: "og:type",
|
|
311
|
+
content: isArticle(mainEntity ?? { "@type": "" }) ? "article" : "website"
|
|
312
|
+
}, {
|
|
313
|
+
property: "og:site_name",
|
|
314
|
+
content: project.name
|
|
315
|
+
}, {
|
|
316
|
+
name: "twitter:card",
|
|
317
|
+
content: "summary_large_image"
|
|
318
|
+
});
|
|
313
319
|
const links = [{
|
|
314
320
|
rel: "icon",
|
|
315
321
|
href: input.faviconUrl
|
|
@@ -317,7 +323,7 @@ function buildPageHead(input) {
|
|
|
317
323
|
if (input.hasMarkdown) links.push({
|
|
318
324
|
rel: "alternate",
|
|
319
325
|
type: "text/markdown",
|
|
320
|
-
href: `${input.origin}${getMarkdownPath(
|
|
326
|
+
href: `${input.origin}${getMarkdownPath(page.path)}`
|
|
321
327
|
});
|
|
322
328
|
return {
|
|
323
329
|
meta,
|
|
@@ -328,10 +334,8 @@ function buildPageHead(input) {
|
|
|
328
334
|
function buildCamoxPageHead(camoxApp, loaderData, locale) {
|
|
329
335
|
if (!loaderData) return {};
|
|
330
336
|
const { page, origin, faviconUrl } = loaderData;
|
|
331
|
-
const metaInput = curatedPageMetaInput(camoxApp, page, locale);
|
|
332
337
|
return buildPageHead({
|
|
333
|
-
...
|
|
334
|
-
ogImageTitle: metaInput.pageMetaTitle,
|
|
338
|
+
...curatedPageMetaInput(camoxApp, page, locale),
|
|
335
339
|
customOgImageUrl: page.page.customOgImageUrl,
|
|
336
340
|
origin,
|
|
337
341
|
faviconUrl,
|
|
@@ -340,4 +344,4 @@ function buildCamoxPageHead(camoxApp, loaderData, locale) {
|
|
|
340
344
|
}
|
|
341
345
|
|
|
342
346
|
//#endregion
|
|
343
|
-
export { buildCamoxPageHead, buildCamoxPageLoaderData, buildDefaultFaviconUrl, buildFaviconUrl, buildPageHead, buildPageMeta,
|
|
347
|
+
export { buildCamoxPageHead, buildCamoxPageLoaderData, buildDefaultFaviconUrl, buildFaviconUrl, buildPageHead, buildPageMeta, createServerApiClient, createServerStudioCache, curatedMarkdownResponse, curatedPageMetaInput, declarePage, getMarkdownPath, isAuthSessionError, isNotFoundError, loadCamoxPage, loadCamoxPageForRequest, parseMarkdownPath, parseQuality, prefersMarkdown, readDraftOrLive, readPageMarkdown, readScopeLocales, renderedHref, resolveMemberProject, withFrontMatter };
|
|
@@ -22,7 +22,7 @@ function RuntimeChrome(t0) {
|
|
|
22
22
|
const website = !input.routeKind;
|
|
23
23
|
const hidden = useSelector(previewStore, _temp);
|
|
24
24
|
const isCanvasShown = useSelector(previewStore, selectIsCanvasShown);
|
|
25
|
-
const showChat =
|
|
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 };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { prefersMarkdown } from "../routes/pageRuntime.js";
|
|
2
|
+
|
|
3
|
+
//#region src/features/runtime/publicCache.ts
|
|
4
|
+
/** How long the edge keeps a render. Live changing replaces its key long before. */
|
|
5
|
+
const EDGE_MAX_AGE = 86400;
|
|
6
|
+
/** Browsers can't be purged, so they ask again each time; the edge answers them. */
|
|
7
|
+
const VISITOR_CACHE_CONTROL = "public, max-age=0, must-revalidate";
|
|
8
|
+
/** The search parameter the cache key adds to a request's URL. */
|
|
9
|
+
const CACHE_KEY_PARAM = "__camox_cache";
|
|
10
|
+
/** The project's Live cache key, as the API serves it from its edge cache. */
|
|
11
|
+
async function readLiveCacheKey(apiUrl, projectUuid) {
|
|
12
|
+
try {
|
|
13
|
+
const response = await fetch(`${apiUrl}/live-cache-keys/${encodeURIComponent(projectUuid)}`);
|
|
14
|
+
return response.ok ? await response.text() || null : null;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Answers a public request from the cache, or renders it and keeps the render when it may be
|
|
21
|
+
* shared. Without a Live cache key, it renders without the cache: nothing tells when Live changed.
|
|
22
|
+
*/
|
|
23
|
+
async function respondThroughPublicCache(request, publicCache, render) {
|
|
24
|
+
const liveCacheKey = await publicCache.liveCacheKey();
|
|
25
|
+
if (!liveCacheKey) return render();
|
|
26
|
+
const key = cacheKeyOf(request, `${liveCacheKey}.${publicCache.build}`);
|
|
27
|
+
const cached = await publicCache.cache.match(withValidators(key, request));
|
|
28
|
+
if (cached) return forVisitors(cached, request);
|
|
29
|
+
const response = await render();
|
|
30
|
+
if (!response || !mayBeShared(response)) return response;
|
|
31
|
+
publicCache.waitUntil(publicCache.cache.put(key, forEdge(response.clone())));
|
|
32
|
+
return forVisitors(response, request);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* What a request's render is kept under: its URL, the representation it asks for, and the
|
|
36
|
+
* version of Live and of the website it renders.
|
|
37
|
+
*/
|
|
38
|
+
function cacheKeyOf(request, version) {
|
|
39
|
+
const url = new URL(request.url);
|
|
40
|
+
const representation = prefersMarkdown(request.headers.get("Accept") ?? "") ? "md" : "any";
|
|
41
|
+
url.searchParams.set(CACHE_KEY_PARAM, `${version}.${representation}`);
|
|
42
|
+
return new Request(url.toString(), { method: "GET" });
|
|
43
|
+
}
|
|
44
|
+
/** The cache key, asking for the part of the render the request does. */
|
|
45
|
+
function withValidators(key, request) {
|
|
46
|
+
const headers = new Headers();
|
|
47
|
+
for (const name of [
|
|
48
|
+
"Range",
|
|
49
|
+
"If-None-Match",
|
|
50
|
+
"If-Modified-Since"
|
|
51
|
+
]) {
|
|
52
|
+
const value = request.headers.get(name);
|
|
53
|
+
if (value !== null) headers.set(name, value);
|
|
54
|
+
}
|
|
55
|
+
return new Request(key, { headers });
|
|
56
|
+
}
|
|
57
|
+
/** Whether a response is the same for every visitor: what a private render never is. */
|
|
58
|
+
function mayBeShared(response) {
|
|
59
|
+
if (response.status !== 200 || response.headers.has("Set-Cookie")) return false;
|
|
60
|
+
const cacheControl = response.headers.get("Cache-Control") ?? "";
|
|
61
|
+
return !/\b(private|no-store)\b/i.test(cacheControl);
|
|
62
|
+
}
|
|
63
|
+
/** How long browsers may keep a render: what it asked for, if it's versioned, else not at all. */
|
|
64
|
+
function visitorCacheControl(response) {
|
|
65
|
+
const cacheControl = response.headers.get("Cache-Control") ?? "";
|
|
66
|
+
return /\bmax-age=[1-9]/.test(cacheControl) ? cacheControl : VISITOR_CACHE_CONTROL;
|
|
67
|
+
}
|
|
68
|
+
/** The render as the edge keeps it: as visitors get it, for as long as the edge keeps renders. */
|
|
69
|
+
function forEdge(response) {
|
|
70
|
+
const kept = new Response(response.body, response);
|
|
71
|
+
kept.headers.set("Cache-Control", `${visitorCacheControl(response)}, s-maxage=${EDGE_MAX_AGE}`);
|
|
72
|
+
return kept;
|
|
73
|
+
}
|
|
74
|
+
/** The render as visitors get it, without the edge's lifetime, which no cache downstream gets. */
|
|
75
|
+
function forVisitors(response, request) {
|
|
76
|
+
const cacheControl = visitorCacheControl(response).replace(/,\s*s-maxage=\d+/, "");
|
|
77
|
+
const answered = new Response(request.method === "HEAD" ? null : response.body, response);
|
|
78
|
+
answered.headers.set("Cache-Control", cacheControl);
|
|
79
|
+
return answered;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
//#endregion
|
|
83
|
+
export { readLiveCacheKey, respondThroughPublicCache };
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { CamoxDocument } from "../../core/defineDocument.js";
|
|
2
|
+
import { AlternatePath, LocaleView } from "../../lib/locale-path.js";
|
|
2
3
|
import { AnyLayoutLoaderResult } from "../../core/createLayout.js";
|
|
3
4
|
import { CamoxApp } from "../../core/createApp.js";
|
|
4
|
-
import { AlternatePath, LocaleView } from "../../lib/locale-path.js";
|
|
5
5
|
import { AnalyticsProviders } from "./analytics.js";
|
|
6
6
|
import { RuntimeOwnedRouteKind } from "./runtimeRoutes.js";
|
|
7
7
|
import { StudioRenderInput } from "./studioApp.js";
|
|
8
|
-
import { DraftRead, MemberProject, createServerApiClient } from "../routes/pageRuntime.js";
|
|
8
|
+
import { DraftRead, MemberProject, PageHead, createServerApiClient } from "../routes/pageRuntime.js";
|
|
9
9
|
import { UseHeadInput } from "unhead/types";
|
|
10
10
|
//#region src/features/runtime/runtime.d.ts
|
|
11
|
-
|
|
11
|
+
/** The studio's own routes, rather than a page or the Canvas. */
|
|
12
|
+
declare const STUDIO_ROUTE_KINDS: readonly ["studio", "studio-content", "studio-settings", "studio-nested"];
|
|
13
|
+
export type StudioRouteKind = (typeof STUDIO_ROUTE_KINDS)[number];
|
|
14
|
+
export type RuntimeRouteKind = RuntimeOwnedRouteKind | "canvas" | "page" | StudioRouteKind;
|
|
12
15
|
export interface RuntimeRouteMatch {
|
|
13
16
|
kind: RuntimeRouteKind;
|
|
14
17
|
pathname: string;
|
|
@@ -32,13 +35,21 @@ export interface PageRenderInput extends DraftRead {
|
|
|
32
35
|
/** Authenticate before rendering, to show the Studio around the page. */
|
|
33
36
|
previewHandoff?: boolean;
|
|
34
37
|
previewDocument?: string;
|
|
35
|
-
routeKind?:
|
|
38
|
+
routeKind?: StudioRouteKind;
|
|
36
39
|
/** Whether the location is the Canvas with nothing selected, which only the Canvas shows. */
|
|
37
40
|
canvasOnly?: boolean;
|
|
38
41
|
project?: MemberProject;
|
|
39
42
|
presentation?: "public" | "studio";
|
|
40
43
|
derived?: {
|
|
41
44
|
layoutId: string;
|
|
45
|
+
/** What the layout's context names, beside the loader's result. */
|
|
46
|
+
page?: {
|
|
47
|
+
/** The params the page's route matched. */
|
|
48
|
+
params: Record<string, string>;
|
|
49
|
+
/** The page's path, with its locale's prefix. */
|
|
50
|
+
path: string;
|
|
51
|
+
projectName: string;
|
|
52
|
+
};
|
|
42
53
|
result?: AnyLayoutLoaderResult;
|
|
43
54
|
layout: Awaited<ReturnType<ReturnType<typeof createServerApiClient>["layouts"]["get"]>>["layout"];
|
|
44
55
|
};
|
|
@@ -88,10 +99,14 @@ export interface RuntimeOptions {
|
|
|
88
99
|
studioStylesheetUrl?: string;
|
|
89
100
|
}
|
|
90
101
|
export declare function getRuntimePathname(url: string, runtimeBasePath?: string): string | null;
|
|
102
|
+
/**
|
|
103
|
+
* Whether a request reads what every visitor sees, so a shared cache may answer it: a GET or
|
|
104
|
+
* HEAD of a page or a public output, with no session, no draft named and no preview handoff. A
|
|
105
|
+
* response that comes out private all the same, such as the Studio's, still says so.
|
|
106
|
+
*/
|
|
107
|
+
export declare function isPublicRequest(request: Request, options?: RuntimeOptions): boolean;
|
|
91
108
|
export declare function matchRuntimeRoute(pathname: string): RuntimeRouteMatch;
|
|
92
|
-
export declare function createPageHeadInput(head:
|
|
93
|
-
meta?: Array<Record<string, string>>;
|
|
94
|
-
links?: Array<Record<string, string>>;
|
|
95
|
-
}): UseHeadInput;
|
|
109
|
+
export declare function createPageHeadInput(head: PageHead): UseHeadInput;
|
|
96
110
|
export declare function handleCamoxRequest(request: Request, options?: RuntimeOptions): Promise<Response | null>;
|
|
97
|
-
//#endregion
|
|
111
|
+
//#endregion
|
|
112
|
+
export {};
|