@jxsuite/studio 2.4.1 → 2.4.3

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 (36) hide show
  1. package/dist/chunks/{content-source-cn9te96r.js → content-source-k9724vcv.js} +8 -8
  2. package/dist/chunks/{convert-to-repeater-65gjgdst.js → convert-to-repeater-k10dvcc7.js} +7 -7
  3. package/dist/chunks/{extension-sections-mdgmqrry.js → extension-sections-0ada0xcw.js} +11 -11
  4. package/dist/chunks/{files-dsh5g8j4.js → files-yew27sn9.js} +12 -12
  5. package/dist/chunks/{format-host-m9h45a79.js → format-host-wewba4nq.js} +2 -2
  6. package/dist/chunks/{git-panel-rfs8pqjj.js → git-panel-2dh1aw50.js} +7 -7
  7. package/dist/chunks/{layers-panel-6cj5nf6a.js → layers-panel-qnsghwwd.js} +10 -10
  8. package/dist/chunks/{publish-panel-b0xh7b4y.js → publish-panel-a0pdft5w.js} +5 -5
  9. package/dist/chunks/{quick-search-a83fhnsh.js → quick-search-9avyc4pc.js} +13 -13
  10. package/dist/chunks/{right-panel-hrw5kdh5.js → right-panel-kcnhtc5j.js} +9 -9
  11. package/dist/chunks/{studio-4m39t01b.js → studio-0b6fs05q.js} +5 -5
  12. package/dist/chunks/{studio-gqpbrrj5.js → studio-26sv44br.js} +8 -8
  13. package/dist/chunks/{studio-d1f2rqxh.js → studio-28xc8ws4.js} +4 -4
  14. package/dist/chunks/{studio-675v08qj.js → studio-aweyr4v1.js} +2 -2
  15. package/dist/chunks/{studio-0svvdm0a.js → studio-cz2j1c0g.js} +4 -4
  16. package/dist/chunks/{studio-qymkxbcq.js → studio-dgj7gq44.js} +3 -3
  17. package/dist/chunks/{studio-rkr6pm84.js → studio-ef8t53vc.js} +3 -3
  18. package/dist/chunks/{studio-1d6bm7eg.js → studio-f5yyvqxv.js} +7 -7
  19. package/dist/chunks/{studio-rz861p2b.js → studio-hb7c46jy.js} +5 -5
  20. package/dist/chunks/{studio-gfzyxa0m.js → studio-hyn3vhwr.js} +5 -5
  21. package/dist/chunks/{studio-9sxn0eaa.js → studio-jnakhg87.js} +4 -4
  22. package/dist/chunks/{studio-tdt7b6qb.js → studio-ke244pam.js} +6 -6
  23. package/dist/chunks/{studio-h6553a8h.js → studio-pyrw8h0w.js} +9 -7
  24. package/dist/chunks/{studio-kfq9ed73.js → studio-ra7ar50f.js} +3 -3
  25. package/dist/chunks/{studio-40fm3hyr.js → studio-rqemfhz6.js} +2 -2
  26. package/dist/chunks/{studio-91sdrn9x.js → studio-sccge2wf.js} +2 -2
  27. package/dist/chunks/{studio-n9k15qdg.js → studio-v1va0vhd.js} +8 -8
  28. package/dist/chunks/{studio-jand3kjg.js → studio-v2c5pmjj.js} +10 -10
  29. package/dist/chunks/{studio-qb6vy1z1.js → studio-ws6ef6js.js} +8 -8
  30. package/dist/iframe-entry.js +35 -3
  31. package/dist/studio.js +131 -32
  32. package/package.json +9 -9
  33. package/src/canvas/canvas-origin.ts +14 -2
  34. package/src/canvas/iframe-inline-edit.ts +79 -25
  35. package/src/platforms/cloud.ts +61 -2
  36. package/src/types.ts +3 -1
package/dist/studio.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  seedPublishConnected
3
- } from "./chunks/studio-qymkxbcq.js";
3
+ } from "./chunks/studio-dgj7gq44.js";
4
4
  import"./chunks/studio-wdt9y92a.js";
