camox 0.57.0 → 0.58.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.
@@ -416,7 +416,7 @@ export declare function createApp({ blocks, lazyBlocks, layouts, collections, do
416
416
  Component: ({ blockData, mode, isFirstBlock, showAddBlockTop, showAddBlockBottom, addBlockAfterPosition }: BlockComponentProps<{
417
417
  [x: string]: unknown;
418
418
  }>) => import("react").JSX.Element;
419
- LayoutItem: ({ collectionId, collection, properties, record, children, fallback }: LayoutItemProps) => string | number | bigint | boolean | Iterable<import("react").ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<import("react").ReactNode> | null | undefined> | import("react").JSX.Element | null | undefined;
419
+ LayoutItem: ({ collectionId, collection, properties, record, children, fallback }: LayoutItemProps) => string | number | bigint | boolean | import("react").JSX.Element | Iterable<import("react").ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<import("react").ReactNode> | null | undefined> | null | undefined;
420
420
  id: string;
421
421
  title: string;
422
422
  description: string;
@@ -858,7 +858,7 @@ export declare function createApp({ blocks, lazyBlocks, layouts, collections, do
858
858
  Component: ({ blockData, mode, isFirstBlock, showAddBlockTop, showAddBlockBottom, addBlockAfterPosition }: BlockComponentProps<{
859
859
  [x: string]: unknown;
860
860
  }>) => import("react").JSX.Element;
861
- LayoutItem: ({ collectionId, collection, properties, record, children, fallback }: LayoutItemProps) => string | number | bigint | boolean | Iterable<import("react").ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<import("react").ReactNode> | null | undefined> | import("react").JSX.Element | null | undefined;
861
+ LayoutItem: ({ collectionId, collection, properties, record, children, fallback }: LayoutItemProps) => string | number | bigint | boolean | import("react").JSX.Element | Iterable<import("react").ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<import("react").ReactNode> | null | undefined> | null | undefined;
862
862
  id: string;
863
863
  title: string;
864
864
  description: string;
@@ -1,4 +1,4 @@
1
- import { artifactFrameProps, artifactStandInProps, isArtifactSource } from "./lib/artifact.js";
1
+ import { artifactFrameProps, artifactStandInProps, isArtifactSource, isArtifactSourceReference } from "./lib/artifact.js";
2
2
  import { artifactData } from "./lib/artifactData.js";
3
3
  import { resolveBlockFields, resolveToMarkdown, resolveToStructuredData, resolveToTitle } from "./lib/contentType.js";
4
4
  import { useProjectUuid } from "../lib/auth.js";
@@ -190,9 +190,13 @@ function createViewBlock(options) {
190
190
  size: useFieldSchema(name)?.size,
191
191
  navigate: useNavigate()
192
192
  });
193
- const initialData = useInitialArtifactData(value, data);
193
+ const initialData = useInitialArtifactData(isArtifactSourceReference(value) ? value.artifactUrl : value, data);
194
194
  const fontLinks = useOptionalCamoxApp()?.getArtifactFontLinks();
195
195
  const runs = React.use(ArtifactsRunContext);
196
+ if (isArtifactSourceReference(value)) return children({
197
+ ...artifactFrameProps(value, { data: initialData }),
198
+ ...hostProps
199
+ });
196
200
  if (!isArtifactSource(value)) return null;
197
201
  return children({
198
202
  ...runs ? artifactFrameProps(value, {
@@ -236,7 +236,7 @@ declare function createLayoutDefinition(options: AnyLayoutOptions): {
236
236
  Item: {
237
237
  ({ children }: {
238
238
  children: (item: any) => React.ReactNode;
239
- }): string | number | bigint | boolean | Iterable<React.ReactNode> | Promise<string | number | bigint | boolean | React.ReactPortal | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | null | undefined> | React.JSX.Element | null | undefined;
239
+ }): string | number | bigint | boolean | React.JSX.Element | Iterable<React.ReactNode> | Promise<string | number | bigint | boolean | React.ReactPortal | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | null | undefined> | null | undefined;
240
240
  displayName: string;
241
241
  };
242
242
  useData(): unknown;
@@ -1694,7 +1694,7 @@ export declare function createEditableBlock<TSchemaShape extends Record<string,
1694
1694
  params: unknown[] & [];
1695
1695
  })["static"]; }, Exclude<keyof TSchemaShape, { [K_1 in keyof TSchemaShape]: TSchemaShape[K_1] extends import("@sinclair/typebox").TReadonly<TSchema> ? TSchemaShape[K_1] extends import("@sinclair/typebox").TOptional<TSchemaShape[K_1]> ? K_1 : never : never; }[keyof TSchemaShape] | { [K_2 in keyof TSchemaShape]: TSchemaShape[K_2] extends import("@sinclair/typebox").TReadonly<TSchema> ? TSchemaShape[K_2] extends import("@sinclair/typebox").TOptional<TSchemaShape[K_2]> ? never : K_2 : never; }[keyof TSchemaShape] | { [K_3 in keyof TSchemaShape]: TSchemaShape[K_3] extends import("@sinclair/typebox").TOptional<TSchema> ? TSchemaShape[K_3] extends import("@sinclair/typebox").TReadonly<TSchemaShape[K_3]> ? never : K_3 : never; }[keyof TSchemaShape]>>>>>) => React.JSX.Element;
1696
1696
  /** Renders a record page's record; see `BlockEditingRuntime.renderLayoutItem`. */
1697
- LayoutItem: ({ collectionId, collection, properties, record, children, fallback }: LayoutItemProps) => string | number | bigint | boolean | Iterable<React.ReactNode> | Promise<string | number | bigint | boolean | React.ReactPortal | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | null | undefined> | React.JSX.Element | null | undefined;
1697
+ LayoutItem: ({ collectionId, collection, properties, record, children, fallback }: LayoutItemProps) => string | number | bigint | boolean | React.JSX.Element | Iterable<React.ReactNode> | Promise<string | number | bigint | boolean | React.ReactPortal | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | null | undefined> | null | undefined;
1698
1698
  id: string;
1699
1699
  title: string;
1700
1700
  description: string;
@@ -3,14 +3,23 @@ import "./artifactData.js";
3
3
  import * as React from "react";
4
4
  import "@camox/api-contract";
5
5
  //#region src/core/lib/artifact.d.ts
6
- /** The frame attributes of an artifact source. */
7
- export type ArtifactFrameProps = {
8
- srcDoc: string;
6
+ /** The frame attributes an artifact's frame has, whichever way it loads its document. */
7
+ type ArtifactFrameAttributes = {
9
8
  sandbox: string;
10
9
  allow: string;
11
10
  title: string;
12
11
  loading: "lazy";
13
12
  };
13
+ /** The frame attributes of an artifact whose document is inlined. */
14
+ export type InlineArtifactFrameProps = ArtifactFrameAttributes & {
15
+ srcDoc: string;
16
+ src?: never;
17
+ };
18
+ /** The frame attributes of an artifact: its document inlined, or loaded by URL. */
19
+ export type ArtifactFrameProps = InlineArtifactFrameProps | (ArtifactFrameAttributes & {
20
+ src: string;
21
+ srcDoc?: never;
22
+ });
14
23
  /** What `block.Artifact` passes to its render prop, to spread onto an `<iframe>`. */
15
24
  export type ArtifactRenderProps = ArtifactFrameProps & {
16
25
  /** Connects the frame to the `camox` runtime inside it. */
@@ -18,4 +27,5 @@ export type ArtifactRenderProps = ArtifactFrameProps & {
18
27
  /** For `size: "content"`, the height the artifact's content needs, once it reports it. */
19
28
  style?: React.CSSProperties;
20
29
  };
21
- //#endregion
30
+ //#endregion
31
+ export {};
@@ -1,8 +1,14 @@
1
1
  import { escapeAttribute } from "../../lib/html.js";
2
2
  import { ARTIFACT_RUNTIME } from "./artifactRuntime.js";
3
- import { artifactTitle } from "@camox/api-contract";
3
+ import { artifactSourceHash, artifactTitle } from "@camox/api-contract";
4
4
 
5
5
  //#region src/core/lib/artifact.ts
6
+ /** Whether a stored artifact value was replaced by a reference to its source. */
7
+ function isArtifactSourceReference(value) {
8
+ if (typeof value !== "object" || value === null) return false;
9
+ const { artifactUrl, title } = value;
10
+ return typeof artifactUrl === "string" && typeof title === "string";
11
+ }
6
12
  const FONT_LINK_RELS = /* @__PURE__ */ new Set(["stylesheet", "preconnect"]);
7
13
  const UNHEAD_TAG_OPTIONS = /* @__PURE__ */ new Set([
8
14
  "key",
@@ -32,6 +38,15 @@ function artifactDataScript(data) {
32
38
  return `${DATA_SCRIPT_OPEN}${json}<\/script>`;
33
39
  }
34
40
  /**
41
+ * The fragment, `#` included, that carries the starting Artifact Data in the URL of a frame that
42
+ * loads its artifact by URL instead of `srcdoc`. The runtime decodes it as `camox.data`. Fragments
43
+ * never reach the server, so one cached document serves every placement. Empty data needs none.
44
+ */
45
+ function artifactDataFragment(data) {
46
+ const json = JSON.stringify(data);
47
+ return json === "{}" ? "" : `#${encodeURIComponent(json)}`;
48
+ }
49
+ /**
35
50
  * The stylesheet and preconnect links of the project's document definition, as HTML, so artifacts
36
51
  * use the website's fonts from their first render. Nothing else from the document's head: its
37
52
  * title, meta, scripts and icons belong to the website, not to the artifact.
@@ -47,12 +62,35 @@ function artifactFontLinks(document) {
47
62
  }).map(renderLink).join("");
48
63
  }
49
64
  /**
50
- * The document an artifact frame loads: the `camox` runtime, the website's font links, the initial
51
- * Artifact Data, then the stored source. The doctype comes first so the runtime's script never
52
- * puts the page in quirks mode; the source's own doctype, if any, is then ignored by the parser.
65
+ * What every artifact's document holds before its source: the `camox` runtime, then the website's
66
+ * font links. The doctype comes first so the runtime's script never puts the page in quirks mode;
67
+ * the source's own doctype, if any, is then ignored by the parser.
68
+ */
69
+ function artifactShell(fontLinks) {
70
+ return `<!doctype html><script>${ARTIFACT_RUNTIME}<\/script>${fontLinks}`;
71
+ }
72
+ /**
73
+ * The hash of the shell around every artifact source, which names it in the URLs public pages
74
+ * load artifacts from. It changes only with the runtime or the website's font links, so a release
75
+ * that changes neither keeps every artifact cached.
76
+ */
77
+ function artifactShellHash(fontLinks) {
78
+ return artifactSourceHash(artifactShell(fontLinks));
79
+ }
80
+ /**
81
+ * The document an inline (`srcdoc`) artifact frame loads: the shell, the initial Artifact Data,
82
+ * then the stored source.
53
83
  */
54
84
  function artifactDocument(source, { fontLinks = "", data = {} }) {
55
- return `<!doctype html>${`<script>${ARTIFACT_RUNTIME}<\/script>${fontLinks}${artifactDataScript(data)}`}${source}`;
85
+ return `${artifactShell(fontLinks)}${artifactDataScript(data)}${source}`;
86
+ }
87
+ /**
88
+ * The document served at an artifact's URL: the shell, then the stored source. No Artifact Data:
89
+ * the frame's URL fragment carries it (`artifactDataFragment`), so one cached document serves
90
+ * every placement, and an inline data script would win over the fragment.
91
+ */
92
+ function artifactUrlDocument(source, fontLinks) {
93
+ return `${artifactShell(fontLinks)}${source}`;
56
94
  }
57
95
  /**
58
96
  * The artifact source inside an artifact's document, the reverse of `artifactDocument`, or `null`
@@ -60,7 +98,7 @@ function artifactDocument(source, { fontLinks = "", data = {} }) {
60
98
  * JSON escapes it too, so the first `<\/script>` after the data's opening tag ends the data.
61
99
  */
62
100
  function artifactSourceOf(document) {
63
- const head = `<!doctype html><script>${ARTIFACT_RUNTIME}<\/script>`;
101
+ const head = artifactShell("");
64
102
  if (!document.startsWith(head)) return null;
65
103
  const data = document.indexOf(DATA_SCRIPT_OPEN, head.length);
66
104
  if (data < 0 || document.slice(head.length, data).includes("<script")) return null;
@@ -68,19 +106,22 @@ function artifactSourceOf(document) {
68
106
  if (end < 0) return null;
69
107
  return document.slice(end + 9);
70
108
  }
71
- /**
72
- * The frame props for an artifact source, with its initial Artifact Data and the website's font
73
- * links. This getter is the boundary that hides how the source reaches the frame (inline `srcdoc`
74
- * today), so blocks never depend on it.
75
- */
76
- function artifactFrameProps(source, options = {}) {
77
- return {
78
- srcDoc: artifactDocument(source, options),
109
+ function artifactFrameProps(artifact, options = {}) {
110
+ const attributes = {
79
111
  sandbox: "allow-scripts",
80
112
  allow: "fullscreen; clipboard-write",
81
- title: artifactTitle(source),
82
113
  loading: "lazy"
83
114
  };
115
+ if (typeof artifact === "string") return {
116
+ srcDoc: artifactDocument(artifact, options),
117
+ ...attributes,
118
+ title: artifactTitle(artifact)
119
+ };
120
+ return {
121
+ src: `${artifact.artifactUrl}${artifactDataFragment(options.data ?? {})}`,
122
+ ...attributes,
123
+ title: artifact.title
124
+ };
84
125
  }
85
126
  const PLACEHOLDER_SOURCE = `<!doctype html>
86
127
  <html>
@@ -151,4 +192,4 @@ function isArtifactSource(value) {
151
192
  }
152
193
 
153
194
  //#endregion
154
- export { artifactFontLinks, artifactFrameProps, artifactPlaceholderProps, artifactSourceOf, artifactStandInProps, artifactTitle, isArtifactSource };
195
+ export { artifactDataFragment, artifactFontLinks, artifactFrameProps, artifactPlaceholderProps, artifactShellHash, artifactSourceOf, artifactStandInProps, artifactTitle, artifactUrlDocument, isArtifactSource, isArtifactSourceReference };
@@ -13,7 +13,9 @@
13
13
  * change. The host converts the colors, so the runtime that every frame inlines stays small.
14
14
  * - host → frame: `data` with the latest Artifact Data. The initial data is inlined in the
15
15
  * `srcDoc` as a `<script type="application/json" data-camox-data>` after the runtime and the
16
- * font links, so it is read lazily, on first access.
16
+ * font links, or, for a frame loaded by URL, encoded in the URL's fragment (see
17
+ * `artifactDataFragment`). It is read lazily, on first access, but the fragment is captured as
18
+ * the runtime starts, before the artifact source can change it.
17
19
  * - runtime → page: `observe-viewport` with `observe`, when the artifact's first `onViewport`
18
20
  * subscription starts or its last one ends. While observed, the host sends `viewport` with
19
21
  * `progress` and `visibleRatio` whenever they change.
@@ -30,7 +32,7 @@
30
32
  * `plugins/minifyArtifactRuntime.ts`), so it must stay one template literal without
31
33
  * interpolations or backslashes.
32
34
  */
33
- const ARTIFACT_RUNTIME = "(()=>{\"use strict\";let e=window.parent,t=null,n=null,r,i,a=new Set,o=()=>{if(i!==void 0)return r;let e=document.querySelector(`script[data-camox-data]`);try{r=e?JSON.parse(e.textContent):{}}catch{r={}}return i=JSON.stringify(r),r},s=(()=>{let e=new Set,t=[],n=Object.freeze({tokens:Object.freeze({}),colors:Object.freeze({})}),r=document.createElement(`style`);r.textContent=`body { visibility: hidden !important; }`,document.head.append(r);let i=()=>r.remove();setTimeout(i,300);let a=e=>Number.isFinite(e)?Math.min(255,Math.max(0,Math.round(e))):0;return{get current(){return n},receive(r){let o={},s={},c=r.colors||{};for(let[e,t]of Object.entries(r.tokens||{})){if(!e.startsWith(`--`)||typeof t!=`string`)continue;o[e]=t;let n=Object.hasOwn(c,e)?c[e]:null;n&&typeof n==`object`&&(s[e]=Object.freeze({r:a(n.r),g:a(n.g),b:a(n.b)}))}let l=document.documentElement.style;for(let e of t)Object.hasOwn(o,e)||l.removeProperty(e);for(let[e,t]of Object.entries(o))l.setProperty(e,t);t=Object.keys(o),i(),n=Object.freeze({tokens:Object.freeze(o),colors:Object.freeze(s)});for(let t of[...e])t(n)},subscribe(t){return e.add(t),()=>{e.delete(t)}}}})(),c=new Set,l=null,u=null,d=null,f=()=>{let e=Math.ceil(document.documentElement.getBoundingClientRect().height);e!==d&&(d=e,p(`height`,{height:d}))},p=(n,r)=>{t&&e.postMessage({...r,camox:n,channel:t},`*`)},m=()=>p(`observe-viewport`,{observe:c.size>0}),h={studio(e){let t=e.studio;n=t?Object.freeze({editing:!!t.editing,canvas:!!t.canvas}):null},data(e){o();let t=JSON.stringify(e.data??{});if(t!==i){r=JSON.parse(t),i=t;for(let e of[...a])e(r)}},theme:s.receive,measure(){d=null,u||(u=new ResizeObserver(f),u.observe(document.documentElement)),f()},viewport(e){let{progress:t,visibleRatio:n}=e;if(typeof t==`number`&&typeof n==`number`){l=Object.freeze({progress:t,visibleRatio:n}),document.documentElement.style.setProperty(`--camox-progress`,String(t));for(let e of c)e(l)}}};window.addEventListener(`message`,e=>{let n=e.data;if(n&&typeof n.camox==`string`){if(n.camox===`connect`){if(typeof n.channel!=`string`||n.channel===t)return;t=n.channel,p(`ready`),c.size>0&&m()}else t&&n.channel===t&&Object.hasOwn(h,n.camox)&&h[n.camox](n)}});let g=Object.freeze({get studio(){return n},get data(){return o()},onData(e){return a.add(e),()=>{a.delete(e)}},get theme(){return s.current},onTheme:s.subscribe,navigate(e){p(`navigate`,{href:String(e)})},onViewport(e){if(typeof e!=`function`)throw TypeError(`camox.onViewport needs a callback`);let t=t=>e(t);return c.add(t),c.size===1?m():l&&queueMicrotask(()=>c.has(t)&&e(l)),()=>{c.delete(t)&&c.size===0&&m()}}});Object.defineProperty(window,\"camox\",{value:g,enumerable:!0}),e!==window&&e.postMessage({camox:`hello`},`*`)})();";
35
+ const ARTIFACT_RUNTIME = "(()=>{\"use strict\";let e=window.parent,t=null,n=null,r,i,a=new Set,o=location.hash.slice(1),s=()=>{if(i!==void 0)return r;let e=document.querySelector(`script[data-camox-data]`);try{r=JSON.parse(e?e.textContent:decodeURIComponent(o)),(!r||typeof r!=`object`||Array.isArray(r))&&(r={})}catch{r={}}return i=JSON.stringify(r),r},c=(()=>{let e=new Set,t=[],n=Object.freeze({tokens:Object.freeze({}),colors:Object.freeze({})}),r=document.createElement(`style`);r.textContent=`body { visibility: hidden !important; }`,document.head.append(r);let i=()=>r.remove();setTimeout(i,300);let a=e=>Number.isFinite(e)?Math.min(255,Math.max(0,Math.round(e))):0;return{get current(){return n},receive(r){let o={},s={},c=r.colors||{};for(let[e,t]of Object.entries(r.tokens||{})){if(!e.startsWith(`--`)||typeof t!=`string`)continue;o[e]=t;let n=Object.hasOwn(c,e)?c[e]:null;n&&typeof n==`object`&&(s[e]=Object.freeze({r:a(n.r),g:a(n.g),b:a(n.b)}))}let l=document.documentElement.style;for(let e of t)Object.hasOwn(o,e)||l.removeProperty(e);for(let[e,t]of Object.entries(o))l.setProperty(e,t);t=Object.keys(o),i(),n=Object.freeze({tokens:Object.freeze(o),colors:Object.freeze(s)});for(let t of[...e])t(n)},subscribe(t){return e.add(t),()=>{e.delete(t)}}}})(),l=new Set,u=null,d=null,f=null,p=()=>{let e=Math.ceil(document.documentElement.getBoundingClientRect().height);e!==f&&(f=e,m(`height`,{height:f}))},m=(n,r)=>{t&&e.postMessage({...r,camox:n,channel:t},`*`)},h=()=>m(`observe-viewport`,{observe:l.size>0}),g={studio(e){let t=e.studio;n=t?Object.freeze({editing:!!t.editing,canvas:!!t.canvas}):null},data(e){s();let t=JSON.stringify(e.data??{});if(t!==i){r=JSON.parse(t),i=t;for(let e of[...a])e(r)}},theme:c.receive,measure(){f=null,d||(d=new ResizeObserver(p),d.observe(document.documentElement)),p()},viewport(e){let{progress:t,visibleRatio:n}=e;if(typeof t==`number`&&typeof n==`number`){u=Object.freeze({progress:t,visibleRatio:n}),document.documentElement.style.setProperty(`--camox-progress`,String(t));for(let e of l)e(u)}}};window.addEventListener(`message`,e=>{let n=e.data;if(n&&typeof n.camox==`string`){if(n.camox===`connect`){if(typeof n.channel!=`string`||n.channel===t)return;t=n.channel,m(`ready`),l.size>0&&h()}else t&&n.channel===t&&Object.hasOwn(g,n.camox)&&g[n.camox](n)}});let _=Object.freeze({get studio(){return n},get data(){return s()},onData(e){return a.add(e),()=>{a.delete(e)}},get theme(){return c.current},onTheme:c.subscribe,navigate(e){m(`navigate`,{href:String(e)})},onViewport(e){if(typeof e!=`function`)throw TypeError(`camox.onViewport needs a callback`);let t=t=>e(t);return l.add(t),l.size===1?h():u&&queueMicrotask(()=>l.has(t)&&e(u)),()=>{l.delete(t)&&l.size===0&&h()}}});Object.defineProperty(window,\"camox\",{value:_,enumerable:!0}),e!==window&&e.postMessage({camox:`hello`},`*`)})();";
34
36
 
35
37
  //#endregion
36
38
  export { ARTIFACT_RUNTIME };
@@ -0,0 +1,58 @@
1
+ import { artifactFontLinks, artifactShellHash, artifactUrlDocument } from "../../core/lib/artifact.js";
2
+ import { ARTIFACTS_PATH_PREFIX } from "./runtimeRoutes.js";
3
+
4
+ //#region src/features/runtime/artifactDocuments.ts
5
+ const shells = /* @__PURE__ */ new WeakMap();
6
+ /**
7
+ * The website's artifact shell, computed once per runtime: its font links come from the build's
8
+ * document definition, so they never change while it runs.
9
+ */
10
+ function readArtifactShell(options) {
11
+ let shell = shells.get(options);
12
+ if (!shell) {
13
+ shell = (async () => {
14
+ const fontLinks = artifactFontLinks(await options.getDocument?.());
15
+ return {
16
+ fontLinks,
17
+ hash: await artifactShellHash(fontLinks)
18
+ };
19
+ })();
20
+ shell.catch(() => shells.delete(options));
21
+ shells.set(options, shell);
22
+ }
23
+ return shell;
24
+ }
25
+ const YEAR = 31536e3;
26
+ /**
27
+ * An artifact's document, served at `/_camox/artifacts/<shellHash>/<sourceHash>`: the source the
28
+ * API stores under its hash, inside the website's current shell. It's the same for every visitor,
29
+ * so cookies are ignored. It keeps an opaque origin even when opened top-level, and only a
30
+ * document whose shell matches its URL is cached: one from a previous release still loads.
31
+ */
32
+ async function createArtifactDocumentResponse(pathname, options) {
33
+ const [shellHash, sourceHash, ...rest] = pathname.slice(ARTIFACTS_PATH_PREFIX.length).split("/");
34
+ const { apiUrl, projectUuid } = options;
35
+ if (!apiUrl || !projectUuid) return Response.json({ message: "Camox artifacts are not configured." }, { status: 500 });
36
+ if (!shellHash || !sourceHash || rest.length) return notFound();
37
+ const [shell, stored] = await Promise.all([readArtifactShell(options), fetch(`${apiUrl}/artifact-sources/${encodeURIComponent(projectUuid)}/${encodeURIComponent(sourceHash)}`)]);
38
+ if (stored.status === 404) return notFound();
39
+ if (!stored.ok) return new Response("Artifact source unavailable", {
40
+ status: 502,
41
+ headers: { "Cache-Control": "no-store" }
42
+ });
43
+ return new Response(artifactUrlDocument(await stored.text(), shell.fontLinks), { headers: {
44
+ "Content-Type": "text/html; charset=utf-8",
45
+ "Cache-Control": shellHash === shell.hash ? `public, max-age=${YEAR}, immutable` : "no-store",
46
+ "Content-Security-Policy": "sandbox allow-scripts",
47
+ "X-Robots-Tag": "noindex"
48
+ } });
49
+ }
50
+ function notFound() {
51
+ return new Response("Not found", {
52
+ status: 404,
53
+ headers: { "Cache-Control": "no-store" }
54
+ });
55
+ }
56
+
57
+ //#endregion
58
+ export { createArtifactDocumentResponse, readArtifactShell };
@@ -1,6 +1,40 @@
1
- import { artifactSourceOf } from "../../core/lib/artifact.js";
1
+ import { artifactSourceOf, artifactTitle } from "../../core/lib/artifact.js";
2
2
 
3
3
  //#region src/features/runtime/artifactSources.ts
4
+ function isStringRecord(value) {
5
+ return isPlainObject(value) && Object.values(value).every((hash) => typeof hash === "string");
6
+ }
7
+ /** A copy of a JSON value with every object replaced, deepest first. */
8
+ function mapObjects(value, replace) {
9
+ if (Array.isArray(value)) return value.map((item) => mapObjects(item, replace));
10
+ if (!isPlainObject(value)) return value;
11
+ return replace(Object.fromEntries(Object.entries(value).map(([key, item]) => [key, mapObjects(item, replace)])));
12
+ }
13
+ /**
14
+ * The page's dehydrated state with each artifact source replaced by a reference to the URL its
15
+ * document loads from, `<artifactsPath><sourceHash>`, and its title: public pages load their
16
+ * artifacts by URL, so neither their HTML nor their hydration data carries a source. Every block
17
+ * and repeatable item the API reads names its sources' hashes beside its content.
18
+ */
19
+ function referenceArtifactSources(dehydratedState, artifactsPath) {
20
+ return mapObjects(dehydratedState, (owner) => {
21
+ const { artifactHashes, content } = owner;
22
+ if (!isStringRecord(artifactHashes) || !isPlainObject(content)) return owner;
23
+ const referenced = { ...content };
24
+ for (const [field, hash] of Object.entries(artifactHashes)) {
25
+ const source = content[field];
26
+ if (typeof source !== "string") continue;
27
+ referenced[field] = {
28
+ artifactUrl: `${artifactsPath}${hash}`,
29
+ title: artifactTitle(source)
30
+ };
31
+ }
32
+ return {
33
+ ...owner,
34
+ content: referenced
35
+ };
36
+ });
37
+ }
4
38
  /**
5
39
  * A page's HTML holds each rendered artifact source in its frame's `srcdoc`, so the server leaves
6
40
  * them out of the hydration data, and the client takes them back from the frames before
@@ -84,4 +118,4 @@ function restoreArtifactSources(dehydratedState, root) {
84
118
  }
85
119
 
86
120
  //#endregion
87
- export { omitArtifactSources, restoreArtifactSources };
121
+ export { omitArtifactSources, referenceArtifactSources, restoreArtifactSources };
@@ -3,7 +3,7 @@ import { createRequestContext } from "../../core/requestContext.js";
3
3
  import { buildClearServerAuthCookieHeader, getServerAuthCookieHeader } from "../../lib/server-auth-cookie.js";
4
4
  import { UNNAMED_DEFAULT_LOCALE, localizedPath, parseLocalePath } from "../../lib/locale-path.js";
5
5
  import { draftNamedBy } from "../../lib/draft-scope.js";
6
- import { RUNTIME_ROUTE_PATHS, matchRuntimeOwnedRoute } from "./runtimeRoutes.js";
6
+ import { ARTIFACTS_PATH_PREFIX, RUNTIME_ROUTE_PATHS, matchRuntimeOwnedRoute } from "./runtimeRoutes.js";
7
7
  import { reads } from "../../lib/studio-reads.js";
8
8
  import { asCollectionItem, recordMarkdownTemplate, recordPageValues } from "../../core/recordItem.js";
9
9
  import { PLATFORM_SCRIPT } from "../../lib/platform.js";
@@ -17,7 +17,8 @@ import { assetPrefix } from "./assetPrefix.js";
17
17
  import { documentGoogleFonts } from "../og/documentFonts.js";
18
18
  import { alternatesByPage, findAlternates, hreflangLinks } from "./alternates.js";
19
19
  import { configuredProviders, renderAnalyticsScripts } from "./analytics.js";
20
- import { omitArtifactSources } from "./artifactSources.js";
20
+ import { createArtifactDocumentResponse, readArtifactShell } from "./artifactDocuments.js";
21
+ import { omitArtifactSources, referenceArtifactSources } from "./artifactSources.js";
21
22
  import { readBreadcrumb } from "./breadcrumbs.js";
22
23
  import { RouteDiscoveryError, listLivePages } from "./livePages.js";
23
24
  import { formatLlmsTxt } from "./llmsTxt.js";
@@ -519,8 +520,9 @@ function preparePreviewDocument(input, options, document) {
519
520
  input.previewDocument = `<!doctype html><html${siteHead.htmlAttrs}><head><base href="${escapeXml(href)}">${siteHead.headTags}</head><body${siteHead.bodyAttrs}>${siteHead.bodyTagsOpen}<div id="root" data-camox-preview-root></div>${siteHead.bodyTags}</body></html>`;
520
521
  }
521
522
  /**
522
- * The render input a page's HTML embeds for hydration. Visitors load public pages, whose HTML
523
- * already holds each rendered artifact source in its frame, so their copy leaves those out.
523
+ * The render input a page's HTML embeds for hydration. Outside the studio, a page whose HTML
524
+ * inlines its artifacts (in dev mode) already holds each rendered source in its frame, so their
525
+ * copy leaves those out. Public pages load artifacts by URL and hold no source at all.
524
526
  */
525
527
  function hydrationInputFor(input, html) {
526
528
  if (input.presentation === "studio") return input;
@@ -529,6 +531,21 @@ function hydrationInputFor(input, html) {
529
531
  dehydratedState: omitArtifactSources(input.dehydratedState, html)
530
532
  };
531
533
  }
534
+ /**
535
+ * The render input with its artifacts loaded by URL, where a visitor reads Live: each artifact
536
+ * source becomes a reference to the document the website serves, so neither the page's HTML nor
537
+ * its data carries a source, and the client renders the frames the server did. The studio, drafts
538
+ * and dev mode keep inlined sources, so an agent's change shows at once.
539
+ */
540
+ async function withArtifactsByUrl(input, options) {
541
+ if (input.presentation !== "public" || input.draftId || options.developmentDraft) return input;
542
+ const shell = await readArtifactShell(options);
543
+ const artifactsPath = `${input.runtimeBasePath}${ARTIFACTS_PATH_PREFIX}${shell.hash}/`;
544
+ return {
545
+ ...input,
546
+ dehydratedState: referenceArtifactSources(input.dehydratedState, artifactsPath)
547
+ };
548
+ }
532
549
  function renderStudioThemeScript(enabled) {
533
550
  return enabled ? `<script data-camox-studio>${STUDIO_THEME_SCRIPT}${PLATFORM_SCRIPT}<\/script>` : "";
534
551
  }
@@ -841,7 +858,7 @@ async function createPageHtmlResponse({ options, pathname, located, request, pag
841
858
  studio,
842
859
  draftId: result.draftId
843
860
  }, result.data.page.project);
844
- const pageRenderInput = {
861
+ const pageRenderInput = await withArtifactsByUrl({
845
862
  alternates,
846
863
  project,
847
864
  presentation: studio ? "studio" : "public",
@@ -860,7 +877,7 @@ async function createPageHtmlResponse({ options, pathname, located, request, pag
860
877
  runtimeBasePath: normalizeRuntimeBasePath(options.runtimeBasePath),
861
878
  locale: located.locale,
862
879
  locales: located.locales
863
- };
880
+ }, options);
864
881
  preparePreviewDocument(pageRenderInput, options, document);
865
882
  const appHtml = await options.renderPage(pageRenderInput);
866
883
  const renderedHead = renderHead(studio ? {} : document, createPageHeadInput(head), studio ? void 0 : options.stylesheetUrl, studio ? options.studioStylesheetUrl : void 0, studio ? void 0 : located.locale);
@@ -1170,7 +1187,7 @@ async function createDerivedResponse(options, pathname, located, request, { data
1170
1187
  studio,
1171
1188
  draftId: read.draftId
1172
1189
  }, shared.project);
1173
- const input = {
1190
+ const input = await withArtifactsByUrl({
1174
1191
  alternates,
1175
1192
  project,
1176
1193
  presentation: studio ? "studio" : "public",
@@ -1199,7 +1216,7 @@ async function createDerivedResponse(options, pathname, located, request, { data
1199
1216
  result: serializedResult,
1200
1217
  layout: shared.layout
1201
1218
  }
1202
- };
1219
+ }, options);
1203
1220
  const document = await options.getDocument?.() ?? {};
1204
1221
  preparePreviewDocument(input, options, document);
1205
1222
  if (dataOnly) return Response.json(input, { headers: {
@@ -1350,7 +1367,7 @@ async function createPageDataResponse({ options, pathname, located, request, pag
1350
1367
  localeReads: localeReads(result.draftId)
1351
1368
  });
1352
1369
  const dehydratedState = dehydrate(queryClient);
1353
- const input = {
1370
+ const navigationInput = await withArtifactsByUrl({
1354
1371
  alternates,
1355
1372
  apiUrl: options.apiUrl,
1356
1373
  authenticationUrl: options.authenticationUrl ?? "",
@@ -1368,9 +1385,9 @@ async function createPageDataResponse({ options, pathname, located, request, pag
1368
1385
  loaderData: result.data,
1369
1386
  head,
1370
1387
  dehydratedState
1371
- };
1372
- preparePreviewDocument(input, options, await options.getDocument?.() ?? {});
1373
- return Response.json(input, { headers });
1388
+ }, options);
1389
+ preparePreviewDocument(navigationInput, options, await options.getDocument?.() ?? {});
1390
+ return Response.json(navigationInput, { headers });
1374
1391
  } catch (error) {
1375
1392
  if (isNotFoundError(error)) {
1376
1393
  const derived = await createDerivedResponse(options, pathname, located, request, { dataOnly: true });
@@ -1397,6 +1414,7 @@ async function handleCamoxRequest(request, options = {}) {
1397
1414
  layouts: camoxApp.getLayouts().map((layout) => layout._internal.id)
1398
1415
  });
1399
1416
  }
1417
+ if (match.kind === "artifact") return createArtifactDocumentResponse(pathname, options);
1400
1418
  if (match.kind === "og") return createOgResponse(request, options);
1401
1419
  if (match.kind === "renderer") return createRendererDocumentResponse(options);
1402
1420
  if (match.kind === "logo") return createProjectLogoResponse(request, options);
@@ -1,9 +1,10 @@
1
1
  //#region src/features/runtime/runtimeRoutes.d.ts
2
2
  /**
3
- * The URLs the runtime answers that aren't pages or the Studio. Worker request filtering, runtime
4
- * route matching and client navigation all read this list, so a new one can't be forgotten in any
5
- * of them. Prefix rules stay with their readers: the Worker also answers every `/camox/` Studio
6
- * path, and client navigation also skips any other `/_camox/` path.
3
+ * The URLs the runtime answers that aren't pages or the Studio, besides artifact documents
4
+ * (`ARTIFACTS_PATH_PREFIX`). Worker request filtering, runtime route matching and client
5
+ * navigation all match them through `matchRuntimeOwnedRoute`, so a new one can't be forgotten in
6
+ * any of them. Other prefix rules stay with their readers: the Worker also answers every
7
+ * `/camox/` Studio path, and client navigation also skips any other `/_camox/` path.
7
8
  * No imports: client navigation ships this module in the browser bundle.
8
9
  */
9
10
  export declare const RUNTIME_ROUTE_PATHS: {
@@ -18,5 +19,5 @@ export declare const RUNTIME_ROUTE_PATHS: {
18
19
  readonly sitemap: "/sitemap.xml";
19
20
  readonly "llms-txt": "/llms.txt";
20
21
  };
21
- export type RuntimeOwnedRouteKind = keyof typeof RUNTIME_ROUTE_PATHS;
22
+ export type RuntimeOwnedRouteKind = keyof typeof RUNTIME_ROUTE_PATHS | "artifact";
22
23
  //#endregion
@@ -1,9 +1,10 @@
1
1
  //#region src/features/runtime/runtimeRoutes.ts
2
2
  /**
3
- * The URLs the runtime answers that aren't pages or the Studio. Worker request filtering, runtime
4
- * route matching and client navigation all read this list, so a new one can't be forgotten in any
5
- * of them. Prefix rules stay with their readers: the Worker also answers every `/camox/` Studio
6
- * path, and client navigation also skips any other `/_camox/` path.
3
+ * The URLs the runtime answers that aren't pages or the Studio, besides artifact documents
4
+ * (`ARTIFACTS_PATH_PREFIX`). Worker request filtering, runtime route matching and client
5
+ * navigation all match them through `matchRuntimeOwnedRoute`, so a new one can't be forgotten in
6
+ * any of them. Other prefix rules stay with their readers: the Worker also answers every
7
+ * `/camox/` Studio path, and client navigation also skips any other `/_camox/` path.
7
8
  * No imports: client navigation ships this module in the browser bundle.
8
9
  */
9
10
  const RUNTIME_ROUTE_PATHS = {
@@ -18,11 +19,17 @@ const RUNTIME_ROUTE_PATHS = {
18
19
  sitemap: "/sitemap.xml",
19
20
  "llms-txt": "/llms.txt"
20
21
  };
22
+ /**
23
+ * Where public pages load artifacts from: `<prefix><shellHash>/<sourceHash>`. Each document is
24
+ * cached by its own URL, never as a page.
25
+ */
26
+ const ARTIFACTS_PATH_PREFIX = "/_camox/artifacts/";
21
27
  /** Which runtime-owned route a path inside the runtime (base path removed) is, if any. */
22
28
  function matchRuntimeOwnedRoute(pathname) {
23
29
  for (const [kind, path] of Object.entries(RUNTIME_ROUTE_PATHS)) if (pathname === path) return kind;
30
+ if (pathname.startsWith("/_camox/artifacts/")) return "artifact";
24
31
  return null;
25
32
  }
26
33
 
27
34
  //#endregion
28
- export { RUNTIME_ROUTE_PATHS, matchRuntimeOwnedRoute };
35
+ export { ARTIFACTS_PATH_PREFIX, RUNTIME_ROUTE_PATHS, matchRuntimeOwnedRoute };