@jxsuite/studio 0.1.0 → 0.5.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.
Files changed (40) hide show
  1. package/dist/studio.js +50941 -34749
  2. package/dist/studio.js.map +461 -345
  3. package/package.json +46 -35
  4. package/src/browse/browse.js +414 -0
  5. package/src/editor/context-menu.js +48 -1
  6. package/src/editor/convert-to-component.js +208 -0
  7. package/src/editor/inline-edit.js +33 -6
  8. package/src/editor/shortcuts.js +6 -1
  9. package/src/files/components.js +4 -2
  10. package/src/files/file-ops.js +102 -54
  11. package/src/files/files.js +22 -8
  12. package/src/markdown/md-convert.js +309 -11
  13. package/src/panels/activity-bar.js +3 -0
  14. package/src/panels/head-panel.js +576 -0
  15. package/src/panels/overlays.js +133 -0
  16. package/src/panels/right-panel.js +130 -0
  17. package/src/panels/shared.js +41 -0
  18. package/src/panels/signals-panel.js +95 -94
  19. package/src/panels/statusbar.js +15 -1
  20. package/src/panels/toolbar.js +223 -0
  21. package/src/platforms/devserver.js +58 -16
  22. package/src/settings/collections-editor.js +428 -0
  23. package/src/settings/defs-editor.js +418 -0
  24. package/src/settings/schema-field-ui.js +329 -0
  25. package/src/state.js +99 -2
  26. package/src/store.js +112 -41
  27. package/src/studio.js +1551 -1565
  28. package/src/ui/button-group.js +91 -0
  29. package/src/ui/color-selector.js +299 -0
  30. package/src/ui/field-row.js +47 -0
  31. package/src/ui/media-picker.js +172 -0
  32. package/src/ui/panel-resize.js +96 -0
  33. package/src/ui/spectrum.js +36 -2
  34. package/src/ui/unit-selector.js +106 -0
  35. package/src/ui/{jx-styled-combobox.js → value-selector.js} +7 -7
  36. package/src/ui/widgets.js +106 -0
  37. package/src/utils/canvas-media.js +151 -0
  38. package/src/utils/inherited-style.js +54 -0
  39. package/src/utils/studio-utils.js +32 -0
  40. package/src/view.js +68 -0
@@ -4,10 +4,15 @@
4
4
  * MdToJx(mdast) → Jx element tree (for loading into the canvas) jxToMd(jx) → mdast (for saving back
5
5
  * to markdown)
6
6
  *
7
+ * JxDocToMd(doc) → Jx Markdown string (for saving Jx component documents back to .md)
8
+ *
7
9
  * Both are pure tree transformations. The remark ecosystem handles all actual parsing and
8
10
  * serialization.
9
11
  */
10
12
 
13
+ import { unified } from "unified";
14
+ import remarkStringify from "remark-stringify";
15
+ import remarkDirective from "remark-directive";
11
16
  import { MD_ALL } from "./md-allowlist.js";
12
17
 
13
18
  // ─── mdast → Jx ──────────────────────────────────────────────────────────