5
5
  import {
6
6
  ElementResolutionController,
@@ -67,7 +67,7 @@ import {
67
67
  registerStyleCommands,
68
68
  render as render3,
69
69
  setInspectorTab
70
- } from "./chunks/studio-1d6bm7eg.js";
70
+ } from "./chunks/studio-f5yyvqxv.js";
71
71
  import {
72
72
  CommandUnavailableError,
73
73
  chordFromEvent,
@@ -99,7 +99,7 @@ import {
99
99
  showSlashMenu,
100
100
  showSlashMenuAtRect,
101
101
  suppressBlockActionBar
102
- } from "./chunks/studio-qb6vy1z1.js";
102
+ } from "./chunks/studio-ws6ef6js.js";
103
103
  import {
104
104
  addableContexts,
105
105
  defaultCommands,
@@ -111,7 +111,7 @@ import {
111
111
  tokenLabel,
112
112
  transposeStylebookStyle,
113
113
  writeTokenOverride
114
- } from "./chunks/studio-gfzyxa0m.js";
114
+ } from "./chunks/studio-hyn3vhwr.js";
115
115
  import {
116
116
  DERIVE_PRESETS,
117
117
  JxColorPopover,
@@ -144,7 +144,7 @@ import {
144
144
  spNumberField,
145
145
  spTextArea,
146
146
  spTextField
147
- } from "./chunks/studio-n9k15qdg.js";
147
+ } from "./chunks/studio-v1va0vhd.js";
148
148
  import {
149
149
  LitElement,
150
150
  css
@@ -160,7 +160,7 @@ import {
160
160
  import {
161
161
  initQuickSearch,
162
162
  openQuickSearch
163
- } from "./chunks/studio-9sxn0eaa.js";
163
+ } from "./chunks/studio-jnakhg87.js";
164
164
  import {
165
165
  addRecentProject,
166
166
  clearRecentProjects,
@@ -187,10 +187,10 @@ import {
187
187
  removeRecentProject,
188
188
  renderFilesTemplate,
189
189
  seedEntry
190
- } from "./chunks/studio-jand3kjg.js";
190
+ } from "./chunks/studio-v2c5pmjj.js";
191
191
  import {
192
192
  computeWindow
193
- } from "./chunks/studio-91sdrn9x.js";
193
+ } from "./chunks/studio-sccge2wf.js";
194
194
  import {
195
195
  autoSyncProjectOnOpen,
196
196
  cleanupGitPanel,
@@ -211,13 +211,13 @@ import {
211
211
  registerSourceControlCommands,
212
212
  renderGitPanel,
213
213
  stripRange
214
- } from "./chunks/studio-tdt7b6qb.js";
214
+ } from "./chunks/studio-ke244pam.js";
215
215
  import {
216
216
  createPanelScheduler
217
217
  } from "./chunks/studio-8jpzqk5s.js";
218
218
  import {
219
219
  selectContributedEntry
220
- } from "./chunks/studio-rz861p2b.js";
220
+ } from "./chunks/studio-hb7c46jy.js";
221
221
  import {
222
222
  notifySettingsDocument,
223
223
  onSettingsDocumentChanged,
@@ -238,7 +238,7 @@ import {
238
238
  renderForm,
239
239
  renderProvenanceChip,
240
240
  resolveContextPointer
241
- } from "./chunks/studio-rkr6pm84.js";
241
+ } from "./chunks/studio-ef8t53vc.js";
242
242
  import {
243
243
  validateDoc,
244
244
  validateProjectConfig
@@ -263,7 +263,7 @@ import {
263
263
  registerGridCommands,
264
264
  scanLibrary,
265
265
  uploadDirForCategory
266
- } from "./chunks/studio-4m39t01b.js";
266
+ } from "./chunks/studio-0b6fs05q.js";
267
267
  import {
268
268
  live
269
269
  } from "./chunks/studio-c31yb4t6.js";
@@ -307,7 +307,7 @@ import {
307
307
  setIncludeDrafts,
308
308
  startFsSync,
309
309
  writesForTurn
310
- } from "./chunks/studio-gqpbrrj5.js";
310
+ } from "./chunks/studio-26sv44br.js";
311
311
  import"./chunks/studio-2w11grav.js";
312
312
  import"./chunks/studio-bw5dz6km.js";
