@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
@@ -34,12 +34,14 @@
34
34
  */
35
35
 
36
36
  import { normalizeChord, overlappingScopes, parseChord } from "../commands/keymap";
37
+ import { SETTINGS } from "../services/settings/definitions";
38
+ import { clearSettings, readStoredSetting, setSetting } from "../services/settings/kernel";
37
39
  import type { KeybindingOverrides } from "../commands/keymap";
38
40
  import type { KeyScope } from "../commands/levels";
39
41
  import type { AnyCommand, CommandRegistry } from "../commands/registry";
40
42
 
41
43
  /** Where the user's layer is kept. One key, one JSON object of `id → chords`. */
42
- export const KEYBINDINGS_STORAGE_KEY = "jx.keybindings";
44
+ export const KEYBINDINGS_STORAGE_KEY = SETTINGS.keybindings.key;
43
45
 
44
46
  /** Keys that type nothing, and so may be bound without a modifier. */
45
47
  const UNMODIFIED_KEYS = new Set([
@@ -114,12 +116,7 @@ export function isBindableChord(chord: string): boolean {
114
116
  }
115
117
 
116
118
  function readRaw(): string {
117
- try {
118
- return globalThis.localStorage?.getItem(KEYBINDINGS_STORAGE_KEY) ?? "";
119
- } catch {
120
- // Storage unavailable (private mode): every command keeps the chord it declared.
121
- return "";
122
- }
119
+ return readStoredSetting(SETTINGS.keybindings);
123
120
  }
124
121
 
125
122
  /** Every chord in one stored entry, canonicalised — or `null` if any of them is not a chord. */
@@ -175,18 +172,11 @@ export function loadKeybindingOverrides(): Map<string, string[]> {
175
172
  }
176
173
 
177
174
  function store(layer: KeybindingOverrides): void {
178
- try {
179
- if (layer.size === 0) {
180
- globalThis.localStorage?.removeItem(KEYBINDINGS_STORAGE_KEY);
181
- return;
182
- }
183
- globalThis.localStorage?.setItem(
184
- KEYBINDINGS_STORAGE_KEY,
185
- JSON.stringify(Object.fromEntries(layer)),
186
- );
187
- } catch {
188
- // Storage unavailable — the rebinding is live in this window and is not remembered.
175
+ if (layer.size === 0) {
176
+ clearSettings([SETTINGS.keybindings]);
177
+ return;
189
178
  }
179
+ setSetting(SETTINGS.keybindings, JSON.stringify(Object.fromEntries(layer)));
190
180
  }
191
181
 
192
182
  /**
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { html, nothing } from "lit-html";
8
- import { camelToLabel } from "../utils/studio-utils";
8
+ import { camelToLabel, isMediaFormat } from "../utils/studio-utils";
9
9
 
10
10
  export interface SchemaProperty {
11
11
  type?: string;
@@ -483,7 +483,7 @@ export function yamlDefault(type: string, format?: string) {
483
483
  if (format === "date") {
484
484
  return new Date().toISOString().split("T")[0];
485
485
  }
486
- if (format === "image") {
486
+ if (isMediaFormat(format)) {
487
487
  return '""';
488
488
  }
489
489
  switch (type) {
package/src/shell.ts CHANGED
@@ -22,6 +22,8 @@
22
22
  */
23
23
 
24
24
  import { effect, effectScope, reactive } from "./reactivity";
25
+ import { SETTINGS } from "./services/settings/definitions";
26
+ import { readStoredSetting, setSetting } from "./services/settings/kernel";
25
27
  import { loadedMonaco } from "./services/monaco-lazy";
26
28
  import { applyStartupProfile } from "./services/profile";
27
29
  import { workspace } from "./workspace/workspace";
@@ -349,8 +351,6 @@ export interface ShellState {
349
351
 
350
352
  const DOCK_STORAGE_KEY = "jx-studio-panel-widths";
351
353
 
352
- const THEME_STORAGE_KEY = "jx-studio-theme";
353
-
354
354
  /**
355
355
  * The per-project record's key prefix — one namespaced record per project root (§4.4).
356
356
  *
@@ -378,14 +378,10 @@ function isChromeTheme(value: unknown): value is ChromeTheme {
378
378
  return typeof value === "string" && (CHROME_THEMES as readonly string[]).includes(value);
379
379
  }
380
380
 
381
- /** Read the persisted theme, falling back to {@link DEFAULT_THEME} on absent/corrupt storage. */
381
+ /** Read the persisted theme, falling back to {@link DEFAULT_THEME} on an absent/unknown value. */
382
382
  function readPersistedTheme(): ChromeTheme {
383
- try {
384
- const stored = localStorage.getItem(THEME_STORAGE_KEY);
385
- return isChromeTheme(stored) ? stored : DEFAULT_THEME;
386
- } catch {
387
- return DEFAULT_THEME;
388
- }
383
+ const stored = readStoredSetting(SETTINGS.theme);
384
+ return isChromeTheme(stored) ? stored : DEFAULT_THEME;
389
385
  }
390
386
 
391
387
  export const DOCK_IDS: DockId[] = ["left", "right", "bottom"];
@@ -1168,11 +1164,7 @@ export function setChromeTheme(theme: ChromeTheme): void {
1168
1164
  return;
1169
1165
  }
1170
1166
  shell.theme = theme;
1171
- try {
1172
- localStorage.setItem(THEME_STORAGE_KEY, theme);
1173
- } catch {
1174
- // Storage full or unavailable — the theme is still applied, just not remembered.
1175
- }
1167
+ setSetting(SETTINGS.theme, theme);
1176
1168
  }
1177
1169
 
1178
1170
  /**
package/src/studio.ts CHANGED
@@ -204,7 +204,12 @@ import { initQuickSearch } from "./panels/quick-search";
204
204
  import { hydrateAccountStatus } from "./account-status";
205
205
  import { hydrateProjectList } from "./project-list";
206
206
  import { addRecentProject, hydrateRecentProjects, removeRecentProject } from "./recent-projects";
207
- import { hydrateSettings } from "./services/settings-store";
207
+ import {
208
+ hydrateSettings,
209
+ onSettingsChanged,
210
+ settingsSettled,
211
+ watchRemoteSettings,
212
+ } from "./services/settings/kernel";
208
213
  import { initWelcome } from "./panels/welcome-screen";
209
214
  import {
210
215
  openAddRepoModal,
@@ -1136,6 +1141,32 @@ void hydrateSettings().then(() => {
1136
1141
  render();
1137
1142
  });
1138
1143
 
1144
+ /*
1145
+ * A setting changing repaints, wherever the change came from — this window's Preferences, or
1146
+ * hydration landing after boot. Key-gated surfaces (the assistant's setup notice, the New Project
1147
+ * gates, the Accounts list) read the store on the render path rather than caching a copy, so this
1148
+ * one subscription is what keeps every one of them honest.
1149
+ */
1150
+ onSettingsChanged(() => {
1151
+ render();
1152
+ });
1153
+
1154
+ /*
1155
+ * Another window changing a setting is news this one wants. Nothing is lost without it — a patch
1156
+ * cannot clobber a key it does not name — but a second window would otherwise show a provider that
1157
+ * had been reconfigured somewhere else until it was restarted.
1158
+ */
1159
+ watchRemoteSettings();
1160
+
1161
+ /*
1162
+ * Settings are written on a queue that coalesces a burst into one send, so a change made in the
1163
+ * last moments before the window closes may still be in flight. Draining it here is the difference
1164
+ * between "your provider was saved" and a settings file that never heard about it.
1165
+ */
1166
+ globalThis.addEventListener("beforeunload", () => {
1167
+ void settingsSettled();
1168
+ });
1169
+
1139
1170
  // ─── Left panel: delegated to panels/left-panel.js ───────────────────────────
1140
1171
 
1141
1172
  function renderLeftPanel() {
package/src/types.ts CHANGED
@@ -13,6 +13,7 @@ import type {
13
13
 
14
14
  import type {
15
15
  AppInfo,
16
+ AssetCapabilities,
16
17
  CfConnection,
17
18
  CodeServiceResult,
18
19
  ComponentMeta,
@@ -42,7 +43,9 @@ import type {
42
43
  RenameResult,
43
44
  SecretsSetRequest,
44
45
  SecretsSetResponse,
46
+ SettingsPatch,
45
47
  StarterInfo,
48
+ UploadResult,
46
49
  } from "@jxsuite/protocol";
47
50
 
48
51
  export type {
@@ -93,6 +96,7 @@ export type {
93
96
  SecretsListResponse,
94
97
  SecretsSetRequest,
95
98
  SecretsSetResponse,
99
+ SettingsPatch,
96
100
  StarterInfo,
97
101
  } from "@jxsuite/protocol";
98
102
 
@@ -200,6 +204,33 @@ export interface StudioPlatform {
200
204
  * trailing `/` is added: without it `new URL` drops the last segment.
201
205
  */
202
206
  documentBaseUrl?: string;
207
+ /**
208
+ * What the canvas ORIGIN answers for a SITE URL — and therefore how Studio must address media.
209
+ *
210
+ * `"site"` (the default when absent): the canvas origin already serves the published site URL
211
+ * space, so `/hero.jpg` and `/styles/main.css` resolve on their own and Studio touches neither.
212
+ * The dev server and the desktop loopback both do — `serveProjectFile` in `@jxsuite/server` IS
213
+ * that URL space — so neither declares anything here and both stay byte-identical.
214
+ *
215
+ * `"repo"`: the host serves PROJECT-RELATIVE paths under {@link documentBaseUrl}, and nothing
216
+ * answers the site URL space. Studio resolves each authored reference to the project file it
217
+ * names and rebases that path onto `documentBaseUrl`. Inert without one — a host that says its
218
+ * site URLs are wrong without saying what is right has told Studio nothing it can act on.
219
+ *
220
+ * This is about the ORIGIN, not the backend: a host whose files are perfectly reachable can still
221
+ * be `"repo"` space, because what decides it is what answers `GET /hero.jpg` on the document the
222
+ * canvas is running in.
223
+ */
224
+ assetSpace?: "site" | "repo";
225
+ /**
226
+ * What this backend will accept as an upload, when it says.
227
+ *
228
+ * Absent, or any field absent, means "no declared limit", and Studio must not invent one — a
229
+ * limit it made up is a file the user cannot upload for no reason anyone can name. A DECLARED
230
+ * limit is different: Studio refuses before spending the round trip and names the number in the
231
+ * refusal, and narrows the file picker's `accept` to match.
232
+ */
233
+ assetCapabilities?: AssetCapabilities;
203
234
  activate: (root?: string) => Promise<void>;
204
235
  openProject: () => Promise<{
205
236
  config: ProjectConfig;
@@ -224,7 +255,16 @@ export interface StudioPlatform {
224
255
  listDirectory: (dir: string) => Promise<DirEntry[]>;
225
256
  readFile: (path: string) => Promise<string>;
226
257
  writeFile: (path: string, content: string) => Promise<void>;
227
- uploadFile: (path: string, data: string | File | Blob | ArrayBuffer) => Promise<unknown>;
258
+ /**
259
+ * Store bytes at a project path, and report where they really landed.
260
+ *
261
+ * The result's `path` is the ANSWER, not an echo: a backend may de-duplicate by content hash,
262
+ * append a collision suffix, or normalize a name, and the reference Studio writes into the
263
+ * document has to name what was actually written. This was `Promise<unknown>` and every caller
264
+ * used the REQUESTED path, so any such backend produced documents pointing at files that are not
265
+ * there.
266
+ */
267
+ uploadFile: (path: string, data: string | File | Blob | ArrayBuffer) => Promise<UploadResult>;
228
268
  deleteFile: (path: string) => Promise<void>;
229
269
  renameFile: (from: string, to: string) => Promise<RenameResult>;
230
270
  /**
@@ -508,8 +548,26 @@ export interface StudioPlatform {
508
548
  * settings live in localStorage only.
509
549
  */
510
550
  getSettings?: () => Promise<Record<string, string>>;
511
- /** Persist the full user-level settings map to the backend store. */
512
- saveSettings?: (settings: Record<string, string>) => Promise<void>;
551
+ /**
552
+ * Apply a set of changes to the backend store, and answer with the store as it then stands.
553
+ *
554
+ * A PATCH rather than the whole map, which is a correctness property and not an optimisation: a
555
+ * key named by neither `set` nor `remove` must be left exactly as it was found. Writing the whole
556
+ * map let any window overwrite the shared file with its own view of it, and on the chromium
557
+ * launcher every window is a separate process with a separate browser profile — so a window that
558
+ * held no settings could, and did, clear the credentials another window had just stored. It also
559
+ * preserves keys this build does not know: one written by a newer version, or by hand.
560
+ */
561
+ patchSettings?: (patch: SettingsPatch) => Promise<Record<string, string>>;
562
+ /**
563
+ * Be told when the backend store changes — by another window, or by hand. Returns the
564
+ * unsubscribe.
565
+ *
566
+ * Optional, and a display concern rather than a correctness one: `patchSettings` already stops a
567
+ * window from clobbering another's keys, so a platform without this leaves a second window
568
+ * showing a stale value until its next boot rather than losing anything.
569
+ */
570
+ subscribeSettings?: (handler: (settings: Record<string, string>) => void) => () => void;
513
571
  }
514
572
 
515
573
  // ─── Studio Types ───────────────────────────────────────────────────────────
@@ -19,16 +19,15 @@
19
19
  */
20
20
 
21
21
  import { html, nothing } from "lit-html";
22
+ import { live } from "lit-html/directives/live.js";
22
23
  import type { TemplateResult } from "lit-html";
23
- import { fetchAvailableModels, invalidateModelCache } from "../services/ai-models";
24
+ import { fetchAvailableModels } from "../services/ai-models";
24
25
  import {
25
26
  getBaseUrl,
26
- getModel,
27
27
  getOpenAiKey,
28
28
  hasOpenAiKey,
29
- setBaseUrl,
30
- setModel,
31
- setOpenAiKey,
29
+ saveAiProvider,
30
+ storedModel,
32
31
  } from "../services/ai-settings";
33
32
 
34
33
  export interface AiCredentialsFormOptions {
@@ -64,11 +63,23 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
64
63
  let modelsLoading = false;
65
64
  let modelsError = "";
66
65
 
67
- /** Open the form pre-filled with the current settings, and load the model list. */
68
- function startEdit() {
66
+ /**
67
+ * Load the drafts from what is stored.
68
+ *
69
+ * The model comes from {@link storedModel} rather than `getModel()`, so a user who has never
70
+ * picked one drafts an empty field instead of the `"gpt-4o"` default. Saving a prefilled default
71
+ * writes a choice nobody made, and `jx.ai.model: "gpt-4o"` is exactly what a broken install was
72
+ * left holding.
73
+ */
74
+ function loadDrafts() {
69
75
  keyDraft = getOpenAiKey();
70
76
  baseUrlDraft = getBaseUrl();
71
- modelDraft = getModel();
77
+ modelDraft = storedModel();
78
+ }
79
+
80
+ /** Open the form pre-filled with the current settings, and load the model list. */
81
+ function startEdit() {
82
+ loadDrafts();
72
83
  opts.requestRender();
73
84
  // Auto-fetch available models if not already loaded.
74
85
  if (availableModels.length === 0 && !modelsLoading) {
@@ -76,26 +87,33 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
76
87
  }
77
88
  }
78
89
 
79
- /** Persist the drafted key + endpoint + model and notify the host. */
90
+ /**
91
+ * Persist the drafted key + endpoint + model and notify the host.
92
+ *
93
+ * **Re-seeds the drafts; it must never blank them.** Preferences is a place rather than a wizard
94
+ * step, so the sheet stays open across Save and `startEdit` is not called again — which meant
95
+ * every field emptied the instant a save succeeded. That reads as "it didn't take", and the
96
+ * obvious response to it destroyed the credentials: a blank draft is what
97
+ * `setOpenAiKey`/`setBaseUrl` treat as _clear_, so pressing Save a second time on the emptied
98
+ * form deleted the key and endpoint that the first press had just stored. Reading back through
99
+ * the getters also shows what was actually kept — trimmed, and with the endpoint's trailing slash
100
+ * stripped — rather than what was typed.
101
+ */
80
102
  function save() {
81
- setOpenAiKey(keyDraft);
82
- setBaseUrl(baseUrlDraft);
83
- setModel(modelDraft);
84
- keyDraft = "";
85
- baseUrlDraft = "";
86
- modelDraft = "";
87
- // Clear fetched models so they're re-fetched with the new credentials next time.
88
- availableModels = [];
89
- invalidateModelCache();
103
+ saveAiProvider({ apiKey: keyDraft, baseUrl: baseUrlDraft, model: modelDraft });
104
+ loadDrafts();
105
+ modelsError = "";
106
+ /* The fetched list stays: it was listed under exactly these credentials, and dropping it
107
+ collapsed the model combobox back to a free-text field on every save. The module cache is
108
+ dropped by ai-models' own settings subscription, so this form does not have to remember to. */
90
109
  opts.onSaved?.();
91
110
  opts.requestRender();
92
111
  }
93
112
 
94
113
  /** Dismiss the form without saving (only offered when a key already exists). */
95
114
  function cancel() {
96
- keyDraft = "";
97
- baseUrlDraft = "";
98
- modelDraft = "";
115
+ loadDrafts();
116
+ modelsError = "";
99
117
  opts.onCancel?.();
100
118
  opts.requestRender();
101
119
  }
@@ -104,6 +122,11 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
104
122
  * Fetch available models via src/services/ai-models.ts, preferring the in-form drafts over the
105
123
  * stored settings so the list reflects the credentials being edited. Always forces past the
106
124
  * module cache — this runs on explicit user action (or first open) with possibly-new drafts.
125
+ *
126
+ * Both lines below read draft-first. The key used to read stored-first, against this paragraph
127
+ * and against the line beside it: editing a key in place and pressing Fetch models listed the OLD
128
+ * key's models, and — worse — a form whose drafts had been blanked still fetched successfully
129
+ * from storage, which is what made an emptied form look like it was working.
107
130
  */
108
131
  async function fetchModels() {
109
132
  modelsLoading = true;
@@ -111,8 +134,10 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
111
134
  opts.requestRender();
112
135
  try {
113
136
  availableModels = await fetchAvailableModels({
114
- apiKey: getOpenAiKey() || keyDraft,
115
- baseUrl: baseUrlDraft || getBaseUrl(),
137
+ credentials: {
138
+ apiKey: keyDraft || getOpenAiKey(),
139
+ baseUrl: baseUrlDraft || getBaseUrl(),
140
+ },
116
141
  force: true,
117
142
  });
118
143
  } catch (error: unknown) {
@@ -144,7 +169,7 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
144
169
  type="password"
145
170
  size="s"
146
171
  placeholder="sk-… or any compatible key"
147
- .value=${keyDraft}
172
+ .value=${live(keyDraft)}
148
173
  @input=${(e: Event) => {
149
174
  keyDraft = (e.target as HTMLInputElement).value;
150
175
  }}
@@ -157,7 +182,7 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
157
182
  class="ai-creds-field"
158
183
  size="s"
159
184
  allows-custom-value
160
- .value=${modelDraft}
185
+ .value=${live(modelDraft)}
161
186
  @change=${(e: Event) => {
162
187
  modelDraft = (e.target as HTMLInputElement).value;
163
188
  }}
@@ -175,7 +200,7 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
175
200
  class="ai-creds-field"
176
201
  size="s"
177
202
  placeholder="Model ID (e.g. gpt-4o, claude-sonnet-4-20250514, etc.)"
178
- .value=${modelDraft}
203
+ .value=${live(modelDraft)}
179
204
  @input=${(e: Event) => {
180
205
  modelDraft = (e.target as HTMLInputElement).value;
181
206
  }}
@@ -198,7 +223,7 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
198
223
  class="ai-creds-field"
199
224
  size="s"
200
225
  placeholder="Endpoint (optional, e.g. http://localhost:11434/v1)"
201
- .value=${baseUrlDraft}
226
+ .value=${live(baseUrlDraft)}
202
227
  @input=${(e: Event) => {
203
228
  baseUrlDraft = (e.target as HTMLInputElement).value;
204
229
  }}
@@ -26,12 +26,11 @@ import { debouncedStyleCommit, renderOnly } from "../store";
26
26
  import { getLayerSlot, popoverLayerFor } from "./layers";
27
27
  import type { LayerKind } from "./layers";
28
28
  import { rectOf } from "../utils/geometry";
29
- import { loopbackAssetSrc } from "../canvas/canvas-origin";
30
- import { previewAssetSrc } from "../canvas/content-assets";
29
+ import { previewAssetSrc } from "../canvas/asset-refs";
31
30
  import {
32
31
  IMAGE_EXTENSIONS,
33
32
  MEDIA_EXTENSIONS,
34
- UPLOAD_ACCEPT,
33
+ uploadAccept,
35
34
  extensionOf,
36
35
  uploadAssets,
37
36
  } from "../files/media-upload";
@@ -42,7 +41,7 @@ import {
42
41
  recordImageSize,
43
42
  seedMediaMeta,
44
43
  } from "../files/media-meta";
45
- import { mediaSiteUrl } from "../files/media-paths";
44
+ import { mediaSiteUrl, previewFileSrc } from "../files/media-paths";
46
45
 
47
46
  // ─── Media file cache ────────────────────────────────────────────────────────
48
47
 
@@ -283,7 +282,7 @@ function renderMediaPickerPopover() {
283
282
  m.isImage
284
283
  ? html`<img
285
284
  slot="icon"
286
- src=${loopbackAssetSrc(m.path)}
285
+ src=${previewFileSrc(m.file)}
287
286
  alt=""
288
287
  style="width:24px;height:24px;object-fit:cover;border-radius:var(--spectrum-corner-radius-75, 2px)"
289
288
  @load=${(e: Event) => noteImageSize(m.file, e.target)}
@@ -394,7 +393,7 @@ function pickAndUpload(onCommit: (val: string) => void) {
394
393
  const input = document.createElement("input");
395
394
  input.type = "file";
396
395
  input.multiple = true;
397
- input.accept = UPLOAD_ACCEPT;
396
+ input.accept = uploadAccept();
398
397
  input.addEventListener("change", () => {
399
398
  if (input.files?.length) {
400
399
  void uploadAndAssign(input.files, onCommit);
@@ -422,11 +421,7 @@ export function renderMediaPicker(prop: string, value: string, onCommit: (val: s
422
421
  <div class="media-picker">
423
422
  ${
424
423
  isImage && currentValue
425
- ? html`<img
426
- class="media-picker-thumb"
427
- src=${loopbackAssetSrc(previewAssetSrc(currentValue))}
428
- alt=""
429
- />`
424
+ ? html`<img class="media-picker-thumb" src=${previewAssetSrc(currentValue)} alt="" />`
430
425
  : nothing
431
426
  }
432
427
  <sp-textfield
@@ -117,6 +117,35 @@ export function abbreviateValue(val: string) {
117
117
  return map[val] || val;
118
118
  }
119
119
 
120
+ /**
121
+ * The placeholder a content type's `source` may carry to say "one directory per locale".
122
+ *
123
+ * Held here rather than imported out of the parser extension's content loader ({@link
124
+ * file://../../../../extensions/parser/src/content-loader.ts}): that module reads the filesystem
125
+ * and this one runs in a browser. It is also an extension, and core may not import one.
126
+ */
127
+ const LOCALE_PLACEHOLDER = "{locale}";
128
+
129
+ /**
130
+ * True when a schema `format` names a MEDIA reference.
131
+ *
132
+ * Two spellings, and both are real. `"uri-reference"` is JSON Schema's own and the one the spec
133
+ * uses — it is what the content loader keys its asset rewrite on (`rewriteEntryAssets`) and what a
134
+ * schema written against the documentation says. `"image"` is Studio's own shorthand, used by
135
+ * component props and settings schemas.
136
+ *
137
+ * They were accepted in different places, so which editor a field got depended on which panel was
138
+ * asking: `inferInputType` took both, the frontmatter panel took only `"image"`, and the drop
139
+ * target, the signals panel and the data grid each took only one. One predicate, so a field is a
140
+ * media field everywhere or nowhere.
141
+ *
142
+ * @param {unknown} format - A schema entry's `format`
143
+ * @returns {boolean}
144
+ */
145
+ export function isMediaFormat(format: unknown): boolean {
146
+ return format === "image" || format === "uri-reference";
147
+ }
148
+
120
149
  /**
121
150
  * Determine input widget type from a css-meta entry
122
151
  *
@@ -136,7 +165,7 @@ export function inferInputType(entry: Record<string, unknown>) {
136
165
  if (entry.format === "color") {
137
166
  return "color";
138
167
  }
139
- if (entry.format === "uri-reference") {
168
+ if (isMediaFormat(entry.format)) {
140
169
  return "media";
141
170
  }
142
171
  if (entry.$units !== undefined) {
@@ -179,7 +208,7 @@ export function findContentTypeSchema(
179
208
  continue;
180
209
  }
181
210
  const src = def.source.replace(/^\.\//, "").replace(/\/$/, "");
182
- const hasExt = src.includes(".") && !src.endsWith("/");
211
+ const hasExt = src.includes(".") && !src.endsWith("/") && !src.includes(LOCALE_PLACEHOLDER);
183
212
  if (hasExt) {
184
213
  if (docPath === src || docPath.endsWith(`/${src}`)) {
185
214
  return { name, schema: def.schema };
@@ -191,7 +220,20 @@ export function findContentTypeSchema(
191
220
  : (formatByName(def.format)?.extensions[0] ??
192
221
  defaultContentFormat()?.extensions[0] ??
193
222
  ".json");
194
- if (docPath.startsWith(`${src}/`) && docPath.endsWith(ext)) {
223
+ /* A localized source is N directories, one content type: `content/posts/{locale}` matches
224
+ `content/posts/fr/hello.md` and carries the SAME schema, because a translation is the same
225
+ post. Without this the placeholder was compared literally, matched no path anybody has, and
226
+ a translated entry showed no frontmatter fields at all. */
227
+ const prefix = src.includes(LOCALE_PLACEHOLDER)
228
+ ? new RegExp(
229
+ `^${src
230
+ .split(LOCALE_PLACEHOLDER)
231
+ .map((part) => part.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))
232
+ .join("[^/]+")}/`,
233
+ )
234
+ : null;
235
+ const matches = prefix ? prefix.test(docPath) : docPath.startsWith(`${src}/`);
236
+ if (matches && docPath.endsWith(ext)) {
195
237
  return { name, schema: def.schema };
196
238
  }
197
239
  }
@@ -1,110 +0,0 @@
1
- import {
2
- getPlatform,
3
- hasPlatform
4
- } from "./studio-sas7mt3k.js";
5
-
6
- // src/services/settings-store.ts
7
- var PERSISTED_SETTINGS_KEYS = [
8
- "jx.ai.openaiKey",
9
- "jx.ai.baseUrl",
10
- "jx.ai.model",
11
- "jx.cf.token",
12
- "jx.cf.accountId"
13
- ];
14
- function readLocal(key) {
15
- try {
16
- return globalThis.localStorage?.getItem(key) ?? "";
17
- } catch {
18
- return "";
19
- }
20
- }
21
- async function hydrateSettings() {
22
- if (!hasPlatform()) {
23
- return;
24
- }
25
- const platform = getPlatform();
26
- if (!platform.getSettings) {
27
- return;
28
- }
29
- let stored;
30
- try {
31
- stored = await platform.getSettings();
32
- } catch {
33
- return;
34
- }
35
- const merged = { ...stored };
36
- let needsMigration = false;
37
- for (const key of PERSISTED_SETTINGS_KEYS) {
38
- const backendValue = stored[key];
39
- if (backendValue) {
40
- try {
41
- globalThis.localStorage?.setItem(key, backendValue);
42
- } catch {}
43
- } else {
44
- const localValue = readLocal(key);
45
- if (localValue) {
46
- merged[key] = localValue;
47
- needsMigration = true;
48
- }
49
- }
50
- }
51
- if (needsMigration && platform.saveSettings) {
52
- platform.saveSettings(merged).catch(() => {});
53
- }
54
- }
55
- function persistSettings() {
56
- if (!hasPlatform()) {
57
- return;
58
- }
59
- const platform = getPlatform();
60
- if (!platform.saveSettings) {
61
- return;
62
- }
63
- const settings = {};
64
- for (const key of PERSISTED_SETTINGS_KEYS) {
65
- const value = readLocal(key);
66
- if (value) {
67
- settings[key] = value;
68
- }
69
- }
70
- platform.saveSettings(settings).catch(() => {});
71
- }
72
-
73
- // src/services/cf-settings.ts
74
- var TOKEN_STORAGE = "jx.cf.token";
75
- var ACCOUNT_STORAGE = "jx.cf.accountId";
76
- function read(key) {
77
- try {
78
- return globalThis.localStorage?.getItem(key) ?? "";
79
- } catch {
80
- return "";
81
- }
82
- }
83
- function write(key, value) {
84
- try {
85
- const trimmed = (value || "").trim();
86
- if (trimmed) {
87
- globalThis.localStorage?.setItem(key, trimmed);
88
- } else {
89
- globalThis.localStorage?.removeItem(key);
90
- }
91
- } catch {}
92
- persistSettings();
93
- }
94
- function getCfToken() {
95
- return read(TOKEN_STORAGE);
96
- }
97
- function setCfToken(token) {
98
- write(TOKEN_STORAGE, token);
99
- }
100
- function getCfAccountId() {
101
- return read(ACCOUNT_STORAGE);
102
- }
103
- function setCfAccountId(accountId) {
104
- write(ACCOUNT_STORAGE, accountId);
105
- }
106
-
107
- export { hydrateSettings, persistSettings, getCfToken, setCfToken, getCfAccountId, setCfAccountId };
108
-
109
- //# debugId=521A72F1905394A564756E2164756E21
110
- //# sourceMappingURL=studio-a7pzveq1.js.map