@@ -48,8 +53,6 @@ const MDAST_TAG_MAP = {
48
53
  export function mdToJx(mdast) {
49
54
  if (mdast.type === "root") {
50
55
  return {
51
- tagName: "div",
52
- $id: "content",
53
56
  children: (mdast.children ?? [])
54
57
  .filter((/** @type {any} */ n) => n.type !== "yaml" && n.type !== "toml")
55
58
  .map(convertMdastNode)
@@ -221,6 +224,23 @@ function convertDirective(node) {
221
224
  *
222
225
  * @type {Record<string, string>}
223
226
  */
227
+ /** Tags whose content model is inline (phrasing content). */
228
+ const INLINE_CONTENT_TAGS = new Set([
229
+ "p",
230
+ "h1",
231
+ "h2",
232
+ "h3",
233
+ "h4",
234
+ "h5",
235
+ "h6",
236
+ "em",
237
+ "strong",
238
+ "del",
239
+ "a",
240
+ "td",
241
+ "th",
242
+ ]);
243
+
224
244
  const TAG_MDAST_MAP = {
225
245
  h1: "heading",
226
246
  h2: "heading",
@@ -263,6 +283,28 @@ export function jxToMd(jx) {
263
283
  return { type: "root", children };
264
284
  }
265
285
 
286
+ /**
287
+ * Check if a Jx element has extra properties beyond the standard mdast-compatible ones. Elements
288
+ * with style, event handlers, state bindings, etc. need directive syntax.
289
+ *
290
+ * @param {any} el
291
+ * @returns {boolean}
292
+ */
293
+ function hasJxProps(el) {
294
+ for (const key of Object.keys(el)) {
295
+ if (
296
+ key === "tagName" ||
297
+ key === "children" ||
298
+ key === "textContent" ||
299
+ key === "innerHTML" ||
300
+ key === "attributes"
301
+ )
302
+ continue;
303
+ return true;
304
+ }
305
+ return false;
306
+ }
307
+
266
308
  /**
267
309
  * Convert a single Jx element to an mdast node.
268
310
  *
@@ -271,16 +313,20 @@ export function jxToMd(jx) {
271
313
  * @returns {any} Mdast node
272
314
  */
273
315
  function convertJxNode(el, isBlock) {
316
+ // Bare string/number text nodes → mdast text nodes
317
+ if (typeof el === "string" || typeof el === "number") {
318
+ return { type: "text", value: String(el) };
319
+ }
274
320
  if (!el || typeof el !== "object") return null;
275
321
 
276
322
  const tag = el.tagName ?? "div";
277
323
 
278
- // If not in the markdown allowlist, convert to directive
279
- if (!MD_ALL.has(tag)) {
324
+ // If not in the markdown allowlist or has Jx-specific props, convert to directive
325
+ if (!MD_ALL.has(tag) || hasJxProps(el)) {
280
326
  return convertToDirective(el, isBlock);
281
327
  }
282
328
 
283
- const mdastType = TAG_MDAST_MAP[tag];
329
+ const mdastType = /** @type {Record<string, string>} */ (TAG_MDAST_MAP)[tag];
284
330
  if (!mdastType) return null;
285
331
 
286
332
  switch (mdastType) {
@@ -443,7 +489,42 @@ function blockChildren(el) {
443
489
  }
444
490
 
445
491
  /**
446
- * Convert a non-markdown-native Jx element to a directive node.
492
+ * Collect all directive attributes from a Jx element. Merges Jx-specific properties (style, event
493
+ * handlers, etc.) and HTML attributes into a flat dot-path attribute map suitable for
494
+ * remark-directive.
495
+ *
496
+ * @param {any} el
497
+ * @returns {Record<string, string>}
498
+ */
499
+ function collectDirectiveAttrs(el) {
500
+ /** @type {Record<string, any>} */
501
+ const propsObj = {};
502
+
503
+ for (const [key, value] of Object.entries(el)) {
504
+ if (
505
+ key === "tagName" ||
506
+ key === "children" ||
507
+ key === "textContent" ||
508
+ key === "innerHTML" ||
509
+ key === "attributes"
510
+ )
511
+ continue;
512
+ propsObj[key] = value;
513
+ }
514
+
515
+ // Merge HTML attributes
516
+ if (el.attributes) {
517
+ for (const [key, value] of Object.entries(el.attributes)) {
518
+ propsObj[key] = value;
519
+ }
520
+ }
521
+
522
+ return collapsePropsToAttrMap(propsObj);
523
+ }
524
+
525
+ /**
526
+ * Convert a Jx element to a directive node, preserving all Jx-specific properties as collapsed
527
+ * dot-path directive attributes.
447
528
  *
448
529
  * @param {any} el
449
530
  * @param {boolean} isBlock
@@ -451,7 +532,7 @@ function blockChildren(el) {
451
532
  */
452
533
  function convertToDirective(el, isBlock) {
453
534
  const tag = el.tagName ?? "div";
454
- const attrs = el.attributes ? { ...el.attributes } : {};
535
+ const attrs = collectDirectiveAttrs(el);
455
536
 
456
537
  if (!isBlock) {
457
538
  // Inline → textDirective
@@ -479,13 +560,230 @@ function convertToDirective(el, isBlock) {
479
560
  }
480
561
 
481
562
  // Block with children → containerDirective
563
+ /** @type {any[]} */
564
+ let directiveChildren;
565
+ if (el.textContent != null) {
566
+ directiveChildren = [
567
+ { type: "paragraph", children: [{ type: "text", value: String(el.textContent) }] },
568
+ ];
569
+ } else if (INLINE_CONTENT_TAGS.has(tag)) {
570
+ // Tags with inline content model: wrap all children in a single paragraph
571
+ // so remark serializes them as one continuous inline flow
572
+ const inlineNodes = (el.children ?? [])
573
+ .map((/** @type {any} */ c) => convertJxNode(c, false))
574
+ .filter(Boolean);
575
+ directiveChildren =
576
+ inlineNodes.length > 0 ? [{ type: "paragraph", children: inlineNodes }] : [];
577
+ } else {
578
+ directiveChildren = (el.children ?? [])
579
+ .map((/** @type {any} */ c) => convertJxNode(c, true))
580
+ .filter(Boolean);
581
+ }
582
+
482
583
  return {
483
584
  type: "containerDirective",
484
585
  name: tag,
485
586
  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),
587
+ children: directiveChildren,
490
588
  };
491
589
  }
590
+
591
+ // ─── Jx Document → Jx Markdown ─────────────────────────────────────────────
592
+
593
+ /** CSS pseudo-class names that need `:` stripped for markdown attributes. */
594
+ const CSS_PSEUDO_NAMES = new Set([
595
+ "hover",
596
+ "focus",
597
+ "active",
598
+ "visited",
599
+ "disabled",
600
+ "checked",
601
+ "valid",
602
+ "invalid",
603
+ "required",
604
+ "empty",
605
+ "first-child",
606
+ "last-child",
607
+ "focus-within",
608
+ "focus-visible",
609
+ "placeholder",
610
+ "selection",
611
+ "before",
612
+ "after",
613
+ ]);
614
+
615
+ /** Jx `$`-prefixed keys that become unprefixed in directive attributes. */
616
+ const JX_DOLLAR_KEYS = new Set([
617
+ "$prototype",
618
+ "$ref",
619
+ "$component",
620
+ "$props",
621
+ "$switch",
622
+ "$elements",
623
+ ]);
624
+
625
+ /**
626
+ * Convert a Jx JSON document back to Jx Markdown source string.
627
+ *
628
+ * Inverse of `transpileJxMarkdown()` from @jxsuite/parser/transpile. Emits YAML frontmatter from
629
+ * top-level props and uses remark-stringify with remark-directive for the body — standard markdown
630
+ * elements emit as native syntax, Jx-decorated elements emit as directives.
631
+ *
632
+ * @param {any} doc - Jx JSON document
633
+ * @returns {string} Jx Markdown source
634
+ */
635
+ export function jxDocToMd(doc) {
636
+ const { stringify: stringifyYaml } = yamlImport();
637
+
638
+ /** @type {string[]} */
639
+ const lines = [];
640
+
641
+ // Emit YAML frontmatter
642
+ /** @type {Record<string, any>} */
643
+ const frontmatter = {};
644
+ for (const [key, value] of Object.entries(doc)) {
645
+ if (key === "children") continue;
646
+ frontmatter[key] = value;
647
+ }
648
+
649
+ if (Object.keys(frontmatter).length > 0) {
650
+ lines.push("---");
651
+ lines.push(stringifyYaml(frontmatter).trim());
652
+ lines.push("---");
653
+ lines.push("");
654
+ }
655
+
656
+ // Convert children to mdast and stringify with remark
657
+ if (Array.isArray(doc.children) && doc.children.length > 0) {
658
+ const mdastChildren = doc.children
659
+ .map((/** @type {any} */ child) => convertJxNode(child, true))
660
+ .filter(Boolean);
661
+
662
+ const mdast = /** @type {any} */ ({ type: "root", children: mdastChildren });
663
+ const md = unified()
664
+ .use(remarkDirective)
665
+ .use(remarkStringify, { bullet: "-", emphasis: "*", strong: "*" })
666
+ .stringify(mdast);
667
+
668
+ lines.push(md);
669
+ }
670
+
671
+ return (
672
+ lines
673
+ .join("\n")
674
+ .replace(/\n{3,}/g, "\n\n")
675
+ .trim() + "\n"
676
+ );
677
+ }
678
+
679
+ /**
680
+ * Lazy import of yaml stringify — avoids importing at module load.
681
+ *
682
+ * @returns {{ stringify: (v: any) => string }}
683
+ */
684
+ let _yaml = /** @type {any} */ (null);
685
+ function yamlImport() {
686
+ if (!_yaml) {
687
+ // Dynamic require avoided; use the yaml package already available in studio
688
+ _yaml = { stringify: yamlStringifySimple };
689
+ }
690
+ return _yaml;
691
+ }
692
+
693
+ /**
694
+ * Simple YAML stringifier for frontmatter. Handles the subset of YAML needed for Jx frontmatter
695
+ * (scalars, arrays, nested objects).
696
+ *
697
+ * @param {any} value
698
+ * @param {number} indent
699
+ * @returns {string}
700
+ */
701
+ function yamlStringifySimple(value, indent = 0) {
702
+ if (value === null || value === undefined) return "null";
703
+ if (typeof value === "boolean") return String(value);
704
+ if (typeof value === "number") return String(value);
705
+ if (typeof value === "string") {
706
+ // Quote if it contains special characters
707
+ if (/[:#[\]{}&*!|>'"%@`\n]/.test(value) || value === "" || value.trim() !== value) {
708
+ return JSON.stringify(value);
709
+ }
710
+ return value;
711
+ }
712
+
713
+ const prefix = " ".repeat(indent);
714
+
715
+ if (Array.isArray(value)) {
716
+ if (value.length === 0) return "[]";
717
+ return value
718
+ .map((item) => {
719
+ const itemStr = yamlStringifySimple(item, indent + 1);
720
+ if (typeof item === "object" && item !== null && !Array.isArray(item)) {
721
+ // Object items: first key on same line as -, rest indented
722
+ const objLines = itemStr.split("\n");
723
+ return `${prefix}- ${objLines[0]}\n${objLines
724
+ .slice(1)
725
+ .map((l) => `${prefix} ${l}`)
726
+ .join("\n")}`;
727
+ }
728
+ return `${prefix}- ${itemStr}`;
729
+ })
730
+ .join("\n");
731
+ }
732
+
733
+ if (typeof value === "object") {
734
+ const entries = Object.entries(value);
735
+ if (entries.length === 0) return "{}";
736
+ return entries
737
+ .map(([k, v]) => {
738
+ const valStr = yamlStringifySimple(v, indent + 1);
739
+ if (typeof v === "object" && v !== null) {
740
+ return `${prefix}${k}:\n${valStr}`;
741
+ }
742
+ return `${prefix}${k}: ${valStr}`;
743
+ })
744
+ .join("\n");
745
+ }
746
+
747
+ return String(value);
748
+ }
749
+
750
+ /**
751
+ * Collapse a Jx props object to a flat directive attribute map. Applies key mapping: strips `$`
752
+ * from Jx keywords, `:` from pseudo-classes, `@` from media queries.
753
+ *
754
+ * @param {Record<string, any>} propsObj
755
+ * @returns {Record<string, string>}
756
+ */
757
+ function collapsePropsToAttrMap(propsObj) {
758
+ /** @type {Record<string, string>} */
759
+ const result = {};
760
+
761
+ function walk(/** @type {Record<string, any>} */ obj, /** @type {string} */ prefix) {
762
+ for (const [key, value] of Object.entries(obj)) {
763
+ let mdAttrKey = key;
764
+ // Strip $ prefix for Jx keywords
765
+ if (JX_DOLLAR_KEYS.has(key)) {
766
+ mdAttrKey = key.slice(1);
767
+ }
768
+ // Strip : prefix for CSS pseudo-classes (inside style.* paths)
769
+ if (key.startsWith(":") && CSS_PSEUDO_NAMES.has(key.slice(1))) {
770
+ mdAttrKey = key.slice(1);
771
+ }
772
+ // Strip @ prefix for media queries (inside style.* paths)
773
+ if (key.startsWith("@--")) {
774
+ mdAttrKey = key.slice(1);
775
+ }
776
+
777
+ const fullKey = prefix ? `${prefix}.${mdAttrKey}` : mdAttrKey;
778
+
779
+ if (value && typeof value === "object" && !Array.isArray(value)) {
780
+ walk(value, fullKey);
781
+ } else {
782
+ result[fullKey] = String(value);
783
+ }
784
+ }
785
+ }
786
+
787
+ walk(propsObj, "");
788
+ return result;
789
+ }
@@ -26,6 +26,8 @@ export function tabIcon(tag, size) {
26
26
  html`<sp-icon-event slot="icon" size=${s}></sp-icon-event>`,
27
27
  "sp-icon-brush": (/** @type {any} */ s) =>
28
28
  html`<sp-icon-brush slot="icon" size=${s}></sp-icon-brush>`,
29
+ "sp-icon-file-single-web-page": (/** @type {any} */ s) =>
30
+ html`<sp-icon-file-single-web-page slot="icon" size=${s}></sp-icon-file-single-web-page>`,
29
31
  "sp-icon-artboard": (/** @type {any} */ s) =>
30
32
  html`<sp-icon-artboard slot="icon" size=${s}></sp-icon-artboard>`,
31
33
  "sp-icon-box": (/** @type {any} */ s) =>
@@ -44,6 +46,7 @@ export function renderActivityBar(S) {
44
46
  { value: "blocks", icon: "sp-icon-view-grid", label: "Elements" },
45
47
  { value: "state", icon: "sp-icon-brackets", label: "State" },
46
48
  { value: "data", icon: "sp-icon-data", label: "Data" },
49
+ { value: "head", icon: "sp-icon-file-single-web-page", label: "Head" },
47
50
  ];
48
51
  const tpl = html`
49
52
  <sp-tabs