@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
@@ -1,846 +0,0 @@
1
- /// <reference lib="dom" />
2
- /**
3
- * Md-convert.js — Bidirectional mdast ↔ Jx conversion
4
- *
5
- * MdToJx(mdast) → Jx element tree (for loading into the canvas) jxToMd(jx) → mdast (for saving back
6
- * to markdown)
7
- *
8
- * JxDocToMd(doc) → Jx Markdown string (for saving Jx component documents back to .md)
9
- *
10
- * Both are pure tree transformations. The remark ecosystem handles all actual parsing and
11
- * serialization.
12
- */
13
-
14
- import { unified } from "unified";
15
- import remarkStringify from "remark-stringify";
16
- import remarkDirective from "remark-directive";
17
- import remarkGfm from "remark-gfm";
18
- import { MD_ALL } from "./md-allowlist";
19
- import { htmlToJx } from "@jxsuite/parser/transpile";
20
-
21
- import type { JxElement, JxMutableNode } from "@jxsuite/schema/types";
22
- import type { MdastNode } from "@jxsuite/parser/types";
23
-
24
- // ─── mdast → Jx ──────────────────────────────────────────────────────────
25
-
26
- /**
27
- * Mdast node-type → Jx tagName mapping
28
- *
29
- * @type {Record<string, (n: MdastNode) => string>}
30
- */
31
- const MDAST_TAG_MAP: Record<string, (n: MdastNode) => string> = {
32
- heading: (n: MdastNode) => `h${n.depth}`,
33
- paragraph: () => "p",
34
- text: () => "span",
35
- emphasis: () => "em",
36
- strong: () => "strong",
37
- delete: () => "del",
38
- inlineCode: () => "code",
39
- link: () => "a",
40
- image: () => "img",
41
- blockquote: () => "blockquote",
42
- list: (n: MdastNode) => (n.ordered ? "ol" : "ul"),
43
- listItem: () => "li",
44
- code: () => "pre",
45
- thematicBreak: () => "hr",
46
- table: () => "table",
47
- tableRow: () => "tr",
48
- tableCell: (n: MdastNode) => (n.isHeader ? "th" : "td"),
49
- break: () => "br",
50
- };
51
-
52
- /**
53
- * Convert an mdast tree to a Jx element tree.
54
- *
55
- * @param {MdastNode} mdast - Root mdast node (type: 'root')
56
- * @returns {JxElement} Jx element tree
57
- */
58
- export function mdToJx(mdast: MdastNode) {
59
- if (mdast.type === "root") {
60
- return {
61
- children: (mdast.children ?? [])
62
- .filter((n: MdastNode) => n.type !== "yaml" && n.type !== "toml")
63
- .flatMap(convertMdastNode)
64
- .filter(Boolean) as (JxElement | string)[],
65
- };
66
- }
67
- return convertMdastNode(mdast) as JxElement;
68
- }
69
-
70
- /**
71
- * @param {MdastNode} node
72
- * @returns {JxElement | null}
73
- */
74
- function convertMdastNode(node: MdastNode) {
75
- if (!node) return null;
76
-
77
- // Directive nodes → custom elements
78
- if (
79
- node.type === "containerDirective" ||
80
- node.type === "leafDirective" ||
81
- node.type === "textDirective"
82
- ) {
83
- return convertDirective(node);
84
- }
85
-
86
- if (node.type === "html") {
87
- if (!node.value) return null;
88
- const nodes = htmlToJx(node.value);
89
- return nodes.length === 1 ? (nodes[0] as JxElement) : { tagName: "div", children: nodes };
90
- }
91
-
92
- const tagFn = MDAST_TAG_MAP[node.type];
93
- if (!tagFn) return null;
94
-
95
- const tag = tagFn(node);
96
- const el: JxElement = { tagName: tag };
97
-
98
- switch (node.type) {
99
- case "heading":
100
- case "paragraph": {
101
- // If contains only a single text child, flatten to textContent
102
- if (node.children?.length === 1 && node.children[0].type === "text") {
103
- el.textContent = node.children[0].value ?? null;
104
- } else if (node.children?.length) {
105
- el.children = node.children.flatMap(convertMdastNode).filter(Boolean) as (
106
- | JxElement
107
- | string
108
- )[];
109
- }
110
- break;
111
- }
112
-
113
- case "text":
114
- el.textContent = node.value ?? null;
115
- break;
116
-
117
- case "emphasis":
118
- case "strong":
119
- case "delete": {
120
- if (node.children?.length === 1 && node.children[0].type === "text") {
121
- el.textContent = node.children[0].value ?? null;
122
- } else if (node.children?.length) {
123
- el.children = node.children.flatMap(convertMdastNode).filter(Boolean) as (
124
- | JxElement
125
- | string
126
- )[];
127
- }
128
- break;
129
- }
130
-
131
- case "inlineCode":
132
- el.textContent = node.value ?? null;
133
- break;
134
-
135
- case "link":
136
- el.attributes = { href: node.url ?? "" };
137
- if (node.title) el.attributes.title = node.title;
138
- if (node.children?.length === 1 && node.children[0].type === "text") {
139
- el.textContent = node.children[0].value ?? null;
140
- } else if (node.children?.length) {
141
- el.children = node.children.flatMap(convertMdastNode).filter(Boolean) as (
142
- | JxElement
143
- | string
144
- )[];
145
- }
146
- break;
147
-
148
- case "image":
149
- el.attributes = { src: node.url ?? "", alt: node.alt ?? "" };
150
- if (node.title) el.attributes.title = node.title;
151
- break;
152
-
153
- case "blockquote":
154
- case "listItem":
155
- if (node.children?.length) {
156
- el.children = node.children.flatMap(convertMdastNode).filter(Boolean) as (
157
- | JxElement
158
- | string
159
- )[];
160
- }
161
- break;
162
-
163
- case "list":
164
- if (node.children?.length) {
165
- el.children = node.children.flatMap(convertMdastNode).filter(Boolean) as (
166
- | JxElement
167
- | string
168
- )[];
169
- }
170
- if (node.start != null && node.start !== 1) {
171
- el.attributes = { start: String(node.start) };
172
- }
173
- break;
174
-
175
- case "code":
176
- // Fenced code → pre > code
177
- el.children = [
178
- {
179
- tagName: "code",
180
- textContent: node.value ?? null,
181
- ...(node.lang ? { attributes: { class: `language-${node.lang}` } } : {}),
182
- },
183
- ];
184
- break;
185
-
186
- case "thematicBreak":
187
- case "break":
188
- // Void elements — no content
189
- break;
190
-
191
- case "table": {
192
- // Mdast tables have rows directly; split into thead/tbody
193
- const rows = (node.children ?? []).flatMap(convertMdastNode).filter(Boolean) as JxElement[];
194
- const thead =
195
- rows.length > 0
196
- ? { tagName: "thead", children: [rows[0]] as (JxElement | string)[] }
197
- : null;
198
- const tbody =
199
- rows.length > 1
200
- ? { tagName: "tbody", children: rows.slice(1) as (JxElement | string)[] }
201
- : null;
202
- el.children = [thead, tbody].filter(Boolean) as (JxElement | string)[];
203
- break;
204
- }
205
-
206
- case "tableRow":
207
- if (node.children?.length) {
208
- el.children = node.children.flatMap(convertMdastNode).filter(Boolean) as (
209
- | JxElement
210
- | string
211
- )[];
212
- }
213
- break;
214
-
215
- case "tableCell":
216
- if (node.children?.length === 1 && node.children[0].type === "text") {
217
- el.textContent = node.children[0].value ?? null;
218
- } else if (node.children?.length) {
219
- el.children = node.children.flatMap(convertMdastNode).filter(Boolean) as (
220
- | JxElement
221
- | string
222
- )[];
223
- }
224
- break;
225
- }
226
-
227
- return el;
228
- }
229
-
230
- /**
231
- * @param {MdastNode} node
232
- * @returns {JxElement | null}
233
- */
234
- function convertDirective(node: MdastNode) {
235
- const el: JxElement = { tagName: node.name ?? "div" };
236
- if (node.attributes && Object.keys(node.attributes).length > 0) {
237
- el.attributes = { ...node.attributes };
238
- }
239
- if (node.type === "textDirective") {
240
- // Text directives place label as textContent
241
- if (node.children?.length === 1 && node.children[0].type === "text") {
242
- el.textContent = node.children[0].value ?? null;
243
- } else if (node.children?.length) {
244
- el.children = node.children.flatMap(convertMdastNode).filter(Boolean) as (
245
- | JxElement
246
- | string
247
- )[];
248
- }
249
- } else if (node.type === "containerDirective" && node.children?.length) {
250
- el.children = node.children.flatMap(convertMdastNode).filter(Boolean) as (JxElement | string)[];
251
- }
252
- return el;
253
- }
254
-
255
- // ─── Jx → mdast ──────────────────────────────────────────────────────────
256
-
257
- /**
258
- * Jx tagName → mdast node-type mapping (inverse of MDAST_TAG_MAP)
259
- *
260
- * @type {Record<string, string>}
261
- */
262
- /** Tags whose content model is inline (phrasing content). */
263
- const INLINE_CONTENT_TAGS = new Set([
264
- "p",
265
- "h1",
266
- "h2",
267
- "h3",
268
- "h4",
269
- "h5",
270
- "h6",
271
- "em",
272
- "strong",
273
- "del",
274
- "a",
275
- "td",
276
- "th",
277
- ]);
278
-
279
- const TAG_MDAST_MAP = {
280
- h1: "heading",
281
- h2: "heading",
282
- h3: "heading",
283
- h4: "heading",
284
- h5: "heading",
285
- h6: "heading",
286
- p: "paragraph",
287
- span: "text",
288
- em: "emphasis",
289
- strong: "strong",
290
- del: "delete",
291
- code: "inlineCode",
292
- a: "link",
293
- img: "image",
294
- blockquote: "blockquote",
295
- ul: "list",
296
- ol: "list",
297
- li: "listItem",
298
- pre: "code",
299
- hr: "thematicBreak",
300
- table: "table",
301
- tr: "tableRow",
302
- th: "tableCell",
303
- td: "tableCell",
304
- br: "break",
305
- };
306
-
307
- /**
308
- * Convert a Jx element tree to an mdast tree.
309
- *
310
- * @param {JxElement} jx - Jx element tree (root content div)
311
- * @returns {MdastNode} Mdast root node
312
- */
313
- export function jxToMd(jx: JxElement) {
314
- const childArray = Array.isArray(jx.children) ? jx.children : ([] as (JxElement | string)[]);
315
- const children = childArray
316
- .map((child: JxElement | string) => convertJxNode(child, true))
317
- .filter(Boolean) as MdastNode[];
318
-
319
- return { type: "root", children };
320
- }
321
-
322
- /**
323
- * Check if a Jx element has extra properties beyond the standard mdast-compatible ones. Elements
324
- * with style, event handlers, state bindings, etc. need directive syntax.
325
- *
326
- * @param {JxElement} el
327
- * @returns {boolean}
328
- */
329
- function hasJxProps(el: JxElement) {
330
- for (const key of Object.keys(el)) {
331
- if (
332
- key === "tagName" ||
333
- key === "children" ||
334
- key === "textContent" ||
335
- key === "innerHTML" ||
336
- key === "attributes"
337
- )
338
- continue;
339
- return true;
340
- }
341
- return false;
342
- }
343
-
344
- /**
345
- * Convert a single Jx element to an mdast node.
346
- *
347
- * @param {JxElement | string | number} el - Jx element
348
- * @param {boolean} isBlock - Whether this element is in a block context
349
- * @returns {MdastNode | null} Mdast node
350
- */
351
- function convertJxNode(el: JxElement | string | number, isBlock: boolean): MdastNode | null {
352
- // Bare string/number text nodes → mdast text nodes
353
- if (typeof el === "string" || typeof el === "number") {
354
- return { type: "text", value: String(el) };
355
- }
356
- if (!el || typeof el !== "object") return null;
357
-
358
- const tag = el.tagName ?? "div";
359
-
360
- // If not in the markdown allowlist or has Jx-specific props, convert to directive
361
- if (!MD_ALL.has(tag) || hasJxProps(el)) {
362
- return convertToDirective(el, isBlock);
363
- }
364
-
365
- const mdastType = (TAG_MDAST_MAP as Record<string, string>)[tag];
366
- if (!mdastType) return null;
367
-
368
- switch (mdastType) {
369
- case "heading":
370
- return {
371
- type: "heading",
372
- depth: parseInt(tag.slice(1), 10),
373
- children: inlineChildren(el),
374
- };
375
-
376
- case "paragraph":
377
- return {
378
- type: "paragraph",
379
- children: inlineChildren(el),
380
- };
381
-
382
- case "text":
383
- return { type: "text", value: el.textContent ?? "" };
384
-
385
- case "emphasis":
386
- case "strong":
387
- case "delete":
388
- return {
389
- type: mdastType,
390
- children: inlineChildren(el),
391
- };
392
-
393
- case "inlineCode":
394
- return { type: "inlineCode", value: el.textContent ?? "" };
395
-
396
- case "link":
397
- return {
398
- type: "link",
399
- url: (el.attributes?.href as string) ?? "",
400
- title: (el.attributes?.title as string | null) ?? null,
401
- children: inlineChildren(el),
402
- };
403
-
404
- case "image":
405
- return {
406
- type: "image",
407
- url: (el.attributes?.src as string) ?? "",
408
- alt: (el.attributes?.alt as string) ?? "",
409
- title: (el.attributes?.title as string | null) ?? null,
410
- };
411
-
412
- case "blockquote":
413
- return {
414
- type: "blockquote",
415
- children: blockChildren(el),
416
- };
417
-
418
- case "list":
419
- return {
420
- type: "list",
421
- ordered: tag === "ol",
422
- start: tag === "ol" ? parseInt(el.attributes?.start as string, 10) || 1 : null,
423
- spread: false,
424
- children: ((el.children ?? []) as (JxElement | string)[])
425
- .map((c: JxElement | string) => convertJxNode(c, true))
426
- .filter(Boolean) as MdastNode[],
427
- };
428
-
429
- case "listItem":
430
- return {
431
- type: "listItem",
432
- spread: false,
433
- children: blockChildren(el),
434
- };
435
-
436
- case "code": {
437
- // pre > code → fenced code block
438
- const codeChild = Array.isArray(el.children)
439
- ? (el.children[0] as JxElement | undefined)
440
- : undefined;
441
- const langClass = (codeChild?.attributes?.class as string) ?? "";
442
- const lang = langClass.replace("language-", "") || null;
443
- return {
444
- type: "code",
445
- lang,
446
- value: codeChild?.textContent ?? el.textContent ?? "",
447
- };
448
- }
449
-
450
- case "thematicBreak":
451
- return { type: "thematicBreak" };
452
-
453
- case "break":
454
- return { type: "break" };
455
-
456
- case "table": {
457
- // Flatten thead/tbody back to rows
458
- const rows: MdastNode[] = [];
459
- for (const section of (el.children ?? []) as (JxElement | string)[]) {
460
- if (typeof section === "string") continue;
461
- if (section.tagName === "thead" || section.tagName === "tbody") {
462
- for (const row of (section.children ?? []) as (JxElement | string)[]) {
463
- const mdRow = convertJxNode(row, true);
464
- if (mdRow) {
465
- // Mark header cells
466
- if (section.tagName === "thead") {
467
- for (const cell of mdRow.children ?? []) {
468
- cell.isHeader = true;
469
- }
470
- }
471
- rows.push(mdRow);
472
- }
473
- }
474
- }
475
- }
476
- return {
477
- type: "table",
478
- children: rows,
479
- };
480
- }
481
-
482
- case "tableRow":
483
- return {
484
- type: "tableRow",
485
- children: ((el.children ?? []) as (JxElement | string)[])
486
- .map((c: JxElement | string) => convertJxNode(c, false))
487
- .filter(Boolean) as MdastNode[],
488
- };
489
-
490
- case "tableCell":
491
- return {
492
- type: "tableCell",
493
- children: inlineChildren(el),
494
- };
495
- }
496
-
497
- return null;
498
- }
499
-
500
- /**
501
- * Get inline children from a Jx element as mdast nodes. Handles both textContent shorthand and
502
- * explicit children array.
503
- *
504
- * @param {JxElement} el
505
- * @returns {MdastNode[]}
506
- */
507
- function inlineChildren(el: JxElement): MdastNode[] {
508
- if (el.textContent != null) {
509
- return [{ type: "text", value: String(el.textContent) }];
510
- }
511
- return ((el.children ?? []) as (JxElement | string)[])
512
- .map((c: JxElement | string) => convertJxNode(c, false))
513
- .filter(Boolean) as MdastNode[];
514
- }
515
-
516
- /**
517
- * Get block children from a Jx element as mdast nodes.
518
- *
519
- * @param {JxElement} el
520
- * @returns {MdastNode[]}
521
- */
522
- function blockChildren(el: JxElement): MdastNode[] {
523
- if (el.textContent != null) {
524
- // Wrap bare text in a paragraph
525
- return [{ type: "paragraph", children: [{ type: "text", value: String(el.textContent) }] }];
526
- }
527
- return ((el.children ?? []) as (JxElement | string)[])
528
- .map((c: JxElement | string) => convertJxNode(c, true))
529
- .filter(Boolean) as MdastNode[];
530
- }
531
-
532
- /**
533
- * Collect all directive attributes from a Jx element. Merges Jx-specific properties (style, event
534
- * handlers, etc.) and HTML attributes into a flat dot-path attribute map suitable for
535
- * remark-directive.
536
- *
537
- * @param {JxElement} el
538
- * @returns {Record<string, string>}
539
- */
540
- function collectDirectiveAttrs(el: JxElement) {
541
- const propsObj: Record<string, unknown> = {};
542
-
543
- for (const [key, value] of Object.entries(el)) {
544
- if (key === "tagName" || key === "textContent" || key === "innerHTML" || key === "attributes")
545
- continue;
546
- if (key === "children") {
547
- if (value && typeof value === "object" && !Array.isArray(value)) {
548
- propsObj.children = value;
549
- }
550
- continue;
551
- }
552
- propsObj[key] = value;
553
- }
554
-
555
- // Merge HTML attributes
556
- if (el.attributes) {
557
- for (const [key, value] of Object.entries(el.attributes as Record<string, unknown>)) {
558
- propsObj[key] = value;
559
- }
560
- }
561
-
562
- return collapsePropsToAttrMap(propsObj);
563
- }
564
-
565
- /**
566
- * Convert a Jx element to a directive node, preserving all Jx-specific properties as collapsed
567
- * dot-path directive attributes.
568
- *
569
- * @param {JxElement} el
570
- * @param {boolean} isBlock
571
- * @returns {MdastNode}
572
- */
573
- function convertToDirective(el: JxElement, isBlock: boolean): MdastNode {
574
- const tag = (el.tagName as string) ?? "div";
575
- const attrs = collectDirectiveAttrs(el);
576
-
577
- if (!isBlock) {
578
- // Inline → textDirective
579
- return {
580
- type: "textDirective",
581
- name: tag,
582
- attributes: attrs,
583
- children:
584
- el.textContent != null
585
- ? [{ type: "text", value: String(el.textContent) }]
586
- : (((el.children ?? []) as (JxElement | string)[])
587
- .map((c: JxElement | string) => convertJxNode(c, false))
588
- .filter(Boolean) as MdastNode[]),
589
- };
590
- }
591
-
592
- // Block without children → leafDirective
593
- const rawChildren = el.children;
594
- const childrenIsObject =
595
- rawChildren && typeof rawChildren === "object" && !Array.isArray(rawChildren);
596
- const childArray = childrenIsObject
597
- ? undefined
598
- : (rawChildren as (JxElement | string)[] | undefined);
599
- if (!childArray?.length && el.textContent == null) {
600
- return {
601
- type: "leafDirective",
602
- name: tag,
603
- attributes: attrs,
604
- children: [],
605
- };
606
- }
607
-
608
- // Block with children → containerDirective
609
- /** @type {MdastNode[]} */
610
- let directiveChildren;
611
- if (el.textContent != null) {
612
- directiveChildren = [
613
- { type: "paragraph", children: [{ type: "text", value: String(el.textContent) }] },
614
- ];
615
- } else if (INLINE_CONTENT_TAGS.has(tag)) {
616
- // Tags with inline content model: wrap all children in a single paragraph
617
- // so remark serializes them as one continuous inline flow
618
- const inlineNodes = ((el.children ?? []) as (JxElement | string)[])
619
- .map((c: JxElement | string) => convertJxNode(c, false))
620
- .filter(Boolean) as MdastNode[];
621
- directiveChildren =
622
- inlineNodes.length > 0 ? [{ type: "paragraph", children: inlineNodes }] : [];
623
- } else {
624
- directiveChildren = ((el.children ?? []) as (JxElement | string)[])
625
- .map((c: JxElement | string) => convertJxNode(c, true))
626
- .filter(Boolean) as MdastNode[];
627
- }
628
-
629
- return {
630
- type: "containerDirective",
631
- name: tag,
632
- attributes: attrs,
633
- children: directiveChildren,
634
- };
635
- }
636
-
637
- // ─── Jx Document → Jx Markdown ─────────────────────────────────────────────
638
-
639
- /** CSS pseudo-class names that need `:` stripped for markdown attributes. */
640
- const CSS_PSEUDO_NAMES = new Set([
641
- "hover",
642
- "focus",
643
- "active",
644
- "visited",
645
- "disabled",
646
- "checked",
647
- "valid",
648
- "invalid",
649
- "required",
650
- "empty",
651
- "first-child",
652
- "last-child",
653
- "focus-within",
654
- "focus-visible",
655
- "placeholder",
656
- "selection",
657
- "before",
658
- "after",
659
- ]);
660
-
661
- /** Jx `$`-prefixed keys that become unprefixed in directive attributes. */
662
- const JX_DOLLAR_KEYS = new Set([
663
- "$prototype",
664
- "$ref",
665
- "$component",
666
- "$props",
667
- "$switch",
668
- "$elements",
669
- ]);
670
-
671
- const JX_ANNOTATION_KEYS = new Set(["$title", "$description"]);
672
-
673
- /**
674
- * Convert a Jx JSON document back to Jx Markdown source string.
675
- *
676
- * Inverse of `transpileJxMarkdown()` from @jxsuite/parser/transpile. Emits YAML frontmatter from
677
- * top-level props and uses remark-stringify with remark-directive for the body — standard markdown
678
- * elements emit as native syntax, Jx-decorated elements emit as directives.
679
- *
680
- * @param {JxMutableNode} doc - Jx JSON document
681
- * @returns {string} Jx Markdown source
682
- */
683
- export function jxDocToMd(doc: JxMutableNode) {
684
- const { stringify: stringifyYaml } = yamlImport();
685
-
686
- const lines: string[] = [];
687
-
688
- // Emit YAML frontmatter
689
- const frontmatter: Record<string, unknown> = {};
690
- for (const [key, value] of Object.entries(doc)) {
691
- if (key === "children") continue;
692
- frontmatter[key] = value;
693
- }
694
-
695
- if (Object.keys(frontmatter).length > 0) {
696
- lines.push("---");
697
- lines.push(stringifyYaml(frontmatter).trim());
698
- lines.push("---");
699
- lines.push("");
700
- }
701
-
702
- // Convert children to mdast and stringify with remark
703
- if (Array.isArray(doc.children) && doc.children.length > 0) {
704
- const mdastChildren = doc.children
705
- .map((child: JxMutableNode | string) => convertJxNode(child as JxElement | string, true))
706
- .filter(Boolean) as MdastNode[];
707
-
708
- const mdast = { type: "root", children: mdastChildren };
709
- const md = unified()
710
- .use(remarkGfm)
711
- .use(remarkDirective)
712
- .use(remarkStringify, { bullet: "-", emphasis: "*", strong: "*" })
713
- .stringify(mdast as unknown as import("mdast").Root);
714
-
715
- lines.push(/** @type {string} */ (md));
716
- }
717
-
718
- return (
719
- lines
720
- .join("\n")
721
- .replace(/\n{3,}/g, "\n\n")
722
- .trim() + "\n"
723
- );
724
- }
725
-
726
- /**
727
- * Lazy import of yaml stringify — avoids importing at module load.
728
- *
729
- * @returns {{ stringify: (v: unknown) => string }}
730
- */
731
- let _yaml: { stringify: (v: unknown) => string } | null = null;
732
- function yamlImport() {
733
- if (!_yaml) {
734
- // Dynamic require avoided; use the yaml package already available in studio
735
- _yaml = { stringify: yamlStringifySimple };
736
- }
737
- return _yaml;
738
- }
739
-
740
- /**
741
- * Simple YAML stringifier for frontmatter. Handles the subset of YAML needed for Jx frontmatter
742
- * (scalars, arrays, nested objects).
743
- *
744
- * @param {unknown} value
745
- * @param {number} indent
746
- * @returns {string}
747
- */
748
- function yamlStringifySimple(value: unknown, indent: number = 0): string {
749
- if (value === null || value === undefined) return "null";
750
- if (typeof value === "boolean") return String(value);
751
- if (typeof value === "number") return String(value);
752
- if (typeof value === "string") {
753
- // Quote if it contains special characters
754
- if (/[:#[\]{}&*!|>'"%@`\n]/.test(value) || value === "" || value.trim() !== value) {
755
- return JSON.stringify(value);
756
- }
757
- return value;
758
- }
759
-
760
- const prefix = " ".repeat(indent);
761
-
762
- if (Array.isArray(value)) {
763
- if (value.length === 0) return "[]";
764
- return value
765
- .map((item) => {
766
- const itemStr: string = yamlStringifySimple(item, indent + 1);
767
- if (typeof item === "object" && item !== null && !Array.isArray(item)) {
768
- // Object items: first key on same line as -, rest indented
769
- const objLines: string[] = itemStr.split("\n");
770
- return `${prefix}- ${objLines[0]}\n${objLines
771
- .slice(1)
772
- .map((l: string) => `${prefix} ${l}`)
773
- .join("\n")}`;
774
- }
775
- return `${prefix}- ${itemStr}`;
776
- })
777
- .join("\n");
778
- }
779
-
780
- if (typeof value === "object") {
781
- const entries = Object.entries(value as Record<string, unknown>);
782
- if (entries.length === 0) return "{}";
783
- return entries
784
- .map(([k, v]) => {
785
- const valStr: string = yamlStringifySimple(v, indent + 1);
786
- if (typeof v === "object" && v !== null) {
787
- return `${prefix}${k}:\n${valStr}`;
788
- }
789
- return `${prefix}${k}: ${valStr}`;
790
- })
791
- .join("\n");
792
- }
793
-
794
- return String(value);
795
- }
796
-
797
- /**
798
- * Collapse a Jx props object to a flat directive attribute map. Applies key mapping: strips `$`
799
- * from Jx keywords, `:` from pseudo-classes, `@` from media queries.
800
- *
801
- * @param {Record<string, unknown>} propsObj
802
- * @returns {Record<string, string>}
803
- */
804
- function collapsePropsToAttrMap(propsObj: Record<string, unknown>) {
805
- const result: Record<string, string> = {};
806
-
807
- function walk(obj: Record<string, unknown>, prefix: string) {
808
- for (const [key, value] of Object.entries(obj)) {
809
- let mdAttrKey = key;
810
- // Strip $ prefix for Jx keywords
811
- if (JX_DOLLAR_KEYS.has(key)) {
812
- mdAttrKey = key.slice(1);
813
- }
814
- // Convert $title/$description to --title/--description
815
- if (JX_ANNOTATION_KEYS.has(key)) {
816
- mdAttrKey = `--${key.slice(1)}`;
817
- }
818
- // Strip : prefix for CSS pseudo-classes (inside style.* paths)
819
- if (key.startsWith(":") && CSS_PSEUDO_NAMES.has(key.slice(1))) {
820
- mdAttrKey = key.slice(1);
821
- }
822
- // Strip @ prefix for media queries (inside style.* paths)
823
- if (key === "@") {
824
- // Bare "@" (no media name) — treat contents as base-level style props
825
- if (value && typeof value === "object" && !Array.isArray(value)) {
826
- walk(value as Record<string, unknown>, prefix);
827
- }
828
- continue;
829
- }
830
- if (key.startsWith("@--")) {
831
- mdAttrKey = key.slice(1);
832
- }
833
-
834
- const fullKey = prefix ? `${prefix}.${mdAttrKey}` : mdAttrKey;
835
-
836
- if (value && typeof value === "object" && !Array.isArray(value)) {
837
- walk(value as Record<string, unknown>, fullKey);
838
- } else {
839
- result[fullKey] = String(value);
840
- }
841
- }
842
- }
843
-
844
- walk(propsObj, "");
845
- return result;
846
- }