@jxsuite/studio 2.4.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/chunks/{cf-settings-g5fbf2zp.js → cf-settings-zj2cj1ar.js} +6 -3
  2. package/dist/chunks/{content-source-k9724vcv.js → content-source-7fy9qrm0.js} +10 -9
  3. package/dist/chunks/{convert-to-repeater-k10dvcc7.js → convert-to-repeater-6vvx96n2.js} +9 -8
  4. package/dist/chunks/{extension-sections-0ada0xcw.js → extension-sections-kwzvpemr.js} +13 -12
  5. package/dist/chunks/{files-yew27sn9.js → files-xcybanxe.js} +14 -13
  6. package/dist/chunks/{format-host-wewba4nq.js → format-host-8fr3545e.js} +2 -2
  7. package/dist/chunks/{git-panel-2dh1aw50.js → git-panel-g01d93vc.js} +9 -8
  8. package/dist/chunks/{layers-panel-qnsghwwd.js → layers-panel-27essgkc.js} +12 -11
  9. package/dist/chunks/{publish-panel-a0pdft5w.js → publish-panel-09sy2bab.js} +8 -7
  10. package/dist/chunks/{quick-search-9avyc4pc.js → quick-search-sefb61v8.js} +15 -14
  11. package/dist/chunks/{right-panel-kcnhtc5j.js → right-panel-9r32jd5j.js} +11 -10
  12. package/dist/chunks/{studio-aweyr4v1.js → studio-2x9zbxmx.js} +2 -2
  13. package/dist/chunks/{studio-ef8t53vc.js → studio-3dkcd4c5.js} +3 -3
  14. package/dist/chunks/{studio-hb7c46jy.js → studio-5efrtn1m.js} +9 -7
  15. package/dist/chunks/{studio-v1va0vhd.js → studio-6z5eyjwn.js} +24 -25
  16. package/dist/chunks/{studio-ke244pam.js → studio-7p35254k.js} +33 -11
  17. package/dist/chunks/{studio-f5yyvqxv.js → studio-8ezdegcx.js} +13 -11
  18. package/dist/chunks/{studio-cz2j1c0g.js → studio-a0yp7syn.js} +55 -18
  19. package/dist/chunks/{studio-26sv44br.js → studio-dc8s8heb.js} +34 -23
  20. package/dist/chunks/{studio-jnakhg87.js → studio-g5gdqnyd.js} +4 -4
  21. package/dist/chunks/{studio-rqemfhz6.js → studio-hcxzyk3x.js} +2 -2
  22. package/dist/chunks/{studio-ws6ef6js.js → studio-hj2se583.js} +12 -10
  23. package/dist/chunks/{studio-pyrw8h0w.js → studio-j595e748.js} +407 -167
  24. package/dist/chunks/{studio-dgj7gq44.js → studio-k4jvjcfx.js} +4 -4
  25. package/dist/chunks/studio-kfpek536.js +28 -0
  26. package/dist/chunks/{studio-sccge2wf.js → studio-p5dja96s.js} +2 -2
  27. package/dist/chunks/{studio-0b6fs05q.js → studio-pkp61mh8.js} +5 -5
  28. package/dist/chunks/studio-qpfq7xhm.js +1802 -0
  29. package/dist/chunks/{studio-ra7ar50f.js → studio-r5xbtctf.js} +51 -1565
  30. package/dist/chunks/{studio-v2c5pmjj.js → studio-rh1cnamv.js} +17 -15
  31. package/dist/chunks/{studio-28xc8ws4.js → studio-ry1vz5sv.js} +10 -8
  32. package/dist/chunks/{studio-hyn3vhwr.js → studio-xntfdy1x.js} +5 -5
  33. package/dist/iframe-entry.js +344 -26
  34. package/dist/studio.js +374 -248
  35. package/package.json +9 -9
  36. package/src/browse/library-layouts.ts +3 -3
  37. package/src/browse/library-pane.ts +2 -2
  38. package/src/browse/library-preview.ts +19 -3
  39. package/src/canvas/asset-refs.ts +119 -0
  40. package/src/canvas/asset-resolve.ts +256 -0
  41. package/src/canvas/canvas-live-render.ts +23 -15
  42. package/src/canvas/iframe-entry.ts +1 -0
  43. package/src/canvas/iframe-host.ts +4 -0
  44. package/src/canvas/iframe-protocol.ts +9 -0
  45. package/src/canvas/iframe-render.ts +28 -10
  46. package/src/canvas/site-style-css.ts +2 -6
  47. package/src/editor/file-drop-action.ts +2 -1
  48. package/src/files/files.ts +2 -2
  49. package/src/files/media-paths.ts +37 -14
  50. package/src/files/media-upload.ts +47 -6
  51. package/src/grid/grid-idle.ts +45 -0
  52. package/src/grid/grid-view.ts +21 -0
  53. package/src/grid/schema-columns.ts +2 -1
  54. package/src/grid/sources/content-source.ts +24 -3
  55. package/src/new-project/import-tab.ts +3 -2
  56. package/src/panels/ai-chat/composer.ts +30 -13
  57. package/src/panels/frontmatter-fields.ts +6 -2
  58. package/src/panels/properties-panel.ts +8 -2
  59. package/src/panels/seo-modal.ts +5 -6
  60. package/src/panels/signals-panel.ts +2 -1
  61. package/src/platforms/cloud.ts +75 -5
  62. package/src/platforms/devserver.ts +5 -1
  63. package/src/services/ai-models.ts +101 -11
  64. package/src/services/ai-settings.ts +69 -71
  65. package/src/services/cf-settings.ts +19 -37
  66. package/src/services/document-assistant.ts +4 -3
  67. package/src/services/idle.ts +6 -1
  68. package/src/services/settings/definitions.ts +164 -0
  69. package/src/services/settings/kernel.ts +335 -0
  70. package/src/services/settings/write-queue.ts +116 -0
  71. package/src/settings/preferences-accounts.ts +6 -19
  72. package/src/settings/preferences-keymap.ts +8 -18
  73. package/src/settings/schema-field-ui.ts +2 -2
  74. package/src/shell.ts +6 -14
  75. package/src/studio.ts +32 -1
  76. package/src/types.ts +61 -3
  77. package/src/ui/ai-credentials-form.ts +52 -27
  78. package/src/ui/media-picker.ts +6 -11
  79. package/src/utils/studio-utils.ts +45 -3
  80. package/dist/chunks/studio-a7pzveq1.js +0 -110
  81. package/src/canvas/content-assets.ts +0 -263
  82. package/src/services/settings-store.ts +0 -99
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "2.4.2",
3
+ "version": "3.0.0",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -57,14 +57,14 @@
57
57
  "dependencies": {
58
58
  "@atlaskit/pragmatic-drag-and-drop": "^3.0.0",
59
59
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.1.0",
60
- "@jxsuite/ai": "^0.36.2",
61
- "@jxsuite/collab": "^0.8.1",
62
- "@jxsuite/create": "^1.3.5",
63
- "@jxsuite/formulas": "^0.0.12",
64
- "@jxsuite/markup": "^0.4.5",
65
- "@jxsuite/protocol": "^1.2.0",
66
- "@jxsuite/runtime": "^2.0.4",
67
- "@jxsuite/schema": "^1.8.0",
60
+ "@jxsuite/ai": "^0.36.4",
61
+ "@jxsuite/collab": "^0.8.3",
62
+ "@jxsuite/create": "^1.3.7",
63
+ "@jxsuite/formulas": "^0.0.14",
64
+ "@jxsuite/markup": "^0.4.7",
65
+ "@jxsuite/protocol": "^2.0.0",
66
+ "@jxsuite/runtime": "^2.1.0",
67
+ "@jxsuite/schema": "^1.9.0",
68
68
  "@spectrum-web-components/accordion": "^1.12.2",
69
69
  "@spectrum-web-components/action-bar": "1.12.2",
70
70
  "@spectrum-web-components/action-button": "^1.12.2",
@@ -19,7 +19,7 @@ import { html, nothing } from "lit-html";
19
19
  import { repeat } from "lit-html/directives/repeat.js";
20
20
  import { ref } from "lit-html/directives/ref.js";
21
21
  import { isImage } from "../files/media-upload";
22
- import { loopbackAssetSrc } from "../canvas/canvas-origin";
22
+ import { previewFileSrc } from "../files/media-paths";
23
23
  import { localeLabel } from "@jxsuite/schema/locale";
24
24
  import { groupByCategory, groupByDate } from "./library-model";
25
25
  import type { LibraryFile, LibraryLayout } from "./library-model";
@@ -154,7 +154,7 @@ function previewTpl(file: LibraryFile, ctx: LayoutContext): TemplateResult {
154
154
  return html`<img
155
155
  class="library-thumb"
156
156
  loading="lazy"
157
- src=${loopbackAssetSrc(`/${file.path}`)}
157
+ src=${previewFileSrc(file.path)}
158
158
  alt=""
159
159
  />`;
160
160
  }
@@ -268,7 +268,7 @@ export function mediaTpl(files: readonly LibraryFile[], ctx: LayoutContext): Tem
268
268
  ? html`<img
269
269
  class="library-thumb"
270
270
  loading="lazy"
271
- src=${loopbackAssetSrc(`/${file.path}`)}
271
+ src=${previewFileSrc(file.path)}
272
272
  alt=""
273
273
  />`
274
274
  : html`<sp-icon-document size="l"></sp-icon-document>`
@@ -43,7 +43,7 @@ import { confirmFileDelete, renamePromptMessage } from "../files/file-ops";
43
43
  import { createFileIn, openFileInTab } from "../files/files";
44
44
  import { createEntry } from "../content/entry-commands";
45
45
  import { entryCollections } from "../content/entry-model";
46
- import { UPLOAD_ACCEPT, uploadAssets } from "../files/media-upload";
46
+ import { uploadAccept, uploadAssets } from "../files/media-upload";
47
47
  import { localeLabel } from "@jxsuite/schema/locale";
48
48
  import {
49
49
  LIBRARY_CATEGORIES,
@@ -760,7 +760,7 @@ function toolbarTpl(panel: ActiveLibraryPane, files: readonly LibraryFile[]) {
760
760
  <input
761
761
  type="file"
762
762
  multiple
763
- accept=${UPLOAD_ACCEPT}
763
+ accept=${uploadAccept()}
764
764
  class="library-upload-input"
765
765
  @change=${(e: Event) => {
766
766
  const input = e.target as HTMLInputElement;
@@ -19,7 +19,13 @@
19
19
  * already knows a preview is in flight without this module declaring an idle source of its own.
20
20
  */
21
21
 
22
- import { buildScope, renderNode, setSkipServerFunctions } from "@jxsuite/runtime";
22
+ import {
23
+ buildScope,
24
+ renderNode,
25
+ setCanvasAssetResolver,
26
+ setSkipServerFunctions,
27
+ } from "@jxsuite/runtime";
28
+ import { assetContextFor, hostAssetDeclarations, resolveAssetRef } from "../canvas/asset-refs";
23
29
  import { componentRegistry } from "../files/components";
24
30
  import { getPlatform } from "../platform";
25
31
  import { loadFormats, formatForPath } from "../format/format-host";
@@ -115,6 +121,7 @@ export async function renderDocPreview(filePath: string): Promise<HTMLElement |
115
121
  try {
116
122
  const content = await getPlatform().readFile(filePath);
117
123
  setSkipServerFunctions(true);
124
+ const assets = assetContextFor(filePath, hostAssetDeclarations());
118
125
  await loadFormats();
119
126
  let document_: JxDocument;
120
127
  if (formatForPath(filePath)) {
@@ -124,8 +131,17 @@ export async function renderDocPreview(filePath: string): Promise<HTMLElement |
124
131
  document_ = JSON.parse(content) as JxDocument;
125
132
  }
126
133
  const scope = await buildScope(document_, {}, location.href);
127
- const element = renderNode(document_, scope);
128
- return element instanceof HTMLElement ? element : null;
134
+ /* This render lands in the PARENT document, so an `<img src="/hero.jpg">` inside it resolves
135
+ against `index.html` — which is the shell, not the site. Same hook the canvas installs, same
136
+ context, restored afterwards because the next preview is a different document and a stale
137
+ `documentDir` would resolve its relative refs against the wrong entry. */
138
+ setCanvasAssetResolver((value) => resolveAssetRef(value, assets));
139
+ try {
140
+ const element = renderNode(document_, scope);
141
+ return element instanceof HTMLElement ? element : null;
142
+ } finally {
143
+ setCanvasAssetResolver(null);
144
+ }
129
145
  } catch {
130
146
  return null;
131
147
  }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Asset references, resolved for the canvas — the half that asks the running editor.
3
+ *
4
+ * The resolution math itself is {@link file://./asset-resolve.ts}, which is pure and which the
5
+ * canvas iframe imports. This module is everything that has to look something up: the open
6
+ * project's content sections and locales, the active tab, the registered platform's declarations.
7
+ *
8
+ * The split is by REALM. The iframe bundle is deliberately dependency-light, and reaching
9
+ * `projectState` or `getPlatform()` from it drags the whole editor shell in behind it — which is
10
+ * what `check-studio-dist` caught the first time these lived in one file.
11
+ *
12
+ * @docs studio/projects/media
13
+ */
14
+
15
+ import { resolveI18n } from "@jxsuite/schema/locale";
16
+ import { BUILD_LANES, dirOfPath } from "@jxsuite/schema/asset-paths";
17
+ import { contentMountFor, resolveAssetRef } from "./asset-resolve";
18
+ import { documentBase, loopbackAssetSrc } from "./canvas-origin";
19
+ import { getPlatform, hasPlatform } from "../platform";
20
+ import { projectState } from "../store";
21
+ import { activeTab } from "../workspace/workspace";
22
+ import type { AssetContext } from "./asset-resolve";
23
+ import type { ContentSectionEntry } from "../types";
24
+
25
+ /* Re-exported so callers that want both halves import one module. The pure ones are defined next
26
+ door precisely so the iframe can reach them WITHOUT reaching any of the above. */
27
+ export { contentMountFor, mountedRefFor, resolveAssetRef } from "./asset-resolve";
28
+ export type { AssetContext } from "./asset-resolve";
29
+
30
+ /**
31
+ * The open project's canonical locale tags, or `[]` when it declares none.
32
+ *
33
+ * Through `resolveI18n` rather than off the raw config, so `EN-us` means the same thing here as it
34
+ * does in the compiler and the content loader — the whole reason that resolver is in
35
+ * `@jxsuite/schema`.
36
+ */
37
+ export function projectLocales(): string[] {
38
+ return resolveI18n(projectState?.projectConfig ?? {}).i18n?.locales ?? [];
39
+ }
40
+
41
+ /**
42
+ * The context for a document in the project currently open, or null when nothing applies.
43
+ *
44
+ * @param {string | null | undefined} documentPath - Project-relative path of the open document
45
+ * @param {object} [over] - Host declarations: the asset space and the project-file base URL
46
+ * @returns {AssetContext | null} A context, or null when no resolution is needed
47
+ */
48
+ export function assetContextFor(
49
+ documentPath: string | null | undefined,
50
+ over?: { space?: "site" | "repo" | undefined; fileBaseUrl?: string | undefined },
51
+ ): AssetContext | null {
52
+ const space = over?.space ?? "site";
53
+ const mount = contentMountFor(
54
+ documentPath,
55
+ projectState?.projectConfig?.content as Record<string, ContentSectionEntry> | undefined,
56
+ projectLocales(),
57
+ );
58
+ /* In site space the mount is the ONLY thing that needs doing, so no mount means no work. In repo
59
+ space every reference needs rebasing, mount or not. */
60
+ if (space === "site" && !mount) {
61
+ return null;
62
+ }
63
+ return {
64
+ documentDir: dirOfPath(documentPath ?? ""),
65
+ ...(over?.fileBaseUrl === undefined ? {} : { fileBaseUrl: over.fileBaseUrl }),
66
+ lanes: BUILD_LANES,
67
+ mounts: mount ? [mount] : [],
68
+ space,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * The src a PARENT-REALM preview should load for an AUTHORED value — a media-picker thumbnail, the
74
+ * social card in the SEO modal.
75
+ *
76
+ * The canvas resolves its own references; panel chrome renders in the parent document, where a
77
+ * content-relative value would resolve against `index.html` and 404 exactly as it did in the
78
+ * canvas. Same mapping, same rule — resolved against the ACTIVE tab, which is the entry whose field
79
+ * is being edited.
80
+ *
81
+ * Takes what the author WROTE. For a project FILE PATH — a library thumbnail, a media-browser tile
82
+ * — use `previewFileSrc` in `files/media-paths`; the two are separate because a file path is not an
83
+ * authored reference and confusing them is silent.
84
+ *
85
+ * The loopback absolutization is folded in rather than left to the caller: every one of the four
86
+ * call sites needs both steps, and one that composed them by hand would work everywhere except the
87
+ * desktop shell, where a relative src resolves against `views://`.
88
+ *
89
+ * @param {string} value - The reference as the author wrote it
90
+ * @returns {string} A src the parent realm can load
91
+ */
92
+ export function previewAssetSrc(value: string): string {
93
+ if (!value) {
94
+ return value;
95
+ }
96
+ const ctx = assetContextFor(activeTab.value?.documentPath, hostAssetDeclarations());
97
+ return loopbackAssetSrc(resolveAssetRef(value, ctx) ?? value);
98
+ }
99
+
100
+ /**
101
+ * What the registered host says about media — the platform's `assetSpace`, and the base project
102
+ * files are served under.
103
+ *
104
+ * Read through the platform rather than passed in, because every caller is panel chrome with no
105
+ * access to the render pipeline and because the answer is a property of the HOST, not of the call.
106
+ * The canvas pipeline builds the same two values from the same two places; the difference is only
107
+ * that it already has the document in hand.
108
+ *
109
+ * @returns {{ space?: "site" | "repo"; fileBaseUrl: string }} The host's declarations
110
+ */
111
+ export function hostAssetDeclarations(): {
112
+ space?: "site" | "repo" | undefined;
113
+ fileBaseUrl: string;
114
+ } {
115
+ return {
116
+ fileBaseUrl: documentBase(projectState?.projectRoot),
117
+ ...(hasPlatform() ? { space: getPlatform().assetSpace } : {}),
118
+ };
119
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Asset references, resolved for the canvas — the PURE half.
3
+ *
4
+ * Separate from `asset-refs.ts` for one reason, and it is a hard one: this module runs **inside the
5
+ * canvas iframe**, which is a deliberately dependency-light bundle. `asset-refs.ts` reaches the
6
+ * open project, the active tab and the registered platform, and importing any of that from the
7
+ * iframe drags the entire editor shell in behind it. The build gate caught exactly that.
8
+ *
9
+ * So the split is by REALM, not by taste: everything here is a pure function of its arguments, and
10
+ * everything that has to ask the running editor a question lives next door.
11
+ *
12
+ * A document names media in one of two shapes, and neither is a URL the canvas can use as written.
13
+ *
14
+ * A content entry references media relative to ITSELF — `![](./images/hero.png)` — so the file
15
+ * reads correctly in a plain markdown editor and so collections sourced outside the project root
16
+ * still reach their media (specs/site-architecture.md §9.3). On the built site the collection
17
+ * loader rewrites those refs onto the content type's asset mount,
18
+ * `/content/<type>/images/hero.png`. Studio opens an entry as a STANDALONE document, so that loader
19
+ * never runs.
20
+ *
21
+ * A page references media by SITE URL — `/hero.jpg` — which is a file the project tree calls
22
+ * `public/hero.jpg`. That works wherever the canvas is hosted by something that serves the site's
23
+ * own URL space, which is what the dev server and the desktop loopback both are.
24
+ *
25
+ * {@link AssetContext} is the host's answer to "which of those two do you serve", and
26
+ * {@link resolveAssetRef} is the whole of the resolution:
27
+ *
28
+ * - `"site"` — the origin already answers site URLs, so only the content-mount mapping is missing.
29
+ * This is what desktop and `jx dev` have always done, and the default when a host says nothing.
30
+ * - `"repo"` — nothing answers a site URL, and the host serves PROJECT PATHS under a base instead.
31
+ * Every reference resolves to the file it names and is rebased onto that base. The mount detour
32
+ * disappears: a content entry's `./images/hero.png` is already `content/posts/images/hero.png`, a
33
+ * real path the host can serve.
34
+ *
35
+ * The SOURCE document is never touched in either case: the authored ref is what gets serialized
36
+ * back to disk, and the properties panel shows the author what they wrote.
37
+ *
38
+ * One deliberate divergence from the content loader: it declines to rewrite a ref whose target does
39
+ * not exist (`existsSync`), leaving an unresolvable path alone rather than changing its meaning.
40
+ * The browser has no filesystem, so this resolves optimistically. A missing file is broken either
41
+ * way; pointing at the URL the built site would have attempted is the more informative failure.
42
+ *
43
+ * @docs studio/projects/media
44
+ */
45
+
46
+ import {
47
+ assetUrlFor,
48
+ encodeProjectPath,
49
+ isNonRelativeRef,
50
+ joinProjectPath,
51
+ projectPathForRef,
52
+ splitRefSuffix,
53
+ } from "@jxsuite/schema/asset-paths";
54
+ import type { AssetLane, AssetMount } from "@jxsuite/schema/asset-paths";
55
+ import type { ContentSectionEntry } from "../types";
56
+
57
+ /** Content type names safe in a URL path segment. Mirrors `SAFE_TYPE_NAME` in the content loader. */
58
+ const SAFE_TYPE_NAME = /^[\w.~-]+$/;
59
+
60
+ /** The project.json section key content types live under; also their URL namespace. */
61
+ const SECTION_KEY = "content";
62
+
63
+ /**
64
+ * The placeholder a content type's `source` may carry to say "one directory per locale".
65
+ *
66
+ * Held here rather than imported out of the parser extension's content loader ({@link
67
+ * file://../../../../extensions/parser/src/content-loader.ts}): that module reads the filesystem
68
+ * and this one runs in a browser. It is also an extension, and core may not import one.
69
+ */
70
+ const LOCALE_PLACEHOLDER = "{locale}";
71
+
72
+ /**
73
+ * Everything the canvas needs to resolve one reference, as PLAIN DATA.
74
+ *
75
+ * Plain data because it has to cross a realm. The resolver itself is a function and cannot be
76
+ * posted into the canvas iframe, so the parent posts this beside the render document and the iframe
77
+ * builds the resolver from it locally.
78
+ */
79
+ export interface AssetContext {
80
+ /** What the canvas ORIGIN answers for a site URL — the platform's `assetSpace`, defaulted. */
81
+ space: "site" | "repo";
82
+ /**
83
+ * Base URL under which the host serves PROJECT PATHS. Required by `"repo"` and inert without it:
84
+ * a host that declares repo space and no base has told us its site URLs are wrong without telling
85
+ * us what is right, and inventing an answer there would be worse than leaving the ref alone.
86
+ */
87
+ fileBaseUrl?: string | undefined;
88
+ /** Project-relative directory of the document being rendered (`""` at the project root). */
89
+ documentDir: string;
90
+ /** Project-relative asset mounts that apply to this document. */
91
+ mounts: AssetMount[];
92
+ /**
93
+ * The resolution order for a site-absolute URL.
94
+ *
95
+ * `BUILD_LANES` by default, and that is the deliberate answer rather than an omission: with no
96
+ * filesystem to probe, a lane list has to collapse to ONE candidate, and the one that makes the
97
+ * preview agree with the deployed site is the build's. See `DEV_SERVER_LANES` for the order the
98
+ * editing servers actually use, and why the two differ.
99
+ */
100
+ lanes: readonly AssetLane[];
101
+ }
102
+
103
+ /**
104
+ * The asset mount for the content type that owns `documentPath`, or null when the document is not a
105
+ * directory-backed content entry.
106
+ *
107
+ * Only in-project sources can match: an entry Studio can address project-relatively is by
108
+ * definition inside the project, and a source pointing elsewhere (`../../docs`) names files the
109
+ * file tree cannot reach in the first place.
110
+ *
111
+ * @param {string | null | undefined} documentPath - Project-relative path of the open document
112
+ * @param {Record<string, ContentSectionEntry> | null | undefined} content - Project.json `content`
113
+ * @param {readonly string[]} [locales] - Canonical locale tags, for `{locale}` sources
114
+ * @returns {AssetMount | null} The mount, project-relative
115
+ */
116
+ export function contentMountFor(
117
+ documentPath: string | null | undefined,
118
+ content: Record<string, ContentSectionEntry> | null | undefined,
119
+ locales: readonly string[] = [],
120
+ ): AssetMount | null {
121
+ if (!documentPath || !content) {
122
+ return null;
123
+ }
124
+ const path = documentPath.replaceAll("\\", "/").replace(/^\.\//, "");
125
+ let best: AssetMount | null = null;
126
+ const consider = (dir: string, urlPrefix: string): void => {
127
+ if (path.startsWith(`${dir}/`) && (!best || dir.length > best.dir.length)) {
128
+ best = { dir, urlPrefix };
129
+ }
130
+ };
131
+ for (const [name, def] of Object.entries(content)) {
132
+ if (!def?.source || !SAFE_TYPE_NAME.test(name)) {
133
+ continue;
134
+ }
135
+ const dir = normalizeSource(def.source);
136
+ // A single-file source is not a collection directory — its siblings are not its assets.
137
+ if (dir === "" || dir.startsWith("..")) {
138
+ continue;
139
+ }
140
+ if (dir.includes(LOCALE_PLACEHOLDER)) {
141
+ const mount = localeMountFor(path, dir, name, locales);
142
+ if (mount) {
143
+ consider(mount.dir, mount.urlPrefix);
144
+ }
145
+ continue;
146
+ }
147
+ if (/\.[a-z0-9]+$/i.test(dir)) {
148
+ continue;
149
+ }
150
+ consider(dir, `/${SECTION_KEY}/${name}`);
151
+ }
152
+ return best;
153
+ }
154
+
155
+ /**
156
+ * The mount for ONE locale of a `{locale}` source — the locale being whichever one `path` is in.
157
+ *
158
+ * A localized content type is N directories, not N content types, and each publishes separately at
159
+ * `/content/<type>/<locale>` so a French post's `./hero.png` and its English translation's cannot
160
+ * collide at one URL. The content loader picks the directory by probing the disk (`localeSource`);
161
+ * Studio has no disk, so it reads the locale out of the document path it was given and checks that
162
+ * against the project's declared tags. That is not a weaker test — the path IS the file, so the
163
+ * only question left is whether its directory is a locale the project declares.
164
+ *
165
+ * Matched case-insensitively, for the same reason the loader does: two writers name that directory
166
+ * and they differ. A project that declared `fr-CA` most likely typed `fr-CA/`, while Studio creates
167
+ * `fr-ca/` because a page directory becomes a URL and the site's URLs are lowercase. The mount's
168
+ * `dir` is the spelling on disk and its `urlPrefix` is the CANONICAL tag, exactly as the loader
169
+ * publishes it.
170
+ */
171
+ function localeMountFor(
172
+ path: string,
173
+ source: string,
174
+ name: string,
175
+ locales: readonly string[],
176
+ ): AssetMount | null {
177
+ const cut = source.indexOf(LOCALE_PLACEHOLDER);
178
+ const head = source.slice(0, cut);
179
+ const tail = source.slice(cut + LOCALE_PLACEHOLDER.length);
180
+ if (!path.startsWith(head)) {
181
+ return null;
182
+ }
183
+ const segment = path.slice(head.length).split("/")[0] ?? "";
184
+ if (segment === "" || !SAFE_TYPE_NAME.test(segment)) {
185
+ return null;
186
+ }
187
+ const canonical = locales.find((tag) => tag.toLowerCase() === segment.toLowerCase());
188
+ if (canonical === undefined || !SAFE_TYPE_NAME.test(canonical)) {
189
+ return null;
190
+ }
191
+ const dir = `${head}${segment}${tail}`.replace(/\/+$/, "");
192
+ return { dir, urlPrefix: `/${SECTION_KEY}/${name}/${canonical}` };
193
+ }
194
+
195
+ /** Strip `./` and any trailing slash from a configured `source`, the way collectionDirs does. */
196
+ function normalizeSource(source: string): string {
197
+ return source.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "");
198
+ }
199
+
200
+ /**
201
+ * The URL the canvas should load for one authored reference, or null to leave it as written.
202
+ *
203
+ * Null is not a failure — it is most of the answers. An absolute URL, a `data:` URI, a `${…}`
204
+ * template that did not resolve to a project file, a `../` escape out of the collection: each one
205
+ * already means what it says, and changing it would be the bug.
206
+ *
207
+ * @param {string} value - The reference as the author wrote it
208
+ * @param {AssetContext | null} ctx - The host's asset context, or null for no resolution at all
209
+ * @returns {string | null} The URL to load, or null to leave `value` untouched
210
+ */
211
+ export function resolveAssetRef(value: string, ctx: AssetContext | null): string | null {
212
+ if (!ctx || value === "") {
213
+ return null;
214
+ }
215
+ if (ctx.space === "repo") {
216
+ if (!ctx.fileBaseUrl) {
217
+ return null;
218
+ }
219
+ const path = projectPathForRef(value, ctx.documentDir, ctx.mounts, ctx.lanes);
220
+ if (path === null) {
221
+ return null;
222
+ }
223
+ const { suffix } = splitRefSuffix(value);
224
+ return `${ctx.fileBaseUrl}${encodeProjectPath(path)}${suffix}`;
225
+ }
226
+ const [mount] = ctx.mounts;
227
+ return mount ? mountedRefFor(value, ctx.documentDir, mount) : null;
228
+ }
229
+
230
+ /**
231
+ * The mounted URL for one content-relative reference, or null to leave it as written. A ref is
232
+ * rewritten only when it is relative and lands inside the collection directory — anything else (an
233
+ * absolute URL, a project-root path, a bound template, a `../` escape out of the collection) keeps
234
+ * the meaning it already had.
235
+ */
236
+ export function mountedRefFor(value: string, entryDir: string, mount: AssetMount): string | null {
237
+ if (isNonRelativeRef(value)) {
238
+ return null;
239
+ }
240
+ const { path: bare, suffix } = splitRefSuffix(value);
241
+ let decoded: string;
242
+ try {
243
+ decoded = decodeURIComponent(bare);
244
+ } catch {
245
+ return null;
246
+ }
247
+ const target = joinProjectPath(entryDir, decoded);
248
+ if (target === null) {
249
+ return null;
250
+ }
251
+ const url = assetUrlFor([mount], target);
252
+ if (!url) {
253
+ return null; // Outside the collection — not the mount's to publish.
254
+ }
255
+ return `${url}${suffix}`;
256
+ }
@@ -20,6 +20,7 @@ import {
20
20
  resolveLayoutDoc,
21
21
  } from "../site-context";
22
22
  import { documentBase } from "./canvas-origin";
23
+ import { getPlatform, hasPlatform } from "../platform";
23
24
  import { componentRegistry, computeRelativePath } from "../files/components";
24
25
  import { prepareForEditMode } from "../utils/edit-display";
25
26
  import {
@@ -28,11 +29,11 @@ import {
28
29
  substitutePreviewParams,
29
30
  } from "../page-params";
30
31
  import { isComponentDoc, substitutePreviewProps } from "../component-props";
31
- import { rewriteContentAssets } from "./content-assets";
32
+ import { assetContextFor } from "./asset-refs";
32
33
 
34
+ import type { AssetContext } from "./asset-refs";
33
35
  import type { JxElement, JxMutableNode, JxPath } from "@jxsuite/schema/types";
34
36
  import type { ComponentEntry } from "../files/components.js";
35
- import type { ContentSectionEntry } from "../types";
36
37
  import type { LayoutMarker } from "./path-mapping";
37
38
  import type { Tab } from "../tabs/tab";
38
39
  import type { WireMapperCtx } from "./iframe-protocol";
@@ -138,6 +139,8 @@ export async function resolveCanvasDocument(
138
139
  ): Promise<{
139
140
  renderDoc: JxMutableNode;
140
141
  docBase: string | undefined;
142
+ /** How the canvas must address project media, or null when its origin serves the site URL space. */
143
+ assets: AssetContext | null;
141
144
  mapperCtx: WireMapperCtx;
142
145
  siteStyle: Record<string, unknown> | null;
143
146
  }> {
@@ -179,9 +182,23 @@ export async function resolveCanvasDocument(
179
182
  }
180
183
  }
181
184
 
182
- const docBase = S.documentPath
183
- ? new URL(S.documentPath, documentBase(projectState?.projectRoot)).href
184
- : undefined;
185
+ const fileBase = documentBase(projectState?.projectRoot);
186
+ const docBase = S.documentPath ? new URL(S.documentPath, fileBase).href : undefined;
187
+ /*
188
+ * How this render addresses media, as PLAIN DATA — the resolver itself is a function and cannot
189
+ * cross into the canvas realm, so the iframe rebuilds it from this.
190
+ *
191
+ * It replaces a walk over the render document that used to sit further down this function. A walk
192
+ * can only ever see LITERAL values: `applyAttributes` resolves a `{"$ref": …}` or `"${…}"` src
193
+ * inside a reactive effect, so at walk time a bound image src is not a string at all. That is why
194
+ * a collection listing of thirty bound card images broke all at once in preview and nowhere else
195
+ * — design and edit swap every bound src for a transparent pixel, so they never reached the
196
+ * network to fail.
197
+ */
198
+ const assets = assetContextFor(S.documentPath, {
199
+ fileBaseUrl: fileBase,
200
+ ...(hasPlatform() ? { space: getPlatform().assetSpace } : {}),
201
+ });
185
202
 
186
203
  // Substitute chosen dynamic route params ({$ref: "#/$params/x"} → literal), inject state.$page,
187
204
  // And bake the substituted class-prototype state entries via the backend resolver — in every
@@ -210,16 +227,6 @@ export async function resolveCanvasDocument(
210
227
  }
211
228
  }
212
229
 
213
- // A content entry references its media relative to ITSELF; the built site serves those files from
214
- // The content type's asset mount. Studio opens the entry standalone, so the collection loader that
215
- // Normally performs that mapping never runs — do it here, on the RENDER doc only, so the canvas
216
- // Previews the URL production serves while the source doc keeps the authored relative ref.
217
- renderDoc = rewriteContentAssets(
218
- renderDoc,
219
- S.documentPath,
220
- projectState?.projectConfig?.content as Record<string, ContentSectionEntry> | undefined,
221
- );
222
-
223
230
  const arrayPaths = new Set<string>();
224
231
  if (canvasMode === "design" || canvasMode === "edit") {
225
232
  (function findArrayPaths(node: JxMutableNode, path: (string | number)[]) {
@@ -321,6 +328,7 @@ export async function resolveCanvasDocument(
321
328
  }
322
329
 
323
330
  return {
331
+ assets,
324
332
  docBase,
325
333
  mapperCtx: {
326
334
  arrayPaths: [...arrayPaths],
@@ -859,6 +859,7 @@ export function startCanvasIframe(opts: {
859
859
  stopDataScopeWatch = null;
860
860
  handle?.dispose();
861
861
  handle = await renderResolvedDocument({
862
+ assets: msg.assets ?? null,
862
863
  container,
863
864
  doc: msg.doc as JxDocument,
864
865
  docBase: msg.docBase,
@@ -2775,6 +2775,7 @@ function preparePassRender(
2775
2775
  const formatElements = formatByName(renderTab?.doc.sourceFormat)?.studio?.elements;
2776
2776
  const editableTags = formatElements ? formatEditableVerdicts(formatElements) : undefined;
2777
2777
  return {
2778
+ assets: resolved.assets,
2778
2779
  doc: cloneableDoc,
2779
2780
  docBase: resolved.docBase ?? `${canvasBaseOrigin()}/`,
2780
2781
  mapperCtx: resolved.mapperCtx,
@@ -2898,6 +2899,9 @@ export function mountStylebookCanvas(
2898
2899
  const message: ParentToIframe = {
2899
2900
  // The specimen carries no tab identity, but the STAGE it is mounted into does: a stylebook is
2900
2901
  // Opened as a tab like anything else, and its `previewColorScheme` is that tab's.
2902
+ /* A stylebook specimen is generated, not a project document, so it references no project
2903
+ media and needs no resolution. */
2904
+ assets: null,
2901
2905
  colorScheme: schemeWireFor(tabOfContainer(canvasEl)),
2902
2906
  doc: cloneableDoc,
2903
2907
  docBase: `${canvasBaseOrigin()}/`,
@@ -9,6 +9,7 @@
9
9
  * the cross-frame analog of the legacy renderer's `renderGeneration` staleness guard.
10
10
  */
11
11
 
12
+ import type { AssetContext } from "./asset-resolve";
12
13
  import type { JxDocOp } from "../tabs/patch-ops";
13
14
  import type { JxContentResult, SlashCommand } from "../editor/inline-edit";
14
15
  import type { JxExpressionNode, JxMutableNode } from "@jxsuite/schema/types";
@@ -88,6 +89,14 @@ export type ParentToIframe =
88
89
  */
89
90
  allowAutoRequests?: boolean;
90
91
  docBase: string;
92
+ /**
93
+ * How this host addresses project media, or null/absent when the canvas origin serves the
94
+ * site's own URL space (desktop, `jx dev`) and no reference needs resolving.
95
+ *
96
+ * Plain data because the resolver is a FUNCTION and functions do not cross a realm: the
97
+ * iframe rebuilds the resolver from this and installs it on the runtime.
98
+ */
99
+ assets?: AssetContext | null;
91
100
  mapperCtx: WireMapperCtx;
92
101
  siteStyle: Record<string, unknown> | null;
93
102
  // Forced color-scheme preview (spec §9.5): "light"/"dark" sets data-color-scheme on the