313
313
  import {
@@ -316,18 +316,18 @@ import {
316
316
  isImage,
317
317
  setMediaChangedHandler,
318
318
  uploadAssets
319
- } from "./chunks/studio-0svvdm0a.js";
319
+ } from "./chunks/studio-cz2j1c0g.js";
320
320
  import {
321
321
  beginActivity,
322
322
  registerActivityPanel,
323
323
  showProgressModal
324
- } from "./chunks/studio-d1f2rqxh.js";
324
+ } from "./chunks/studio-28xc8ws4.js";
325
325
  import {
326
326
  currentDeploy,
327
327
  latestDeployment,
328
328
  noteDeployment,
329
329
  platformSupportsPublish
330
- } from "./chunks/studio-675v08qj.js";
330
+ } from "./chunks/studio-aweyr4v1.js";
331
331
  import {
332
332
  CATEGORIES,
333
333
  SPAN_FULL_RENDER,
@@ -439,7 +439,7 @@ import {
439
439
  timeSpan,
440
440
  timeSpanAsync,
441
441
  updateActivePanelHeaders
442
- } from "./chunks/studio-h6553a8h.js";
442
+ } from "./chunks/studio-pyrw8h0w.js";
443
443
  import {
444
444
  BOTTOM_TAB_IDS,
445
445
  BUFFER_COMMIT,
@@ -606,7 +606,7 @@ import {
606
606
  updateSiteConfig,
607
607
  view,
608
608
  workspace
609
- } from "./chunks/studio-kfq9ed73.js";
609
+ } from "./chunks/studio-ra7ar50f.js";
610
610
  import {
611
611
  classMap,
612
612
  ifDefined,
@@ -669,7 +669,7 @@ import {
669
669
  mountStillWanted,
670
670
  refreshExtensionUi,
671
671
  refreshFormats
672
- } from "./chunks/studio-40fm3hyr.js";
672
+ } from "./chunks/studio-rqemfhz6.js";
673
673
  import {
674
674
  getPlatform,
675
675
  hasPlatform,
@@ -16807,7 +16807,7 @@ function entryIdsFor(collection) {
16807
16807
  if (known) {
16808
16808
  return known;
16809
16809
  }
16810
- const pending = import("./chunks/content-source-cn9te96r.js").then((m) => m.listCollectionEntryIds(collection));
16810
+ const pending = import("./chunks/content-source-k9724vcv.js").then((m) => m.listCollectionEntryIds(collection));
16811
16811
  entryIdCache.set(collection, pending);
16812
16812
  return pending;
16813
16813
  }
@@ -17068,7 +17068,7 @@ function renderEntryMode(surface, tab) {
17068
17068
  });
17069
17069
  }
