@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
@@ -2,74 +2,119 @@
2
2
  /**
3
3
  * File Operations — open, save, export documents.
4
4
  *
5
- * All functions read/write directly from/to `activeTab.value` (the reactive tab).
5
+ * All functions read/write directly from/to `activeTab.value` (the reactive tab). `.json` is the
6
+ * native document format; every other extension dispatches through the project's format registry
7
+ * (parse to open, serialize to save).
6
8
  */
7
9
 
8
- import { unified } from "unified";
9
- import remarkStringify from "remark-stringify";
10
- import remarkDirective from "remark-directive";
11
- import remarkGfm from "remark-gfm";
12
- import { stringify as stringifyYaml } from "yaml";
13
- import { jxToMd, jxDocToMd } from "../markdown/md-convert";
14
10
  import { locateDocument } from "../services/code-services";
15
11
  import { statusMessage } from "../panels/statusbar";
16
12
  import { getPlatform } from "../platform";
17
13
  import { activeTab, openTab } from "../workspace/workspace";
18
14
  import { isEditing, stopEditing } from "../editor/inline-edit";
15
+ import {
16
+ loadFormats,
17
+ getFormats,
18
+ formatForPath,
19
+ formatByName,
20
+ formatParse,
21
+ formatSerialize,
22
+ defaultContentFormat,
23
+ splitFormatDocument,
24
+ type StudioFormat,
25
+ } from "../format/format-host";
19
26
 
20
- import type { JxElement, JxMutableNode } from "@jxsuite/schema/types";
27
+ /**
28
+ * Parse a format-class source string into document + frontmatter + mode per the format's
29
+ * $studio.documentMode hints.
30
+ *
31
+ * @param {StudioFormat} format
32
+ * @param {string} source
33
+ */
34
+ export async function parseFormatSource(format: StudioFormat, source: string) {
35
+ const doc = await formatParse(format.name, source);
36
+ return splitFormatDocument(format, doc);
37
+ }
38
+
39
+ /**
40
+ * Parse a source string for a file path, dispatching by extension through the format registry.
41
+ * Returns null for `.json` (native — callers JSON.parse) and throws when no format class claims the
42
+ * extension.
43
+ *
44
+ * @param {string} path
45
+ * @param {string} source
46
+ */
47
+ export async function parseSourceForPath(path: string, source: string) {
48
+ await loadFormats();
49
+ const format = formatForPath(path);
50
+ if (!format || !format.capabilities.parse) {
51
+ throw new Error(
52
+ `No format class imported for "${path}" — add one to project.json imports ` +
53
+ `(e.g. "Markdown": "@jxsuite/parser/Markdown.class.json")`,
54
+ );
55
+ }
56
+ const result = await parseFormatSource(format, source);
57
+ return { ...result, format };
58
+ }
21
59
 
22
60
  /** Open a file via the File System Access API (or fallback input). */
