@jxsuite/studio 0.0.1

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.
@@ -0,0 +1,491 @@
1
+ /**
2
+ * Md-convert.js — Bidirectional mdast ↔ Jx conversion
3
+ *
4
+ * MdToJx(mdast) → Jx element tree (for loading into the canvas) jxToMd(jx) → mdast (for saving back
5
+ * to markdown)
6
+ *
7
+ * Both are pure tree transformations. The remark ecosystem handles all actual parsing and
8
+ * serialization.
9
+ */
10
+
11
+ import { MD_ALL } from "./md-allowlist.js";
12
+
13
+ // ─── mdast → Jx ──────────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Mdast node-type → Jx tagName mapping
17
+ *
18
+ * @type {Record<string, (n: any) => string>}
19
+ */
20
+ const MDAST_TAG_MAP = {
21
+ heading: (/** @type {any} */ n) => `h${n.depth}`,
22
+ paragraph: () => "p",
23
+ text: () => "span",
24
+ emphasis: () => "em",
25
+ strong: () => "strong",
26
+ delete: () => "del",
27
+ inlineCode: () => "code",
28
+ link: () => "a",
29
+ image: () => "img",
30
+ blockquote: () => "blockquote",
31
+ list: (/** @type {any} */ n) => (n.ordered ? "ol" : "ul"),
32
+ listItem: () => "li",
33
+ code: () => "pre",
34
+ thematicBreak: () => "hr",
35
+ table: () => "table",
36
+ tableRow: () => "tr",
37
+ tableCell: (/** @type {any} */ n) => (n.isHeader ? "th" : "td"),
38
+ html: () => "div",
39
+ break: () => "br",
40
+ };
41
+
42
+ /**
43
+ * Convert an mdast tree to a Jx element tree.
44
+ *
45
+ * @param {any} mdast - Root mdast node (type: 'root')
46
+ * @returns {any} Jx element tree
47
+ */
48
+ export function mdToJx(mdast) {
49
+ if (mdast.type === "root") {
50
+ return {
51
+ tagName: "div",
52
+ $id: "content",
53
+ children: (mdast.children ?? [])
54
+ .filter((/** @type {any} */ n) => n.type !== "yaml" && n.type !== "toml")
55
+ .map(convertMdastNode)
56
+ .filter(Boolean),
57
+ };
58
+ }
59
+ return convertMdastNode(mdast);
60
+ }
61
+
62
+ /**
63
+ * @param {any} node
64
+ * @returns {any}
65
+ */
66
+ function convertMdastNode(node) {
67
+ if (!node) return null;
68
+
69
+ // Directive nodes → custom elements
70
+ if (
71
+ node.type === "containerDirective" ||
72
+ node.type === "leafDirective" ||
73
+ node.type === "textDirective"
74
+ ) {
75
+ return convertDirective(node);
76
+ }
77
+
78
+ const tagFn = MDAST_TAG_MAP[node.type];
79
+ if (!tagFn) return null;
80
+
81
+ const tag = tagFn(node);
82
+ /** @type {Record<string, any>} */
83
+ const el = { tagName: tag };
84
+
85
+ switch (node.type) {
86
+ case "heading":
87
+ case "paragraph": {
88
+ // If contains only a single text child, flatten to textContent
89
+ if (node.children?.length === 1 && node.children[0].type === "text") {
90
+ el.textContent = node.children[0].value;
91
+ } else if (node.children?.length > 0) {
92
+ el.children = node.children.map(convertMdastNode).filter(Boolean);
93
+ }
94
+ break;
95
+ }
96
+
97
+ case "text":
98
+ el.textContent = node.value;
99
+ break;
100
+
101
+ case "emphasis":
102
+ case "strong":
103
+ case "delete": {
104
+ if (node.children?.length === 1 && node.children[0].type === "text") {
105
+ el.textContent = node.children[0].value;
106
+ } else if (node.children?.length > 0) {
107
+ el.children = node.children.map(convertMdastNode).filter(Boolean);
108
+ }
109
+ break;
110
+ }
111
+
112
+ case "inlineCode":
113
+ el.textContent = node.value;
114
+ break;
115
+
116
+ case "link":
117
+ el.attributes = { href: node.url };
118
+ if (node.title) el.attributes.title = node.title;
119
+ if (node.children?.length === 1 && node.children[0].type === "text") {
120
+ el.textContent = node.children[0].value;
121
+ } else if (node.children?.length > 0) {
122
+ el.children = node.children.map(convertMdastNode).filter(Boolean);
123
+ }
124
+ break;
125
+
126
+ case "image":
127
+ el.attributes = { src: node.url, alt: node.alt ?? "" };
128
+ if (node.title) el.attributes.title = node.title;
129
+ break;
130
+
131
+ case "blockquote":
132
+ case "listItem":
133
+ if (node.children?.length > 0) {
134
+ el.children = node.children.map(convertMdastNode).filter(Boolean);
135
+ }
136
+ break;
137
+
138
+ case "list":
139
+ if (node.children?.length > 0) {
140
+ el.children = node.children.map(convertMdastNode).filter(Boolean);
141
+ }
142
+ if (node.start != null && node.start !== 1) {
143
+ el.attributes = { start: String(node.start) };
144
+ }
145
+ break;
146
+
147
+ case "code":
148
+ // Fenced code → pre > code
149
+ el.children = [
150
+ {
151
+ tagName: "code",
152
+ textContent: node.value,
153
+ ...(node.lang ? { attributes: { class: `language-${node.lang}` } } : {}),
154
+ },
155
+ ];
156
+ break;
157
+
158
+ case "thematicBreak":
159
+ case "break":
160
+ // Void elements — no content
161
+ break;
162
+
163
+ case "table": {
164
+ // Mdast tables have rows directly; split into thead/tbody
165
+ const rows = (node.children ?? []).map(convertMdastNode).filter(Boolean);
166
+ const thead = rows.length > 0 ? { tagName: "thead", children: [rows[0]] } : null;
167
+ const tbody = rows.length > 1 ? { tagName: "tbody", children: rows.slice(1) } : null;
168
+ el.children = [thead, tbody].filter(Boolean);
169
+ break;
170
+ }
171
+
172
+ case "tableRow":
173
+ if (node.children?.length > 0) {
174
+ el.children = node.children.map(convertMdastNode).filter(Boolean);
175
+ }
176
+ break;
177
+
178
+ case "tableCell":
179
+ if (node.children?.length === 1 && node.children[0].type === "text") {
180
+ el.textContent = node.children[0].value;
181
+ } else if (node.children?.length > 0) {
182
+ el.children = node.children.map(convertMdastNode).filter(Boolean);
183
+ }
184
+ break;
185
+
186
+ case "html":
187
+ el.innerHTML = node.value;
188
+ break;
189
+ }
190
+
191
+ return el;
192
+ }
193
+
194
+ /**
195
+ * @param {any} node
196
+ * @returns {any}
197
+ */
198
+ function convertDirective(node) {
199
+ /** @type {Record<string, any>} */
200
+ const el = { tagName: node.name };
201
+ if (node.attributes && Object.keys(node.attributes).length > 0) {
202
+ el.attributes = { ...node.attributes };
203
+ }
204
+ if (node.type === "textDirective") {
205
+ // Text directives place label as textContent
206
+ if (node.children?.length === 1 && node.children[0].type === "text") {
207
+ el.textContent = node.children[0].value;
208
+ } else if (node.children?.length > 0) {
209
+ el.children = node.children.map(convertMdastNode).filter(Boolean);
210
+ }
211
+ } else if (node.type === "containerDirective" && node.children?.length > 0) {
212
+ el.children = node.children.map(convertMdastNode).filter(Boolean);
213
+ }
214
+ return el;
215
+ }
216
+
217
+ // ─── Jx → mdast ──────────────────────────────────────────────────────────
218
+
219
+ /**
220
+ * Jx tagName → mdast node-type mapping (inverse of MDAST_TAG_MAP)
221
+ *
222
+ * @type {Record<string, string>}
223
+ */
224
+ const TAG_MDAST_MAP = {
225
+ h1: "heading",
226
+ h2: "heading",
227
+ h3: "heading",
228
+ h4: "heading",
229
+ h5: "heading",
230
+ h6: "heading",
231
+ p: "paragraph",
232
+ span: "text",
233
+ em: "emphasis",
234
+ strong: "strong",
235
+ del: "delete",
236
+ code: "inlineCode",
237
+ a: "link",
238
+ img: "image",
239
+ blockquote: "blockquote",
240
+ ul: "list",
241
+ ol: "list",
242
+ li: "listItem",
243
+ pre: "code",
244
+ hr: "thematicBreak",
245
+ table: "table",
246
+ tr: "tableRow",
247
+ th: "tableCell",
248
+ td: "tableCell",
249
+ br: "break",
250
+ };
251
+
252
+ /**
253
+ * Convert a Jx element tree to an mdast tree.
254
+ *
255
+ * @param {any} jx - Jx element tree (root content div)
256
+ * @returns {any} Mdast root node
257
+ */
258
+ export function jxToMd(jx) {
259
+ const children = (jx.children ?? [])
260
+ .map((/** @type {any} */ child, /** @type {number} */ _i) => convertJxNode(child, true))
261
+ .filter(Boolean);
262
+
263
+ return { type: "root", children };
264
+ }
265
+
266
+ /**
267
+ * Convert a single Jx element to an mdast node.
268
+ *
269
+ * @param {any} el - Jx element
270
+ * @param {boolean} isBlock - Whether this element is in a block context
271
+ * @returns {any} Mdast node
272
+ */
273
+ function convertJxNode(el, isBlock) {
274
+ if (!el || typeof el !== "object") return null;
275
+
276
+ const tag = el.tagName ?? "div";
277
+
278
+ // If not in the markdown allowlist, convert to directive
279
+ if (!MD_ALL.has(tag)) {
280
+ return convertToDirective(el, isBlock);
281
+ }
282
+
283
+ const mdastType = TAG_MDAST_MAP[tag];
284
+ if (!mdastType) return null;
285
+
286
+ switch (mdastType) {
287
+ case "heading":
288
+ return {
289
+ type: "heading",
290
+ depth: parseInt(tag.slice(1), 10),
291
+ children: inlineChildren(el),
292
+ };
293
+
294
+ case "paragraph":
295
+ return {
296
+ type: "paragraph",
297
+ children: inlineChildren(el),
298
+ };
299
+
300
+ case "text":
301
+ return { type: "text", value: el.textContent ?? "" };
302
+
303
+ case "emphasis":
304
+ case "strong":
305
+ case "delete":
306
+ return {
307
+ type: mdastType,
308
+ children: inlineChildren(el),
309
+ };
310
+
311
+ case "inlineCode":
312
+ return { type: "inlineCode", value: el.textContent ?? "" };
313
+
314
+ case "link":
315
+ return {
316
+ type: "link",
317
+ url: el.attributes?.href ?? "",
318
+ title: el.attributes?.title ?? null,
319
+ children: inlineChildren(el),
320
+ };
321
+
322
+ case "image":
323
+ return {
324
+ type: "image",
325
+ url: el.attributes?.src ?? "",
326
+ alt: el.attributes?.alt ?? "",
327
+ title: el.attributes?.title ?? null,
328
+ };
329
+
330
+ case "blockquote":
331
+ return {
332
+ type: "blockquote",
333
+ children: blockChildren(el),
334
+ };
335
+
336
+ case "list":
337
+ return {
338
+ type: "list",
339
+ ordered: tag === "ol",
340
+ start: tag === "ol" ? parseInt(el.attributes?.start, 10) || 1 : null,
341
+ spread: false,
342
+ children: (el.children ?? [])
343
+ .map((/** @type {any} */ c) => convertJxNode(c, true))
344
+ .filter(Boolean),
345
+ };
346
+
347
+ case "listItem":
348
+ return {
349
+ type: "listItem",
350
+ spread: false,
351
+ children: blockChildren(el),
352
+ };
353
+
354
+ case "code": {
355
+ // pre > code → fenced code block
356
+ const codeChild = el.children?.[0];
357
+ const langClass = codeChild?.attributes?.class ?? "";
358
+ const lang = langClass.replace("language-", "") || null;
359
+ return {
360
+ type: "code",
361
+ lang,
362
+ value: codeChild?.textContent ?? el.textContent ?? "",
363
+ };
364
+ }
365
+
366
+ case "thematicBreak":
367
+ return { type: "thematicBreak" };
368
+
369
+ case "break":
370
+ return { type: "break" };
371
+
372
+ case "table": {
373
+ // Flatten thead/tbody back to rows
374
+ /** @type {any[]} */
375
+ const rows = [];
376
+ for (const section of el.children ?? []) {
377
+ if (section.tagName === "thead" || section.tagName === "tbody") {
378
+ for (const row of section.children ?? []) {
379
+ const mdRow = convertJxNode(row, true);
380
+ if (mdRow) {
381
+ // Mark header cells
382
+ if (section.tagName === "thead") {
383
+ for (const cell of mdRow.children ?? []) {
384
+ cell.isHeader = true;
385
+ }
386
+ }
387
+ rows.push(mdRow);
388
+ }
389
+ }
390
+ }
391
+ }
392
+ return {
393
+ type: "table",
394
+ align: null,
395
+ children: rows,
396
+ };
397
+ }
398
+
399
+ case "tableRow":
400
+ return {
401
+ type: "tableRow",
402
+ children: (el.children ?? [])
403
+ .map((/** @type {any} */ c) => convertJxNode(c, false))
404
+ .filter(Boolean),
405
+ };
406
+
407
+ case "tableCell":
408
+ return {
409
+ type: "tableCell",
410
+ children: inlineChildren(el),
411
+ };
412
+ }
413
+
414
+ return null;
415
+ }
416
+
417
+ /**
418
+ * Get inline children from a Jx element as mdast nodes. Handles both textContent shorthand and
419
+ * explicit children array.
420
+ *
421
+ * @param {any} el
422
+ * @returns {any[]}
423
+ */
424
+ function inlineChildren(el) {
425
+ if (el.textContent != null) {
426
+ return [{ type: "text", value: String(el.textContent) }];
427
+ }
428
+ return (el.children ?? []).map((/** @type {any} */ c) => convertJxNode(c, false)).filter(Boolean);
429
+ }
430
+
431
+ /**
432
+ * Get block children from a Jx element as mdast nodes.
433
+ *
434
+ * @param {any} el
435
+ * @returns {any[]}
436
+ */
437
+ function blockChildren(el) {
438
+ if (el.textContent != null) {
439
+ // Wrap bare text in a paragraph
440
+ return [{ type: "paragraph", children: [{ type: "text", value: String(el.textContent) }] }];
441
+ }
442
+ return (el.children ?? []).map((/** @type {any} */ c) => convertJxNode(c, true)).filter(Boolean);
443
+ }
444
+
445
+ /**
446
+ * Convert a non-markdown-native Jx element to a directive node.
447
+ *
448
+ * @param {any} el
449
+ * @param {boolean} isBlock
450
+ * @returns {any}
451
+ */
452
+ function convertToDirective(el, isBlock) {
453
+ const tag = el.tagName ?? "div";
454
+ const attrs = el.attributes ? { ...el.attributes } : {};
455
+
456
+ if (!isBlock) {
457
+ // Inline → textDirective
458
+ return {
459
+ type: "textDirective",
460
+ name: tag,
461
+ attributes: attrs,
462
+ children:
463
+ el.textContent != null
464
+ ? [{ type: "text", value: String(el.textContent) }]
465
+ : (el.children ?? [])
466
+ .map((/** @type {any} */ c) => convertJxNode(c, false))
467
+ .filter(Boolean),
468
+ };
469
+ }
470
+
471
+ // Block without children → leafDirective
472
+ if (!el.children?.length && el.textContent == null) {
473
+ return {
474
+ type: "leafDirective",
475
+ name: tag,
476
+ attributes: attrs,
477
+ children: [],
478
+ };
479
+ }
480
+
481
+ // Block with children → containerDirective
482
+ return {
483
+ type: "containerDirective",
484
+ name: tag,
485
+ attributes: attrs,
486
+ children:
487
+ el.textContent != null
488
+ ? [{ type: "paragraph", children: [{ type: "text", value: String(el.textContent) }] }]
489
+ : (el.children ?? []).map((/** @type {any} */ c) => convertJxNode(c, true)).filter(Boolean),
490
+ };
491
+ }
@@ -0,0 +1,69 @@
1
+ /** Activity bar — tab icons for switching left panel views. */
2
+
3
+ import { html, render as litRender, nothing } from "lit-html";
4
+ import { activityBar, update, getState, renderOnly } from "../store.js";
5
+
6
+ /**
7
+ * @param {any} tag
8
+ * @param {any} size
9
+ */
10
+ export function tabIcon(tag, size) {
11
+ /** @type {Record<string, any>} */
12
+ const m = {
13
+ "sp-icon-folder": (/** @type {any} */ s) =>
14
+ html`<sp-icon-folder slot="icon" size=${s}></sp-icon-folder>`,
15
+ "sp-icon-layers": (/** @type {any} */ s) =>
16
+ html`<sp-icon-layers slot="icon" size=${s}></sp-icon-layers>`,
17
+ "sp-icon-view-grid": (/** @type {any} */ s) =>
18
+ html`<sp-icon-view-grid slot="icon" size=${s}></sp-icon-view-grid>`,
19
+ "sp-icon-brackets": (/** @type {any} */ s) =>
20
+ html`<sp-icon-brackets slot="icon" size=${s}></sp-icon-brackets>`,
21
+ "sp-icon-data": (/** @type {any} */ s) =>
22
+ html`<sp-icon-data slot="icon" size=${s}></sp-icon-data>`,
23
+ "sp-icon-properties": (/** @type {any} */ s) =>
24
+ html`<sp-icon-properties slot="icon" size=${s}></sp-icon-properties>`,
25
+ "sp-icon-event": (/** @type {any} */ s) =>
26
+ html`<sp-icon-event slot="icon" size=${s}></sp-icon-event>`,
27
+ "sp-icon-brush": (/** @type {any} */ s) =>
28
+ html`<sp-icon-brush slot="icon" size=${s}></sp-icon-brush>`,
29
+ "sp-icon-artboard": (/** @type {any} */ s) =>
30
+ html`<sp-icon-artboard slot="icon" size=${s}></sp-icon-artboard>`,
31
+ "sp-icon-box": (/** @type {any} */ s) =>
32
+ html`<sp-icon-box slot="icon" size=${s}></sp-icon-box>`,
33
+ };
34
+ const fn = m[tag];
35
+ return fn ? fn(size || "s") : nothing;
36
+ }
37
+
38
+ /** @param {any} S — current studio state */
39
+ export function renderActivityBar(S) {
40
+ const tabs = [
41
+ { value: "files", icon: "sp-icon-folder", label: "Files" },
42
+ { value: "layers", icon: "sp-icon-layers", label: "Layers" },
43
+ { value: "imports", icon: "sp-icon-box", label: "Imports" },
44
+ { value: "blocks", icon: "sp-icon-view-grid", label: "Elements" },
45
+ { value: "state", icon: "sp-icon-brackets", label: "State" },
46
+ { value: "data", icon: "sp-icon-data", label: "Data" },
47
+ ];
48
+ const tpl = html`
49
+ <sp-tabs
50
+ selected=${S.ui.leftTab}
51
+ direction="vertical"
52
+ quiet
53
+ @change=${(/** @type {any} */ e) => {
54
+ const current = getState();
55
+ update({ ...current, ui: { ...current.ui, leftTab: e.target.selected } });
56
+ renderOnly("activityBar", "leftPanel");
57
+ }}
58
+ >
59
+ ${tabs.map(
60
+ (t) => html`
61
+ <sp-tab value=${t.value} title=${t.label} aria-label=${t.label}>
62
+ ${tabIcon(t.icon, "m")}
63
+ </sp-tab>
64
+ `,
65
+ )}
66
+ </sp-tabs>
67
+ `;
68
+ litRender(tpl, /** @type {any} */ (activityBar));
69
+ }