@jxsuite/studio 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ /** Statusbar — status message display for Jx Studio */
2
+
3
+ import { statusbarEl, getNodeAtPath, nodeLabel } from "../store.js";
4
+
5
+ // ─── Module state ────────────────────────────────────────────────────────────
6
+
7
+ let statusMsg = "";
8
+ /** @type {any} */
9
+ let statusTimeout;
10
+ /** @type {(() => void) | null} */
11
+ let _rerender = null;
12
+
13
+ /**
14
+ * Register the callback used to re-render the statusbar. Called once from studio.js during init.
15
+ *
16
+ * @param {() => void} fn
17
+ */
18
+ export function setStatusbarRenderer(fn) {
19
+ _rerender = fn;
20
+ }
21
+
22
+ // ─── Statusbar ───────────────────────────────────────────────────────────────
23
+
24
+ /**
25
+ * Render the statusbar text. Receives current studio state so the module stays decoupled from the
26
+ * mutable `S` local in studio.js.
27
+ *
28
+ * @param {any} S - Current studio state
29
+ */
30
+ export function renderStatusbar(S) {
31
+ const parts = [];
32
+ if (S.mode === "content") parts.push("Content Mode");
33
+ if (S.selection) {
34
+ const node = getNodeAtPath(S.document, S.selection);
35
+ parts.push(`Selected: ${nodeLabel(node)}`);
36
+ parts.push(`Path: ${S.selection.join(" > ") || "root"}`);
37
+ }
38
+ if (statusMsg) parts.push(statusMsg);
39
+ statusbarEl.textContent = parts.join(" | ") || "Jx Studio";
40
+ }
41
+
42
+ /**
43
+ * Show a temporary status message.
44
+ *
45
+ * @param {any} msg
46
+ * @param {number} [duration]
47
+ */
48
+ export function statusMessage(msg, duration = 3000) {
49
+ statusMsg = msg;
50
+ _rerender?.();
51
+ clearTimeout(statusTimeout);
52
+ statusTimeout = setTimeout(() => {
53
+ statusMsg = "";
54
+ _rerender?.();
55
+ }, duration);
56
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Platform.js — Platform Abstraction Layer (PAL)
3
+ *
4
+ * Studio is backend-agnostic. Each deployment target (desktop, dev server, cloud) registers a
5
+ * platform adapter at startup. All file I/O, project loading, and component discovery goes through
6
+ * this interface.
7
+ *
8
+ * See spec/desktop.md §3 for the full StudioPlatform interface.
9
+ */
10
+
11
+ /** @typedef {Record<string, any>} StudioPlatform */
12
+
13
+ /** @type {StudioPlatform | null} */
14
+ let _platform = null;
15
+
16
+ /** @param {StudioPlatform} platform */
17
+ export function registerPlatform(platform) {
18
+ _platform = platform;
19
+ }
20
+
21
+ /** @returns {StudioPlatform} */
22
+ export function getPlatform() {
23
+ if (!_platform)
24
+ throw new Error("No platform registered. Call registerPlatform() before starting Studio.");
25
+ return _platform;
26
+ }
27
+
28
+ /** @returns {boolean} */
29
+ export function hasPlatform() {
30
+ return _platform !== null;
31
+ }
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Devserver.js — Dev Server Platform Adapter
3
+ *
4
+ * Implements the StudioPlatform interface for the @jxplatform/server development workflow. All file
5
+ * I/O goes through /__studio/* REST endpoints. Project opening uses the Chrome File System Access
6
+ * API (showDirectoryPicker).
7
+ *
8
+ * See spec/desktop.md §8 for the full specification.
9
+ */
10
+
11
+ /**
12
+ * Create a DevServerPlatform instance.
13
+ *
14
+ * The adapter is stateless apart from `_projectRoot`, which tracks the server-relative project
15
+ * directory (e.g. "examples/site-demo"). All paths passed INTO PAL methods are project-relative;
16
+ * the adapter prefixes them with `_projectRoot` before hitting the server, and strips the prefix
17
+ * from responses.
18
+ */
19
+ export function createDevServerPlatform() {
20
+ let _projectRoot = ".";
21
+
22
+ /**
23
+ * Prefix a project-relative path with the active project root for server API calls.
24
+ *
25
+ * @param {string} rel
26
+ */
27
+ function serverPath(rel) {
28
+ if (!_projectRoot || _projectRoot === ".") return rel;
29
+ return rel === "." ? _projectRoot : `${_projectRoot}/${rel}`;
30
+ }
31
+
32
+ /**
33
+ * Strip the project root prefix from a server-root-relative path.
34
+ *
35
+ * @param {string} path
36
+ */
37
+ function stripRoot(path) {
38
+ if (!_projectRoot || _projectRoot === ".") return path;
39
+ return path.startsWith(_projectRoot + "/") ? path.slice(_projectRoot.length + 1) : path;
40
+ }
41
+
42
+ return {
43
+ id: "devserver",
44
+
45
+ /** Get or set the current project root (server-relative path). */
46
+ get projectRoot() {
47
+ return _projectRoot;
48
+ },
49
+ set projectRoot(v) {
50
+ _projectRoot = v || ".";
51
+ },
52
+
53
+ // ─── Project opening ──────────────────────────────────────────────────
54
+
55
+ async openProject() {
56
+ // Use Chrome's showDirectoryPicker API
57
+ if (!("showDirectoryPicker" in window)) {
58
+ throw new Error("showDirectoryPicker not available — use a Chromium-based browser");
59
+ }
60
+
61
+ let dirHandle;
62
+ try {
63
+ dirHandle = await /** @type {any} */ (window).showDirectoryPicker({ mode: "readwrite" });
64
+ } catch (/** @type {any} */ e) {
65
+ // User cancelled the picker
66
+ if (e.name === "AbortError") return null;
67
+ throw e;
68
+ }
69
+
70
+ // Read project.json from the chosen directory
71
+ let siteHandle;
72
+ try {
73
+ siteHandle = await dirHandle.getFileHandle("project.json");
74
+ } catch {
75
+ throw new Error("No project.json found in selected folder");
76
+ }
77
+
78
+ const file = await siteHandle.getFile();
79
+ const config = JSON.parse(await file.text());
80
+
81
+ // Resolve server-relative path by matching against known sites
82
+ const sitesRes = await fetch("/__studio/sites");
83
+ if (!sitesRes.ok) throw new Error("Failed to fetch site list from server");
84
+ const sites = await sitesRes.json();
85
+ const match = sites.find(
86
+ /** @param {any} s */ (s) => JSON.stringify(s.config) === JSON.stringify(config),
87
+ );
88
+
89
+ if (!match) {
90
+ throw new Error("Selected project is not under the dev server root");
91
+ }
92
+
93
+ _projectRoot = match.path;
94
+
95
+ return {
96
+ config,
97
+ handle: {
98
+ root: match.path,
99
+ name: config.name || match.path.split("/").pop(),
100
+ projectConfig: config,
101
+ },
102
+ };
103
+ },
104
+
105
+ /**
106
+ * Probe the server root to see if it is itself a site project. Used at startup to auto-detect
107
+ * projects.
108
+ */
109
+ async probeRootProject() {
110
+ try {
111
+ const [projectRes, infoRes] = await Promise.all([
112
+ fetch("/__studio/project"),
113
+ fetch("/__studio/project-info?dir=."),
114
+ ]);
115
+ const meta = projectRes.ok ? await projectRes.json() : { root: ".", name: "project" };
116
+ const info = infoRes.ok ? await infoRes.json() : { isSiteProject: false };
117
+ return { meta, info };
118
+ } catch {
119
+ return null;
120
+ }
121
+ },
122
+
123
+ // ─── File operations ──────────────────────────────────────────────────
124
+
125
+ /** @param {string} dir */
126
+ async listDirectory(dir) {
127
+ const res = await fetch(`/__studio/files?dir=${encodeURIComponent(serverPath(dir))}`);
128
+ if (!res.ok) throw new Error(`Failed to list directory: ${dir}`);
129
+ const entries = await res.json();
130
+ for (const e of entries) e.path = stripRoot(e.path);
131
+ return entries;
132
+ },
133
+
134
+ /** @param {string} path */
135
+ async readFile(path) {
136
+ const res = await fetch(`/__studio/file?path=${encodeURIComponent(serverPath(path))}`);
137
+ if (!res.ok) throw new Error(`Failed to read file: ${path}`);
138
+ const data = await res.json();
139
+ return data.content;
140
+ },
141
+
142
+ /**
143
+ * @param {string} path
144
+ * @param {string} content
145
+ */
146
+ async writeFile(path, content) {
147
+ const res = await fetch(`/__studio/file?path=${encodeURIComponent(serverPath(path))}`, {
148
+ method: "PUT",
149
+ body: content,
150
+ });
151
+ if (!res.ok) throw new Error(`Failed to write file: ${path}`);
152
+ },
153
+
154
+ /** @param {string} path */
155
+ async deleteFile(path) {
156
+ const res = await fetch(`/__studio/file?path=${encodeURIComponent(serverPath(path))}`, {
157
+ method: "DELETE",
158
+ });
159
+ if (!res.ok && res.status !== 404) throw new Error(`Failed to delete file: ${path}`);
160
+ },
161
+
162
+ /**
163
+ * @param {string} from
164
+ * @param {string} to
165
+ */
166
+ async renameFile(from, to) {
167
+ const res = await fetch("/__studio/file/rename", {
168
+ method: "POST",
169
+ headers: { "Content-Type": "application/json" },
170
+ body: JSON.stringify({ from: serverPath(from), to: serverPath(to) }),
171
+ });
172
+ if (!res.ok) throw new Error(`Failed to rename: ${from} → ${to}`);
173
+ },
174
+
175
+ /** @param {string} _path */
176
+ async createDirectory(_path) {
177
+ // The server creates directories implicitly when writing files.
178
+ // Write a placeholder and delete it, or rely on mkdir behavior.
179
+ // For now, use the writeFile + delete approach if directory creation
180
+ // is explicitly needed. The server's writeFile already calls mkdir().
181
+ },
182
+
183
+ // ─── Component discovery ──────────────────────────────────────────────
184
+
185
+ /** @param {string} dir */
186
+ async discoverComponents(dir) {
187
+ const scanDir = dir || _projectRoot;
188
+ const url =
189
+ scanDir === "."
190
+ ? "/__studio/components"
191
+ : `/__studio/components?dir=${encodeURIComponent(scanDir)}`;
192
+ const res = await fetch(url);
193
+ if (!res.ok) return [];
194
+ return await res.json();
195
+ },
196
+
197
+ // ─── Package management ──────────────────────────────────────────────
198
+
199
+ /** @param {string} name */
200
+ async addPackage(name) {
201
+ const res = await fetch("/__studio/packages/add", {
202
+ method: "POST",
203
+ headers: { "Content-Type": "application/json" },
204
+ body: JSON.stringify({ name }),
205
+ });
206
+ if (!res.ok) throw new Error(await res.text());
207
+ return await res.json();
208
+ },
209
+
210
+ /** @param {string} name */
211
+ async removePackage(name) {
212
+ const res = await fetch("/__studio/packages/remove", {
213
+ method: "POST",
214
+ headers: { "Content-Type": "application/json" },
215
+ body: JSON.stringify({ name }),
216
+ });
217
+ if (!res.ok) throw new Error(await res.text());
218
+ return await res.json();
219
+ },
220
+
221
+ async listPackages() {
222
+ const res = await fetch("/__studio/packages");
223
+ if (!res.ok) return [];
224
+ return await res.json();
225
+ },
226
+
227
+ // ─── Code services (optional) ─────────────────────────────────────────
228
+
229
+ /**
230
+ * @param {string} action
231
+ * @param {any} payload
232
+ */
233
+ async codeService(action, payload) {
234
+ try {
235
+ const res = await fetch(`/__studio/code/${action}`, {
236
+ method: "POST",
237
+ headers: { "Content-Type": "application/json" },
238
+ body: JSON.stringify(payload),
239
+ });
240
+ if (!res.ok) return null;
241
+ return await res.json();
242
+ } catch {
243
+ return null;
244
+ }
245
+ },
246
+
247
+ // ─── Site context resolution ──────────────────────────────────────
248
+
249
+ /**
250
+ * Given an absolute file path, walk up to find the nearest project.json ancestor. Returns {
251
+ * sitePath, projectConfig } or { sitePath: null }.
252
+ *
253
+ * @param {string} filePath — absolute system path
254
+ */
255
+ async resolveSiteContext(filePath) {
256
+ const res = await fetch(`/__studio/resolve-site?path=${encodeURIComponent(filePath)}`);
257
+ if (!res.ok) return { sitePath: null };
258
+ return await res.json();
259
+ },
260
+
261
+ // ─── File location ────────────────────────────────────────────────────
262
+
263
+ /** @param {string} name */
264
+ async locateFile(name) {
265
+ try {
266
+ const res = await fetch("/__studio/locate", {
267
+ method: "POST",
268
+ headers: { "Content-Type": "application/json" },
269
+ body: JSON.stringify({ name }),
270
+ });
271
+ if (res.ok) return (await res.json()).path || null;
272
+ } catch {}
273
+ return null;
274
+ },
275
+
276
+ // ─── Plugin schema ────────────────────────────────────────────────────
277
+
278
+ /**
279
+ * @param {string} src
280
+ * @param {string} prototype
281
+ * @param {string} base
282
+ */
283
+ async fetchPluginSchema(src, prototype, base) {
284
+ const params = new URLSearchParams({ src });
285
+ if (prototype) params.set("prototype", prototype);
286
+ if (base) params.set("base", base);
287
+ const res = await fetch(`/__studio/plugin-schema?${params}`);
288
+ if (!res.ok) return null;
289
+ const { schema } = await res.json();
290
+ return schema;
291
+ },
292
+ };
293
+ }
@@ -0,0 +1,130 @@
1
+ /** Collect slot elements from the document tree. */
2
+ export function collectSlots(/** @type {any} */ node, /** @type {any} */ slots = []) {
3
+ if (node?.tagName === "slot") {
4
+ slots.push(node.attributes?.name || "");
5
+ }
6
+ if (Array.isArray(node?.children))
7
+ node.children.forEach((/** @type {any} */ c) => collectSlots(c, slots));
8
+ return slots;
9
+ }
10
+
11
+ /**
12
+ * Generate and download a CEM 2.1.0 manifest for the current document.
13
+ *
14
+ * @param {any} S - Studio state
15
+ * @param {{
16
+ * defCategory: (d: any) => string;
17
+ * normParam: (p: any) => any;
18
+ * collectCssParts: (node: any) => any[];
19
+ * }} helpers
20
+ */
21
+ export function exportCemManifest(S, helpers) {
22
+ const { defCategory, normParam, collectCssParts } = helpers;
23
+ const doc = S.document;
24
+ const tagName = doc.tagName;
25
+ if (!tagName || !tagName.includes("-")) return;
26
+
27
+ const state = doc.state || {};
28
+ const members = [];
29
+ const attributes = [];
30
+ const events = [];
31
+ const seenEvents = new Set();
32
+
33
+ for (const [key, d] of Object.entries(state)) {
34
+ if (key.startsWith("#")) continue; // private
35
+
36
+ const cat = defCategory(d);
37
+
38
+ if (cat === "function") {
39
+ members.push({
40
+ kind: "method",
41
+ name: key,
42
+ ...(d.description ? { description: d.description } : {}),
43
+ ...(d.parameters ? { parameters: d.parameters.map(normParam) } : {}),
44
+ ...(d.deprecated
45
+ ? { deprecated: typeof d.deprecated === "string" ? d.deprecated : true }
46
+ : {}),
47
+ });
48
+ // Collect emits
49
+ if (Array.isArray(d.emits)) {
50
+ for (const ev of d.emits) {
51
+ if (ev.name && !seenEvents.has(ev.name)) {
52
+ seenEvents.add(ev.name);
53
+ events.push({
54
+ name: ev.name,
55
+ ...(ev.type ? { type: ev.type } : {}),
56
+ ...(ev.description ? { description: ev.description } : {}),
57
+ });
58
+ }
59
+ }
60
+ }
61
+ } else if (cat === "state") {
62
+ members.push({
63
+ kind: "field",
64
+ name: key,
65
+ ...(d.type ? { type: { text: d.type } } : {}),
66
+ ...(d.default !== undefined ? { default: String(d.default) } : {}),
67
+ ...(d.description ? { description: d.description } : {}),
68
+ ...(d.attribute ? { attribute: d.attribute } : {}),
69
+ ...(d.reflects ? { reflects: true } : {}),
70
+ ...(d.deprecated
71
+ ? { deprecated: typeof d.deprecated === "string" ? d.deprecated : true }
72
+ : {}),
73
+ });
74
+ if (d.attribute) {
75
+ attributes.push({
76
+ name: d.attribute,
77
+ ...(d.type ? { type: { text: d.type } } : {}),
78
+ fieldName: key,
79
+ });
80
+ }
81
+ }
82
+ }
83
+
84
+ // Slots
85
+ const slotNames = collectSlots(doc);
86
+ const slots = slotNames.map((/** @type {any} */ name) => ({
87
+ name: name || "",
88
+ ...(name ? {} : { description: "Default slot" }),
89
+ }));
90
+
91
+ // CSS custom properties
92
+ const style = doc.style || {};
93
+ const cssProperties = Object.entries(style)
94
+ .filter(([k]) => k.startsWith("--"))
95
+ .map(([name, val]) => ({ name, default: String(val) }));
96
+
97
+ // CSS parts
98
+ const cssParts = collectCssParts(doc).map((p) => ({ name: p.name }));
99
+
100
+ const manifest = {
101
+ schemaVersion: "2.1.0",
102
+ modules: [
103
+ {
104
+ kind: "javascript-module",
105
+ path: "",
106
+ declarations: [
107
+ {
108
+ kind: "class",
109
+ name: tagName,
110
+ tagName,
111
+ members,
112
+ ...(attributes.length ? { attributes } : {}),
113
+ ...(events.length ? { events } : {}),
114
+ ...(slots.length ? { slots } : {}),
115
+ ...(cssProperties.length ? { cssProperties } : {}),
116
+ ...(cssParts.length ? { cssParts } : {}),
117
+ },
118
+ ],
119
+ },
120
+ ],
121
+ };
122
+
123
+ const blob = new Blob([JSON.stringify(manifest, null, 2)], { type: "application/json" });
124
+ const url = URL.createObjectURL(blob);
125
+ const a = document.createElement("a");
126
+ a.href = url;
127
+ a.download = `${tagName}.cem.json`;
128
+ a.click();
129
+ URL.revokeObjectURL(url);
130
+ }
@@ -0,0 +1,98 @@
1
+ /** OXC code services (server-backed) */
2
+
3
+ import { getPlatform } from "../platform.js";
4
+ import { getNodeAtPath } from "../store.js";
5
+ import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js";
6
+
7
+ /**
8
+ * @param {any} action
9
+ * @param {any} payload
10
+ */
11
+ export async function codeService(action, payload) {
12
+ const platform = getPlatform();
13
+ if (!platform.codeService) return null;
14
+ return platform.codeService(action, payload);
15
+ }
16
+
17
+ /**
18
+ * Ask the server to locate a document by filename within the project root.
19
+ *
20
+ * @param {any} name
21
+ */
22
+ export async function locateDocument(name) {
23
+ const platform = getPlatform();
24
+ if (!platform.locateFile) return null;
25
+ return platform.locateFile(name);
26
+ }
27
+
28
+ /** Cache of plugin schemas keyed by "$src::$prototype". */
29
+ export const pluginSchemaCache = new Map();
30
+
31
+ /**
32
+ * Fetch and cache the schema for an external $prototype + $src module via the server.
33
+ *
34
+ * @param {any} def
35
+ * @param {any} state
36
+ */
37
+ export async function fetchPluginSchema(def, state) {
38
+ if (!def.$src || !def.$prototype) return null;
39
+ const cacheKey = `${def.$src}::${def.$prototype}`;
40
+ if (pluginSchemaCache.has(cacheKey)) return pluginSchemaCache.get(cacheKey);
41
+
42
+ try {
43
+ const platform = getPlatform();
44
+ if (!platform.fetchPluginSchema) {
45
+ pluginSchemaCache.set(cacheKey, null);
46
+ return null;
47
+ }
48
+ const base = state.documentPath ? `${location.origin}/${state.documentPath}` : undefined;
49
+ const schema = await platform.fetchPluginSchema(def.$src, def.$prototype, base);
50
+ pluginSchemaCache.set(cacheKey, schema);
51
+ return schema;
52
+ } catch {
53
+ pluginSchemaCache.set(cacheKey, null);
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * @param {any} editor
60
+ * @param {any[]} diagnostics
61
+ */
62
+ export function setLintMarkers(editor, diagnostics) {
63
+ const model = editor.getModel();
64
+ if (!model) return;
65
+ const markers = diagnostics
66
+ .map((d) => {
67
+ const label = d.labels?.[0];
68
+ if (!label) return null;
69
+ const { line, column, length } = label.span;
70
+ return {
71
+ severity:
72
+ d.severity === "error" ? monaco.MarkerSeverity.Error : monaco.MarkerSeverity.Warning,
73
+ message: d.message + (d.help ? `\n${d.help}` : ""),
74
+ startLineNumber: line,
75
+ startColumn: column,
76
+ endLineNumber: line,
77
+ endColumn: column + (length || 1),
78
+ code: d.url ? { value: d.code, target: monaco.Uri.parse(d.url) } : d.code,
79
+ source: "oxlint",
80
+ };
81
+ })
82
+ .filter(Boolean);
83
+ monaco.editor.setModelMarkers(model, "oxlint", /** @type {any} */ (markers));
84
+ }
85
+
86
+ /**
87
+ * @param {any} editing
88
+ * @param {any} state
89
+ */
90
+ export function getFunctionArgs(editing, state) {
91
+ if (editing.type === "def") {
92
+ return state.document.state?.[editing.defName]?.parameters || ["state", "event"];
93
+ } else if (editing.type === "event") {
94
+ const node = getNodeAtPath(state.document, editing.path);
95
+ return node?.[editing.eventKey]?.parameters || ["state", "event"];
96
+ }
97
+ return ["state", "event"];
98
+ }