@jxsuite/studio 0.10.1 → 0.11.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,35 +30,36 @@
30
30
  "dependencies": {
31
31
  "@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
32
32
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
33
- "@jxsuite/runtime": "^0.10.0",
34
- "@spectrum-web-components/accordion": "^1.12.0",
35
- "@spectrum-web-components/action-bar": "1.12.0",
36
- "@spectrum-web-components/action-button": "^1.12.0",
37
- "@spectrum-web-components/action-group": "^1.12.0",
38
- "@spectrum-web-components/checkbox": "^1.12.0",
39
- "@spectrum-web-components/color-area": "^1.12.0",
40
- "@spectrum-web-components/color-handle": "^1.12.0",
41
- "@spectrum-web-components/color-slider": "^1.12.0",
42
- "@spectrum-web-components/combobox": "^1.12.0",
43
- "@spectrum-web-components/dialog": "^1.12.0",
44
- "@spectrum-web-components/divider": "^1.12.0",
45
- "@spectrum-web-components/icon": "^1.12.0",
46
- "@spectrum-web-components/icons-workflow": "^1.12.0",
47
- "@spectrum-web-components/menu": "^1.12.0",
48
- "@spectrum-web-components/number-field": "^1.12.0",
49
- "@spectrum-web-components/overlay": "^1.12.0",
33
+ "@jxsuite/runtime": "^0.10.2",
34
+ "@spectrum-web-components/accordion": "^1.12.1",
35
+ "@spectrum-web-components/action-bar": "1.12.1",
36
+ "@spectrum-web-components/action-button": "^1.12.1",
37
+ "@spectrum-web-components/action-group": "^1.12.1",
38
+ "@spectrum-web-components/checkbox": "^1.12.1",
39
+ "@spectrum-web-components/color-area": "^1.12.1",
40
+ "@spectrum-web-components/color-handle": "^1.12.1",
41
+ "@spectrum-web-components/color-slider": "^1.12.1",
42
+ "@spectrum-web-components/combobox": "^1.12.1",
43
+ "@spectrum-web-components/dialog": "^1.12.1",
44
+ "@spectrum-web-components/divider": "^1.12.1",
45
+ "@spectrum-web-components/icon": "^1.12.1",
46
+ "@spectrum-web-components/icons-workflow": "^1.12.1",
47
+ "@spectrum-web-components/menu": "^1.12.1",
48
+ "@spectrum-web-components/number-field": "^1.12.1",
49
+ "@spectrum-web-components/overlay": "^1.12.1",
50
50
  "@spectrum-web-components/overlay-trigger": "^0.1.7",
51
- "@spectrum-web-components/picker": "^1.12.0",
52
- "@spectrum-web-components/picker-button": "^1.12.0",
53
- "@spectrum-web-components/popover": "^1.12.0",
54
- "@spectrum-web-components/search": "^1.12.0",
55
- "@spectrum-web-components/swatch": "^1.12.0",
56
- "@spectrum-web-components/switch": "^1.12.0",
57
- "@spectrum-web-components/table": "^1.12.0",
58
- "@spectrum-web-components/tabs": "^1.12.0",
59
- "@spectrum-web-components/textfield": "^1.12.0",
60
- "@spectrum-web-components/theme": "^1.12.0",
61
- "@spectrum-web-components/tooltip": "^1.12.0",
51
+ "@spectrum-web-components/picker": "^1.12.1",
52
+ "@spectrum-web-components/picker-button": "^1.12.1",
53
+ "@spectrum-web-components/popover": "^1.12.1",
54
+ "@spectrum-web-components/search": "^1.12.1",
55
+ "@spectrum-web-components/swatch": "^1.12.1",
56
+ "@spectrum-web-components/switch": "^1.12.1",
57
+ "@spectrum-web-components/table": "^1.12.1",
58
+ "@spectrum-web-components/tabs": "^1.12.1",
59
+ "@spectrum-web-components/textfield": "^1.12.1",
60
+ "@spectrum-web-components/theme": "^1.12.1",
61
+ "@spectrum-web-components/toast": "^1.12.1",
62
+ "@spectrum-web-components/tooltip": "^1.12.1",
62
63
  "lit-html": "^3.3.3",
63
64
  "monaco-editor": "^0.55.1",
64
65
  "remark-directive": "^4.0.0",
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Displays pages, layouts, components, content, and media in a filterable table grid. Fills the
5
5
  * center canvas area as a parallel state to Edit/Design/Preview/Code/Settings. Includes a "New +"
6
- * button with type-aware entity creation (including collections from project.json).
6
+ * button with type-aware entity creation (including content types from project.json).
7
7
  */
8
8
 
9
9
  import { html, render as litRender } from "lit-html";
@@ -95,11 +95,14 @@ async function collectFiles(dir, platform) {
95
95
  results.push(...sub);
96
96
  } else {
97
97
  const ext = extOf(entry.name);
98
+ const category = categoryFor(entry.path, ext);
99
+ const type =
100
+ category === "Content" ? contentTypeFor(entry.path) || ext || "file" : ext || "file";
98
101
  results.push({
99
102
  name: entry.name,
100
103
  path: entry.path,
101
- type: ext || "file",
102
- category: categoryFor(entry.path, ext),
104
+ type,
105
+ category,
103
106
  ext,
104
107
  });
105
108
  }
@@ -110,6 +113,27 @@ async function collectFiles(dir, platform) {
110
113
  return results;
111
114
  }
112
115
 
116
+ /**
117
+ * Match a file path against project contentTypes source globs to find its content type name.
118
+ * Returns the content type name (capitalized) or null if no match.
119
+ *
120
+ * @param {string} filePath
121
+ * @returns {string | null}
122
+ */
123
+ function contentTypeFor(filePath) {
124
+ const config = projectState?.projectConfig;
125
+ if (!config?.contentTypes) return null;
126
+ for (const [name, def] of Object.entries(config.contentTypes)) {
127
+ const d = /** @type {any} */ (def);
128
+ if (!d.source) continue;
129
+ const prefix = d.source.replace(/^\.\//, "").split("/**")[0].split("/*")[0];
130
+ if (filePath.startsWith(prefix + "/") || filePath === prefix) {
131
+ return name.charAt(0).toUpperCase() + name.slice(1);
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+
113
137
  // ─── Data loading ────────────────────────────────────────────────────────────
114
138
 
115
139
  async function loadFiles() {
@@ -153,14 +177,14 @@ const ENTITY_TYPES = [
153
177
  ];
154
178
 
155
179
  /**
156
- * Build frontmatter YAML from a collection's schema properties.
180
+ * Build frontmatter YAML from a content type's schema properties.
157
181
  *
158
- * @param {string} collectionName
182
+ * @param {string} contentTypeName
159
183
  * @returns {string}
160
184
  */
161
- function buildFrontmatterYaml(collectionName) {
185
+ function buildFrontmatterYaml(contentTypeName) {
162
186
  const config = projectState?.projectConfig;
163
- const col = config?.collections?.[collectionName];
187
+ const col = config?.contentTypes?.[contentTypeName];
164
188
  if (!col?.schema?.properties) return "title: Untitled\n";
165
189
 
166
190
  let yaml = "";
@@ -172,22 +196,22 @@ function buildFrontmatterYaml(collectionName) {
172
196
  }
173
197
 
174
198
  /**
175
- * Get collection-derived entity types from project config.
199
+ * Get content-type-derived entity types from project config.
176
200
  *
177
- * @returns {{ key: string; label: string; dir: string; ext: string; collectionName: string }[]}
201
+ * @returns {{ key: string; label: string; dir: string; ext: string; contentTypeName: string }[]}
178
202
  */
179
- function getCollectionTypes() {
203
+ function getContentTypeTypes() {
180
204
  const config = projectState?.projectConfig;
181
- if (!config?.collections) return [];
182
- return Object.entries(config.collections).map(([name, def]) => {
205
+ if (!config?.contentTypes) return [];
206
+ return Object.entries(config.contentTypes).map(([name, def]) => {
183
207
  const d = /** @type {any} */ (def);
184
208
  const dir = d.source ? d.source.replace(/^\.\//, "").split("/")[0] : name;
185
209
  return {
186
- key: `collection:${name}`,
210
+ key: `contentType:${name}`,
187
211
  label: name.charAt(0).toUpperCase() + name.slice(1),
188
212
  dir,
189
213
  ext: ".md",
190
- collectionName: name,
214
+ contentTypeName: name,
191
215
  };
192
216
  });
193
217
  }
@@ -200,9 +224,9 @@ function getCollectionTypes() {
200
224
  * @param {{ openFile: (path: string) => void }} ctx
201
225
  */
202
226
  async function handleNewEntity(typeKey, container, ctx) {
203
- const isCollection = typeKey.startsWith("collection:");
204
- const collectionName = isCollection ? typeKey.slice("collection:".length) : null;
205
- const allTypes = [...ENTITY_TYPES, ...getCollectionTypes()];
227
+ const isContentType = typeKey.startsWith("contentType:");
228
+ const contentTypeName = isContentType ? typeKey.slice("contentType:".length) : null;
229
+ const allTypes = [...ENTITY_TYPES, ...getContentTypeTypes()];
206
230
  const typeInfo = allTypes.find((t) => t.key === typeKey);
207
231
  if (!typeInfo) return;
208
232
 
@@ -217,7 +241,9 @@ async function handleNewEntity(typeKey, container, ctx) {
217
241
 
218
242
  let content;
219
243
  if (typeInfo.ext === ".md") {
220
- const frontmatter = collectionName ? buildFrontmatterYaml(collectionName) : "title: Untitled\n";
244
+ const frontmatter = contentTypeName
245
+ ? buildFrontmatterYaml(contentTypeName)
246
+ : "title: Untitled\n";
221
247
  content = `---\n${frontmatter}---\n\n`;
222
248
  } else {
223
249
  content = JSON.stringify({ tagName: "div", children: [] }, null, "\t");
@@ -278,7 +304,7 @@ export async function renderBrowse(container, ctx) {
278
304
 
279
305
  const files = filteredFiles();
280
306
 
281
- const collectionTypes = getCollectionTypes();
307
+ const contentTypeTypes = getContentTypeTypes();
282
308
 
283
309
  const filterBar = html`
284
310
  <div class="browse-filter-bar">
@@ -317,8 +343,8 @@ export async function renderBrowse(container, ctx) {
317
343
  @change=${(/** @type {any} */ e) => handleNewEntity(e.target.value, container, ctx)}
318
344
  >
319
345
  ${ENTITY_TYPES.map((t) => html`<sp-menu-item value=${t.key}>${t.label}</sp-menu-item>`)}
320
- ${collectionTypes.length
321
- ? html`<sp-menu-divider></sp-menu-divider> ${collectionTypes.map(
346
+ ${contentTypeTypes.length
347
+ ? html`<sp-menu-divider></sp-menu-divider> ${contentTypeTypes.map(
322
348
  (t) => html`<sp-menu-item value=${t.key}>${t.label}</sp-menu-item>`,
323
349
  )}`
324
350
  : ""}
@@ -374,7 +400,7 @@ export async function renderBrowse(container, ctx) {
374
400
  >
375
401
  <sp-table-cell class="browse-name-cell">${f.name}</sp-table-cell>
376
402
  <sp-table-cell>${f.category}</sp-table-cell>
377
- <sp-table-cell>${f.ext || "—"}</sp-table-cell>
403
+ <sp-table-cell>${f.type}</sp-table-cell>
378
404
  <sp-table-cell class="browse-path-cell">${f.path}</sp-table-cell>
379
405
  </sp-table-row>
380
406
  `,
@@ -6,12 +6,20 @@
6
6
 
7
7
  import { elToPath, stripEventHandlers, projectState } from "../store.js";
8
8
  import { view } from "../view.js";
9
- import { renderNode as runtimeRenderNode, buildScope, defineElement } from "@jxsuite/runtime";
9
+ import {
10
+ renderNode as runtimeRenderNode,
11
+ buildScope,
12
+ defineElement,
13
+ setSkipServerFunctions,
14
+ } from "@jxsuite/runtime";
10
15
  import {
11
16
  getEffectiveElements,
12
17
  getEffectiveImports,
13
18
  getEffectiveMedia,
14
19
  getEffectiveHead,
20
+ getEffectiveLayoutPath,
21
+ resolveLayoutDoc,
22
+ distributePageIntoLayout,
15
23
  } from "../site-context.js";
16
24
  import { componentRegistry, computeRelativePath } from "../files/components.js";
17
25
  import { prepareForEditMode } from "../utils/edit-display.js";
@@ -20,6 +28,56 @@ import { getActiveElement } from "../editor/inline-edit.js";
20
28
  /** @type {any} */
21
29
  let _ctx = null;
22
30
 
31
+ /** Set of DOM elements that originated from the layout (not page content). */
32
+ export const layoutElements = new WeakSet();
33
+
34
+ /**
35
+ * Walk the merged document tree to find the path prefix where page children were distributed into
36
+ * the layout slot. Returns the path to the container whose children are the page content (first
37
+ * non-$__layout children array).
38
+ *
39
+ * @param {any} node
40
+ * @param {any[]} [path]
41
+ * @returns {any[] | null}
42
+ */
43
+ function findPageContentPrefix(node, path = []) {
44
+ if (!node || typeof node !== "object") return null;
45
+ if (Array.isArray(node.children)) {
46
+ for (let i = 0; i < node.children.length; i++) {
47
+ const child = node.children[i];
48
+ if (child && typeof child === "object" && !child.$__layout) {
49
+ return [...path, "children"];
50
+ }
51
+ }
52
+ for (let i = 0; i < node.children.length; i++) {
53
+ const child = node.children[i];
54
+ if (child && typeof child === "object" && child.$__layout) {
55
+ const found = findPageContentPrefix(child, [...path, "children", i]);
56
+ if (found) return found;
57
+ }
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /** The path of the currently active layout file, or null. */
64
+ export let activeLayoutPath = /** @type {string | null} */ (null);
65
+
66
+ /**
67
+ * Recursively mark all nodes in a layout doc tree with $__layout: true so we can identify which
68
+ * rendered DOM elements came from the layout vs page content.
69
+ */
70
+ function markLayoutNodes(/** @type {any} */ node) {
71
+ if (!node || typeof node !== "object") return;
72
+ node.$__layout = true;
73
+ if (Array.isArray(node.children)) {
74
+ for (const child of node.children) markLayoutNodes(child);
75
+ }
76
+ if (node.$elements) {
77
+ for (const el of node.$elements) markLayoutNodes(el);
78
+ }
79
+ }
80
+
23
81
  /**
24
82
  * Initialize the canvas live render module.
25
83
  *
@@ -45,9 +103,46 @@ export async function renderCanvasLive(gen, doc, canvasEl) {
45
103
  const S = _ctx.getState();
46
104
  const canvasMode = _ctx.getCanvasMode();
47
105
 
48
- const renderDoc =
106
+ // Suppress server function resolution in non-preview modes to avoid
107
+ // failed proxy calls and infinite reactive retries (also covers
108
+ // async custom element connectedCallbacks that run after this function returns)
109
+ setSkipServerFunctions(canvasMode !== "preview");
110
+
111
+ let renderDoc =
49
112
  canvasMode === "preview" ? structuredClone(doc) : prepareForEditMode(stripEventHandlers(doc));
50
113
 
114
+ // ─── Layout wrapping ────────────────────────────────────────────────────
115
+ // For page documents, resolve the layout and wrap content in the layout shell.
116
+ // Layout-originated nodes are marked with $__layout so we can distinguish them.
117
+ let layoutWrapped = false;
118
+ activeLayoutPath = null;
119
+
120
+ const isPage =
121
+ S.documentPath &&
122
+ projectState?.isSiteProject &&
123
+ (S.documentPath.startsWith("pages/") || S.documentPath.startsWith("./pages/"));
124
+
125
+ /** @type {any[] | null} Path prefix in merged doc where page children live */
126
+ let pageContentPrefix = null;
127
+
128
+ if (isPage) {
129
+ const layoutPath = getEffectiveLayoutPath(doc.$layout);
130
+ if (layoutPath) {
131
+ const layoutDoc = await resolveLayoutDoc(layoutPath);
132
+ if (layoutDoc) {
133
+ if (gen !== view.renderGeneration) return null;
134
+ activeLayoutPath = layoutPath.replace(/^\.\//, "");
135
+ markLayoutNodes(layoutDoc);
136
+ const pageForSlots = canvasMode === "preview" ? structuredClone(doc) : renderDoc;
137
+ const merged = distributePageIntoLayout(layoutDoc, pageForSlots);
138
+ renderDoc =
139
+ canvasMode === "preview" ? merged : prepareForEditMode(stripEventHandlers(merged));
140
+ layoutWrapped = true;
141
+ pageContentPrefix = findPageContentPrefix(merged);
142
+ }
143
+ }
144
+ }
145
+
51
146
  // In edit mode, collect paths where $map templates were inlined as children[0]
52
147
  // so we can remap runtime paths (children,0,...) → (children,map,...)
53
148
  const mapParentPaths = new Set();
@@ -127,7 +222,13 @@ export async function renderCanvasLive(gen, doc, canvasEl) {
127
222
  console.warn("Studio: failed to import package", entry, e);
128
223
  }
129
224
  } else if (entry?.$ref) {
130
- const href = new URL(entry.$ref, docBase).href;
225
+ let href;
226
+ try {
227
+ href = new URL(entry.$ref, docBase).href;
228
+ } catch (/** @type {any} */ urlErr) {
229
+ console.warn("Studio: invalid element URL", { ref: entry.$ref, docBase }, urlErr);
230
+ continue;
231
+ }
131
232
  try {
132
233
  await defineElement(href);
133
234
  } catch (/** @type {any} */ e) {
@@ -218,26 +319,49 @@ export async function renderCanvasLive(gen, doc, canvasEl) {
218
319
  if (gen !== view.renderGeneration) return null;
219
320
  const el = /** @type {HTMLElement} */ (
220
321
  runtimeRenderNode(renderDoc, $defs, {
221
- onNodeCreated(/** @type {any} */ el, /** @type {any} */ path) {
322
+ onNodeCreated(/** @type {any} */ el, /** @type {any} */ path, /** @type {any} */ def) {
323
+ // Track layout-originated elements — don't store in elToPath to avoid
324
+ // path collisions with remapped page content paths
325
+ if (layoutWrapped && def?.$__layout) {
326
+ layoutElements.add(el);
327
+ if (el.setAttribute) el.setAttribute("data-jx-layout", "");
328
+ return;
329
+ }
330
+
331
+ // Remap layout-wrapped paths: strip the layout prefix so paths are
332
+ // relative to the original page document (which is what S.document holds)
333
+ let mappedPath = path;
334
+ if (layoutWrapped && pageContentPrefix) {
335
+ const pfx = pageContentPrefix;
336
+ if (
337
+ path.length >= pfx.length &&
338
+ pfx.every((/** @type {any} */ seg, /** @type {number} */ i) => path[i] === seg)
339
+ ) {
340
+ mappedPath = ["children", ...path.slice(pfx.length)];
341
+ }
342
+ }
343
+
222
344
  // Remap $map paths: wrapper and template children → real document paths
223
345
  // prepareForEditMode wraps $map template in: children[0] (wrapper) > children[0] (template)
224
346
  // Real paths: wrapper → ['children'] ($map container), template → ['children', 'map']
225
- let mappedPath = path;
226
347
  if ((canvasMode === "design" || canvasMode === "edit") && mapParentPaths.size > 0) {
227
- for (let i = 0; i < path.length - 1; i++) {
228
- if (path[i] === "children" && path[i + 1] === 0) {
229
- const parentKey = path.slice(0, i).join("/");
348
+ for (let i = 0; i < mappedPath.length - 1; i++) {
349
+ if (mappedPath[i] === "children" && mappedPath[i + 1] === 0) {
350
+ const parentKey = mappedPath.slice(0, i).join("/");
230
351
  if (mapParentPaths.has(parentKey)) {
231
- if (path.length === i + 2) {
232
- // Wrapper div itself $map container path
233
- mappedPath = path.slice(0, i + 1);
352
+ if (mappedPath.length === i + 2) {
353
+ mappedPath = mappedPath.slice(0, i + 1);
234
354
  } else if (
235
- path.length >= i + 4 &&
236
- path[i + 2] === "children" &&
237
- path[i + 3] === 0
355
+ mappedPath.length >= i + 4 &&
356
+ mappedPath[i + 2] === "children" &&
357
+ mappedPath[i + 3] === 0
238
358
  ) {
239
- // Template or its descendants → children/map/...rest
240
- mappedPath = [...path.slice(0, i), "children", "map", ...path.slice(i + 4)];
359
+ mappedPath = [
360
+ ...mappedPath.slice(0, i),
361
+ "children",
362
+ "map",
363
+ ...mappedPath.slice(i + 4),
364
+ ];
241
365
  }
242
366
  break;
243
367
  }
@@ -66,12 +66,15 @@ export function renderCanvas() {
66
66
  // Advance render generation so stale async renders from the previous cycle bail out
67
67
  ++view.renderGeneration;
68
68
 
69
- // Always clear Lit's internal state so it builds fresh DOM. Stale async
70
- // renderCanvasLive calls from a previous cycle can corrupt nested ChildPart
71
- // markers (Comment nodes inside panzoom-wrap) in ways the root-only
72
- // ensureLitState check cannot detect.
69
+ // Detect whether this is a mode transition or a content-only re-render
70
+ const modeChanged = canvasMode !== view.prevCanvasMode;
71
+
72
+ // Only clear Lit's internal state on mode transitions (structural panel changes).
73
+ // For content re-renders in the same mode, Lit's template diffing preserves
74
+ // the panel structure. Bailed async renders can't corrupt the DOM because
75
+ // renderCanvasLive uses atomic clear (innerHTML = "" right before appendChild).
73
76
  // @ts-ignore
74
- if (canvasWrap["_$litPart$"]) {
77
+ if (modeChanged && canvasWrap["_$litPart$"]) {
75
78
  canvasWrap.textContent = "";
76
79
  // @ts-ignore
77
80
  delete canvasWrap["_$litPart$"];
@@ -101,7 +104,6 @@ export function renderCanvas() {
101
104
  }
102
105
 
103
106
  // Detect whether this is a mode transition or a content-only re-render
104
- const modeChanged = canvasMode !== view.prevCanvasMode;
105
107
  view.prevCanvasMode = canvasMode;
106
108
 
107
109
  // DnD handlers are registered on inner canvas elements that get replaced on every
@@ -247,10 +249,11 @@ export function renderCanvas() {
247
249
  resetZoomIndicator();
248
250
  }
249
251
 
252
+ const { baseWidth } = parseMediaEntries(getEffectiveMedia(S.document.$media));
250
253
  const { tpl: panelTpl, panel } = canvasPanelTemplate(null, null, true);
251
254
  const editTpl = html`
252
255
  <div class="content-edit-canvas">
253
- <div class="content-edit-column">${panelTpl}</div>
256
+ <div class="content-edit-column" style="max-width:${baseWidth}px">${panelTpl}</div>
254
257
  </div>
255
258
  `;
256
259
  litRender(editTpl, canvasWrap);
@@ -564,6 +564,8 @@ export async function openFileFromTree(ctx, path) {
564
564
  if (path.endsWith(".md")) {
565
565
  await ctx.loadMarkdown(content, null);
566
566
  ctx.S.documentPath = path;
567
+ ctx.S.dirty = false;
568
+ ctx.commit(ctx.S);
567
569
  } else {
568
570
  const doc = JSON.parse(content);
569
571
  const newS = createState(doc);