@jxsuite/studio 0.27.0 → 0.28.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 (58) hide show
  1. package/dist/studio.js +33900 -56705
  2. package/dist/studio.js.map +128 -396
  3. package/package.json +4 -10
  4. package/src/browse/browse.ts +62 -22
  5. package/src/canvas/canvas-live-render.ts +57 -55
  6. package/src/canvas/canvas-render.ts +50 -21
  7. package/src/editor/context-menu.ts +4 -1
  8. package/src/editor/convert-to-component.ts +8 -2
  9. package/src/editor/inline-edit.ts +7 -2
  10. package/src/editor/insertion-helper.ts +12 -3
  11. package/src/editor/slash-menu.ts +5 -1
  12. package/src/files/file-ops.ts +121 -102
  13. package/src/files/files.ts +53 -35
  14. package/src/format/constraints.ts +55 -0
  15. package/src/format/format-host.ts +212 -0
  16. package/src/new-project/new-project-modal.ts +18 -3
  17. package/src/panels/ai-panel.ts +3 -1
  18. package/src/panels/block-action-bar.ts +4 -2
  19. package/src/panels/canvas-dnd.ts +37 -8
  20. package/src/panels/data-explorer.ts +9 -2
  21. package/src/panels/editors.ts +1 -1
  22. package/src/panels/git-panel.ts +18 -8
  23. package/src/panels/head-panel.ts +12 -5
  24. package/src/panels/left-panel.ts +7 -4
  25. package/src/panels/panel-events.ts +3 -1
  26. package/src/panels/properties-panel.ts +9 -3
  27. package/src/panels/quick-search.ts +10 -10
  28. package/src/panels/right-panel.ts +6 -2
  29. package/src/panels/shared.ts +6 -1
  30. package/src/panels/signals-panel.ts +35 -10
  31. package/src/panels/style-inputs.ts +5 -1
  32. package/src/panels/style-panel.ts +6 -2
  33. package/src/panels/stylebook-layers-panel.ts +12 -3
  34. package/src/panels/stylebook-panel.ts +24 -8
  35. package/src/panels/toolbar.ts +16 -3
  36. package/src/platforms/devserver.ts +32 -3
  37. package/src/resize-edges.ts +12 -2
  38. package/src/services/cem-export.ts +9 -3
  39. package/src/settings/content-types-editor.ts +7 -2
  40. package/src/settings/defs-editor.ts +6 -1
  41. package/src/settings/general-settings.ts +3 -1
  42. package/src/settings/schema-field-ui.ts +11 -3
  43. package/src/settings/settings-modal.ts +4 -1
  44. package/src/state.ts +5 -1
  45. package/src/studio.ts +27 -16
  46. package/src/tabs/tab.ts +29 -6
  47. package/src/tabs/transact.ts +4 -1
  48. package/src/types.ts +15 -6
  49. package/src/ui/button-group.ts +6 -1
  50. package/src/ui/expression-editor.ts +14 -3
  51. package/src/ui/layers.ts +5 -1
  52. package/src/ui/unit-selector.ts +6 -1
  53. package/src/utils/canvas-media.ts +1 -1
  54. package/src/utils/google-fonts.ts +4 -1
  55. package/src/utils/studio-utils.ts +7 -1
  56. package/src/view.ts +8 -2
  57. package/src/markdown/md-allowlist.ts +0 -104
  58. package/src/markdown/md-convert.ts +0 -846
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Format-host — the studio's view of the project's format registry.
3
+ *
4
+ * Format classes are auto-discovered server-side from the project imports map
5
+ * (specs/extensions.md); the studio introspects them via the PAL (`listFormats`) and invokes
6
+ * parse/serialize capabilities via `formatAction` (POST /__studio/format in the dev server, RPC on
7
+ * desktop). The studio itself holds zero format knowledge — `.json` is the single native built-in.
8
+ */
9
+
10
+ import { getPlatform } from "../platform";
11
+ import type { JxMutableNode } from "@jxsuite/schema/types";
12
+
13
+ export interface StudioFormatHints {
14
+ icon?: string;
15
+ modes?: string[];
16
+ documentMode?: {
17
+ default?: "content" | "component";
18
+ componentWhen?: { frontmatterKey?: string; matches?: string };
19
+ };
20
+ newFileTemplate?: string;
21
+ elements?: {
22
+ block?: string[];
23
+ inline?: string[];
24
+ void?: string[];
25
+ textOnly?: string[];
26
+ nesting?: Record<
27
+ string,
28
+ {
29
+ block?: boolean;
30
+ inline?: boolean;
31
+ directive?: boolean;
32
+ only?: string[];
33
+ }
34
+ >;
35
+ };
36
+ [key: string]: unknown;
37
+ }
38
+
39
+ export interface StudioFormat {
40
+ name: string;
41
+ extensions: string[];
42
+ mediaType: string | null;
43
+ documentKinds: string[];
44
+ exportTarget: boolean;
45
+ remote: boolean;
46
+ studio: StudioFormatHints | null;
47
+ capabilities: Record<string, { identifier: string; timing: string[] }>;
48
+ }
49
+
50
+ let _formats: StudioFormat[] = [];
51
+ let _loaded: Promise<StudioFormat[]> | null = null;
52
+
53
+ /** Load (and cache) the project's format registry from the platform. */
54
+ export function loadFormats(): Promise<StudioFormat[]> {
55
+ if (!_loaded) {
56
+ _loaded = (async () => {
57
+ try {
58
+ const platform = getPlatform() as {
59
+ listFormats?: () => Promise<StudioFormat[]>;
60
+ };
61
+ _formats = (await platform.listFormats?.()) ?? [];
62
+ } catch {
63
+ _formats = [];
64
+ }
65
+ return _formats;
66
+ })();
67
+ }
68
+ return _loaded;
69
+ }
70
+
71
+ /** Invalidate the cached registry (call on project switch). */
72
+ export function refreshFormats() {
73
+ _loaded = null;
74
+ _formats = [];
75
+ }
76
+
77
+ /** Seed the registry directly (tests and hosts that preload format metadata). */
78
+ export function setFormats(formats: StudioFormat[]) {
79
+ _formats = formats;
80
+ _loaded = Promise.resolve(formats);
81
+ }
82
+
83
+ /** The last-loaded registry (synchronous access for render paths). */
84
+ export function getFormats(): StudioFormat[] {
85
+ return _formats;
86
+ }
87
+
88
+ /** Look up the format claiming a file extension (".md" or "md"), optionally by capability. */
89
+ export function formatByExtension(ext: string, capability?: string): StudioFormat | undefined {
90
+ const norm = ext.startsWith(".") ? ext.toLowerCase() : `.${ext.toLowerCase()}`;
91
+ return _formats.find(
92
+ (f) => f.extensions.includes(norm) && (!capability || f.capabilities[capability]),
93
+ );
94
+ }
95
+
96
+ /** Look up a format by its import name. */
97
+ export function formatByName(name: string | null | undefined): StudioFormat | undefined {
98
+ if (!name) return undefined;
99
+ return _formats.find((f) => f.name === name);
100
+ }
101
+
102
+ /** The format claiming a file path's extension, if any. */
103
+ export function formatForPath(path: string | null | undefined): StudioFormat | undefined {
104
+ if (!path) return undefined;
105
+ const dot = path.lastIndexOf(".");
106
+ if (dot === -1) return undefined;
107
+ return formatByExtension(path.slice(dot));
108
+ }
109
+
110
+ /** Registered extensions, optionally filtered by document kind. Never includes ".json". */
111
+ export function documentExtensions(kind?: string): string[] {
112
+ const exts = new Set<string>();
113
+ for (const f of _formats) {
114
+ if (kind && !f.documentKinds.includes(kind)) continue;
115
+ for (const e of f.extensions) exts.add(e);
116
+ }
117
+ exts.delete(".json");
118
+ return [...exts];
119
+ }
120
+
121
+ /** The default format for new content documents (first content-kind serializer). */
122
+ export function defaultContentFormat(): StudioFormat | undefined {
123
+ return _formats.find((f) => f.capabilities.serialize && f.documentKinds.includes("content"));
124
+ }
125
+
126
+ async function formatAction(payload: Record<string, unknown>): Promise<unknown> {
127
+ const platform = getPlatform() as {
128
+ formatAction?: (payload: Record<string, unknown>) => Promise<unknown>;
129
+ };
130
+ if (!platform.formatAction) {
131
+ throw new Error("Platform does not support format actions");
132
+ }
133
+ return platform.formatAction(payload);
134
+ }
135
+
136
+ /** Parse source text into a Jx document via the named format's parse capability. */
137
+ export async function formatParse(
138
+ name: string,
139
+ source: string,
140
+ options?: Record<string, unknown>,
141
+ ): Promise<JxMutableNode> {
142
+ return (await formatAction({
143
+ format: name,
144
+ action: "parse",
145
+ source,
146
+ options,
147
+ })) as JxMutableNode;
148
+ }
149
+
150
+ /** Serialize a Jx document to source text via the named format's serialize capability. */
151
+ export async function formatSerialize(
152
+ name: string,
153
+ doc: Record<string, unknown>,
154
+ options?: Record<string, unknown>,
155
+ ): Promise<string> {
156
+ return (await formatAction({
157
+ format: name,
158
+ action: "serialize",
159
+ doc,
160
+ options,
161
+ })) as string;
162
+ }
163
+
164
+ /**
165
+ * Split a parsed format document into { document, frontmatter, mode } per the format's
166
+ * `$studio.documentMode` hints. Component documents (e.g. a frontmatter `tagName` with a hyphen)
167
+ * keep their full shape; content documents separate frontmatter metadata from the body children.
168
+ */
169
+ export function splitFormatDocument(format: StudioFormat | undefined, doc: JxMutableNode) {
170
+ const hints = format?.studio?.documentMode;
171
+ const componentWhen = hints?.componentWhen;
172
+ if (componentWhen?.frontmatterKey) {
173
+ const value = doc[componentWhen.frontmatterKey];
174
+ const pattern = componentWhen.matches ? new RegExp(componentWhen.matches) : /./;
175
+ if (typeof value === "string" && pattern.test(value)) {
176
+ return {
177
+ document: doc,
178
+ frontmatter: {} as Record<string, unknown>,
179
+ mode: "component",
180
+ };
181
+ }
182
+ }
183
+ if (hints?.default === "component") {
184
+ return {
185
+ document: doc,
186
+ frontmatter: {} as Record<string, unknown>,
187
+ mode: "component",
188
+ };
189
+ }
190
+
191
+ // Content document — children form the root-level body; other keys are frontmatter
192
+ const children = (doc.children as unknown[]) ?? [];
193
+ if (children.length === 0) children.push({ tagName: "p", children: [] });
194
+
195
+ const documentKeys = new Set(["state", "imports"]);
196
+ const contentDoc: Record<string, unknown> = { children };
197
+ const frontmatter: Record<string, unknown> = {};
198
+ for (const [key, value] of Object.entries(doc)) {
199
+ if (key === "children") continue;
200
+ if (documentKeys.has(key)) {
201
+ contentDoc[key] = value;
202
+ } else {
203
+ frontmatter[key] = value;
204
+ }
205
+ }
206
+
207
+ return {
208
+ document: contentDoc as JxMutableNode,
209
+ frontmatter,
210
+ mode: "content",
211
+ };
212
+ }
@@ -20,7 +20,13 @@ let _handle: ReturnType<typeof openModal> | null = null;
20
20
  * directory: string;
