@jxsuite/studio 2.4.3 → 4.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 (208) hide show
  1. package/README.md +5 -5
  2. package/dist/chunks/Overlay.dev-723bv0df.js +23 -0
  3. package/dist/chunks/cf-settings-45fgyx8y.js +21 -0
  4. package/dist/chunks/{components-z6cnkyy1.js → components-zmx10t62.js} +6 -5
  5. package/dist/chunks/content-source-t88vb2dr.js +49 -0
  6. package/dist/chunks/{convert-to-repeater-k10dvcc7.js → convert-to-repeater-dhw1nrz9.js} +27 -18
  7. package/dist/chunks/entry-commands-6z6bhxg0.js +46 -0
  8. package/dist/chunks/{extension-sections-0ada0xcw.js → extension-sections-3ydgjbdy.js} +32 -23
  9. package/dist/chunks/file-ops-rskmhxyq.js +48 -0
  10. package/dist/chunks/files-0cratsr0.js +92 -0
  11. package/dist/chunks/{format-host-wewba4nq.js → format-host-tgcmfs4p.js} +3 -3
  12. package/dist/chunks/fs-events-hrrg328g.js +21 -0
  13. package/dist/chunks/{git-panel-2dh1aw50.js → git-panel-0yywyk76.js} +21 -15
  14. package/dist/chunks/{jx-validate-jj9rnt4n.js → jx-validate-8q2g05x1.js} +6 -4
  15. package/dist/chunks/layers-panel-z3tqe5cd.js +67 -0
  16. package/dist/chunks/layers-q75tj2mf.js +45 -0
  17. package/dist/chunks/lit-html-j055mkh8.js +22 -0
  18. package/dist/chunks/monaco-buffer-dqw0xxpx.js +25 -0
  19. package/dist/chunks/{monaco-setup-a5wkmbgh.js → monaco-setup-rws7k4br.js} +4 -4
  20. package/dist/chunks/notify-yjm79n4s.js +30 -0
  21. package/dist/chunks/parse-24d0gay3.js +19 -0
  22. package/dist/chunks/platform-ba1j1gbh.js +16 -0
  23. package/dist/chunks/publish-panel-54jvhskq.js +28 -0
  24. package/dist/chunks/quick-search-cgfmjwq5.js +65 -0
  25. package/dist/chunks/references-3qx8fkyp.js +29 -0
  26. package/dist/chunks/right-panel-cqp8bc4v.js +46 -0
  27. package/dist/chunks/sp-overlay.dev-yztmtrs1.js +18 -0
  28. package/dist/chunks/sp-popover.dev-n2etmqdp.js +9 -0
  29. package/dist/chunks/sp-progress-circle.dev-hryhj0es.js +15 -0
  30. package/dist/chunks/sp-tooltip.dev-xcvpnqr0.js +15 -0
  31. package/dist/chunks/{sp-tray.dev-64v4pxw8.js → sp-tray.dev-tkbscm06.js} +5 -5
  32. package/dist/chunks/{studio-f5yyvqxv.js → studio-1hbqnt19.js} +37 -31
  33. package/dist/chunks/studio-2zkz8evn.js +3163 -0
  34. package/dist/chunks/{studio-0b6fs05q.js → studio-4pqqxbrt.js} +23 -15
  35. package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
  36. package/dist/chunks/{studio-ef8t53vc.js → studio-5q69gfag.js} +60 -56
  37. package/dist/chunks/{studio-v2c5pmjj.js → studio-5rywrkxz.js} +565 -133
  38. package/dist/chunks/{studio-6kv3apa9.js → studio-68n67094.js} +25 -9
  39. package/dist/chunks/{studio-0rqqhrdy.js → studio-7nte2hj4.js} +2 -2
  40. package/dist/chunks/{studio-hyn3vhwr.js → studio-8swrjbny.js} +31 -18
  41. package/dist/chunks/studio-92ab2jqe.js +282 -0
  42. package/dist/chunks/{studio-sas7mt3k.js → studio-9ab8gqsm.js} +2 -2
  43. package/dist/chunks/{studio-28xc8ws4.js → studio-9c4k4vhv.js} +14 -10
  44. package/dist/chunks/studio-9n4mrmnb.js +184 -0
  45. package/dist/chunks/{studio-tr20m8g8.js → studio-9r6j5ev1.js} +37 -2
  46. package/dist/chunks/{studio-aweyr4v1.js → studio-ahjfdy6e.js} +9 -7
  47. package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
  48. package/dist/chunks/{studio-26sv44br.js → studio-cb12yz9w.js} +8543 -8784
  49. package/dist/chunks/studio-cft9r4r7.js +119 -0
  50. package/dist/chunks/{studio-rqemfhz6.js → studio-eenfkt4e.js} +6 -6
  51. package/dist/chunks/studio-eesa1myv.js +498 -0
  52. package/dist/chunks/{studio-ay8wcr2b.js → studio-eqq1w638.js} +7 -9
  53. package/dist/chunks/{studio-ke244pam.js → studio-ex8zyp98.js} +51 -25
  54. package/dist/chunks/studio-gv8vvshc.js +118 -0
  55. package/dist/chunks/studio-hbtp0eh1.js +1315 -0
  56. package/dist/chunks/{studio-cz2j1c0g.js → studio-htsf0w6m.js} +75 -123
  57. package/dist/chunks/{studio-pyrw8h0w.js → studio-kjjsjak4.js} +528 -212
  58. package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
  59. package/dist/chunks/studio-kyh9avvv.js +28 -0
  60. package/dist/chunks/{studio-jnakhg87.js → studio-kyk2v92a.js} +14 -14
  61. package/dist/chunks/{studio-twsafr1e.js → studio-m3hwn7y1.js} +3 -3
  62. package/dist/chunks/{studio-tzx3xmvx.js → studio-mmv72a27.js} +8 -8
  63. package/dist/chunks/{studio-vhk5fmfv.js → studio-n8b9wqwy.js} +5 -5
  64. package/dist/chunks/{studio-cjm7fdjf.js → studio-p4ngc046.js} +8 -104
  65. package/dist/chunks/{studio-sccge2wf.js → studio-p8j86wag.js} +2 -2
  66. package/dist/chunks/{studio-v1va0vhd.js → studio-pdfjntbk.js} +75 -139
  67. package/dist/chunks/studio-q43a5y9h.js +394 -0
  68. package/dist/chunks/studio-q7v10cg2.js +106 -0
  69. package/dist/chunks/{studio-2w11grav.js → studio-qq6jym04.js} +3 -3
  70. package/dist/chunks/studio-r1r7afxx.js +1066 -0
  71. package/dist/chunks/{studio-3fk1ac9z.js → studio-rdx8jcxy.js} +4 -4
  72. package/dist/chunks/{studio-4mqb9a0j.js → studio-rn9ta2mz.js} +4 -4
  73. package/dist/chunks/studio-sf7zjgv1.js +91 -0
  74. package/dist/chunks/studio-t7qb9db8.js +665 -0
  75. package/dist/chunks/studio-tmc5emrw.js +8 -0
  76. package/dist/chunks/{studio-wdt9y92a.js → studio-txwkm6p9.js} +2 -2
  77. package/dist/chunks/{studio-dgj7gq44.js → studio-v38hmvx1.js} +7 -7
  78. package/dist/chunks/{studio-7ejemhej.js → studio-vadzgkrz.js} +6 -6
  79. package/dist/chunks/studio-wcy8trfx.js +263 -0
  80. package/dist/chunks/{studio-hb7c46jy.js → studio-wx5609xd.js} +21 -15
  81. package/dist/chunks/studio-xrapxwwc.js +1539 -0
  82. package/dist/chunks/{studio-ws6ef6js.js → studio-yxrkyv8a.js} +41 -29
  83. package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
  84. package/dist/chunks/workspace-xpres40v.js +89 -0
  85. package/dist/iframe-entry.js +450 -48
  86. package/dist/studio.js +2455 -1761
  87. package/package.json +16 -15
  88. package/src/browse/library-layouts.ts +3 -3
  89. package/src/browse/library-pane.ts +59 -5
  90. package/src/browse/library-preview.ts +19 -3
  91. package/src/canvas/asset-refs.ts +119 -0
  92. package/src/canvas/asset-resolve.ts +256 -0
  93. package/src/canvas/canvas-live-render.ts +23 -15
  94. package/src/canvas/canvas-render.ts +23 -1
  95. package/src/canvas/iframe-entry.ts +1 -0
  96. package/src/canvas/iframe-host.ts +4 -0
  97. package/src/canvas/iframe-patch.ts +47 -21
  98. package/src/canvas/iframe-protocol.ts +9 -0
  99. package/src/canvas/iframe-render.ts +50 -16
  100. package/src/commands/app-commands.ts +4 -0
  101. package/src/commands/command-args.ts +29 -0
  102. package/src/commands/context.ts +15 -1
  103. package/src/commands/defaults.ts +29 -1
  104. package/src/commands/levels.ts +16 -0
  105. package/src/commands/live-context.ts +3 -0
  106. package/src/content/collection-match.ts +267 -0
  107. package/src/content/entry-commands.ts +38 -29
  108. package/src/content/entry-fields.ts +6 -0
  109. package/src/editor/file-drop-action.ts +2 -1
  110. package/src/editor/shortcuts.ts +5 -0
  111. package/src/files/file-ops.ts +10 -42
  112. package/src/files/files.ts +487 -61
  113. package/src/files/fs-events.ts +9 -0
  114. package/src/files/gitignore.ts +432 -0
  115. package/src/files/media-paths.ts +37 -14
  116. package/src/files/media-upload.ts +47 -6
  117. package/src/files/serialize-document.ts +60 -0
  118. package/src/format/convert-file.ts +511 -0
  119. package/src/format/format-choices.ts +199 -0
  120. package/src/grid/grid-idle.ts +45 -0
  121. package/src/grid/grid-view.ts +35 -0
  122. package/src/grid/schema-columns.ts +2 -1
  123. package/src/grid/sources/content-source.ts +24 -3
  124. package/src/media/media-open.ts +58 -0
  125. package/src/media/media-pane.ts +310 -0
  126. package/src/new-project/import-tab.ts +333 -160
  127. package/src/new-project/new-project-modal.ts +27 -34
  128. package/src/packages/ensure-deps.ts +14 -7
  129. package/src/packages/jxsuite-update.ts +11 -11
  130. package/src/page-params.ts +10 -46
  131. package/src/panels/activity-bar.ts +45 -16
  132. package/src/panels/ai-chat/attached-context.ts +6 -1
  133. package/src/panels/ai-chat/chat-view.ts +255 -13
  134. package/src/panels/ai-chat/composer.ts +21 -80
  135. package/src/panels/ai-panel.ts +116 -2
  136. package/src/panels/frontmatter-fields.ts +6 -2
  137. package/src/panels/navigator-panels.ts +7 -6
  138. package/src/panels/properties-panel.ts +8 -2
  139. package/src/panels/seo-modal.ts +5 -6
  140. package/src/panels/settings-menu.ts +799 -0
  141. package/src/panels/signals-panel.ts +2 -1
  142. package/src/panels/toolbar.ts +135 -15
  143. package/src/platforms/cloud.ts +34 -3
  144. package/src/platforms/devserver.ts +72 -34
  145. package/src/preview/preview-overlay.ts +226 -0
  146. package/src/services/ai-ask.ts +247 -0
  147. package/src/services/ai-import-tools.ts +494 -0
  148. package/src/services/ai-models.ts +101 -11
  149. package/src/services/ai-project-tools.ts +20 -26
  150. package/src/services/ai-settings.ts +69 -71
  151. package/src/services/ai-system-prompt.ts +88 -2
  152. package/src/services/ai-turn-signal.ts +70 -0
  153. package/src/services/cf-settings.ts +19 -37
  154. package/src/services/connection.ts +74 -0
  155. package/src/services/context-manager.ts +89 -0
  156. package/src/services/document-assistant.ts +49 -7
  157. package/src/services/idle.ts +6 -1
  158. package/src/services/import-client.ts +33 -6
  159. package/src/services/import-run.ts +158 -0
  160. package/src/services/import-seed.ts +120 -0
  161. package/src/services/jx-validate.ts +58 -6
  162. package/src/services/project-adoption.ts +71 -0
  163. package/src/services/references.ts +0 -0
  164. package/src/services/settings/definitions.ts +181 -0
  165. package/src/services/settings/kernel.ts +335 -0
  166. package/src/services/settings/write-queue.ts +116 -0
  167. package/src/services/tool-executor.ts +34 -2
  168. package/src/settings/contexts-section.ts +45 -3
  169. package/src/settings/dependencies-editor.ts +43 -23
  170. package/src/settings/general-settings.ts +30 -12
  171. package/src/settings/preferences-accounts.ts +6 -19
  172. package/src/settings/preferences-dialog.ts +18 -41
  173. package/src/settings/preferences-keymap.ts +8 -18
  174. package/src/settings/preferences-sections.ts +57 -0
  175. package/src/settings/schema-field-ui.ts +2 -2
  176. package/src/settings/settings-document.ts +65 -9
  177. package/src/shell.ts +6 -14
  178. package/src/studio.ts +65 -7
  179. package/src/tabs/transact.ts +42 -19
  180. package/src/types.ts +164 -8
  181. package/src/ui/ai-credentials-form.ts +52 -27
  182. package/src/ui/ai-model-picker.ts +162 -0
  183. package/src/ui/choice-field.ts +72 -0
  184. package/src/ui/layers.ts +83 -5
  185. package/src/ui/media-picker.ts +6 -11
  186. package/src/utils/studio-utils.ts +43 -31
  187. package/styles/forced-colors.css +7 -0
  188. package/styles/overlays.css +256 -0
  189. package/styles/panels.css +13 -1
  190. package/styles/shell.css +111 -0
  191. package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
  192. package/dist/chunks/cf-settings-g5fbf2zp.js +0 -17
  193. package/dist/chunks/content-source-k9724vcv.js +0 -39
  194. package/dist/chunks/files-yew27sn9.js +0 -69
  195. package/dist/chunks/layers-panel-qnsghwwd.js +0 -59
  196. package/dist/chunks/publish-panel-a0pdft5w.js +0 -21
  197. package/dist/chunks/quick-search-9avyc4pc.js +0 -54
  198. package/dist/chunks/right-panel-kcnhtc5j.js +0 -38
  199. package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
  200. package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
  201. package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
  202. package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
  203. package/dist/chunks/studio-a7pzveq1.js +0 -110
  204. package/dist/chunks/studio-c31yb4t6.js +0 -55
  205. package/dist/chunks/studio-ra7ar50f.js +0 -6682
  206. package/src/canvas/content-assets.ts +0 -263
  207. package/src/canvas/site-style-css.ts +0 -121
  208. package/src/services/settings-store.ts +0 -99