23
61
  export async function openFile() {
24
62
  try {
25
- if ("showOpenFilePicker" in window) {
26
- const [handle] = await (
27
- window as unknown as { showOpenFilePicker: Function }
28
- ).showOpenFilePicker({
29
- types: [
30
- { description: "Jx Component", accept: { "application/json": [".json"] } },
31
- { description: "Markdown Content", accept: { "text/markdown": [".md"] } },
32
- ],
33
- });
34
- const file = await handle.getFile();
35
- const text = await file.text();
36
- const documentPath = await locateDocument(handle.name);
37
-
38
- if (handle.name.endsWith(".md")) {
39
- const { document, frontmatter } = await loadMarkdown(text);
63
+ await loadFormats();
64
+ const formats = getFormats();
65
+ const pickerTypes = [
66
+ {
67
+ description: "Jx Component",
68
+ accept: { "application/json": [".json"] },
69
+ },
70
+ ...formats
71
+ .filter((f) => f.capabilities.parse)
72
+ .map((f) => ({
73
+ description: f.name,
74
+ accept: { [f.mediaType ?? "text/plain"]: f.extensions },
75
+ })),
76
+ ];
77
+ const acceptExts = [".json", ...formats.flatMap((f) => f.extensions)].join(",");
78
+
79
+ const handleSource = async (
80
+ name: string,
81
+ text: string,
82
+ handle: FileSystemFileHandle | null = null,
83
+ ) => {
84
+ const documentPath = handle ? await locateDocument(name) : null;
85
+ const format = formatForPath(name);
86
+ if (format) {
87
+ const { document, frontmatter } = await parseFormatSource(format, text);
40
88
  openTab({
41
- id: handle.name,
89
+ id: name,
42
90
  documentPath,
43
91
  fileHandle: handle,
44
92
  document,
45
93
  frontmatter,
46
- sourceFormat: "md",
94
+ sourceFormat: format.name,
47
95
  });
48
96
  } else {
49
97
  const document = JSON.parse(text);
50
- openTab({ id: handle.name, documentPath, fileHandle: handle, document });
98
+ openTab({ id: name, documentPath, fileHandle: handle, document });
51
99
  }
100
+ statusMessage(`Opened ${name}`);
101
+ };
52
102
 
53
- statusMessage(`Opened ${handle.name}`);
103
+ if ("showOpenFilePicker" in window) {
104
+ const [handle] = await (
105
+ window as unknown as { showOpenFilePicker: Function }
106
+ ).showOpenFilePicker({ types: pickerTypes });
107
+ const file = await handle.getFile();
108
+ await handleSource(handle.name, await file.text(), handle);
54
109
  } else {
55
110
  // Fallback: file input
56
111
  const input = document.createElement("input");
57
112
  input.type = "file";
58
- input.accept = ".json,.md";
113
+ input.accept = acceptExts;
59
114
  input.onchange = async () => {
60
115
  const file = input.files?.[0];
61
116
  if (!file) return;
62
- const text = await file.text();
63
-
64
- if (file.name.endsWith(".md")) {
65
- const { document, frontmatter } = await loadMarkdown(text);
66
- openTab({ id: file.name, document, frontmatter, sourceFormat: "md" });
67
- } else {
68
- const document = JSON.parse(text);
69
- openTab({ id: file.name, document });
70
- }
71
-
72
- statusMessage(`Opened ${file.name}`);
117
+ await handleSource(file.name, await file.text());
73
118
  };
74
119
  input.click();
75
120
  }
@@ -78,49 +123,13 @@ export async function openFile() {
78
123
  }
79
124
  }
80
125
 
81
- /**
82
- * Parse a markdown string into document + frontmatter (pure — no side effects).
83
- *
84
- * @param {string} source Markdown text
85
- * @returns {Promise<{ document: JxMutableNode; frontmatter: Record<string, unknown> }>}
86
- */
87
- export async function loadMarkdown(source: string) {
88
- const { transpileJxMarkdown } = await import("@jxsuite/parser/transpile");
89
- const doc = transpileJxMarkdown(source) as JxMutableNode;
90
-
91
- const isComponent = doc.tagName && String(doc.tagName).includes("-");
92
-
93
- if (isComponent) {
94
- return { document: doc, frontmatter: {} };
95
- }
96
-
97
- // Content markdown — children form the root-level document body
98
- const children = doc.children ?? [];
99
- if (children.length === 0) children.push({ tagName: "p", children: [] });
100
-
101
- const documentKeys = new Set(["state", "imports"]);
102
- const contentDoc: Record<string, unknown> = { children };
103
-
104
- const frontmatter: Record<string, unknown> = {};
105
- for (const [key, value] of Object.entries(doc)) {
106
- if (key === "children") continue;
107
- if (documentKeys.has(key)) {
108
- contentDoc[key] = value;
109
- } else {
110
- frontmatter[key] = value;
111
- }
112
- }
113
-
114
- return { document: contentDoc, frontmatter };
115
- }
116
-
117
126
  /** Save the current document back to its source location. */
118
127
  export async function saveFile() {
119
128
  if (isEditing()) stopEditing();
120
129
  const tab = activeTab.value;
121
130
  if (!tab) return;
122
131
  try {
123
- const output = serializeDocument(tab);
132
+ const output = await serializeDocument(tab);
124
133
 
125
134
  if (tab.documentPath) {
126
135
  const platform = getPlatform();
@@ -136,7 +145,7 @@ export async function saveFile() {
136
145
  * close: () => Promise<void>;
137
146
  * }>;
138
147
  * }}
139
- */ (tab.fileHandle).createWritable();
148
+ */ tab.fileHandle.createWritable();
140
149
  await writable.write(output);
141
150
  await writable.close();
142
151
  tab.doc.dirty = false;
@@ -149,23 +158,29 @@ export async function saveFile() {
149
158
  }
150
159
  }
151
160
 
161
+ /** The output format for a tab: its source format, or the default content format. */
162
+ function tabFormat(tab: import("../tabs/tab.js").Tab): StudioFormat | undefined {
163
+ return (
164
+ formatByName(tab.doc.sourceFormat) ??
165
+ (tab.doc.mode === "content" ? defaultContentFormat() : undefined)
166
+ );
167
+ }
168
+
152
169
  /** Export the current document to a new location (Save As / download). */
153
170
  export async function exportFile() {
154
171
  const tab = activeTab.value;
155
172
  if (!tab) return;
156
173
  try {
157
- const isContent = tab.doc.mode === "content";
158
- const output = serializeDocument(tab);
159
- const mimeType = isContent ? "text/markdown" : "application/json";
160
- const ext = isContent ? ".md" : ".json";
161
- const description = isContent ? "Markdown Content" : "Jx Component";
174
+ await loadFormats();
175
+ const format = tabFormat(tab);
176
+ const output = await serializeDocument(tab);
177
+ const mimeType = format ? (format.mediaType ?? "text/plain") : "application/json";
178
+ const ext = format ? format.extensions[0] : ".json";
179
+ const description = format ? format.name : "Jx Component";
180
+ const fallbackName = format ? `content${ext}` : "component.json";
162
181
 
163
182
  if ("showSaveFilePicker" in window) {
164
- const suggestedName = tab.documentPath
165
- ? tab.documentPath.split("/").pop()
166
- : isContent
167
- ? "content.md"
168
- : "component.json";
183
+ const suggestedName = tab.documentPath ? tab.documentPath.split("/").pop() : fallbackName;
169
184
  const handle = await (
170
185
  window as unknown as { showSaveFilePicker: Function }
171
186
  ).showSaveFilePicker({
@@ -183,7 +198,7 @@ export async function exportFile() {
183
198
  const url = URL.createObjectURL(blob);
184
199
  const a = document.createElement("a");
185
200
  a.href = url;
186
- a.download = isContent ? "content.md" : "component.json";
201
+ a.download = fallbackName;
187
202
  a.click();
188
203
  URL.revokeObjectURL(url);
189
204
  tab.doc.dirty = false;
@@ -195,28 +210,32 @@ export async function exportFile() {
195
210
  }
196
211
 
197
212
  /**
198
- * Serialize the current document to its output format (JSON or Markdown).
213
+ * Serialize the current document to its output format. Format tabs round-trip through the format
214
+ * class's serialize capability; everything else is native JSON.
199
215
  *
200
216
  * @param {import("../tabs/tab.js").Tab} tab
201
- * @returns {string}
217
+ * @returns {Promise<string>}
202
218
  */
203
- export function serializeDocument(tab: import("../tabs/tab.js").Tab) {
204
- if (tab.doc.sourceFormat === "md") {
219
+ export async function serializeDocument(tab: import("../tabs/tab.js").Tab): Promise<string> {
220
+ await loadFormats();
221
+ const sourceFormat = formatByName(tab.doc.sourceFormat);
222
+ if (sourceFormat?.capabilities.serialize) {
205
223
  const fm = tab.doc.content?.frontmatter || {};
206
224
  const doc = tab.doc.document;
207
225
  const fullDoc = { ...fm, ...doc, children: doc.children ?? [] };
208
- return jxDocToMd(/** @type {JxMutableNode} */ (fullDoc));
226
+ return formatSerialize(sourceFormat.name, fullDoc, { mode: "roundtrip" });
209
227
  }
210
228
  if (tab.doc.mode === "content") {
211
- const mdast = jxToMd(tab.doc.document as JxElement);
212
- const md = unified()
213
- .use(remarkGfm)
214
- .use(remarkDirective)
215
- .use(remarkStringify, { bullet: "-", emphasis: "*", strong: "*" })
216
- .stringify(mdast as unknown as import("mdast").Root);
217
- const fm = tab.doc.content?.frontmatter;
218
- const hasFrontmatter = fm && Object.keys(fm).length > 0;
219
- return hasFrontmatter ? `---\n${stringifyYaml(fm).trim()}\n---\n\n${md}` : md;
229
+ const format = defaultContentFormat();
230
+ if (format) {
231
+ const fm = tab.doc.content?.frontmatter ?? {};
232
+ const hasFrontmatter = Object.keys(fm).length > 0;
233
+ const fullDoc = { ...fm, ...tab.doc.document };
234
+ return formatSerialize(format.name, fullDoc, {
235
+ mode: "roundtrip",
236
+ frontmatter: hasFrontmatter,
237
+ });
238
+ }
220
239
  }
221
240
  return JSON.stringify(tab.doc.document, null, 2);
222
241
  }
@@ -10,12 +10,7 @@
10
10
  import { html, nothing } from "lit-html";
11
11
  import { classMap } from "lit-html/directives/class-map.js";
12
12
  import { ref } from "lit-html/directives/ref.js";
13
- import { unified } from "unified";
14
- import remarkStringify from "remark-stringify";
15
13
  import { renderPopover, showDialog, showConfirmDialog } from "../ui/layers";
16
- import remarkDirective from "remark-directive";
17
- import { stringify as stringifyYaml } from "yaml";
18
- import { jxToMd } from "../markdown/md-convert";
19
14
  import { createState, projectState, setProjectState, requireProjectState } from "../store";
20
15
  import { getPlatform } from "../platform";
21
16
  import { statusMessage } from "../panels/statusbar";
@@ -34,12 +29,16 @@ import {
34
29
  replaceAllTabs,
35
30
  activeTab,
36
31
  } from "../workspace/workspace";
37
- import { loadMarkdown } from "./file-ops";
32
+ import { parseSourceForPath, serializeDocument } from "./file-ops";
33
+ import {
34
+ loadFormats,
35
+ refreshFormats,
36
+ formatForPath,
37
+ documentExtensions,
38
+ } from "../format/format-host";
38
39
  import { view } from "../view";
39
40
  import { addRecentProject, trackRecentFile } from "../recent-projects";
40
41
 
41
- import type { JxElement } from "@jxsuite/schema/types";
42
-
43
42
  // ─── File icon map ────────────────────────────────────────────────────────────
44
43
 
45
44
  const fileIconMap = {
@@ -72,6 +71,9 @@ export async function loadProject() {
72
71
  if (!result) return;
73
72
  const { meta, info } = result;
74
73
 
74
+ refreshFormats();
75
+ void loadFormats();
76
+
75
77
  setProjectState({
76
78
  root: meta.root,
77
79
  name: info.isSiteProject ? info.projectConfig?.name || meta.name : meta.name,
@@ -120,10 +122,16 @@ export async function openProject({
120
122
 
121
123
  const { config, handle } = result;
122
124
 
123
- replaceAllTabs({ id: "initial", document: { tagName: "div", children: [] } });
125
+ replaceAllTabs({
126
+ id: "initial",
127
+ document: { tagName: "div", children: [] },
128
+ });
129
+
130
+ refreshFormats();
131
+ void loadFormats();
124
132
 
125
133
  setProjectState({
126
- .../** @type {ProjectState} */ (projectState),
134
+ .../** @type {ProjectState} */ projectState,
127
135
  projectRoot: handle.root,
128
136
  isSiteProject: true,
129
137
  projectConfig: config,
@@ -172,7 +180,11 @@ export async function openProject({
172
180
 
173
181
  export async function openHomePage() {
174
182
  const platform = getPlatform();
175
- const candidates = ["pages/index.md", "pages/index.json"];
183
+ await loadFormats();
184
+ const candidates = [
185
+ ...documentExtensions("page").map((ext) => `pages/index${ext}`),
186
+ "pages/index.json",
187
+ ];
176
188
  for (const path of candidates) {
177
189
  try {
178
190
  await platform.readFile(path);
@@ -623,7 +635,10 @@ function showFileContextMenu(
623
635
  });
624
636
  }
625
637
  items.push({ label: "\u2014" });
626
- items.push({ label: "Rename\u2026", action: () => renameFile(entry, ctx.renderLeftPanel) });
638
+ items.push({
639
+ label: "Rename\u2026",
640
+ action: () => renameFile(entry, ctx.renderLeftPanel),
641
+ });
627
642
  items.push({
628
643
  label: "Delete",
629
644
  action: () => deleteFile(entry, ctx.renderLeftPanel),
@@ -680,9 +695,13 @@ async function createNewFile(dirPath = ".", renderLeftPanel: () => void) {
680
695
  const name = prompt("File name:", "untitled.json");
681
696
  if (!name) return;
682
697
  const path = dirPath === "." ? name : `${dirPath}/${name}`;
683
- const content = name.endsWith(".md")
684
- ? "---\ntitle: Untitled\n---\n\n"
685
- : JSON.stringify({ tagName: "div", children: [{ tagName: "p", children: [] }] }, null, 2);
698
+ await loadFormats();
699
+ const format = formatForPath(name);
700
+ const content =
701
+ format?.studio?.newFileTemplate ??
702
+ (format
703
+ ? ""
704
+ : JSON.stringify({ tagName: "div", children: [{ tagName: "p", children: [] }] }, null, 2));
686
705
  try {
687
706
  const platform = getPlatform();
688
707
  await platform.writeFile(path, content);
@@ -816,23 +835,19 @@ export async function openFileFromTree(
816
835
  path: string,
817
836
  ) {
818
837
  const platform = getPlatform();
838
+ await loadFormats();
819
839
  // Auto-save current dirty document
820
840
  if (ctx.S.dirty && ctx.S.documentPath) {
821
841
  try {
822
- const isContent = ctx.S.mode === "content";
823
- let output;
824
- if (isContent) {
825
- const mdast = jxToMd(ctx.S.document as JxElement);
826
- const md = unified()
827
- .use(remarkDirective)
828
- .use(remarkStringify, { bullet: "-", emphasis: "*", strong: "*" })
829
- .stringify(mdast as unknown as import("mdast").Root);
830
- const fm = ctx.S.content?.frontmatter;
831
- const hasFrontmatter = fm && Object.keys(fm).length > 0;
832
- output = hasFrontmatter ? `---\n${stringifyYaml(fm).trim()}\n---\n\n${md}` : md;
833
- } else {
834
- output = JSON.stringify(ctx.S.document, null, 2);
835
- }
842
+ const tabLike = {
843
+ doc: {
844
+ sourceFormat: formatForPath(ctx.S.documentPath)?.name ?? null,
845
+ mode: ctx.S.mode,
846
+ content: ctx.S.content,
847
+ document: ctx.S.document,
848
+ },
849
+ } as unknown as import("../tabs/tab.js").Tab;
850
+ const output = await serializeDocument(tabLike);
836
851
  await platform.writeFile(ctx.S.documentPath, output);
837
852
  } catch (e) {
838
853
  statusMessage(`Save error: ${(e as Error).message}`);
@@ -844,7 +859,7 @@ export async function openFileFromTree(
844
859
  const content = await platform.readFile(path);
845
860
  if (!content) return;
846
861
 
847
- if (path.endsWith(".md")) {
862
+ if (formatForPath(path)) {
848
863
  await ctx.loadMarkdown(content, null);
849
864
  ctx.S.documentPath = path;
850
865
  ctx.S.dirty = false;
@@ -886,9 +901,11 @@ export async function openFileInTab(path: string) {
886
901
  const content = await platform.readFile(path);
887
902
  if (!content) return;
888
903
 
904
+ await loadFormats();
889
905
  let document, frontmatter;
890
- if (path.endsWith(".md")) {
891
- const result = await loadMarkdown(content);
906
+ const format = formatForPath(path);
907
+ if (format) {
908
+ const result = await parseSourceForPath(path, content);
892
909
  document = result.document;
893
910
  frontmatter = result.frontmatter;
894
911
  } else {
@@ -901,7 +918,7 @@ export async function openFileInTab(path: string) {
901
918
  documentPath: path,
902
919
  document,
903
920
  ...(frontmatter != null && { frontmatter }),
904
- sourceFormat: path.endsWith(".md") ? "md" : null,
921
+ sourceFormat: format?.name ?? null,
905
922
  });
906
923
  requireProjectState().selectedPath = path;
907
924
  trackRecentFile({ path, name: path.split("/").pop() || path });
@@ -930,8 +947,9 @@ export async function reloadFileInTab(path: string) {
930
947
  try {
931
948
  const content = await platform.readFile(path);
932
949
  if (!content) return;
933
- if (path.endsWith(".md")) {
934
- const { document, frontmatter } = await loadMarkdown(content);
950
+ await loadFormats();
951
+ if (formatForPath(path)) {
952
+ const { document, frontmatter } = await parseSourceForPath(path, content);
935
953
  tab.doc.document = document;
936
954
  tab.doc.content.frontmatter = frontmatter;
937
955
  } else {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Constraints — generic interpreter for a format class's `$studio.elements` metadata.
3
+ *
4
+ * Element allowlists and nesting rules are declared by the format (.class.json), not the studio;
5
+ * this module turns that declaration into the validation helpers the editing surfaces use. Replaces
6
+ * the hard-coded markdown allowlist.
7
+ */
8
+
9
+ import type { StudioFormatHints } from "./format-host";
10
+
11
+ type ElementsHints = NonNullable<StudioFormatHints["elements"]>;
12
+
13
+ export interface NestingValidator {
14
+ blockTags: ReadonlySet<string>;
15
+ inlineTags: ReadonlySet<string>;
16
+ allTags: ReadonlySet<string>;
17
+ isVoid(tag: string): boolean;
18
+ isTextOnly(tag: string): boolean;
19
+ /** Whether childTag may appear inside parentTag ("_root" for the document root). */
20
+ isValidChild(parentTag: string, childTag: string): boolean;
21
+ }
22
+
23
+ /** Build a nesting validator from a format's `$studio.elements` declaration. */
24
+ export function createNestingValidator(elements: ElementsHints | undefined): NestingValidator {
25
+ const blockTags = new Set(elements?.block ?? []);
26
+ const inlineTags = new Set(elements?.inline ?? []);
27
+ const allTags = new Set([...blockTags, ...inlineTags]);
28
+ const voidTags = new Set(elements?.void ?? []);
29
+ const textOnly = new Set(elements?.textOnly ?? []);
30
+ const nesting = elements?.nesting ?? {};
31
+
32
+ return {
33
+ blockTags,
34
+ inlineTags,
35
+ allTags,
36
+ isVoid: (tag) => voidTags.has(tag),
37
+ isTextOnly: (tag) => textOnly.has(tag),
38
+ isValidChild(parentTag, childTag) {
39
+ const rule = nesting[parentTag];
40
+ if (!rule) return true; // unknown parents (directive components) allow anything
41
+
42
+ if (rule.only) return rule.only.includes(childTag);
43
+
44
+ const isBlock = blockTags.has(childTag);
45
+ const isInline = inlineTags.has(childTag);
46
+ const isDirective = !allTags.has(childTag);
47
+
48
+ if (isBlock && rule.block) return true;
49
+ if (isInline && rule.inline) return true;
50
+ if (isDirective && rule.directive) return true;
51
+
52
+ return false;
53
+ },
54
+ };
55
+ }