@jxsuite/studio 0.33.0 → 0.34.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 (85) hide show
  1. package/dist/iframe-entry.js +6230 -0
  2. package/dist/iframe-entry.js.map +35 -0
  3. package/dist/studio.js +10754 -11060
  4. package/dist/studio.js.map +83 -72
  5. package/package.json +7 -7
  6. package/src/browse/browse.ts +11 -4
  7. package/src/canvas/canvas-helpers.ts +2 -56
  8. package/src/canvas/canvas-live-render.ts +102 -435
  9. package/src/canvas/canvas-origin.ts +66 -0
  10. package/src/canvas/canvas-patcher.ts +63 -403
  11. package/src/canvas/canvas-render.ts +70 -212
  12. package/src/canvas/canvas-utils.ts +37 -65
  13. package/src/canvas/iframe-channel.ts +154 -0
  14. package/src/canvas/iframe-drop.ts +484 -0
  15. package/src/canvas/iframe-entry.ts +600 -0
  16. package/src/canvas/iframe-host.ts +1373 -0
  17. package/src/canvas/iframe-inline-edit.ts +367 -0
  18. package/src/canvas/iframe-insert.ts +164 -0
  19. package/src/canvas/iframe-interaction.ts +176 -0
  20. package/src/canvas/iframe-keys.ts +85 -0
  21. package/src/canvas/iframe-overlay.ts +218 -0
  22. package/src/canvas/iframe-patch.ts +363 -0
  23. package/src/canvas/iframe-protocol.ts +361 -0
  24. package/src/canvas/iframe-render.ts +458 -0
  25. package/src/canvas/iframe-slash.ts +114 -0
  26. package/src/canvas/iframe-subtree.ts +113 -0
  27. package/src/canvas/path-mapping.ts +86 -0
  28. package/src/canvas/serialize-scope.ts +65 -0
  29. package/src/editor/canvas-context-menu.ts +40 -0
  30. package/src/editor/canvas-slash-bridge.ts +21 -0
  31. package/src/editor/context-menu.ts +2 -1
  32. package/src/editor/inline-edit-apply.ts +183 -0
  33. package/src/editor/inline-edit.ts +99 -21
  34. package/src/editor/inline-link.ts +89 -0
  35. package/src/editor/insert-zone-action.ts +35 -0
  36. package/src/editor/merge-tags.ts +26 -2
  37. package/src/editor/repeater-scope.ts +144 -0
  38. package/src/editor/shortcuts.ts +14 -28
  39. package/src/editor/slash-menu.ts +73 -42
  40. package/src/files/files.ts +2 -1
  41. package/src/page-params.ts +383 -0
  42. package/src/panels/ai-panel.ts +5 -7
  43. package/src/panels/block-action-bar.ts +296 -138
  44. package/src/panels/canvas-dnd-bridge.ts +397 -0
  45. package/src/panels/component-preview.ts +56 -0
  46. package/src/panels/dnd.ts +41 -17
  47. package/src/panels/drag-ghost.ts +62 -0
  48. package/src/panels/editors.ts +1 -1
  49. package/src/panels/overlays.ts +10 -125
  50. package/src/panels/properties-panel.ts +210 -0
  51. package/src/panels/right-panel.ts +0 -2
  52. package/src/panels/signals-panel.ts +136 -22
  53. package/src/panels/stylebook-doc.ts +373 -0
  54. package/src/panels/stylebook-panel.ts +46 -689
  55. package/src/panels/tab-bar.ts +159 -13
  56. package/src/panels/toolbar.ts +3 -2
  57. package/src/platforms/devserver.ts +15 -0
  58. package/src/services/monaco-setup.ts +12 -0
  59. package/src/services/render-critic.ts +9 -9
  60. package/src/settings/css-vars-editor.ts +2 -2
  61. package/src/store.ts +4 -62
  62. package/src/studio.ts +90 -40
  63. package/src/tabs/doc-op-apply.ts +89 -0
  64. package/src/tabs/patch-ops.ts +6 -2
  65. package/src/tabs/tab.ts +23 -4
  66. package/src/tabs/transact.ts +2 -74
  67. package/src/types.ts +14 -18
  68. package/src/ui/jx-theme.ts +63 -0
  69. package/src/ui/media-picker.ts +6 -4
  70. package/src/ui/spectrum.ts +5 -0
  71. package/src/utils/canvas-media.ts +0 -137
  72. package/src/utils/edit-display.ts +23 -3
  73. package/src/utils/geometry.ts +43 -0
  74. package/src/utils/link-target.ts +93 -0
  75. package/src/utils/strip-events.ts +54 -0
  76. package/src/view.ts +0 -23
  77. package/src/workspace/workspace.ts +14 -1
  78. package/src/canvas/canvas-subtree-render.ts +0 -113
  79. package/src/editor/component-inline-edit.ts +0 -349
  80. package/src/editor/content-inline-edit.ts +0 -207
  81. package/src/editor/insertion-helper.ts +0 -308
  82. package/src/panels/canvas-dnd.ts +0 -329
  83. package/src/panels/panel-events.ts +0 -306
  84. package/src/panels/preview-render.ts +0 -132
  85. package/src/panels/pseudo-preview.ts +0 -75
