@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.
- package/dist/studio.js +33900 -56705
- package/dist/studio.js.map +128 -396
- package/package.json +4 -10
- package/src/browse/browse.ts +62 -22
- package/src/canvas/canvas-live-render.ts +57 -55
- package/src/canvas/canvas-render.ts +50 -21
- package/src/editor/context-menu.ts +4 -1
- package/src/editor/convert-to-component.ts +8 -2
- package/src/editor/inline-edit.ts +7 -2
- package/src/editor/insertion-helper.ts +12 -3
- package/src/editor/slash-menu.ts +5 -1
- package/src/files/file-ops.ts +121 -102
- package/src/files/files.ts +53 -35
- package/src/format/constraints.ts +55 -0
- package/src/format/format-host.ts +212 -0
- package/src/new-project/new-project-modal.ts +18 -3
- package/src/panels/ai-panel.ts +3 -1
- package/src/panels/block-action-bar.ts +4 -2
- package/src/panels/canvas-dnd.ts +37 -8
- package/src/panels/data-explorer.ts +9 -2
- package/src/panels/editors.ts +1 -1
- package/src/panels/git-panel.ts +18 -8
- package/src/panels/head-panel.ts +12 -5
- package/src/panels/left-panel.ts +7 -4
- package/src/panels/panel-events.ts +3 -1
- package/src/panels/properties-panel.ts +9 -3
- package/src/panels/quick-search.ts +10 -10
- package/src/panels/right-panel.ts +6 -2
- package/src/panels/shared.ts +6 -1
- package/src/panels/signals-panel.ts +35 -10
- package/src/panels/style-inputs.ts +5 -1
- package/src/panels/style-panel.ts +6 -2
- package/src/panels/stylebook-layers-panel.ts +12 -3
- package/src/panels/stylebook-panel.ts +24 -8
- package/src/panels/toolbar.ts +16 -3
- package/src/platforms/devserver.ts +32 -3
- package/src/resize-edges.ts +12 -2
- package/src/services/cem-export.ts +9 -3
- package/src/settings/content-types-editor.ts +7 -2
- package/src/settings/defs-editor.ts +6 -1
- package/src/settings/general-settings.ts +3 -1
- package/src/settings/schema-field-ui.ts +11 -3
- package/src/settings/settings-modal.ts +4 -1
- package/src/state.ts +5 -1
- package/src/studio.ts +27 -16
- package/src/tabs/tab.ts +29 -6
- package/src/tabs/transact.ts +4 -1
- package/src/types.ts +15 -6
- package/src/ui/button-group.ts +6 -1
- package/src/ui/expression-editor.ts +14 -3
- package/src/ui/layers.ts +5 -1
- package/src/ui/unit-selector.ts +6 -1
- package/src/utils/canvas-media.ts +1 -1
- package/src/utils/google-fonts.ts +4 -1
- package/src/utils/studio-utils.ts +7 -1
- package/src/view.ts +8 -2
- package/src/markdown/md-allowlist.ts +0 -104
- package/src/markdown/md-convert.ts +0 -846
package/src/files/file-ops.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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:
|
|
89
|
+
id: name,
|
|
42
90
|
documentPath,
|
|
43
91
|
fileHandle: handle,
|
|
44
92
|
document,
|
|
45
93
|
frontmatter,
|
|
46
|
-
sourceFormat:
|
|
94
|
+
sourceFormat: format.name,
|
|
47
95
|
});
|
|
48
96
|
} else {
|
|
49
97
|
const document = JSON.parse(text);
|
|
50
|
-
openTab({ id:
|
|
98
|
+
openTab({ id: name, documentPath, fileHandle: handle, document });
|
|
51
99
|
}
|
|
100
|
+
statusMessage(`Opened ${name}`);
|
|
101
|
+
};
|
|
52
102
|
|
|
53
|
-
|
|
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 =
|
|
113
|
+
input.accept = acceptExts;
|
|
59
114
|
input.onchange = async () => {
|
|
60
115
|
const file = input.files?.[0];
|
|
61
116
|
if (!file) return;
|
|
62
|
-
|
|
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
|
-
*/
|
|
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
|
-
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
const
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
226
|
+
return formatSerialize(sourceFormat.name, fullDoc, { mode: "roundtrip" });
|
|
209
227
|
}
|
|
210
228
|
if (tab.doc.mode === "content") {
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
.
|
|
214
|
-
.
|
|
215
|
-
|
|
216
|
-
.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
}
|
package/src/files/files.ts
CHANGED
|
@@ -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 {
|
|
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({
|
|
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} */
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
.
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
|
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
|
-
|
|
891
|
-
|
|
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:
|
|
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
|
-
|
|
934
|
-
|
|
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
|
+
}
|