@@ -72,6 +72,8 @@ export interface LiveContextSources {
72
72
  * private, so there is nothing to read yet; the caller passes a probe when one exists.
73
73
  */
74
74
  aiStreaming?: () => boolean;
75
+ /** Whether a turn is suspended on an `ask_user` question (`panels/ai-panel.ts`). */
76
+ aiWaiting?: () => boolean;
75
77
  }
76
78
 
77
79
  /** `capability.*` is presence-of-method on the PAL — the check every call site does by hand today. */
@@ -187,6 +189,7 @@ export function createLiveContext(sources: LiveContextSources): () => CommandCon
187
189
 
188
190
  ctx.ai.configured = sources.aiConfigured();
189
191
  ctx.ai.streaming = sources.aiStreaming?.() ?? false;
192
+ ctx.ai.waiting = sources.aiWaiting?.() ?? false;
190
193
  ctx.capability = capabilities(sources.platform());
191
194
  return ctx;
192
195
  };
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Collection-match — the ONE answer to "which content collection owns this path".
3
+ *
4
+ * Three matchers already existed and disagreed: `utils/studio-utils.ts`'s `findContentTypeSchema`,
5
+ * `grid/sources/content-source.ts`'s `collectionInfo` and `browse/library-model.ts`'s
6
+ * `contentTypeFor`. Each was right about the question it was written for and wrong about the
7
+ * others', which was tolerable while the consumers were a form and a grid. It stopped being
8
+ * tolerable when the Files tree began asking, because a mismatch there does not draw the wrong
9
+ * field — it CONSTRAINS or REROUTES a creation, and both are wrong answers the author cannot see.
10
+ *
11
+ * Four things this states that no predecessor did:
12
+ *
13
+ * 1. **A locale segment is one of the project's DECLARED locales** wherever it declares any. The bare
14
+ * `[^/]+` wildcard binds `content/exhibitions/images` — the co-located media directory
15
+ * `site-architecture.md` §6.5 blesses — as if it were a locale root. The wildcard survives only
16
+ * as the fallback for a project that declares a `{locale}` source and no `i18n` section at all,
17
+ * where it is the sole thing that can match.
18
+ * 2. **`dir` is the directory that MATCHED**, not the declared `source`. `collectionInfo` hands back a
19
+ * literal `content/exhibitions/{locale}`, which is a path nobody has; a caller that creates a
20
+ * file there creates a directory named `{locale}`.
21
+ * 3. **The longest source wins**, not the first declared. With sources `./content/` and
22
+ * `./content/blog/`, a file in the latter belongs to the latter.
23
+ * 4. **An unregistered format is `null`, and says so.** `ext` falling back to `.json` when the
24
+ * declared format class is missing produces a locked picker asserting an extension the
25
+ * collection does not use — a stated lie plus an enforced refusal, which is worse than no
26
+ * constraint at all.
27
+ *
28
+ * Pure over `projectState.projectConfig` and the format registry, so every rule is assertable with
29
+ * no DOM and no platform.
30
+ */
31
+
32
+ import { defaultContentFormat, formatByName } from "../format/format-host";
33
+ import { projectState } from "../store";
34
+ import { resolveI18n } from "@jxsuite/schema/locale";
35
+ import type { ContentSectionEntry } from "../types";
36
+ import type { ProjectConfig } from "@jxsuite/schema/types";
37
+
38
+ /** The placeholder a localized `source` carries, standing for one directory per declared locale. */
39
+ export const LOCALE_PLACEHOLDER = "{locale}";
40
+
41
+ /** What a path's collection is, and everything a caller needs in order to act on it. */
42
+ export interface CollectionMatch {
43
+ name: string;
44
+ def: ContentSectionEntry;
45
+ /**
46
+ * The extension this collection's entries carry, or `null` when its declared `format` names a
47
+ * class the project has not registered. Never a silent `.json`.
48
+ */
49
+ ext: string | null;
50
+ /** The declared-but-missing format name behind a `null` `ext`, for the message that says so. */
51
+ unresolvedFormat: string | null;
52
+ /** The REAL directory that matched: `{locale}` substituted, a subdirectory preserved. */
53
+ dir: string;
54
+ /** True when `dir` is the collection's own source root rather than a directory beneath it. */
55
+ isSourceRoot: boolean;
56
+ /** True when `source` names a single file (a CSV catalogue) — its entries are rows, not files. */
57
+ fileBacked: boolean;
58
+ }
59
+
60
+ /** Escape a literal for embedding in a RegExp — `sites/jxsuite.com` ships `source: "../../docs"`. */
61
+ function escapeLiteral(part: string): string {
62
+ return part.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
63
+ }
64
+
65
+ /** Normalise a project-relative path: forward slashes, no `./` prefix, no trailing slash. */
66
+ function normalize(path: string): string {
67
+ return path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "");
68
+ }
69
+
70
+ /** The config a lookup reads: the caller's, or the open project's. */
71
+ function configOf(config: ProjectConfig | null | undefined): ProjectConfig | null {
72
+ return config ?? (projectState?.projectConfig as ProjectConfig | null | undefined) ?? null;
73
+ }
74
+
75
+ /** The `content` section of a config, or an empty one. */
76
+ function contentSection(config: ProjectConfig | null): Record<string, ContentSectionEntry> {
77
+ return (config?.content ?? {}) as Record<string, ContentSectionEntry>;
78
+ }
79
+
80
+ /**
81
+ * The locales a `{locale}` segment may be, canonicalised — or `null` when the project declares none
82
+ * and the segment must fall back to matching anything.
83
+ */
84
+ function declaredLocales(config: ProjectConfig | null): string[] | null {
85
+ const locales = resolveI18n(config ?? {}).i18n?.locales ?? [];
86
+ return locales.length > 0 ? [...locales] : null;
87
+ }
88
+
89
+ /**
90
+ * Whether `source` names a single file rather than a directory.
91
+ *
92
+ * A trailing slash settles it outright; otherwise the BASENAME must carry an extension. Testing the
93
+ * whole string — which `collectionDirs` does — calls `./content/v1.2/` a file, and testing merely
94
+ * for a dot calls `../../docs` one.
95
+ */
96
+ function isFileBacked(source: string): boolean {
97
+ if (source.endsWith("/")) {
98
+ return false;
99
+ }
100
+ const base = source.slice(source.lastIndexOf("/") + 1);
101
+ return /\.[a-z\d]+$/i.test(base);
102
+ }
103
+
104
+ /**
105
+ * The extension a collection's entries carry, and the format name that failed when there is none.
106
+ *
107
+ * `json` is the one native collection shape (`site-architecture.md` §6.2) and has no format class
108
+ * to ask; a collection declaring no format at all is resolved from the source extension by the
109
+ * loader, which `defaultContentFormat()` approximates. Anything else must resolve through the
110
+ * registry, and when it does not the answer is `null` plus the name, never a fallback.
111
+ */
112
+ function resolveExt(def: ContentSectionEntry): Pick<CollectionMatch, "ext" | "unresolvedFormat"> {
113
+ if (def.format === "json") {
114
+ return { ext: ".json", unresolvedFormat: null };
115
+ }
116
+ if (def.format === undefined || def.format === "") {
117
+ return { ext: defaultContentFormat()?.extensions[0] ?? ".json", unresolvedFormat: null };
118
+ }
119
+ const format = formatByName(def.format);
120
+ return format?.extensions[0] === undefined
121
+ ? { ext: null, unresolvedFormat: def.format }
122
+ : { ext: format.extensions[0], unresolvedFormat: null };
123
+ }
124
+
125
+ /**
126
+ * The pattern a declared `source` matches paths with — the root itself, or anything beneath it.
127
+ *
128
+ * Locale segments come from the project's own `i18n.locales`, which is what keeps
129
+ * `content/exhibitions/images` out; the bare wildcard survives only when the project declares no
130
+ * locales at all, where it is the sole thing that can match a `{locale}` source. Case-insensitive,
131
+ * because a `fr-FR` declaration and an `fr-fr` directory are the same locale — and because APFS and
132
+ * NTFS are case-insensitive too.
133
+ */
134
+ function sourcePattern(source: string, locales: string[] | null): RegExp {
135
+ const segment =
136
+ locales === null ? "[^/]+" : `(?:${locales.map((locale) => escapeLiteral(locale)).join("|")})`;
137
+ const body = normalize(source)
138
+ .split(LOCALE_PLACEHOLDER)
139
+ .map((part) => escapeLiteral(part))
140
+ .join(segment);
141
+ return new RegExp(`^${body}(?:/|$)`, "i");
142
+ }
143
+
144
+ /**
145
+ * The matched source ROOT inside `target`, or null.
146
+ *
147
+ * Returned rather than merely tested, because `dir` must be the real directory and `isSourceRoot`
148
+ * must know where the root ended — neither is recoverable from a `source` that still carries a
149
+ * placeholder.
150
+ */
151
+ function matchedRoot(source: string, target: string, locales: string[] | null): string | null {
152
+ const hit = sourcePattern(source, locales).exec(target);
153
+ if (!hit) {
154
+ return null;
155
+ }
156
+ // The match includes the trailing "/" when the target is beneath the root; trim it back.
157
+ return hit[0].replace(/\/$/, "");
158
+ }
159
+
160
+ /**
161
+ * How specific a source is, for "longest wins". Measured on the LITERAL text with the placeholder
162
+ * removed: two sources cannot be compared on their expansions, because a localized one expands
163
+ * differently per locale.
164
+ */
165
+ function sourceWeight(source: string): number {
166
+ return normalize(source).replaceAll(LOCALE_PLACEHOLDER, "").length;
167
+ }
168
+
169
+ /**
170
+ * The collection whose source tree contains `dir` — the source root itself, or any directory under
171
+ * it.
172
+ *
173
+ * Entry discovery is RECURSIVE (`Markdown.discover` walks with `readdirSync(recursive: true)`, and
174
+ * the grid's `listEntryFiles` walks subdirectories), so a document in `content/posts/2026/` is an
175
+ * entry of `posts`. `isSourceRoot` is what lets a caller treat the two depths differently without
176
+ * consulting a second matcher.
177
+ *
178
+ * File-backed collections are skipped: a CSV catalogue's directory is an ordinary directory, and
179
+ * its entries are rows rather than files. {@link collectionForFile} answers for the catalogue
180
+ * itself.
181
+ *
182
+ * @param dir - Project-relative directory. `"."` is the project root.
183
+ * @param config - Config to read; defaults to the open project's.
184
+ * @returns The most specific matching collection, or null.
185
+ */
186
+ export function collectionForDirectory(
187
+ dir: string | null | undefined,
188
+ config?: ProjectConfig | null,
189
+ ): CollectionMatch | null {
190
+ if (dir === null || dir === undefined || dir === "") {
191
+ return null;
192
+ }
193
+ const resolved = configOf(config);
194
+ const locales = declaredLocales(resolved);
195
+ const target = normalize(dir);
196
+ let best: CollectionMatch | null = null;
197
+ let bestWeight = -1;
198
+ for (const [name, def] of Object.entries(contentSection(resolved))) {
199
+ const { source } = def;
200
+ if (source === undefined || source === "" || isFileBacked(source)) {
201
+ continue;
202
+ }
203
+ const weight = sourceWeight(source);
204
+ if (weight <= bestWeight) {
205
+ continue;
206
+ }
207
+ const root = matchedRoot(source, target, locales);
208
+ if (root === null) {
209
+ continue;
210
+ }
211
+ best = {
212
+ def,
213
+ dir: target,
214
+ fileBacked: false,
215
+ isSourceRoot: target.length === root.length,
216
+ name,
217
+ ...resolveExt(def),
218
+ };
219
+ bestWeight = weight;
220
+ }
221
+ return best;
222
+ }
223
+
224
+ /**
225
+ * The collection a FILE belongs to: the collection of the directory holding it, or — for a
226
+ * file-backed collection — the one whose `source` the file IS.
227
+ *
228
+ * The file's own extension is NOT consulted, deliberately. A `.json` sitting in a Markdown
229
+ * collection's folder is still that collection's business: it is exactly the file a convert must
230
+ * refuse to produce, and answering "no collection" for it is how it would slip through.
231
+ *
232
+ * @param path - Project-relative file path.
233
+ * @param config - Config to read; defaults to the open project's.
234
+ * @returns The collection, or null.
235
+ */
236
+ export function collectionForFile(
237
+ path: string | null | undefined,
238
+ config?: ProjectConfig | null,
239
+ ): CollectionMatch | null {
240
+ if (path === null || path === undefined || path === "") {
241
+ return null;
242
+ }
243
+ const resolved = configOf(config);
244
+ const target = normalize(path);
245
+ const lower = target.toLowerCase();
246
+ for (const [name, def] of Object.entries(contentSection(resolved))) {
247
+ const { source } = def;
248
+ if (source === undefined || source === "" || !isFileBacked(source)) {
249
+ continue;
250
+ }
251
+ const src = normalize(source).toLowerCase();
252
+ if (lower === src || lower.endsWith(`/${src}`)) {
253
+ return {
254
+ def,
255
+ dir: target.includes("/") ? target.slice(0, target.lastIndexOf("/")) : ".",
256
+ fileBacked: true,
257
+ isSourceRoot: false,
258
+ name,
259
+ ...resolveExt(def),
260
+ };
261
+ }
262
+ }
263
+ return collectionForDirectory(
264
+ target.includes("/") ? target.slice(0, target.lastIndexOf("/")) : ".",
265
+ resolved,
266
+ );
267
+ }
@@ -14,6 +14,7 @@ import {
14
14
  argsSchema,
15
15
  booleanArg,
16
16
  booleanProperty,
17
+ derivedEnumProperty,
17
18
  enumArg,
18
19
  stringArg,
19
20
  stringProperty,
@@ -31,6 +32,7 @@ import { invalidateReferenceEntries } from "../ui/form-controls";
31
32
  import { reloadDraftAwareGrids } from "../grid/sources/content-source";
32
33
  import { setIncludeDrafts } from "./draft-state";
33
34
  import { collectionOfPath, entryCollection, entryCollections, seedEntry } from "./entry-model";
35
+ import { LOCALE_PLACEHOLDER, collectionForDirectory } from "./collection-match";
34
36
  import { openEntryEditor, setEntryDraft } from "./entry-editor";
35
37
  import { errorMessage } from "@jxsuite/schema/parse";
36
38
  import type { EntryCollection } from "./entry-model";
@@ -42,32 +44,6 @@ function creatableCollections(): string[] {
42
44
  return entryCollections().map((collection) => collection.name);
43
45
  }
44
46
 
45
- /**
46
- * An enum property whose values are derived WHEN THE SCHEMA IS READ, not when the record is
47
- * defined.
48
- *
49
- * `enumProperty(creatableCollections(), …)` looks identical and is wrong here, and the difference
50
- * is invisible in a unit test: a command record is built at module scope —
51
- * `commands/app-commands.ts` and `studio.ts` both call {@link contentCommands} before a project is
52
- * open — so the array is frozen at `[]`, and `panels/quick-search.ts`'s `paletteArgs` offered an
53
- * empty choice list forever after. `run` re-derived the list, which is why the programmatic call
54
- * worked and the palette did not: two answers to "which collections exist", one of them a snapshot
55
- * of the empty boot state.
56
- *
57
- * A getter is the whole fix — the palette reads `property.enum` when it opens the prompt, the AI
58
- * tool's parameters serialise it when the tool list is built, and `scripts/check-shot-contract.ts`
59
- * reads it in a bare Bun process where the honest answer really is "none declared".
60
- */
61
- function derivedEnumProperty(declared: () => readonly string[], description: string): object {
62
- return {
63
- description,
64
- get enum() {
65
- return [...declared()];
66
- },
67
- type: "string",
68
- };
69
- }
70
-
71
47
  /**
72
48
  * The source text a new entry starts as.
73
49
  *
@@ -106,9 +82,17 @@ async function seedText(collection: EntryCollection): Promise<string> {
106
82
  * created in, and a **body seeded from the schema**, so the entry is valid the moment it exists
107
83
  * instead of being a pile of absent required fields.
108
84
  *
85
+ * `opts.dir` names a directory INSIDE the collection — the Files tree creating in a subdirectory of
86
+ * the source root. It is checked by MEMBERSHIP (`collectionForDirectory` must answer with this same
87
+ * collection) rather than by string prefix, because `collection.dir` may still carry a `{locale}`
88
+ * placeholder, and a prefix test against a template rejects every real directory it stands for.
89
+ *
109
90
  * @returns The created path, or null when the author cancelled or the write failed.
110
91
  */
111
- export async function createEntry(collectionName: string): Promise<string | null> {
92
+ export async function createEntry(
93
+ collectionName: string,
94
+ opts: { dir?: string } = {},
95
+ ): Promise<string | null> {
112
96
  const collection = entryCollection(collectionName);
113
97
  if (!collection) {
114
98
  notify.error(`No content collection named "${collectionName}" to create an entry in.`, {
@@ -135,11 +119,36 @@ export async function createEntry(collectionName: string): Promise<string | null
135
119
  return null;
136
120
  }
137
121
 
122
+ /*
123
+ * Where the entry lands.
124
+ *
125
+ * A localized collection's `dir` is `content/exhibitions/{locale}` — a path nobody has. Creating
126
+ * there makes a directory literally named `{locale}`, which is what `content.newEntry` from the
127
+ * palette and the `new_content_entry` tool have been doing. There is no default locale to pick on
128
+ * the author's behalf (choosing one silently files the entry under a language), so this refuses
129
+ * and names the gesture that works.
130
+ */
131
+ const requested = opts.dir;
132
+ const destination =
133
+ requested !== undefined && collectionForDirectory(requested)?.name === collection.name
134
+ ? requested
135
+ : collection.dir;
136
+ if (destination.includes(LOCALE_PLACEHOLDER)) {
137
+ notify.error(`"${collection.name}" is a localized collection.`, {
138
+ detail:
139
+ `Its entries live in one directory per language (${collection.dir}). Open the folder for ` +
140
+ "the language you are writing in and create the entry there.",
141
+ path: collection.dir,
142
+ source: "Content",
143
+ });
144
+ return null;
145
+ }
146
+
138
147
  const { createFileIn } = await import("../files/files");
139
148
  const created = await createFileIn({
140
149
  content,
141
- dir: collection.dir,
142
- ext: collection.ext,
150
+ dir: destination,
151
+ format: { ext: collection.ext, kind: "fixed" },
143
152
  source: "Content",
144
153
  suggestedName: "untitled",
145
154
  title: `New ${collection.name} entry`,
@@ -29,6 +29,7 @@
29
29
  */
30
30
 
31
31
  import { cloneValue } from "@jxsuite/collab/ops";
32
+ import { notePreviewOverlayEdit } from "../preview/preview-overlay";
32
33
  import { recordDocOp } from "../tabs/patch-ops";
33
34
  import { mutateUpdateFrontmatter, transactDoc } from "../tabs/transact";
34
35
  import type { Tab } from "../tabs/tab";
@@ -85,6 +86,11 @@ function mutateDocumentField(tab: Tab, field: string, value?: JsonValue): void {
85
86
  inverse: { key: field, op: "set-key", path: [], value: before },
86
87
  });
87
88
  tab.doc.dirty = true;
89
+ /* The document's ROOT REFERENCE is untouched here — the key is written in place — so the preview
90
+ overlay's effect, which reads that reference, sees nothing. `transactDoc` and the Monaco source
91
+ commit both assign a fresh one and need no such call; this path is the exception, and without
92
+ the note an entry field edited twice would stop reaching a live preview after the first. */
93
+ notePreviewOverlayEdit();
88
94
  }
89
95
 
90
96
  /**
@@ -27,6 +27,7 @@ import type { DropPreview, FileDropHit } from "../canvas/iframe-protocol";
27
27
  import type { JxPath } from "../state";
28
28
  import type { Tab } from "../tabs/tab";
29
29
  import type { JxMutableNode } from "@jxsuite/schema/types";
30
+ import { isMediaFormat } from "../utils/studio-utils";
30
31
 
31
32
  /** Where an asset's URL goes when it replaces an existing element's picture. */
32
33
  const REPLACE_ATTRS: Record<string, "src" | "poster"> = {
@@ -71,7 +72,7 @@ export function resolveFileDropTarget(
71
72
  }
72
73
  if (hit.tagName.includes("-")) {
73
74
  const entry = registry.find((c) => c.tagName === hit.tagName);
74
- const imageProps = (entry?.props ?? []).filter((p) => p.format === "image");
75
+ const imageProps = (entry?.props ?? []).filter((p) => isMediaFormat(p.format));
75
76
  if (imageProps.length === 1) {
76
77
  return { mode: "replace-prop", path: hit.path as JxPath, prop: imageProps[0]!.name };
77
78
  }
@@ -123,6 +123,7 @@ export interface StudioCommandHooks {
123
123
  */
124
124
  openProject: (target: ProjectOpenTarget) => Promise<ProjectOpenOutcome>;
125
125
  openInBrowser: () => void | Promise<void>;
126
+ buildSite: () => void | Promise<void>;
126
127
  }
127
128
 
128
129
  // ─── Shared predicates ────────────────────────────────────────────────────────
@@ -832,6 +833,7 @@ export function registerStudioCommands(
832
833
  duplicateSelection,
833
834
  focusInspectorTab: (tabId) => focusInspectorTab(registry, tabId),
834
835
  focusPanel,
836
+ buildSite: () => hooks.buildSite(),
835
837
  openInBrowser: () => hooks.openInBrowser(),
836
838
  openPalette: (mode) => {
837
839
  openQuickSearch(mode);
@@ -958,6 +960,9 @@ const SELF_SCROLLING_MODES: ReadonlySet<string> = new Set([
958
960
  "entry",
959
961
  "grid",
960
962
  "manage",
963
+ /* `media` — the Media viewer's own scrolling column (`media/media-pane.ts`). There is no artboard
964
+ here either: a wheel over an image is a scroll down the page it sits on, not a zoom. */
965
+ "media",
961
966
  "preview",
962
967
  "settings",
963
968
  ]);
@@ -29,12 +29,13 @@ import { getGridController } from "../grid/grid-controller";
29
29
  import { activeTab, openTab } from "../workspace/workspace";
30
30
  import { collabReadOnly, collabSave } from "../collab/collab-session";
31
31
  import { flushCanvasEdits } from "../canvas/iframe-host";
32
+ import { flushPreviewOverlay } from "../preview/preview-overlay";
33
+ import { serializeDocument } from "./serialize-document";
32
34
  import {
33
35
  defaultContentFormat,
34
36
  formatByName,
35
37
  formatForPath,
36
38
  formatParse,
37
- formatSerialize,
38
39
  getFormats,
39
40
  loadFormats,
40
41
  noFormatError,
@@ -254,6 +255,10 @@ export async function saveFile(tab: Tab | null = activeTab.value): Promise<boole
254
255
  const platform = getPlatform();
255
256
  await platform.writeFile(tab.documentPath, output);
256
257
  tab.doc.dirty = false;
258
+ /* Retract the unsaved bytes NOW rather than a debounce later. The write also reaches the
259
+ backend's filesystem watcher, and a live preview coalesces both into one reload only if
260
+ they arrive together — otherwise a save costs two. */
261
+ void flushPreviewOverlay();
257
262
  reportSaved(tab);
258
263
  return true;
259
264
  }
@@ -352,44 +357,6 @@ export async function exportFile() {
352
357
  }
353
358
  }
354
359
 
355
- /**
356
- * Serialize the current document to its output format. Format tabs round-trip through the format
357
- * class's serialize capability; everything else is native JSON.
358
- *
359
- * @param {import("../tabs/tab.js").Tab} tab
360
- * @returns {Promise<string>}
361
- */
362
- export async function serializeDocument(tab: Tab): Promise<string> {
363
- // Grid tabs serialize through their source (pending edits included) — e.g. the Monaco source
364
- // View of a CSV grid tab shows the live file text.
365
- const grid = getGridController(tab);
366
- const gridText = grid?.serializeForSource();
367
- if (gridText) {
368
- return gridText;
369
- }
370
- await loadFormats();
371
- const sourceFormat = formatByName(tab.doc.sourceFormat);
372
- if (sourceFormat?.capabilities.serialize) {
373
- const fm = tab.doc.content?.frontmatter || {};
374
- const doc = tab.doc.document;
375
- const fullDoc = { ...fm, ...doc, children: doc.children ?? [] };
376
- return formatSerialize(sourceFormat.name, fullDoc, { mode: "roundtrip" });
377
- }
378
- if (tab.doc.mode === "content") {
379
- const format = defaultContentFormat();
380
- if (format) {
381
- const fm = tab.doc.content?.frontmatter ?? {};
382
- const hasFrontmatter = Object.keys(fm).length > 0;
383
- const fullDoc = { ...fm, ...tab.doc.document };
384
- return formatSerialize(format.name, fullDoc, {
385
- frontmatter: hasFrontmatter,
386
- mode: "roundtrip",
387
- });
388
- }
389
- }
390
- return JSON.stringify(tab.doc.document, null, 2);
391
- }
392
-
393
360
  // ─── Destructive confirmations ───────────────────────────────────────────────
394
361
 
395
362
  /**
@@ -412,7 +379,7 @@ export async function serializeDocument(tab: Tab): Promise<string> {
412
379
  * @param path — the file about to be deleted or renamed.
413
380
  * @param verb — which way the references go. A rename repairs them; a delete breaks them.
414
381
  */
415
- async function usageLine(path: string, verb: "delete" | "rename") {
382
+ async function usageLine(path: string, verb: "delete" | "rename" | "convert") {
416
383
  const state = isMediaFile(path) ? await loadMediaUsages(path) : await loadUsages({ path });
417
384
  const sentence = usageWarning(state, verb);
418
385
  return sentence === null ? nothing : html`<p class="dialog-consequence">${sentence}</p>`;
@@ -445,8 +412,9 @@ export async function confirmFileDelete(file: { name: string; path: string }): P
445
412
  * is there to say how much work is silently being done on the user's behalf.
446
413
  *
447
414
  * @param path — the file about to be renamed.
415
+ * @param verb — `"rename"`, or `"convert"` when the bytes change with the name.
448
416
  */
449
- export async function renamePromptMessage(path: string) {
450
- const consequence = await usageLine(path, "rename");
417
+ export async function renamePromptMessage(path: string, verb: "rename" | "convert" = "rename") {
418
+ const consequence = await usageLine(path, verb);
451
419
  return consequence === nothing ? undefined : html`${consequence}`;
452
420
  }