17070
17070
  async function openEntryEditor(path) {
17071
- const { openFileInTab: openFileInTab2 } = await import("./chunks/files-dsh5g8j4.js");
17071
+ const { openFileInTab: openFileInTab2 } = await import("./chunks/files-yew27sn9.js");
17072
17072
  await openFileInTab2(path);
17073
17073
  for (const [id, tab] of workspace.tabs.entries()) {
17074
17074
  if (tab.documentPath !== path) {
@@ -17129,7 +17129,7 @@ async function createEntry(collectionName) {
17129
17129
  });
17130
17130
  return null;
17131
17131
  }
17132
- const { createFileIn: createFileIn2 } = await import("./chunks/files-dsh5g8j4.js");
17132
+ const { createFileIn: createFileIn2 } = await import("./chunks/files-yew27sn9.js");
17133
17133
  const created = await createFileIn2({
17134
17134
  content,
17135
17135
  dir: collection.dir,
@@ -22349,7 +22349,7 @@ function groupProblems(records = problems) {
22349
22349
  return groups;
22350
22350
  }
22351
22351
  function openProblemPath(path) {
22352
- import("./chunks/files-dsh5g8j4.js").then((m) => m.openFileInTab(path));
22352
+ import("./chunks/files-yew27sn9.js").then((m) => m.openFileInTab(path));
22353
22353
  }
22354
22354
  function actionTpl(record) {
22355
22355
  const id = record.action;
@@ -28687,6 +28687,71 @@ function renderAiPanelTemplate() {
28687
28687
  `;
28688
28688
  }
28689
28689
 
28690
+ // ../schema/src/component-meta.ts
28691
+ function collectSlotDefs(node, out = []) {
28692
+ if (!node || typeof node !== "object" || Array.isArray(node)) {
28693
+ return out;
28694
+ }
28695
+ const el = node;
28696
+ if (el.tagName === "slot") {
28697
+ const attrs = el.attributes;
28698
+ const rawName = attrs?.name;
28699
+ const name = typeof rawName === "string" ? rawName.trim() : "";
28700
+ const { children } = el;
28701
+ out.push({
28702
+ name,
28703
+ ...Array.isArray(children) && children.length > 0 ? { fallback: children } : {}
28704
+ });
28705
+ }
28706
+ if (Array.isArray(el.children)) {
28707
+ for (const c of el.children) {
28708
+ collectSlotDefs(c, out);
28709
+ }
28710
+ }
28711
+ return out;
28712
+ }
28713
+ function isPropEntry(def) {
28714
+ if (def == null) {
28715
+ return false;
28716
+ }
28717
+ if (typeof def !== "object") {
28718
+ return true;
28719
+ }
28720
+ const obj = def;
28721
+ return !obj.$prototype && !obj.$handler && !obj.$compute;
28722
+ }
28723
+ function componentMetaFrom(doc, path) {
28724
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
28725
+ return null;
28726
+ }
28727
+ const content = doc;
28728
+ const { tagName } = content;
28729
+ if (typeof tagName !== "string" || !tagName.includes("-")) {
28730
+ return null;
28731
+ }
28732
+ const slots = collectSlotDefs(content);
28733
+ const state2 = content.state ?? {};
28734
+ return {
28735
+ $id: typeof content.$id === "string" && content.$id ? content.$id : null,
28736
+ hasElements: Array.isArray(content.$elements) && content.$elements.length > 0,
28737
+ path,
28738
+ props: Object.entries(state2).filter(([, def]) => isPropEntry(def)).map(([name, def]) => {
28739
+ if (typeof def !== "object" || def === null) {
28740
+ return { default: def, name, type: typeof def };
28741
+ }
28742
+ const obj = def;
28743
+ return {
28744
+ default: obj.default,
28745
+ format: obj.format,
28746
+ name,
28747
+ type: obj.type
28748
+ };
28749
+ }),
28750
+ ...slots.length > 0 ? { slots } : {},
28751
+ tagName
28752
+ };
28753
+ }
28754
+
28690
28755
  // ../protocol/src/problem.ts
28691
28756
  var PROBLEM_TYPE_BASE = "https://jxsuite.com/problems/";
28692
28757
  function problemSlug(type) {
@@ -29098,7 +29163,41 @@ function createCloudPlatform(project) {
29098
29163
  return data.result;
29099
29164
  },
29100
29165
  async discoverComponents() {
29101
- return [];
29166
+ const SKIP = new Set(["node_modules", "dist", ".git", ".claude", ".obsidian", "build"]);
29167
+ const MAX_FILES = 400;
29168
+ const MAX_DEPTH = 6;
29169
+ const jsonPaths = [];
29170
+ const walk = async (dir, depth) => {
29171
+ if (depth > MAX_DEPTH || jsonPaths.length >= MAX_FILES) {
29172
+ return;
29173
+ }
29174
+ let entries;
29175
+ try {
29176
+ entries = await platform.listDirectory(dir);
29177
+ } catch {
29178
+ return;
29179
+ }
29180
+ const dirs = [];
29181
+ for (const entry of entries) {
29182
+ if (entry.type === "directory") {
29183
+ if (!SKIP.has(entry.name) && !entry.name.startsWith(".")) {
29184
+ dirs.push(entry.path);
29185
+ }
29186
+ } else if (entry.name.endsWith(".json") && jsonPaths.length < MAX_FILES) {
29187
+ jsonPaths.push(entry.path);
29188
+ }
29189
+ }
29190
+ await Promise.all(dirs.map(async (d) => walk(d, depth + 1)));
29191
+ };
29192
+ await walk("", 0);
29193
+ const metas = await Promise.all(jsonPaths.map(async (path) => {
29194
+ try {
29195
+ return componentMetaFrom(JSON.parse(await platform.readFile(path)), path);
29196
+ } catch {
29197
+ return null;
29198
+ }
29199
+ }));
29200
+ return metas.filter((m) => m !== null);
29102
29201
  },
29103
29202
  async codeService() {
29104
29203
  return null;
@@ -58591,9 +58690,9 @@ function renderDefsEditor(container) {
58591
58690
 
58592
58691
  // src/version.ts
58593
58692
  var APP_NAME = "Jx Studio";
58594
- var VERSION = "2.4.1";
58595
- var BUILD_DATE = "2026-08-24T11:52:26.010Z";
58596
- var GIT_COMMIT = "eb4fe5d";
58693
+ var VERSION = "2.4.3";
58694
+ var BUILD_DATE = "2026-08-25T03:20:06.206Z";
58695
+ var GIT_COMMIT = "624ef53";
58597
58696
  var LINKS = {
58598
58697
  github: "https://github.com/jxsuite/jx",
58599
58698
  docs: "https://github.com/jxsuite/jx#readme",
@@ -59670,7 +59769,7 @@ async function openProjectSettings(section) {
59670
59769
  showSettingsDocument();
59671
59770
  notifySettingsDocument();
59672
59771
  try {
59673
- const { syncExtensionSettingsSections } = await import("./chunks/extension-sections-mdgmqrry.js");
59772
+ const { syncExtensionSettingsSections } = await import("./chunks/extension-sections-0ada0xcw.js");
59674
59773
  await syncExtensionSettingsSections();
59675
59774
  } catch (error) {
59676
59775
  notify.warn(`Could not load extension settings sections — ${errorMessage(error)}`, {
@@ -60294,7 +60393,7 @@ function publishCommands() {
60294
60393
  name: "set_up_publishing"
60295
60394
  },
60296
60395
  run: async () => {
60297
- const { openPublishPanel } = await import("./chunks/publish-panel-b0xh7b4y.js");
60396
+ const { openPublishPanel } = await import("./chunks/publish-panel-a0pdft5w.js");
60298
60397
  openPublishPanel();
60299
60398
  },
60300
60399
  title: "Set Up Publishing"
@@ -61016,7 +61115,7 @@ function i18nCommands() {
61016
61115
  });
61017
61116
  return;
61018
61117
  }
61019
- const { openFileInTab: openFileInTab2 } = await import("./chunks/files-dsh5g8j4.js");
61118
+ const { openFileInTab: openFileInTab2 } = await import("./chunks/files-yew27sn9.js");
61020
61119
  await openFileInTab2(path);
61021
61120
  },
61022
61121
  title: "Open Translation"
@@ -61063,7 +61162,7 @@ function i18nCommands() {
61063
61162
  return;
61064
61163
  }
61065
61164
  const cut = path.lastIndexOf("/");
61066
- const { createFileIn: createFileIn2, openFileInTab: openFileInTab2 } = await import("./chunks/files-dsh5g8j4.js");
61165
+ const { createFileIn: createFileIn2, openFileInTab: openFileInTab2 } = await import("./chunks/files-yew27sn9.js");
61067
61166
  const created = await createFileIn2({
61068
61167
  content,
61069
61168
  dir: cut === -1 ? "." : path.slice(0, cut),
@@ -61876,5 +61975,5 @@ export {
61876
61975
  hasUnsavedTabs
61877
61976
  };
61878
61977
 
61879
- //# debugId=C20E21297FE0976064756E2164756E21
61978
+ //# debugId=980E02AF5DBA79D264756E2164756E21
61880
61979
  //# sourceMappingURL=studio.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "2.4.1",
3
+ "version": "2.4.3",
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.11",
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.3",
61
+ "@jxsuite/collab": "^0.8.2",
62
+ "@jxsuite/create": "^1.3.6",
63
+ "@jxsuite/formulas": "^0.0.13",
64
+ "@jxsuite/markup": "^0.4.6",
65
+ "@jxsuite/protocol": "^1.2.1",
66
+ "@jxsuite/runtime": "^2.0.5",
67
+ "@jxsuite/schema": "^1.8.1",
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",
@@ -42,12 +42,24 @@ export function canvasBaseOrigin(): string {
42
42
  * @returns {string} A base URL ending in `/`, safe to pass to `new URL(relativePath, base)`.
43
43
  */
44
44
  export function documentBase(projectRoot?: string): string {
45
+ const origin = canvasBaseOrigin();
45
46
  const declared = hasPlatform() ? getPlatform().documentBaseUrl : undefined;
46
47
  if (declared) {
47
- return declared.endsWith("/") ? declared : `${declared}/`;
48
+ const withSlash = declared.endsWith("/") ? declared : `${declared}/`;
49
+ /*
50
+ * ABSOLUTE, ALWAYS. The caller uses this as `new URL(documentPath, base)`, and a `base` that is
51
+ * itself relative throws `Failed to construct 'URL': Invalid base URL` — which is not a failed
52
+ * fetch but a failed MOUNT: the canvas renders nothing at all.
53
+ *
54
+ * Jx Cloud declares a root-relative base (`/api/v1/p/:owner/:repo/:branch/studio/raw/`), which
55
+ * is the natural thing to declare and must keep working. Resolving it against the canvas origin
56
+ * here means a platform may declare either shape. An already-absolute URL passes through
57
+ * unchanged, because that is what `new URL` does with one.
58
+ */
59
+ return new URL(withSlash, `${origin}/`).href;
48
60
  }
49
61
  const root = projectRoot || "";
50
- return `${canvasBaseOrigin()}/${root ? `${root}/` : ""}`;
62
+ return `${origin}/${root ? `${root}/` : ""}`;
51
63
  }
52
64
 
53
65
  /**
@@ -38,6 +38,7 @@ import { adjacentBlock, blocksInOrder } from "./iframe-position";
38
38
  import { isTagActiveInSelection, toggleInlineFormat } from "../editor/inline-format";
39
39
  import { applyLink, insertTemplateToken, linkStateForSelection } from "../editor/inline-link";
40
40
  import { restoreTemplateExpressions } from "../utils/edit-display";
41
+ import { RESERVED_KEYS, camelToKebab } from "@jxsuite/runtime";
41
42
  import { rectOfRange } from "../utils/geometry";
42
43
  import { getNodeAtPath } from "../state";
43
44
  import { parseJxPath, serializeJxPath } from "./path-mapping";
@@ -86,6 +87,77 @@ function ownerInstanceOf(el: HTMLElement): HTMLElement | null {
86
87
  return null;
87
88
  }
88
89
 
90
+ /**
91
+ * Whether the instance node delivers `prop` by some route OTHER than `$props` — in which case
92
+ * "absent from `$props`" does not mean "unset", and a `$props` write would add a SECOND source for
93
+ * one rendered value.
94
+ *
95
+ * The routes are the property bridge's own, in the order `connectedCallback` reads them
96
+ * (`compiler.md` §4.4) — a `data-jx-props` payload, literal `props.*` attributes, then JS
97
+ * properties set before connection — plus the one that reaches that third source without going
98
+ * through `$props`. Each was reproduced against the real runtime:
99
+ *
100
+ * 1. `attributes: { "data-jx-props": "{…}" }` — the compiler's pre-rendered payload, parsed into state
101
+ * before anything else. Studio edits source documents rather than build output, so this is the
102
+ * rare one; it is here because the bridge reads it.
103
+ * 2. The JSON shorthand `attributes: { "props.text": "…" }`. `connectedCallback` collects `props.*`
104
+ * attributes into state, so the marker renders that text while `$props` is empty. This repo's
105
+ * own site carries 95 of them.
106
+ * 3. A prop whose name collides with a REFLECTED HTMLElement property — `title`, `role`, `id`, `lang`,
107
+ * `dir`, `slot`, `hidden`. `setAttribute("title", …)` writes the JS property as a side effect,
108
+ * and the merge `key in this && this[key] !== undefined` picks it up. 41 shipped starter
109
+ * components declare `title` or `role` as state. Matched the way `instanceSupplies` matches:
110
+ * attribute names are case-insensitive, and an ARIA-style reflection carries a kebab attribute
111
+ * for a camelCase property (`ariaLabel` → `aria-label`).
112
+ * 4. A TOP-LEVEL key on the instance node — `{ "tagName": "x-card", "heading": "Local" }`. This is the
113
+ * property-first interface written straight onto the node: `applyProperties` sets every
114
+ * non-reserved key as a JS property, which is exactly what the merge then reads back off `this`.
115
+ * Reserved keys (`name`, `items`, `children`, …) are skipped there and so are skipped here —
116
+ * refusing on one would block a prop that the key never delivered.
117
+ *
118
+ * Editing any of them writes `$props[prop]` and leaves the other value standing. For case 3 the
119
+ * attribute WINS (applyAttributes runs after the $props assignment) so the edit is simply
120
+ * invisible; for the rest `$props` wins and the stale value waits — clearing the prop later
121
+ * resurrects it. Refusing keeps the properties panel, which edits the real source, as the one way
122
+ * in.
123
+ */
124
+ function deliveredOutsideProps(node: JxMutableNode | undefined, prop: string): boolean {
125
+ if (!node) {
126
+ return false;
127
+ }
128
+ const attrs = node.attributes as Record<string, unknown> | undefined;
129
+ if (attrs) {
130
+ if (attrs[`props.${prop}`] !== undefined) {
131
+ return true;
132
+ }
133
+ const reflected = new Set([prop.toLowerCase(), camelToKebab(prop).toLowerCase()]);
134
+ for (const [name, val] of Object.entries(attrs)) {
135
+ if (val !== undefined && reflected.has(name.toLowerCase())) {
136
+ return true;
137
+ }
138
+ }
139
+ const payload = attrs["data-jx-props"];
140
+ if (typeof payload === "string" && payloadNames(payload, prop)) {
141
+ return true;
142
+ }
143
+ }
144
+ // `$`-prefixed keys are scope bindings, never DOM properties — `applyProperties` skips them too.
145
+ if (RESERVED_KEYS.has(prop) || prop.startsWith("$")) {
146
+ return false;
147
+ }
148
+ return (node as Record<string, unknown>)[prop] !== undefined;
149
+ }
150
+
151
+ /** Whether a `data-jx-props` JSON payload carries `prop`. Unparseable payloads deliver nothing. */
152
+ function payloadNames(payload: string, prop: string): boolean {
153
+ try {
154
+ const parsed: unknown = JSON.parse(payload);
155
+ return typeof parsed === "object" && parsed !== null && prop in parsed;
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
160
+
89
161
  /** Locate the rendered element for a document path via its stamped `data-jx-path`. */
90
162
  function elementForPath(container: HTMLElement, path: JxPath): HTMLElement | null {
91
163
  const serialized = serializeJxPath(path);
@@ -267,10 +339,12 @@ export function startIframeInlineEdit(
267
339
  };
268
340
 
269
341
  /**
270
- * Whether the instance's RAW `$props[prop]` value (from the pre-edit-display shadow doc) is
271
- * plain-text editable. Template-valued (`"${...}"`) and `$ref`-valued props render display sugar,
272
- * not the value — committing typed text would silently destroy the binding, so they're blocked.
273
- * An absent getShadowDoc is permissive (tests, same contract as getMode).
342
+ * Whether the instance's prop (read from the pre-edit-display shadow doc) is plain-text editable
343
+ * AND `$props` is the place it lives — a value the instance delivers by another route belongs to
344
+ * that route, and `deliveredOutsideProps` refuses it. Template-valued (`"${...}"`) and
345
+ * `$ref`-valued props render display sugar, not the value — committing typed text would silently
346
+ * destroy the binding, so they're blocked. An absent getShadowDoc is permissive (tests, same
347
+ * contract as getMode).
274
348
  */
275
349
  const propEditableAt = (hostPath: JxPath, prop: string): boolean => {
276
350
  const shadowDoc = opts?.getShadowDoc?.();
@@ -278,27 +352,7 @@ export function startIframeInlineEdit(
278
352
  return true;
279
353
  }
280
354
  const node = getNodeAtPath(shadowDoc, hostPath) as JxMutableNode | undefined;
281
- /*
282
- * `$props` IS NOT THE ONLY PLACE A PROP VALUE LIVES, and "absent from $props" is therefore not
283
- * the same question as "unset". Two shapes deliver one through `attributes` instead, and both
284
- * were reproduced against the real runtime:
285
- *
286
- * 1. The JSON shorthand `attributes: { "props.text": "…" }`. `connectedCallback` collects
287
- * `props.*` attributes into state, so the marker renders that text while `$props` is
288
- * empty. This repo's own site carries 95 of them.
289
- * 2. A prop whose name collides with a REFLECTED HTMLElement property — `title`, `role`,
290
- * `id`, `lang`, `dir`, `slot`, `hidden`. `setAttribute("title", …)` writes the JS property
291
- * as a side effect, and the merge `key in this && this[key] !== undefined` picks it up.
292
- * 41 shipped starter components declare `title` or `role` as state.
293
- *
294
- * Editing either would write `$props[prop]` and leave the attribute standing — two sources for
295
- * one rendered value. In case 2 the attribute WINS (applyAttributes runs after the $props
296
- * assignment), so the edit is simply invisible; in case 1 clearing the prop later resurrects
297
- * the old text. Refusing keeps the properties panel, which edits the attribute itself, as the
298
- * one way in.
299
- */
300
- const attrs = node?.attributes as Record<string, unknown> | undefined;
301
- if (attrs?.[`props.${prop}`] !== undefined || attrs?.[prop] !== undefined) {
355
+ if (deliveredOutsideProps(node, prop)) {
302
356
  return false;
303
357
  }
304
358
  const raw = (node?.$props as Record<string, unknown> | undefined)?.[prop];
@@ -15,8 +15,10 @@ import { negotiateCollab } from "@jxsuite/collab/negotiate";
15
15
  import type { CollabNegotiation } from "@jxsuite/collab/negotiate";
16
16
  import type { WsCollabConnection } from "@jxsuite/collab/client";
17
17
  import type { ProjectConfig } from "@jxsuite/schema/types";
18
+ import { componentMetaFrom } from "@jxsuite/schema/component-meta";
18
19
  import type {
19
20
  AccountStatus,
21
+ ComponentMeta,
20
22
  CreateProjectDestination,
21
23
  DirEntry,
22
24
  ExtensionsInfo,
@@ -518,9 +520,66 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
518
520
 
519
521
  // ─── Components / code services (cloud: static-only posture) ──────────
520
522
 
523
+ /**
524
+ * Component discovery, by READING rather than executing.
525
+ *
526
+ * This returned `[]` under "no execution of project JS in the cloud", and the cost was not the
527
+ * loss of discovery — it was the canvas. `canvas-live-render` injects the `$elements` a
528
+ * document's tags need only when the registry is non-empty, so an empty registry meant nothing
529
+ * was ever registered, no component was ever fetched, and every component instance rendered as
530
+ * an unregistered custom element: an empty inline box. A page built from components came up as
531
+ * blank space between the layout's chrome.
532
+ *
533
+ * Discovery does not need to execute anything. For a JSON component it is a file read and a
534
+ * property lookup — `componentMetaFrom` is that function, shared with the two Bun backends so
535
+ * "which `state` entries are props" has one answer. Non-JSON component formats DO need a
536
+ * project-supplied parser, and those stay out: the posture is kept where it actually applies
537
+ * rather than across the whole feature.
538
+ *
539
+ * Bounded rather than unbounded: the walk skips the directories a scan has no business in and
540
+ * stops at a depth and a file count, because this runs against a remote session over HTTP and a
541
+ * pathological tree must not turn opening a project into thousands of requests.
542
+ */
521
543
  async discoverComponents() {
522
- // No execution of project JS in the cloud (MVP security posture).
523
- return [];
544
+ const SKIP = new Set(["node_modules", "dist", ".git", ".claude", ".obsidian", "build"]);
545
+ const MAX_FILES = 400;
546
+ const MAX_DEPTH = 6;
547
+ const jsonPaths: string[] = [];
548
+
549
+ const walk = async (dir: string, depth: number): Promise<void> => {
550
+ if (depth > MAX_DEPTH || jsonPaths.length >= MAX_FILES) {
551
+ return;
552
+ }
553
+ let entries: DirEntry[];
554
+ try {
555
+ entries = await platform.listDirectory(dir);
556
+ } catch {
557
+ return; // A directory that vanished mid-walk is not a discovery failure.
558
+ }
559
+ const dirs: string[] = [];
560
+ for (const entry of entries) {
561
+ if (entry.type === "directory") {
562
+ if (!SKIP.has(entry.name) && !entry.name.startsWith(".")) {
563
+ dirs.push(entry.path);
564
+ }
565
+ } else if (entry.name.endsWith(".json") && jsonPaths.length < MAX_FILES) {
566
+ jsonPaths.push(entry.path);
567
+ }
568
+ }
569
+ await Promise.all(dirs.map(async (d) => walk(d, depth + 1)));
570
+ };
571
+ await walk("", 0);
572
+
573
+ const metas = await Promise.all(
574
+ jsonPaths.map(async (path) => {
575
+ try {
576
+ return componentMetaFrom(JSON.parse(await platform.readFile(path)), path);
577
+ } catch {
578
+ return null; // Not JSON, not readable, or not a component — all the same answer here.
579
+ }
580
+ }),
581
+ );
582
+ return metas.filter((m): m is NonNullable<typeof m> => m !== null) as ComponentMeta[];
524
583
  },
525
584
 
526
585
  async codeService() {
package/src/types.ts CHANGED
@@ -195,7 +195,9 @@ export interface StudioPlatform {
195
195
  * every `$ref` fetch landed on the SPA fallback — which answers HTML at **200**, so `res.ok`
196
196
  * passed and the parse died on `Unexpected token '<'`. Images failed the same way in silence.
197
197
  *
198
- * Must end in `/`; a project-relative path is appended to it verbatim.
198
+ * May be absolute or root-relative — a root-relative value is resolved against the canvas origin,
199
+ * because the canvas uses this as `new URL(path, base)` and a relative BASE throws. A missing
200
+ * trailing `/` is added: without it `new URL` drops the last segment.
199
201
  */
200
202
  documentBaseUrl?: string;
201
203
  activate: (root?: string) => Promise<void>;