@@ -0,0 +1,383 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Dynamic route-param helpers for the tab-bar's $params picker.
4
+ *
5
+ * A page like `pages/products/[sku].json` declares its candidate values in a `$paths` block (spec
6
+ * §4.3). At build time the compiler expands those into concrete routes (`resolvePathEntries` in
7
+ * `@jxsuite/compiler`); in the studio the same shapes are resolved live so the author can pick one
8
+ * value to render. The chosen values are substituted into the canvas render document
9
+ * (`substitutePreviewParams`), which mirrors what the compiler's context injection produces for the
10
+ * matching built page.
11
+ */
12
+
13
+ import { getPlatform } from "./platform";
14
+ import type { JxMutableNode, JxPathsDef } from "@jxsuite/schema/types";
15
+
16
+ /** Param name → candidate values, in `$paths` declaration order. */
17
+ export type ParamValues = Record<string, string[]>;
18
+
19
+ /** Bracket route segments: `[param]` (named) and `[...param]` (catch-all). */
20
+ const PARAM_SEGMENT = /\[\.\.\.(\w+)\]|\[(\w+)\]/g;
21
+
22
+ /**
23
+ * Param names declared by a document path's bracket segments, e.g. `pages/products/[sku].json` →
24
+ * `["sku"]`. Mirrors the compiler's `fileToRoute` parsing.
25
+ *
26
+ * @param {string | null | undefined} documentPath
27
+ * @returns {string[]}
28
+ */
29
+ export function dynamicRouteParams(documentPath: string | null | undefined) {
30
+ if (!documentPath) {
31
+ return [];
32
+ }
33
+ const params: string[] = [];
34
+ for (const match of documentPath.matchAll(PARAM_SEGMENT)) {
35
+ params.push((match[1] ?? match[2])!);
36
+ }
37
+ return params;
38
+ }
39
+
40
+ /**
41
+ * URL pattern for a page document path, mirroring the compiler's `fileToRoute`:
42
+ * `pages/products/[sku].json` → `/products/:sku`, `pages/index.json` → `/`.
43
+ *
44
+ * @param {string | null | undefined} documentPath
45
+ * @returns {string}
46
+ */
47
+ export function documentUrlPattern(documentPath: string | null | undefined) {
48
+ if (!documentPath) {
49
+ return "/";
50
+ }
51
+ let urlPath = documentPath
52
+ .replace(/^\.\//, "")
53
+ .replace(/^pages\//, "")
54
+ .replace(/\.[^/.]+$/, "");
55
+ if (urlPath.endsWith("/index")) {
56
+ urlPath = urlPath.slice(0, -6) || "/";
57
+ } else if (urlPath === "index") {
58
+ urlPath = "/";
59
+ }
60
+ if (!urlPath.startsWith("/")) {
61
+ urlPath = `/${urlPath}`;
62
+ }
63
+ return urlPath.replaceAll(PARAM_SEGMENT, (_match, spread: string, named: string) =>
64
+ spread ? "*" : `:${named}`,
65
+ );
66
+ }
67
+
68
+ /**
69
+ * The `$paths` declaration of an open document. JSON pages carry it on the document root;
70
+ * format-class pages (markdown) keep it in the frontmatter.
71
+ *
72
+ * @param {{ document?: unknown; frontmatter?: Record<string, unknown> }} doc
73
+ * @returns {JxPathsDef | null}
74
+ */
75
+ export function pagePathsDef(doc: {
76
+ document?: JxMutableNode | null;
77
+ frontmatter?: Record<string, unknown> | null;
78
+ }) {
79
+ const fromDoc = (doc.document as { $paths?: JxPathsDef } | null | undefined)?.$paths;
80
+ const fromFrontmatter = doc.frontmatter?.$paths as JxPathsDef | undefined;
81
+ return fromDoc ?? fromFrontmatter ?? null;
82
+ }
83
+
84
+ const valueCache = new Map<string, Promise<ParamValues>>();
85
+
86
+ /** Drop all cached enumerations (e.g. after content files change). */
87
+ export function invalidateParamValues() {
88
+ valueCache.clear();
89
+ }
90
+
91
+ /**
92
+ * Enumerate candidate values for each param declared by a `$paths` block. Results are cached per
93
+ * (documentPath, $paths) pair; failures are not cached so a transient backend error retries on the
94
+ * next call.
95
+ *
96
+ * @param {string | null | undefined} documentPath - Cache key component
97
+ * @param {JxPathsDef | null} pathsDef
98
+ * @returns {Promise<ParamValues>}
99
+ */
100
+ export function loadParamValues(
101
+ documentPath: string | null | undefined,
102
+ pathsDef: JxPathsDef | null,
103
+ ) {
104
+ if (!pathsDef) {
105
+ return Promise.resolve({} as ParamValues);
106
+ }
107
+ const key = `${documentPath ?? ""}::${JSON.stringify(pathsDef)}`;
108
+ let pending = valueCache.get(key);
109
+ if (!pending) {
110
+ pending = resolveParamValues(pathsDef).catch((error: unknown) => {
111
+ valueCache.delete(key);
112
+ console.warn("page-params: failed to enumerate $paths values:", error);
113
+ return {} as ParamValues;
114
+ });
115
+ valueCache.set(key, pending);
116
+ }
117
+ return pending;
118
+ }
119
+
120
+ /**
121
+ * Resolve one `$paths` shape to values, mirroring the compiler's `resolvePathEntries`.
122
+ *
123
+ * @param {JxPathsDef} pathsDef
124
+ * @returns {Promise<ParamValues>}
125
+ */
126
+ async function resolveParamValues(pathsDef: JxPathsDef): Promise<ParamValues> {
127
+ // Legacy: array of param objects — group values per param key
128
+ if (Array.isArray(pathsDef)) {
129
+ const out: ParamValues = {};
130
+ for (const entry of pathsDef) {
131
+ for (const [param, value] of Object.entries(entry)) {
132
+ pushValue(out, param, value);
133
+ }
134
+ }
135
+ return out;
136
+ }
137
+
138
+ // Content type-based: { contentType: "blog", param: "slug", field: "id" } — resolved through the
139
+ // Backend's ContentCollection pipeline (the same one the canvas's ContentEntry uses), so every
140
+ // Offered value is guaranteed to resolve in preview.
141
+ if ("contentType" in pathsDef && pathsDef.contentType) {
142
+ const param = pathsDef.param ?? "slug";
143
+ const field = pathsDef.field ?? "id";
144
+ const entries = (await resolveContentCollection(pathsDef.contentType)) as {
145
+ id?: unknown;
146
+ data?: Record<string, unknown>;
147
+ }[];
148
+ const out: ParamValues = { [param]: [] };
149
+ if (!Array.isArray(entries)) {
150
+ return out;
151
+ }
152
+ for (const entry of entries) {
153
+ pushValue(out, param, field === "id" ? entry.id : (entry.data?.[field] ?? entry.id));
154
+ }
155
+ return out;
156
+ }
157
+
158
+ // Explicit values: { values: ["en", "fr"], param: "lang" }
159
+ if ("values" in pathsDef && Array.isArray(pathsDef.values)) {
160
+ const param = pathsDef.param ?? "value";
161
+ const out: ParamValues = { [param]: [] };
162
+ for (const value of pathsDef.values) {
163
+ pushValue(out, param, value);
164
+ }
165
+ return out;
166
+ }
167
+
168
+ // Data file ref: { "$ref": "./data/products.json", param: "id", field: "sku" }
169
+ if ("$ref" in pathsDef && pathsDef.$ref) {
170
+ const param = pathsDef.param ?? "id";
171
+ const field = pathsDef.field ?? "id";
172
+ const content = await getPlatform().readFile(pathsDef.$ref.replace(/^\.\//, ""));
173
+ const data = JSON.parse(content) as unknown;
174
+ const out: ParamValues = { [param]: [] };
175
+ if (!Array.isArray(data)) {
176
+ return out;
177
+ }
178
+ for (const item of data as Record<string, unknown>[]) {
179
+ pushValue(out, param, item?.[field] ?? item?.id ?? item);
180
+ }
181
+ return out;
182
+ }
183
+
184
+ return {};
185
+ }
186
+
187
+ /**
188
+ * Resolve a ContentCollection for a content type via the PAL, falling back to a plain dev-proxy
189
+ * fetch on platforms that predate `resolveClass`.
190
+ *
191
+ * @param {string} contentType
192
+ * @returns {Promise<unknown>}
193
+ */
194
+ function resolveContentCollection(contentType: string) {
195
+ const body = {
196
+ $prototype: "ContentCollection",
197
+ $src: "@jxsuite/parser/ContentCollection.class.json",
198
+ contentType,
199
+ };
200
+ const platform = getPlatform();
201
+ if (platform.resolveClass) {
202
+ return platform.resolveClass(body);
203
+ }
204
+ return fetch("/__jx_resolve__", {
205
+ body: JSON.stringify(body),
206
+ headers: { "content-type": "application/json" },
207
+ method: "POST",
208
+ }).then((res) => {
209
+ if (!res.ok) {
210
+ throw new Error(`Class resolution failed: ${res.status}`);
211
+ }
212
+ return res.json() as Promise<unknown>;
213
+ });
214
+ }
215
+
216
+ /**
217
+ * @param {ParamValues} out
218
+ * @param {string} param
219
+ * @param {unknown} value
220
+ */
221
+ function pushValue(out: ParamValues, param: string, value: unknown) {
222
+ if (value === null || value === undefined || value === "") {
223
+ return;
224
+ }
225
+ const str = String(value);
226
+ const list = (out[param] ??= []);
227
+ if (!list.includes(str)) {
228
+ list.push(str);
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Substitute chosen param values into a canvas render document:
234
+ *
235
+ * - Every `{ "$ref": "#/$params/<name>" }` value with a chosen `<name>` becomes the literal string
236
+ * (the exact dereference `ContentEntry.resolve()` performs against `route._pathParams`), and
237
+ * - `state.$page` is injected mirroring the compiler's `injectContext`, so `${state.$page.params.x}`
238
+ * template expressions render.
239
+ *
240
+ * Pure: returns a rebuilt tree. The design/edit render doc shares node references with the tab's
241
+ * source document (stripEventHandlers/prepareForEditMode shallow-rebuild), so in-place mutation
242
+ * here would corrupt the document being edited and saved.
243
+ *
244
+ * @param {JxMutableNode} renderDoc
245
+ * @param {Record<string, string>} params
246
+ * @param {string | null | undefined} documentPath
247
+ * @returns {JxMutableNode}
248
+ */
249
+ export function substitutePreviewParams(
250
+ renderDoc: JxMutableNode,
251
+ params: Record<string, string>,
252
+ documentPath: string | null | undefined,
253
+ ): JxMutableNode {
254
+ if (Object.keys(params).length === 0) {
255
+ return renderDoc;
256
+ }
257
+ const doc = substituteParamRefs(renderDoc, params) as JxMutableNode & {
258
+ state?: Record<string, unknown>;
259
+ title?: unknown;
260
+ };
261
+ doc.state ??= {};
262
+ doc.state.$page = {
263
+ params: { ...params },
264
+ title: typeof doc.title === "string" ? doc.title : "",
265
+ url: documentUrlPattern(documentPath),
266
+ };
267
+ return doc;
268
+ }
269
+
270
+ /**
271
+ * State keys whose entries are class prototypes ($prototype + $src) referencing a route param.
272
+ * These are the entries `substitutePreviewParams` rewrites, and the ones `resolveParamBoundState`
273
+ * bakes.
274
+ *
275
+ * @param {Record<string, unknown> | null | undefined} state - The ORIGINAL document state (with
276
+ * `$ref`s intact — substitution removes the marker)
277
+ * @returns {string[]}
278
+ */
279
+ export function paramBoundStateKeys(state: Record<string, unknown> | null | undefined) {
280
+ if (!state) {
281
+ return [];
282
+ }
283
+ return Object.keys(state).filter((key) => {
284
+ const entry = state[key] as Record<string, unknown> | null;
285
+ return (
286
+ entry &&
287
+ typeof entry === "object" &&
288
+ typeof entry.$prototype === "string" &&
289
+ typeof entry.$src === "string" &&
290
+ JSON.stringify(entry).includes('"#/$params/')
291
+ );
292
+ });
293
+ }
294
+
295
+ /**
296
+ * Resolve substituted class-prototype state entries through the backend and bake the values into
297
+ * `renderDoc.state` — compiler parity: the built site resolves ContentEntry/ContentCollection
298
+ * BEFORE templates run. Without baking, the iframe runtime resolves these entries asynchronously
299
+ * (null on the first render pass), so preview templates like `${state.product.data.title}`
300
+ * dereference null and abort the whole render.
301
+ *
302
+ * Failures leave the entry in place (the runtime's own async resolution remains the fallback).
303
+ *
304
+ * @param {JxMutableNode} renderDoc - The substituted render doc (mutated in place — it is the fresh
305
+ * rebuild from substitutePreviewParams, never the source doc)
306
+ * @param {string[]} keys - From paramBoundStateKeys(originalDoc.state)
307
+ * @param {string | undefined} docBase - Document base URL for relative-$src rebasing
308
+ */
309
+ export async function resolveParamBoundState(
310
+ renderDoc: JxMutableNode,
311
+ keys: string[],
312
+ docBase?: string,
313
+ ) {
314
+ if (keys.length === 0) {
315
+ return;
316
+ }
317
+ const platform = getPlatform();
318
+ if (!platform.resolveClass) {
319
+ return;
320
+ }
321
+ const { state } = renderDoc as { state?: Record<string, unknown> };
322
+ if (!state) {
323
+ return;
324
+ }
325
+ await Promise.all(
326
+ keys.map(async (key) => {
327
+ const entry = state[key];
328
+ if (!entry || typeof entry !== "object") {
329
+ return;
330
+ }
331
+ try {
332
+ state[key] = await platform.resolveClass!({
333
+ ...(entry as Record<string, unknown>),
334
+ ...(docBase ? { $base: docBase } : {}),
335
+ });
336
+ } catch (error) {
337
+ console.warn(`page-params: failed to resolve state.${key} for preview:`, error);
338
+ }
339
+ }),
340
+ );
341
+ }
342
+
343
+ /**
344
+ * @param {unknown} node
345
+ * @param {Record<string, string>} params
346
+ * @returns {unknown} — a rebuilt copy with matched param refs replaced by literals
347
+ */
348
+ function substituteParamRefs(node: unknown, params: Record<string, string>): unknown {
349
+ const replaced = paramRefValue(node, params);
350
+ if (replaced !== null) {
351
+ return replaced;
352
+ }
353
+ if (!node || typeof node !== "object") {
354
+ return node;
355
+ }
356
+ if (Array.isArray(node)) {
357
+ return node.map((item) => substituteParamRefs(item, params));
358
+ }
359
+ const out: Record<string, unknown> = {};
360
+ for (const [key, value] of Object.entries(node)) {
361
+ out[key] = substituteParamRefs(value, params);
362
+ }
363
+ return out;
364
+ }
365
+
366
+ /**
367
+ * The chosen literal for a `{ "$ref": "#/$params/<name>" }` value, or null when the value is not a
368
+ * param ref (or no value was chosen for it).
369
+ *
370
+ * @param {unknown} value
371
+ * @param {Record<string, string>} params
372
+ * @returns {string | null}
373
+ */
374
+ function paramRefValue(value: unknown, params: Record<string, string>) {
375
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
376
+ return null;
377
+ }
378
+ const ref = (value as { $ref?: unknown }).$ref;
379
+ if (typeof ref !== "string" || !ref.startsWith("#/$params/")) {
380
+ return null;
381
+ }
382
+ return params[ref.slice("#/$params/".length)] ?? null;
383
+ }
@@ -218,13 +218,13 @@ function renderKeyGate() {
218
218
  <div class="ai-tab-body">
219
219
  <div class="ai-status-center" style="gap:10px;max-width:320px;text-align:left">
220
220
  <div style="font-weight:600;align-self:center">AI provider key</div>
221
- <div style="font-size:11px;color:var(--spectrum-global-color-gray-600)">
221
+ <div style="font-size:11px;color:var(--fg-dim)">
222
222
  Any OpenAI-compatible key works. Stored locally in this browser; sent only to the Studio
223
223
  proxy (never to a third party except your chosen endpoint).
224
224
  </div>
225
225
  <input
226
226
  type="password"
227
- style="width:100%;box-sizing:border-box;padding:6px 8px;border-radius:4px;border:1px solid var(--spectrum-global-color-gray-400);background:var(--spectrum-global-color-gray-50);color:var(--spectrum-global-color-gray-900);font-size:12px"
227
+ style="width:100%;box-sizing:border-box;padding:6px 8px;border-radius:4px;border:1px solid var(--border);background:var(--bg-input);color:var(--fg);font-size:12px"
228
228
  placeholder="sk-… or any compatible key"
229
229
  .value=${keyDraft}
230
230
  @input=${(e: Event) => {
@@ -253,7 +253,7 @@ function renderKeyGate() {
253
253
  : html`
254
254
  <input
255
255
  type="text"
256
- style="width:100%;box-sizing:border-box;padding:6px 8px;border-radius:4px;border:1px solid var(--spectrum-global-color-gray-400);background:var(--spectrum-global-color-gray-50);color:var(--spectrum-global-color-gray-900);font-size:12px"
256
+ style="width:100%;box-sizing:border-box;padding:6px 8px;border-radius:4px;border:1px solid var(--border);background:var(--bg-input);color:var(--fg);font-size:12px"
257
257
  placeholder="Model ID (e.g. gpt-4o, claude-sonnet-4-20250514, etc.)"
258
258
  .value=${modelDraft}
259
259
  @input=${(e: Event) => {
@@ -270,14 +270,12 @@ function renderKeyGate() {
270
270
  : "Fetch models"}
271
271
  </sp-button>
272
272
  ${modelsError
273
- ? html`<span style="font-size:10px;color:var(--spectrum-global-color-red-600)"
274
- >${modelsError}</span
275
- >`
273
+ ? html`<span style="font-size:10px;color:var(--danger)">${modelsError}</span>`
276
274
  : nothing}
277
275
  </div>
278
276
  <input
279
277
  type="text"
280
- style="width:100%;box-sizing:border-box;padding:6px 8px;border-radius:4px;border:1px solid var(--spectrum-global-color-gray-400);background:var(--spectrum-global-color-gray-50);color:var(--spectrum-global-color-gray-900);font-size:12px"
278
+ style="width:100%;box-sizing:border-box;padding:6px 8px;border-radius:4px;border:1px solid var(--border);background:var(--bg-input);color:var(--fg);font-size:12px"
281
279
  placeholder="Endpoint (optional, e.g. http://localhost:11434/v1)"
282
280
  .value=${baseUrlDraft}
283
281
  @input=${(e: Event) => {