@jxsuite/studio 0.10.1 → 0.11.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/README.md +82 -0
- package/dist/studio.js +1105 -235
- package/dist/studio.js.map +35 -25
- package/package.json +30 -29
- package/src/browse/browse.js +48 -22
- package/src/canvas/canvas-live-render.js +140 -16
- package/src/canvas/canvas-render.js +10 -7
- package/src/files/files.js +2 -0
- package/src/panels/canvas-dnd.js +132 -50
- package/src/panels/imports-panel.js +2 -2
- package/src/panels/overlays.js +15 -3
- package/src/panels/panel-events.js +10 -0
- package/src/panels/properties-panel.js +302 -8
- package/src/panels/stylebook-panel.js +58 -22
- package/src/settings/{collections-editor.js → content-types-editor.js} +70 -49
- package/src/settings/schema-field-ui.js +58 -5
- package/src/site-context.js +105 -0
- package/src/state.js +1 -1
- package/src/store.js +9 -1
- package/src/studio.js +10 -4
- package/src/ui/spectrum.js +2 -0
- package/src/utils/studio-utils.js +5 -5
- package/src/view.js +3 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Content Types Editor — visual schema builder for project content types.
|
|
3
3
|
*
|
|
4
|
-
* Renders inside the Settings view "
|
|
5
|
-
*
|
|
4
|
+
* Renders inside the Settings view "Content Types" tab. Two-column layout: left column lists
|
|
5
|
+
* content type names, right column edits the selected content type's schema.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { html, render as litRender } from "lit-html";
|
|
@@ -13,11 +13,11 @@ import { fieldCardTpl, addFieldFormTpl, schemaForType } from "./schema-field-ui.
|
|
|
13
13
|
// ─── Module state ─────────────────────────────────────────────────────────────
|
|
14
14
|
|
|
15
15
|
/** @type {string | null} */
|
|
16
|
-
let
|
|
16
|
+
let selectedContentType = null;
|
|
17
17
|
let showAddField = false;
|
|
18
18
|
let newFieldState = { name: "", type: "string", required: false };
|
|
19
|
-
let
|
|
20
|
-
let
|
|
19
|
+
let showNewContentType = false;
|
|
20
|
+
let newContentTypeName = "";
|
|
21
21
|
|
|
22
22
|
// ─── Persistence ──────────────────────────────────────────────────────────────
|
|
23
23
|
|
|
@@ -29,17 +29,17 @@ async function saveProjectConfig() {
|
|
|
29
29
|
|
|
30
30
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
31
31
|
|
|
32
|
-
/** Get the schema object for the selected
|
|
32
|
+
/** Get the schema object for the selected content type. */
|
|
33
33
|
function getSelectedSchema() {
|
|
34
34
|
const config = projectState?.projectConfig;
|
|
35
|
-
return config?.
|
|
35
|
+
return config?.contentTypes?.[/** @type {string} */ (selectedContentType)]?.schema;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// ─── Handlers ─────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
40
|
/** @param {() => void} rerender */
|
|
41
|
-
function
|
|
42
|
-
const slug =
|
|
41
|
+
function handleNewContentType(rerender) {
|
|
42
|
+
const slug = newContentTypeName
|
|
43
43
|
.toLowerCase()
|
|
44
44
|
.replace(/\s+/g, "-")
|
|
45
45
|
.replace(/[^a-z0-9-]/g, "");
|
|
@@ -47,30 +47,30 @@ function handleNewCollection(rerender) {
|
|
|
47
47
|
|
|
48
48
|
const config = projectState?.projectConfig;
|
|
49
49
|
if (!config) return;
|
|
50
|
-
if (!config.
|
|
51
|
-
if (config.
|
|
50
|
+
if (!config.contentTypes) config.contentTypes = {};
|
|
51
|
+
if (config.contentTypes[slug]) return; // already exists
|
|
52
52
|
|
|
53
|
-
config.
|
|
54
|
-
source:
|
|
53
|
+
config.contentTypes[slug] = {
|
|
54
|
+
source: `./content/${slug}/**/*.md`,
|
|
55
55
|
schema: { type: "object", properties: {}, required: [] },
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
selectedContentType = slug;
|
|
59
|
+
showNewContentType = false;
|
|
60
|
+
newContentTypeName = "";
|
|
61
61
|
rerender();
|
|
62
62
|
|
|
63
63
|
// Persist in background
|
|
64
64
|
saveProjectConfig().then(async () => {
|
|
65
65
|
const platform = getPlatform();
|
|
66
|
-
await platform.writeFile(
|
|
66
|
+
await platform.writeFile(`content/${slug}/.gitkeep`, "");
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/** @param {() => void} rerender */
|
|
71
71
|
function handleAddField(rerender) {
|
|
72
72
|
const name = newFieldState.name.trim();
|
|
73
|
-
if (!name || !
|
|
73
|
+
if (!name || !selectedContentType) return;
|
|
74
74
|
|
|
75
75
|
const schema = getSelectedSchema();
|
|
76
76
|
if (!schema) return;
|
|
@@ -163,6 +163,20 @@ function handleChangeType(fieldName, newType, rerender) {
|
|
|
163
163
|
saveProjectConfig();
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* @param {string} fieldName
|
|
168
|
+
* @param {string} target
|
|
169
|
+
* @param {() => void} rerender
|
|
170
|
+
*/
|
|
171
|
+
function handleChangeRefTarget(fieldName, target, rerender) {
|
|
172
|
+
const schema = getSelectedSchema();
|
|
173
|
+
if (!schema?.properties) return;
|
|
174
|
+
|
|
175
|
+
schema.properties[fieldName] = { $ref: `#/contentTypes/${target}` };
|
|
176
|
+
rerender();
|
|
177
|
+
saveProjectConfig();
|
|
178
|
+
}
|
|
179
|
+
|
|
166
180
|
// ─── Nested field handlers ───────────────────────────────────────────────────
|
|
167
181
|
|
|
168
182
|
/**
|
|
@@ -270,13 +284,13 @@ function handleChangeNestedType(parentName, childName, newType, rerender) {
|
|
|
270
284
|
}
|
|
271
285
|
|
|
272
286
|
/** @param {() => void} rerender */
|
|
273
|
-
function
|
|
274
|
-
if (!
|
|
287
|
+
function handleDeleteContentType(rerender) {
|
|
288
|
+
if (!selectedContentType) return;
|
|
275
289
|
const config = projectState?.projectConfig;
|
|
276
|
-
if (!config?.
|
|
290
|
+
if (!config?.contentTypes?.[selectedContentType]) return;
|
|
277
291
|
|
|
278
|
-
delete config.
|
|
279
|
-
|
|
292
|
+
delete config.contentTypes[selectedContentType];
|
|
293
|
+
selectedContentType = null;
|
|
280
294
|
|
|
281
295
|
rerender();
|
|
282
296
|
saveProjectConfig();
|
|
@@ -285,26 +299,26 @@ function handleDeleteCollection(rerender) {
|
|
|
285
299
|
// ─── Render ───────────────────────────────────────────────────────────────────
|
|
286
300
|
|
|
287
301
|
/**
|
|
288
|
-
* Render the
|
|
302
|
+
* Render the content types editor.
|
|
289
303
|
*
|
|
290
304
|
* @param {HTMLElement} container
|
|
291
305
|
*/
|
|
292
|
-
export function
|
|
293
|
-
const rerender = () =>
|
|
306
|
+
export function renderContentTypesEditor(container) {
|
|
307
|
+
const rerender = () => renderContentTypesEditor(container);
|
|
294
308
|
const config = projectState?.projectConfig;
|
|
295
|
-
const
|
|
296
|
-
const
|
|
309
|
+
const contentTypes = config?.contentTypes || {};
|
|
310
|
+
const contentTypeNames = Object.keys(contentTypes);
|
|
297
311
|
|
|
298
|
-
// Left column —
|
|
312
|
+
// Left column — content type list
|
|
299
313
|
const listTpl = html`
|
|
300
314
|
<div class="settings-list-panel">
|
|
301
|
-
${
|
|
315
|
+
${contentTypeNames.map(
|
|
302
316
|
(name) => html`
|
|
303
317
|
<sp-action-button
|
|
304
318
|
size="s"
|
|
305
|
-
?selected=${
|
|
319
|
+
?selected=${selectedContentType === name}
|
|
306
320
|
@click=${() => {
|
|
307
|
-
|
|
321
|
+
selectedContentType = name;
|
|
308
322
|
showAddField = false;
|
|
309
323
|
rerender();
|
|
310
324
|
}}
|
|
@@ -313,25 +327,25 @@ export function renderCollectionsEditor(container) {
|
|
|
313
327
|
</sp-action-button>
|
|
314
328
|
`,
|
|
315
329
|
)}
|
|
316
|
-
${
|
|
330
|
+
${showNewContentType
|
|
317
331
|
? html`
|
|
318
332
|
<div class="settings-inline-form">
|
|
319
333
|
<sp-textfield
|
|
320
334
|
size="s"
|
|
321
|
-
placeholder="
|
|
322
|
-
.value=${
|
|
335
|
+
placeholder="content-type-name"
|
|
336
|
+
.value=${newContentTypeName}
|
|
323
337
|
@input=${(/** @type {any} */ e) => {
|
|
324
|
-
|
|
338
|
+
newContentTypeName = e.target.value;
|
|
325
339
|
}}
|
|
326
340
|
@keydown=${(/** @type {any} */ e) => {
|
|
327
|
-
if (e.key === "Enter")
|
|
341
|
+
if (e.key === "Enter") handleNewContentType(rerender);
|
|
328
342
|
if (e.key === "Escape") {
|
|
329
|
-
|
|
343
|
+
showNewContentType = false;
|
|
330
344
|
rerender();
|
|
331
345
|
}
|
|
332
346
|
}}
|
|
333
347
|
></sp-textfield>
|
|
334
|
-
<sp-action-button size="s" @click=${() =>
|
|
348
|
+
<sp-action-button size="s" @click=${() => handleNewContentType(rerender)}>
|
|
335
349
|
Create
|
|
336
350
|
</sp-action-button>
|
|
337
351
|
</div>
|
|
@@ -341,11 +355,11 @@ export function renderCollectionsEditor(container) {
|
|
|
341
355
|
size="s"
|
|
342
356
|
quiet
|
|
343
357
|
@click=${() => {
|
|
344
|
-
|
|
358
|
+
showNewContentType = true;
|
|
345
359
|
rerender();
|
|
346
360
|
}}
|
|
347
361
|
>
|
|
348
|
-
<sp-icon-add slot="icon"></sp-icon-add> New
|
|
362
|
+
<sp-icon-add slot="icon"></sp-icon-add> New Content Type
|
|
349
363
|
</sp-action-button>
|
|
350
364
|
`}
|
|
351
365
|
</div>
|
|
@@ -353,10 +367,10 @@ export function renderCollectionsEditor(container) {
|
|
|
353
367
|
|
|
354
368
|
// Right column — schema editor
|
|
355
369
|
let editorTpl;
|
|
356
|
-
if (!
|
|
357
|
-
editorTpl = html`<div class="settings-empty-state">Select or create a
|
|
370
|
+
if (!selectedContentType || !contentTypes[selectedContentType]) {
|
|
371
|
+
editorTpl = html`<div class="settings-empty-state">Select or create a content type</div>`;
|
|
358
372
|
} else {
|
|
359
|
-
const col =
|
|
373
|
+
const col = contentTypes[selectedContentType];
|
|
360
374
|
const schema = col.schema || {};
|
|
361
375
|
const properties = schema.properties || {};
|
|
362
376
|
const required = schema.required || [];
|
|
@@ -367,6 +381,7 @@ export function renderCollectionsEditor(container) {
|
|
|
367
381
|
onToggleRequired: (n) => handleToggleRequired(n, rerender),
|
|
368
382
|
onRename: (oldN, newN) => handleRenameField(oldN, newN, rerender),
|
|
369
383
|
onChangeType: (n, t) => handleChangeType(n, t, rerender),
|
|
384
|
+
onChangeRefTarget: (n, target) => handleChangeRefTarget(n, target, rerender),
|
|
370
385
|
onAddNestedField: (p, s) => handleAddNestedField(p, s, rerender),
|
|
371
386
|
onDeleteNested: (p, c) => handleDeleteNested(p, c, rerender),
|
|
372
387
|
onToggleNestedRequired: (p, c) => handleToggleNestedRequired(p, c, rerender),
|
|
@@ -375,19 +390,25 @@ export function renderCollectionsEditor(container) {
|
|
|
375
390
|
};
|
|
376
391
|
|
|
377
392
|
const fieldCards = Object.entries(properties).map(([name, def]) =>
|
|
378
|
-
fieldCardTpl(
|
|
393
|
+
fieldCardTpl(
|
|
394
|
+
name,
|
|
395
|
+
/** @type {any} */ (def),
|
|
396
|
+
required.includes(name),
|
|
397
|
+
handlers,
|
|
398
|
+
contentTypeNames,
|
|
399
|
+
),
|
|
379
400
|
);
|
|
380
401
|
|
|
381
402
|
editorTpl = html`
|
|
382
403
|
<div class="settings-editor-panel">
|
|
383
404
|
<div class="settings-editor-header">
|
|
384
|
-
<h3>${
|
|
405
|
+
<h3>${selectedContentType}</h3>
|
|
385
406
|
<sp-field-label size="s">Source: ${col.source || "—"}</sp-field-label>
|
|
386
407
|
<sp-action-button
|
|
387
408
|
size="xs"
|
|
388
409
|
quiet
|
|
389
|
-
title="Delete
|
|
390
|
-
@click=${() =>
|
|
410
|
+
title="Delete content type"
|
|
411
|
+
@click=${() => handleDeleteContentType(rerender)}
|
|
391
412
|
>
|
|
392
413
|
<sp-icon-delete slot="icon"></sp-icon-delete>
|
|
393
414
|
</sp-action-button>
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Schema field UI — shared field-card and add-field-dialog templates for the
|
|
2
|
+
* Schema field UI — shared field-card and add-field-dialog templates for the content types and
|
|
3
3
|
* definitions editors.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { html, nothing } from "lit-html";
|
|
7
7
|
|
|
8
|
-
export const FIELD_TYPES = [
|
|
8
|
+
export const FIELD_TYPES = [
|
|
9
|
+
"string",
|
|
10
|
+
"number",
|
|
11
|
+
"boolean",
|
|
12
|
+
"array",
|
|
13
|
+
"object",
|
|
14
|
+
"date",
|
|
15
|
+
"image",
|
|
16
|
+
"gallery",
|
|
17
|
+
"reference",
|
|
18
|
+
];
|
|
9
19
|
|
|
10
20
|
/**
|
|
11
21
|
* @typedef {{
|
|
@@ -14,6 +24,7 @@ export const FIELD_TYPES = ["string", "number", "boolean", "array", "object", "d
|
|
|
14
24
|
* required?: string[];
|
|
15
25
|
* items?: any;
|
|
16
26
|
* format?: string;
|
|
27
|
+
* $ref?: string;
|
|
17
28
|
* }} SchemaProperty
|
|
18
29
|
*/
|
|
19
30
|
|
|
@@ -23,6 +34,7 @@ export const FIELD_TYPES = ["string", "number", "boolean", "array", "object", "d
|
|
|
23
34
|
* onToggleRequired: (name: string) => void;
|
|
24
35
|
* onRename: (oldName: string, newName: string) => void;
|
|
25
36
|
* onChangeType: (name: string, newType: string) => void;
|
|
37
|
+
* onChangeRefTarget?: (name: string, target: string) => void;
|
|
26
38
|
* onAddNestedField?: (
|
|
27
39
|
* parentName: string,
|
|
28
40
|
* state: { name: string; type: string; required: boolean },
|
|
@@ -34,6 +46,20 @@ export const FIELD_TYPES = ["string", "number", "boolean", "array", "object", "d
|
|
|
34
46
|
* }} FieldHandlers
|
|
35
47
|
*/
|
|
36
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Detect the studio field type from a JSON Schema property definition.
|
|
51
|
+
*
|
|
52
|
+
* @param {SchemaProperty} schema
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function detectFieldType(schema) {
|
|
56
|
+
if (schema.$ref) return "reference";
|
|
57
|
+
if (schema.format === "image") return "image";
|
|
58
|
+
if (schema.format === "date") return "date";
|
|
59
|
+
if (schema.type === "array" && schema.items?.format === "image") return "gallery";
|
|
60
|
+
return schema.type || "string";
|
|
61
|
+
}
|
|
62
|
+
|
|
37
63
|
/**
|
|
38
64
|
* Render a single schema field as an inline-editable form row.
|
|
39
65
|
*
|
|
@@ -41,13 +67,16 @@ export const FIELD_TYPES = ["string", "number", "boolean", "array", "object", "d
|
|
|
41
67
|
* @param {SchemaProperty} fieldSchema — JSON Schema property definition
|
|
42
68
|
* @param {boolean} isRequired
|
|
43
69
|
* @param {FieldHandlers} handlers
|
|
70
|
+
* @param {string[]} [contentTypeNames] - Available content type names for reference target picker
|
|
44
71
|
* @returns {any}
|
|
45
72
|
*/
|
|
46
|
-
export function fieldCardTpl(fieldName, fieldSchema, isRequired, handlers) {
|
|
47
|
-
const type = fieldSchema
|
|
73
|
+
export function fieldCardTpl(fieldName, fieldSchema, isRequired, handlers, contentTypeNames = []) {
|
|
74
|
+
const type = detectFieldType(fieldSchema);
|
|
48
75
|
const isNested = type === "object";
|
|
76
|
+
const isRef = type === "reference";
|
|
49
77
|
const nestedProps = fieldSchema.properties || {};
|
|
50
78
|
const nestedRequired = fieldSchema.required || [];
|
|
79
|
+
const refTarget = fieldSchema.$ref ? fieldSchema.$ref.replace("#/contentTypes/", "") : "";
|
|
51
80
|
|
|
52
81
|
return html`
|
|
53
82
|
<div class="schema-field-card">
|
|
@@ -87,6 +116,23 @@ export function fieldCardTpl(fieldName, fieldSchema, isRequired, handlers) {
|
|
|
87
116
|
<sp-icon-delete slot="icon"></sp-icon-delete>
|
|
88
117
|
</sp-action-button>
|
|
89
118
|
</div>
|
|
119
|
+
${isRef && contentTypeNames.length
|
|
120
|
+
? html`
|
|
121
|
+
<div class="schema-field-ref-target">
|
|
122
|
+
<sp-picker
|
|
123
|
+
size="s"
|
|
124
|
+
label="Target"
|
|
125
|
+
value=${refTarget}
|
|
126
|
+
@change=${(/** @type {any} */ e) => {
|
|
127
|
+
if (handlers.onChangeRefTarget)
|
|
128
|
+
handlers.onChangeRefTarget(fieldName, e.target.value);
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
${contentTypeNames.map((n) => html`<sp-menu-item value=${n}>${n}</sp-menu-item>`)}
|
|
132
|
+
</sp-picker>
|
|
133
|
+
</div>
|
|
134
|
+
`
|
|
135
|
+
: nothing}
|
|
90
136
|
${isNested
|
|
91
137
|
? html`
|
|
92
138
|
<div class="schema-field-nested">
|
|
@@ -119,7 +165,7 @@ export function fieldCardTpl(fieldName, fieldSchema, isRequired, handlers) {
|
|
|
119
165
|
* @returns {any}
|
|
120
166
|
*/
|
|
121
167
|
function nestedFieldCardTpl(parentName, childName, childSchema, isRequired, handlers) {
|
|
122
|
-
const type = childSchema
|
|
168
|
+
const type = detectFieldType(childSchema);
|
|
123
169
|
|
|
124
170
|
return html`
|
|
125
171
|
<div class="schema-field-card schema-field-card--nested">
|
|
@@ -300,6 +346,12 @@ export function schemaForType(type) {
|
|
|
300
346
|
return { type: "object", properties: {}, required: [] };
|
|
301
347
|
case "date":
|
|
302
348
|
return { type: "string", format: "date" };
|
|
349
|
+
case "image":
|
|
350
|
+
return { type: "string", format: "image" };
|
|
351
|
+
case "gallery":
|
|
352
|
+
return { type: "array", items: { type: "string", format: "image" } };
|
|
353
|
+
case "reference":
|
|
354
|
+
return { $ref: "#/contentTypes/" };
|
|
303
355
|
default:
|
|
304
356
|
return { type: "string" };
|
|
305
357
|
}
|
|
@@ -314,6 +366,7 @@ export function schemaForType(type) {
|
|
|
314
366
|
*/
|
|
315
367
|
export function yamlDefault(type, format) {
|
|
316
368
|
if (format === "date") return new Date().toISOString().split("T")[0];
|
|
369
|
+
if (format === "image") return '""';
|
|
317
370
|
switch (type) {
|
|
318
371
|
case "boolean":
|
|
319
372
|
return "false";
|
package/src/site-context.js
CHANGED
|
@@ -109,6 +109,111 @@ export function getEffectiveHead(docHead) {
|
|
|
109
109
|
return merged;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// ─── Layout resolution ──────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/** @type {Map<string, any>} */
|
|
115
|
+
const layoutCache = new Map();
|
|
116
|
+
|
|
117
|
+
export function invalidateLayoutCache() {
|
|
118
|
+
layoutCache.clear();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Determine the effective layout path for a document.
|
|
123
|
+
*
|
|
124
|
+
* @param {any} docLayout - The document's $layout value (string path, false, or undefined)
|
|
125
|
+
* @returns {string | null} The layout path, or null if no layout applies
|
|
126
|
+
*/
|
|
127
|
+
export function getEffectiveLayoutPath(docLayout) {
|
|
128
|
+
if (docLayout === false) return null;
|
|
129
|
+
const defaultLayout = projectState?.projectConfig?.defaults?.layout;
|
|
130
|
+
return docLayout || defaultLayout || null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve a layout document by path. Fetches and caches the parsed JSON.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} layoutPath - Relative path to the layout file (e.g., "./layouts/base.json")
|
|
137
|
+
* @returns {Promise<any | null>} The parsed layout document, or null on failure
|
|
138
|
+
*/
|
|
139
|
+
export async function resolveLayoutDoc(layoutPath) {
|
|
140
|
+
const normalized = layoutPath.replace(/^\.\//, "");
|
|
141
|
+
if (layoutCache.has(normalized)) return structuredClone(layoutCache.get(normalized));
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const platform = getPlatform();
|
|
145
|
+
const content = await platform.readFile(normalized);
|
|
146
|
+
const doc = JSON.parse(content);
|
|
147
|
+
layoutCache.set(normalized, doc);
|
|
148
|
+
return structuredClone(doc);
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Distribute page children into a layout document's <slot> elements. Returns the merged document
|
|
156
|
+
* with page content injected into slots.
|
|
157
|
+
*
|
|
158
|
+
* @param {any} layoutDoc - Deep-cloned layout document
|
|
159
|
+
* @param {any} pageDoc - The page document
|
|
160
|
+
* @returns {any} The merged document
|
|
161
|
+
*/
|
|
162
|
+
export function distributePageIntoLayout(layoutDoc, pageDoc) {
|
|
163
|
+
const pageChildren = pageDoc.children ?? [];
|
|
164
|
+
const children = typeof pageChildren === "string" ? [pageChildren] : pageChildren;
|
|
165
|
+
|
|
166
|
+
const named = new Map();
|
|
167
|
+
const defaults = [];
|
|
168
|
+
|
|
169
|
+
for (const child of children) {
|
|
170
|
+
if (child && typeof child === "object" && child.attributes?.slot) {
|
|
171
|
+
const slotName = child.attributes.slot;
|
|
172
|
+
if (!named.has(slotName)) named.set(slotName, []);
|
|
173
|
+
named.get(slotName).push(child);
|
|
174
|
+
} else {
|
|
175
|
+
defaults.push(child);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
fillSlots(layoutDoc, named, defaults);
|
|
180
|
+
|
|
181
|
+
if (pageDoc.state) layoutDoc.state = { ...layoutDoc.state, ...pageDoc.state };
|
|
182
|
+
if (pageDoc.$media) layoutDoc.$media = { ...layoutDoc.$media, ...pageDoc.$media };
|
|
183
|
+
if (pageDoc.style) layoutDoc.style = { ...layoutDoc.style, ...pageDoc.style };
|
|
184
|
+
if (pageDoc.attributes) layoutDoc.attributes = { ...layoutDoc.attributes, ...pageDoc.attributes };
|
|
185
|
+
|
|
186
|
+
return layoutDoc;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function fillSlots(
|
|
190
|
+
/** @type {any} */ node,
|
|
191
|
+
/** @type {Map<string, any[]>} */ named,
|
|
192
|
+
/** @type {any[]} */ defaults,
|
|
193
|
+
) {
|
|
194
|
+
if (!node || typeof node !== "object") return;
|
|
195
|
+
if (!Array.isArray(node.children)) return;
|
|
196
|
+
|
|
197
|
+
const newChildren = [];
|
|
198
|
+
for (const child of node.children) {
|
|
199
|
+
if (child && typeof child === "object" && child.tagName === "slot") {
|
|
200
|
+
const slotName = child.attributes?.name;
|
|
201
|
+
if (slotName && named.has(slotName)) {
|
|
202
|
+
newChildren.push(.../** @type {any[]} */ (named.get(slotName)));
|
|
203
|
+
named.delete(slotName);
|
|
204
|
+
} else if (!slotName && defaults.length > 0) {
|
|
205
|
+
newChildren.push(...defaults);
|
|
206
|
+
} else if (child.children) {
|
|
207
|
+
newChildren.push(...child.children);
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
fillSlots(child, named, defaults);
|
|
211
|
+
newChildren.push(child);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
node.children = newChildren;
|
|
215
|
+
}
|
|
216
|
+
|
|
112
217
|
/**
|
|
113
218
|
* Update the project's project.json with a partial patch and persist to disk.
|
|
114
219
|
*
|
package/src/state.js
CHANGED
|
@@ -229,7 +229,7 @@ export function createState(doc) {
|
|
|
229
229
|
stylebookTab: "elements", // "elements" | "variables"
|
|
230
230
|
stylebookFilter: "", // search filter text
|
|
231
231
|
stylebookCustomizedOnly: false, // show only customized elements
|
|
232
|
-
settingsTab: "stylebook", // "stylebook" | "definitions" | "
|
|
232
|
+
settingsTab: "stylebook", // "stylebook" | "definitions" | "contentTypes"
|
|
233
233
|
gitStatus: null, // { branch, ahead, behind, files: [] }
|
|
234
234
|
gitBranches: null, // { current, branches: [] }
|
|
235
235
|
gitCommitMessage: "", // commit message input
|
package/src/store.js
CHANGED
|
@@ -164,7 +164,15 @@ export function stripEventHandlers(node) {
|
|
|
164
164
|
const cases = {};
|
|
165
165
|
for (const [ck, cv] of Object.entries(v)) cases[ck] = stripEventHandlers(cv);
|
|
166
166
|
out.cases = cases;
|
|
167
|
-
} else if (k === "state"
|
|
167
|
+
} else if (k === "state" && typeof v === "object" && v !== null) {
|
|
168
|
+
/** @type {Record<string, any>} */
|
|
169
|
+
const state = {};
|
|
170
|
+
for (const [sk, sv] of Object.entries(v)) {
|
|
171
|
+
if (sv && typeof sv === "object" && sv.timing === "server") continue;
|
|
172
|
+
state[sk] = sv;
|
|
173
|
+
}
|
|
174
|
+
out.state = state;
|
|
175
|
+
} else if (k === "style" || k === "attributes" || k === "$media") {
|
|
168
176
|
out[k] = v;
|
|
169
177
|
} else {
|
|
170
178
|
out[k] = v;
|
package/src/studio.js
CHANGED
|
@@ -543,13 +543,19 @@ if (_openParam) {
|
|
|
543
543
|
}
|
|
544
544
|
|
|
545
545
|
// Read and open the file
|
|
546
|
-
const
|
|
546
|
+
const _fileParam = new URLSearchParams(location.search).get("file");
|
|
547
|
+
const fileRelPath = _fileParam || siteCtx.fileRelPath || _openParam;
|
|
547
548
|
const content = await platform.readFile(fileRelPath);
|
|
548
549
|
if (content) {
|
|
549
|
-
|
|
550
|
-
|
|
550
|
+
if (fileRelPath.endsWith(".md")) {
|
|
551
|
+
await loadMarkdown(content, null);
|
|
552
|
+
S.documentPath = fileRelPath;
|
|
553
|
+
} else {
|
|
554
|
+
const parsed = JSON.parse(content);
|
|
555
|
+
S = createState(parsed);
|
|
556
|
+
S.documentPath = fileRelPath;
|
|
557
|
+
}
|
|
551
558
|
S.dirty = false;
|
|
552
|
-
S.documentPath = fileRelPath;
|
|
553
559
|
S.ui = { ...S.ui, leftTab: "files" };
|
|
554
560
|
({ doc, session } = fromFlat(S));
|
|
555
561
|
render();
|
package/src/ui/spectrum.js
CHANGED
|
@@ -47,6 +47,7 @@ import { PickerButton } from "@spectrum-web-components/picker-button/src/PickerB
|
|
|
47
47
|
import { Accordion } from "@spectrum-web-components/accordion/src/Accordion.js";
|
|
48
48
|
import { AccordionItem } from "@spectrum-web-components/accordion/src/AccordionItem.js";
|
|
49
49
|
import { ActionBar } from "@spectrum-web-components/action-bar/src/ActionBar.js";
|
|
50
|
+
import { Toast } from "@spectrum-web-components/toast/src/Toast.js";
|
|
50
51
|
import { Table } from "@spectrum-web-components/table/src/Table.js";
|
|
51
52
|
import { TableHead } from "@spectrum-web-components/table/src/TableHead.js";
|
|
52
53
|
import { TableHeadCell } from "@spectrum-web-components/table/src/TableHeadCell.js";
|
|
@@ -180,6 +181,7 @@ const components = [
|
|
|
180
181
|
["sp-accordion", Accordion],
|
|
181
182
|
["sp-accordion-item", AccordionItem],
|
|
182
183
|
["sp-action-bar", ActionBar],
|
|
184
|
+
["sp-toast", Toast],
|
|
183
185
|
["sp-table", Table],
|
|
184
186
|
["sp-table-head", TableHead],
|
|
185
187
|
["sp-table-head-cell", TableHeadCell],
|
|
@@ -123,17 +123,17 @@ export function inferInputType(entry) {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
/**
|
|
126
|
-
* Match a document path to a content
|
|
127
|
-
*
|
|
126
|
+
* Match a document path to a content type and return its schema. Uses simple directory-prefix +
|
|
127
|
+
* extension matching against the content type's `source` glob.
|
|
128
128
|
*
|
|
129
129
|
* @param {string | null} documentPath — project-relative path (e.g. "blog/hello.md")
|
|
130
130
|
* @param {any} projectConfig — parsed project.json
|
|
131
131
|
* @returns {{ name: string; schema: any } | null}
|
|
132
132
|
*/
|
|
133
|
-
export function
|
|
134
|
-
if (!documentPath || !projectConfig?.
|
|
133
|
+
export function findContentTypeSchema(documentPath, projectConfig) {
|
|
134
|
+
if (!documentPath || !projectConfig?.contentTypes) return null;
|
|
135
135
|
for (const [name, def] of Object.entries(
|
|
136
|
-
/** @type {Record<string, any>} */ (projectConfig.
|
|
136
|
+
/** @type {Record<string, any>} */ (projectConfig.contentTypes),
|
|
137
137
|
)) {
|
|
138
138
|
if (!def.source || !def.schema) continue;
|
|
139
139
|
const src = def.source.replace(/^\.\//, "");
|