21
21
  * }}
22
22
  */
23
- let _form = { name: "", description: "", url: "", adapter: "static", directory: "" };
23
+ let _form = {
24
+ name: "",
25
+ description: "",
26
+ url: "",
27
+ adapter: "static",
28
+ directory: "",
29
+ };
24
30
 
25
31
  let _error: string = "";
26
32
 
@@ -35,9 +41,18 @@ let _resolve: ((result: { root: string; config: ProjectConfig } | null) => void)
35
41
  *
36
42
  * @returns {Promise<{ root: string; config: object } | null>}
37
43
  */
38
- export function openNewProjectModal(): Promise<{ root: string; config: ProjectConfig } | null> {
44
+ export function openNewProjectModal(): Promise<{
45
+ root: string;
46
+ config: ProjectConfig;
47
+ } | null> {
39
48
  if (_handle) return Promise.resolve(null);
40
- _form = { name: "", description: "", url: "", adapter: "static", directory: "" };
49
+ _form = {
50
+ name: "",
51
+ description: "",
52
+ url: "",
53
+ adapter: "static",
54
+ directory: "",
55
+ };
41
56
  _error = "";
42
57
  _creating = false;
43
58
 
@@ -48,7 +48,9 @@ export function mountAiPanel() {
48
48
  checkAuth();
49
49
  }
50
50
 
51
- const _g = globalThis as unknown as { __jxRightPanelRender?: { render: () => void } };
51
+ const _g = globalThis as unknown as {
52
+ __jxRightPanelRender?: { render: () => void };
53
+ };
52
54
 
53
55
  function rerenderPanel() {
54
56
  const { render } = _g.__jxRightPanelRender || {};
@@ -31,8 +31,10 @@ import type { JxPath } from "../state";
31
31
  * navigateToComponent: (path: string) => void;
32
32
  * } | null}
33
33
  */
34
- let _ctx: { getCanvasMode: () => string; navigateToComponent: (path: string) => void } | null =
35
- null;
34
+ let _ctx: {
35
+ getCanvasMode: () => string;
36
+ navigateToComponent: (path: string) => void;
37
+ } | null = null;
36
38
 
37
39
  /**
38
40
  * Initialize the block action bar module.
@@ -47,7 +47,12 @@ export function registerPanelDnD(panel: CanvasPanel) {
47
47
  // driven from the monitor using only the innermost target.
48
48
  /** Innermost drop target if it belongs to this panel's canvas, else null */
49
49
  const innermostCanvasTarget = (location: {
50
- current: { dropTargets: { data: Record<string | symbol, unknown>; element: Element }[] };
50
+ current: {
51
+ dropTargets: {
52
+ data: Record<string | symbol, unknown>;
53
+ element: Element;
54
+ }[];
55
+ };
51
56
  }) => {
52
57
  const target = location.current.dropTargets[0];
53
58
  if (!target) return null;
@@ -122,7 +127,7 @@ export function registerPanelDnD(panel: CanvasPanel) {
122
127
  const isLeaf = VOID_ELEMENTS.has(tag) || !hasElementChildren;
123
128
 
124
129
  const cleanup = dropTargetForElements({
125
- element: /** @type {HTMLElement} */ (el),
130
+ element: /** @type {HTMLElement} */ el,
126
131
  canDrop({ source }) {
127
132
  const srcPath = source.data.path as JxPath | undefined;
128
133
  if (srcPath && isAncestor(srcPath, elPath)) return false;
@@ -144,13 +149,21 @@ export function registerPanelDnD(panel: CanvasPanel) {
144
149
  */
145
150
  function getCanvasDropResult(el: HTMLElement, elPath: JxPath, isLeaf: boolean): DropResult {
146
151
  if (!view.lastDragInput)
147
- return { instruction: { type: "make-child" }, referenceEl: el, targetPath: elPath };
152
+ return {
153
+ instruction: { type: "make-child" },
154
+ referenceEl: el,
155
+ targetPath: elPath,
156
+ };
148
157
  const y = view.lastDragInput.clientY;
149
158
 
150
159
  if (elPath.length === 0) {
151
160
  const children = Array.from(el.children) as HTMLElement[];
152
161
  if (children.length === 0)
153
- return { instruction: { type: "make-child" }, referenceEl: el, targetPath: elPath };
162
+ return {
163
+ instruction: { type: "make-child" },
164
+ referenceEl: el,
165
+ targetPath: elPath,
166
+ };
154
167
  return nearestChildEdge(children, y, elPath);
155
168
  }
156
169
 
@@ -164,10 +177,22 @@ function getCanvasDropResult(el: HTMLElement, elPath: JxPath, isLeaf: boolean):
164
177
  }
165
178
 
166
179
  if (relY < 0.25)
167
- return { instruction: { type: "reorder-above" }, referenceEl: el, targetPath: elPath };
180
+ return {
181
+ instruction: { type: "reorder-above" },
182
+ referenceEl: el,
183
+ targetPath: elPath,
184
+ };
168
185
  if (relY > 0.75)
169
- return { instruction: { type: "reorder-below" }, referenceEl: el, targetPath: elPath };
170
- return { instruction: { type: "make-child" }, referenceEl: el, targetPath: elPath };
186
+ return {
187
+ instruction: { type: "reorder-below" },
188
+ referenceEl: el,
189
+ targetPath: elPath,
190
+ };
191
+ return {
192
+ instruction: { type: "make-child" },
193
+ referenceEl: el,
194
+ targetPath: elPath,
195
+ };
171
196
  }
172
197
 
173
198
  /**
@@ -202,7 +227,11 @@ function nearestChildEdge(children: HTMLElement[], cursorY: number, parentPath:
202
227
  }
203
228
 
204
229
  const childPath = [...parentPath, "children", closestIdx];
205
- return { instruction, referenceEl: children[closestIdx], targetPath: childPath };
230
+ return {
231
+ instruction,
232
+ referenceEl: children[closestIdx],
233
+ targetPath: childPath,
234
+ };
206
235
  }
207
236
 
208
237
  /**
@@ -77,7 +77,10 @@ export function renderDataExplorerTemplate(
77
77
  return html`
78
78
  <div class="data-row">
79
79
  <div
80
- class=${classMap({ "data-row-header": true, expanded: isExpanded })}
80
+ class=${classMap({
81
+ "data-row-header": true,
82
+ expanded: isExpanded,
83
+ })}
81
84
  @click=${() => {
82
85
  if (expandedDataKeys.has(name)) expandedDataKeys.delete(name);
83
86
  else expandedDataKeys.add(name);
@@ -86,7 +89,11 @@ export function renderDataExplorerTemplate(
86
89
  >
87
90
  <span class="signal-badge ${defCategory(def)}">${defBadgeLabel(def)}</span>
88
91
  <span class="data-name">${name}</span>
89
- <span class=${classMap({ "data-type": true, "data-pending": unwrapped === null })}
92
+ <span
93
+ class=${classMap({
94
+ "data-type": true,
95
+ "data-pending": unwrapped === null,
96
+ })}
90
97
  >${dataTypeLabel(value)}</span
91
98
  >
92
99
  </div>
@@ -104,7 +104,7 @@ export function renderFunctionEditor(closeFunctionEditor: () => void) {
104
104
 
105
105
  const body = getFunctionBody(editing);
106
106
  const args = getFunctionArgs(
107
- /** @type {EditingTarget} */ (editing as EditingTarget),
107
+ /** @type {EditingTarget} */ editing as EditingTarget,
108
108
  activeTab.value?.doc.document,
109
109
  );
110
110
 
@@ -8,6 +8,7 @@ import { html, nothing } from "lit-html";
8
8
  import { live } from "lit-html/directives/live.js";
9
9
  import { repeat } from "lit-html/directives/repeat.js";
10
10
  import { getPlatform } from "../platform";
11
+ import { formatForPath } from "../format/format-host";
11
12
  import { updateUi, renderOnly, projectState } from "../store";
12
13
  import { activeTab } from "../workspace/workspace";
13
14
  import { view } from "../view";
@@ -191,7 +192,9 @@ export function renderGitPanel(
191
192
 
192
193
  if (!status && !loading) {
193
194
  refreshGitStatus();
194
- return html`<div class="git-panel"><div class="git-loading">Loading...</div></div>`;
195
+ return html`<div class="git-panel">
196
+ <div class="git-loading">Loading...</div>
197
+ </div>`;
195
198
  }
196
199
 
197
200
  if (status && !status.isRepo) {
@@ -213,7 +216,10 @@ export function renderGitPanel(
213
216
  </sp-action-button>
214
217
  <sp-action-button
215
218
  size="m"
216
- @click=${() => publishToGithub({ projectName: projectState?.name || "my-project" })}
219
+ @click=${() =>
220
+ publishToGithub({
221
+ projectName: projectState?.name || "my-project",
222
+ })}
217
223
  ?disabled=${loading}
218
224
  >
219
225
  <sp-icon-share slot="icon"></sp-icon-share>
@@ -266,7 +272,10 @@ export function renderGitPanel(
266
272
  ? "Up to date"
267
273
  : `${status?.ahead ? `${status.ahead} ahead` : ""}${status?.ahead && status?.behind ? ", " : ""}${status?.behind ? `${status.behind} behind` : ""}`;
268
274
  const lastUpdatedStr = _lastUpdated
269
- ? _lastUpdated.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
275
+ ? _lastUpdated.toLocaleTimeString([], {
276
+ hour: "2-digit",
277
+ minute: "2-digit",
278
+ })
270
279
  : "";
271
280
 
272
281
  const hasRemotes = (status?.remotes?.length ?? 0) > 0;
@@ -332,7 +341,10 @@ export function renderGitPanel(
332
341
  </div>
333
342
  <sp-action-button
334
343
  size="s"
335
- @click=${() => publishToGithub({ projectName: projectState?.name || "my-project" })}
344
+ @click=${() =>
345
+ publishToGithub({
346
+ projectName: projectState?.name || "my-project",
347
+ })}
336
348
  ?disabled=${loading}
337
349
  >
338
350
  <sp-icon-share slot="icon"></sp-icon-share>
@@ -481,7 +493,7 @@ export function renderGitPanel(
481
493
 
482
494
  const onFileClick = async () => {
483
495
  if (file.status !== "M" && file.status !== "A") return;
484
- if (!file.path.endsWith(".md") && !file.path.endsWith(".json")) return;
496
+ if (!file.path.endsWith(".json") && !formatForPath(file.path)) return;
485
497
 
486
498
  try {
487
499
  const plat = getPlatform();
@@ -494,12 +506,10 @@ export function renderGitPanel(
494
506
  plat.readFile(file.path),
495
507
  ]);
496
508
 
497
- const isMarkdown = file.path.endsWith(".md");
498
509
  const diffState = {
499
510
  filePath: file.path,
500
511
  originalContent,
501
512
  currentContent,
502
- isMarkdown,
503
513
  fileStatus: file.status,
504
514
  };
505
515
 
@@ -579,7 +589,7 @@ export function renderGitPanel(
579
589
  for (const f of files) {
580
590
  const parts = f.path.split("/");
581
591
  let component;
582
- if (f.path.endsWith(".json") || f.path.endsWith(".class.json") || f.path.endsWith(".md")) {
592
+ if (f.path.endsWith(".json") || f.path.endsWith(".class.json") || formatForPath(f.path)) {
583
593
  component = parts.length > 1 ? `/${parts[parts.length - 2]}` : `/${parts[0]}`;
584
594
  } else {
585
595
  component = "Other";
@@ -74,7 +74,12 @@ const PAGE_FIELDS: MetaField[] = [
74
74
 
75
75
  const OG_FIELDS: MetaField[] = [
76
76
  { label: "Title", attr: "property", key: "og:title" },
77
- { label: "Description", attr: "property", key: "og:description", multiline: true },
77
+ {
78
+ label: "Description",
79
+ attr: "property",
80
+ key: "og:description",
81
+ multiline: true,
82
+ },
78
83
  { label: "Image", attr: "property", key: "og:image", media: true },
79
84
  { label: "Type", attr: "property", key: "og:type" },
80
85
  ];
@@ -243,7 +248,9 @@ function renderMetaFieldRow(
243
248
  const placeholder =
244
249
  field.key === "viewport" ? "width=device-width, initial-scale=1" : `${field.label}…`;
245
250
  const widget = field.multiline
246
- ? spTextArea(`head:${field.key}`, val, commit, { placeholder: `${field.label}…` })
251
+ ? spTextArea(`head:${field.key}`, val, commit, {
252
+ placeholder: `${field.label}…`,
253
+ })
247
254
  : spTextField(`head:${field.key}`, val, commit, { placeholder });
248
255
 
249
256
  return renderFieldRow({
@@ -522,7 +529,7 @@ function renderFrontmatterSection() {
522
529
  const fields = [];
523
530
  if (schemaProps) {
524
531
  for (const [field, fieldSchema] of Object.entries(
525
- /** @type {Record<string, FmSchemaEntry>} */ (schemaProps),
532
+ /** @type {Record<string, FmSchemaEntry>} */ schemaProps,
526
533
  )) {
527
534
  if (RESERVED_FM_KEYS.has(field)) continue;
528
535
  fields.push({ field, entry: fieldSchema, value: fm[field] as JsonValue });
@@ -532,7 +539,7 @@ function renderFrontmatterSection() {
532
539
  fields.push({
533
540
  field,
534
541
  entry: { type: typeof value === "boolean" ? "boolean" : "string" },
535
- value: /** @type {JsonValue} */ (value),
542
+ value: /** @type {JsonValue} */ value,
536
543
  });
537
544
  }
538
545
  } else {
@@ -541,7 +548,7 @@ function renderFrontmatterSection() {
541
548
  fields.push({
542
549
  field,
543
550
  entry: { type: typeof value === "boolean" ? "boolean" : "string" },
544
- value: /** @type {JsonValue} */ (value),
551
+ value: /** @type {JsonValue} */ value,
545
552
  });
546
553
  }
547
554
  }
@@ -170,7 +170,7 @@ function _render() {
170
170
  * content?: { frontmatter?: Record<string, unknown> };
171
171
  * documentPath?: string;
172
172
  * }}
173
- */ ({
173
+ */ {
174
174
  ui: aTab.session.ui,
175
175
  document: aTab.doc.document,
176
176
  mode: aTab.doc.mode,
@@ -178,7 +178,7 @@ function _render() {
178
178
  canvas: aTab.session.canvas,
179
179
  content: aTab.doc.content,
180
180
  documentPath: aTab.documentPath,
181
- });
181
+ };
182
182
 
183
183
  /** @type {TemplateResult | typeof nothing} */
184
184
  let content;
@@ -226,12 +226,15 @@ function _render() {
226
226
  const tab = activeTab.value!;
227
227
  const fm = (tab.doc.content?.frontmatter ?? {}) as Record<string, unknown>;
228
228
  const fmHead = fm.$head as unknown[] | undefined;
229
- const tmp = { title: fm.title, $head: fmHead ? [...fmHead] : undefined };
229
+ const tmp = {
230
+ title: fm.title,
231
+ $head: fmHead ? [...fmHead] : undefined,
232
+ };
230
233
  fn(tmp);
231
234
  if (tmp.title !== fm.title)
232
235
  mutateUpdateFrontmatter(tab, "title", tmp.title as JsonValue);
233
236
  const newHead = tmp.$head && tmp.$head.length > 0 ? tmp.$head : undefined;
234
- mutateUpdateFrontmatter(tab, "$head", /** @type {JsonValue} */ (newHead));
237
+ mutateUpdateFrontmatter(tab, "$head", /** @type {JsonValue} */ newHead);
235
238
  render();
236
239
  }
237
240
  : (fn: (doc: object) => void) => {
@@ -211,7 +211,9 @@ export function registerPanelEvents(panel: CanvasPanel) {
211
211
  let path = elToPath.get(el);
212
212
  if (path) {
213
213
  path = bubbleInlinePath(tab?.doc.document, path);
214
- showContextMenu(e, path, { onEditComponent: ctx.navigateToComponent });
214
+ showContextMenu(e, path, {
215
+ onEditComponent: ctx.navigateToComponent,
216
+ });
215
217
  return;
216
218
  }
217
219
  }
@@ -490,12 +490,15 @@ function renderComponentPropsFieldsTemplate(
490
490
  }}
491
491
  ></sp-number-field>`;
492
492
  } else if (parsed.kind === "combobox") {
493
- const options = /** @type {{ options?: string[] }} */ (parsed).options as string[];
493
+ const options = /** @type {{ options?: string[] }} */ parsed.options as string[];
494
494
  widgetTpl = html`<jx-value-selector
495
495
  .value=${String(staticVal)}
496
496
  size="s"
497
497
  placeholder="—"
498
- .options=${options.map((o) => ({ value: o, label: camelToLabel(o) }))}
498
+ .options=${options.map((o) => ({
499
+ value: o,
500
+ label: camelToLabel(o),
501
+ }))}
499
502
  @change=${(e: Event & { detail?: { value?: string } }) =>
500
503
  onChange(e.detail?.value ?? (e.target as HTMLInputElement).value)}
501
504
  ></jx-value-selector>`;
@@ -849,7 +852,10 @@ function renderPageSection(node: JxMutableNode) {
849
852
  // ─── Layout selection panel ─────────────────────────────────────────────────
850
853
 
851
854
  function renderLayoutSelectionPanel(ctx: { navigateToComponent: (path: string) => void }) {
852
- const { el, layoutPath } = view.layoutSelection as { el: HTMLElement; layoutPath: string };
855
+ const { el, layoutPath } = view.layoutSelection as {
856
+ el: HTMLElement;
857
+ layoutPath: string;
858
+ };
853
859
  const tagName = el?.tagName?.toLowerCase() || "element";
854
860
  const className = el?.className || "";
855
861
  const displayPath = layoutPath || "layout";
@@ -4,6 +4,7 @@ import { classMap } from "lit-html/directives/class-map.js";
4
4
  import { live } from "lit-html/directives/live.js";
5
5
  import { ref } from "lit-html/directives/ref.js";
6
6
  import { getPlatform } from "../platform";
7
+ import { loadFormats, documentExtensions, formatByExtension } from "../format/format-host";
7
8
  import { openFileInTab } from "../files/files";
8
9
  import { getRecentFiles, trackRecentFile } from "../recent-projects";
9
10
  import { getLayerSlot } from "../ui/layers";
@@ -46,7 +47,8 @@ async function doSearch(query: string) {
46
47
  }
47
48
  try {
48
49
  const platform = getPlatform();
49
- _results = await platform.searchFiles(query.trim().toLowerCase());
50
+ await loadFormats();
51
+ _results = await platform.searchFiles(query.trim().toLowerCase(), documentExtensions());
50
52
  _selectedIndex = 0;
51
53
  renderOverlay();
52
54
  } catch {
@@ -95,14 +97,9 @@ function selectItem(item: { path: string; name?: string }) {
95
97
 
96
98
  function fileIcon(name: string) {
97
99
  const ext = name.split(".").pop()?.toLowerCase();
98
- switch (ext) {
99
- case "json":
100
- return html`<sp-icon-file-code size="s"></sp-icon-file-code>`;
101
- case "md":
102
- return html`<sp-icon-file-txt size="s"></sp-icon-file-txt>`;
103
- default:
104
- return html`<sp-icon-document size="s"></sp-icon-document>`;
105
- }
100
+ if (ext === "json") return html`<sp-icon-file-code size="s"></sp-icon-file-code>`;
101
+ if (ext && formatByExtension(ext)) return html`<sp-icon-file-txt size="s"></sp-icon-file-txt>`;
102
+ return html`<sp-icon-document size="s"></sp-icon-document>`;
106
103
  }
107
104
 
108
105
  function dirPart(path: string) {
@@ -149,7 +146,10 @@ function renderOverlay() {
149
146
  ${items.map(
150
147
  (item, i) => html`
151
148
  <div
152
- class=${classMap({ "quick-search-item": true, selected: i === _selectedIndex })}
149
+ class=${classMap({
150
+ "quick-search-item": true,
151
+ selected: i === _selectedIndex,
152
+ })}
153
153
  @click=${() => selectItem(item)}
154
154
  @mouseenter=${() => {
155
155
  _selectedIndex = i;
@@ -189,12 +189,16 @@ function _doRender() {
189
189
  // Only render the active panel's content
190
190
  if (tab === "properties") {
191
191
  litRender(
192
- renderPropertiesPanelTemplate({ navigateToComponent: ctx.navigateToComponent }),
192
+ renderPropertiesPanelTemplate({
193
+ navigateToComponent: ctx.navigateToComponent,
194
+ }),
193
195
  _propsContainer!,
194
196
  );
195
197
  } else if (tab === "events") {
196
198
  litRender(
197
- eventsSidebarTemplate({ isCustomElementDoc: () => isCustomElementDoc(S) }),
199
+ eventsSidebarTemplate({
200
+ isCustomElementDoc: () => isCustomElementDoc(S),
201
+ }),
198
202
  _eventsContainer!,
199
203
  );
200
204
  } else if (tab === "style") {