@jxsuite/studio 0.32.0 → 0.34.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/iframe-entry.js +6230 -0
- package/dist/iframe-entry.js.map +35 -0
- package/dist/studio.js +22791 -13709
- package/dist/studio.js.map +181 -74
- package/package.json +12 -7
- package/src/browse/browse.ts +11 -4
- package/src/canvas/canvas-helpers.ts +2 -56
- package/src/canvas/canvas-live-render.ts +102 -435
- package/src/canvas/canvas-origin.ts +66 -0
- package/src/canvas/canvas-patcher.ts +63 -403
- package/src/canvas/canvas-render.ts +70 -212
- package/src/canvas/canvas-utils.ts +37 -65
- package/src/canvas/iframe-channel.ts +154 -0
- package/src/canvas/iframe-drop.ts +484 -0
- package/src/canvas/iframe-entry.ts +600 -0
- package/src/canvas/iframe-host.ts +1373 -0
- package/src/canvas/iframe-inline-edit.ts +367 -0
- package/src/canvas/iframe-insert.ts +164 -0
- package/src/canvas/iframe-interaction.ts +176 -0
- package/src/canvas/iframe-keys.ts +85 -0
- package/src/canvas/iframe-overlay.ts +218 -0
- package/src/canvas/iframe-patch.ts +363 -0
- package/src/canvas/iframe-protocol.ts +361 -0
- package/src/canvas/iframe-render.ts +458 -0
- package/src/canvas/iframe-slash.ts +114 -0
- package/src/canvas/iframe-subtree.ts +113 -0
- package/src/canvas/path-mapping.ts +86 -0
- package/src/canvas/serialize-scope.ts +65 -0
- package/src/editor/canvas-context-menu.ts +40 -0
- package/src/editor/canvas-slash-bridge.ts +21 -0
- package/src/editor/context-menu.ts +2 -1
- package/src/editor/inline-edit-apply.ts +183 -0
- package/src/editor/inline-edit.ts +99 -21
- package/src/editor/inline-link.ts +89 -0
- package/src/editor/insert-zone-action.ts +35 -0
- package/src/editor/merge-tags.ts +26 -2
- package/src/editor/repeater-scope.ts +144 -0
- package/src/editor/shortcuts.ts +14 -28
- package/src/editor/slash-menu.ts +73 -42
- package/src/files/files.ts +2 -1
- package/src/page-params.ts +383 -0
- package/src/panels/ai-panel.ts +386 -328
- package/src/panels/block-action-bar.ts +296 -138
- package/src/panels/canvas-dnd-bridge.ts +397 -0
- package/src/panels/component-preview.ts +56 -0
- package/src/panels/dnd.ts +41 -17
- package/src/panels/drag-ghost.ts +62 -0
- package/src/panels/editors.ts +1 -1
- package/src/panels/overlays.ts +10 -125
- package/src/panels/properties-panel.ts +210 -0
- package/src/panels/right-panel.ts +0 -2
- package/src/panels/signals-panel.ts +136 -22
- package/src/panels/stylebook-doc.ts +373 -0
- package/src/panels/stylebook-panel.ts +46 -689
- package/src/panels/tab-bar.ts +159 -13
- package/src/panels/toolbar.ts +3 -2
- package/src/platforms/devserver.ts +18 -47
- package/src/services/ai-settings.ts +107 -0
- package/src/services/ai-system-prompt.ts +617 -0
- package/src/services/ai-tools.ts +854 -0
- package/src/services/context-manager.ts +200 -0
- package/src/services/document-assistant.ts +183 -0
- package/src/services/jx-validate.ts +65 -0
- package/src/services/monaco-setup.ts +12 -0
- package/src/services/render-critic.ts +75 -0
- package/src/services/token-lint.ts +140 -0
- package/src/services/tool-executor.ts +156 -0
- package/src/settings/css-vars-editor.ts +2 -2
- package/src/state.ts +29 -0
- package/src/store.ts +4 -62
- package/src/studio.ts +90 -40
- package/src/tabs/doc-op-apply.ts +89 -0
- package/src/tabs/patch-ops.ts +6 -2
- package/src/tabs/tab.ts +23 -4
- package/src/tabs/transact.ts +33 -69
- package/src/types.ts +16 -24
- package/src/ui/jx-theme.ts +63 -0
- package/src/ui/media-picker.ts +6 -4
- package/src/ui/spectrum.ts +5 -0
- package/src/utils/canvas-media.ts +0 -137
- package/src/utils/edit-display.ts +23 -3
- package/src/utils/geometry.ts +43 -0
- package/src/utils/link-target.ts +93 -0
- package/src/utils/strip-events.ts +54 -0
- package/src/view.ts +0 -23
- package/src/workspace/workspace.ts +14 -1
- package/src/canvas/canvas-subtree-render.ts +0 -113
- package/src/editor/component-inline-edit.ts +0 -349
- package/src/editor/content-inline-edit.ts +0 -207
- package/src/editor/insertion-helper.ts +0 -308
- package/src/panels/canvas-dnd.ts +0 -329
- package/src/panels/panel-events.ts +0 -306
- package/src/panels/preview-render.ts +0 -132
- package/src/panels/pseudo-preview.ts +0 -75
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
import { html, nothing } from "lit-html";
|
|
9
9
|
import { classMap } from "lit-html/directives/class-map.js";
|
|
10
10
|
import { ifDefined } from "lit-html/directives/if-defined.js";
|
|
11
|
+
import { live } from "lit-html/directives/live.js";
|
|
11
12
|
import { styleMap } from "lit-html/directives/style-map.js";
|
|
13
|
+
import { isRef } from "@jxsuite/schema/guards";
|
|
14
|
+
import { dynamicRouteParams } from "../page-params";
|
|
12
15
|
import { projectState } from "../state";
|
|
13
16
|
import type { JsonValue } from "../types";
|
|
14
17
|
import { activeTab } from "../workspace/workspace";
|
|
@@ -111,6 +114,14 @@ let expandedSignal: string | null = null;
|
|
|
111
114
|
/** Track which functions have the advanced param editor open. */
|
|
112
115
|
const advancedParamOpen = new Set();
|
|
113
116
|
|
|
117
|
+
/** Schema fields whose binding picker is in free-form Custom mode (cleared on commit). */
|
|
118
|
+
const bindingCustomOpen = new Set<string>();
|
|
119
|
+
|
|
120
|
+
/** Reset binding-control ephemeral UI state (test hook). */
|
|
121
|
+
export function resetBindingUiState() {
|
|
122
|
+
bindingCustomOpen.clear();
|
|
123
|
+
}
|
|
124
|
+
|
|
114
125
|
/** Default templates for creating new signal definitions. */
|
|
115
126
|
const DEF_TEMPLATES = {
|
|
116
127
|
computed: { $compute: "", $deps: [] },
|
|
@@ -1294,6 +1305,18 @@ function renderInlineField(
|
|
|
1294
1305
|
onChange: (val: unknown) => void,
|
|
1295
1306
|
parentDef?: Record<string, unknown>,
|
|
1296
1307
|
) {
|
|
1308
|
+
if (isRef(value)) {
|
|
1309
|
+
return html`<sp-textfield
|
|
1310
|
+
size="s"
|
|
1311
|
+
label=${key}
|
|
1312
|
+
placeholder=${key}
|
|
1313
|
+
.value=${live(value.$ref)}
|
|
1314
|
+
@change=${(e: Event) => {
|
|
1315
|
+
const v = (e.target as HTMLInputElement).value.trim();
|
|
1316
|
+
onChange(v ? { $ref: v } : undefined);
|
|
1317
|
+
}}
|
|
1318
|
+
></sp-textfield>`;
|
|
1319
|
+
}
|
|
1297
1320
|
const enumValues = resolveSchemaEnum(schema.enum, parentDef);
|
|
1298
1321
|
|
|
1299
1322
|
if (enumValues) {
|
|
@@ -1344,6 +1367,63 @@ function renderInlineField(
|
|
|
1344
1367
|
></sp-textfield>`;
|
|
1345
1368
|
}
|
|
1346
1369
|
|
|
1370
|
+
/**
|
|
1371
|
+
* Render a binding picker for a `{ $ref }` config value — route params derived from the document
|
|
1372
|
+
* path plus a free-form custom ref, with a switch back to a static value.
|
|
1373
|
+
*/
|
|
1374
|
+
function renderBindingControl(opts: {
|
|
1375
|
+
refVal: string;
|
|
1376
|
+
params: string[];
|
|
1377
|
+
fieldKey: string;
|
|
1378
|
+
commit: (next: { $ref: string } | undefined) => void;
|
|
1379
|
+
rerender: (() => void) | undefined;
|
|
1380
|
+
}) {
|
|
1381
|
+
const paramRefs = opts.params.map((p) => `#/$params/${p}`);
|
|
1382
|
+
const isCustom =
|
|
1383
|
+
bindingCustomOpen.has(opts.fieldKey) ||
|
|
1384
|
+
(opts.refVal !== "" && !paramRefs.includes(opts.refVal));
|
|
1385
|
+
return html`
|
|
1386
|
+
<div style="display:flex;flex-direction:column;gap:4px">
|
|
1387
|
+
<sp-picker
|
|
1388
|
+
size="s"
|
|
1389
|
+
.value=${live(isCustom ? "__custom__" : opts.refVal || "__static__")}
|
|
1390
|
+
@change=${(e: Event) => {
|
|
1391
|
+
const v = (e.target as HTMLInputElement).value;
|
|
1392
|
+
if (v === "__custom__") {
|
|
1393
|
+
bindingCustomOpen.add(opts.fieldKey);
|
|
1394
|
+
opts.rerender?.();
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
bindingCustomOpen.delete(opts.fieldKey);
|
|
1398
|
+
opts.commit(v === "__static__" ? undefined : { $ref: v });
|
|
1399
|
+
opts.rerender?.();
|
|
1400
|
+
}}
|
|
1401
|
+
>
|
|
1402
|
+
<sp-menu-item value="__static__">Static value</sp-menu-item>
|
|
1403
|
+
${paramRefs.length > 0 ? html`<sp-menu-divider></sp-menu-divider>` : nothing}
|
|
1404
|
+
${paramRefs.map((r) => html`<sp-menu-item value=${r}>${r.slice(2)}</sp-menu-item>`)}
|
|
1405
|
+
<sp-menu-divider></sp-menu-divider>
|
|
1406
|
+
<sp-menu-item value="__custom__">Custom…</sp-menu-item>
|
|
1407
|
+
</sp-picker>
|
|
1408
|
+
${isCustom
|
|
1409
|
+
? html`<sp-textfield
|
|
1410
|
+
size="s"
|
|
1411
|
+
placeholder="#/$params/…"
|
|
1412
|
+
.value=${live(opts.refVal)}
|
|
1413
|
+
@change=${(e: Event) => {
|
|
1414
|
+
const v = (e.target as HTMLInputElement).value.trim();
|
|
1415
|
+
if (!v) {
|
|
1416
|
+
bindingCustomOpen.delete(opts.fieldKey);
|
|
1417
|
+
}
|
|
1418
|
+
opts.commit(v ? { $ref: v } : undefined);
|
|
1419
|
+
opts.rerender?.();
|
|
1420
|
+
}}
|
|
1421
|
+
></sp-textfield>`
|
|
1422
|
+
: nothing}
|
|
1423
|
+
</div>
|
|
1424
|
+
`;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1347
1427
|
/** Render a debounced multiline JSON text field for array/object schema properties. */
|
|
1348
1428
|
function renderJsonTextField(
|
|
1349
1429
|
currentValue: unknown,
|
|
@@ -1382,7 +1462,7 @@ export function renderSchemaFieldsTemplate(
|
|
|
1382
1462
|
schema: JsonSchema | null | undefined,
|
|
1383
1463
|
def: SignalDef,
|
|
1384
1464
|
name: string,
|
|
1385
|
-
|
|
1465
|
+
S: SignalsPanelState,
|
|
1386
1466
|
ctx: SignalsPanelCtx | null = null,
|
|
1387
1467
|
) {
|
|
1388
1468
|
if (!schema?.properties) {
|
|
@@ -1390,6 +1470,7 @@ export function renderSchemaFieldsTemplate(
|
|
|
1390
1470
|
}
|
|
1391
1471
|
|
|
1392
1472
|
const required = new Set(schema.required);
|
|
1473
|
+
const params = dynamicRouteParams(S.documentPath);
|
|
1393
1474
|
|
|
1394
1475
|
const propertyFields = Object.entries(schema.properties)
|
|
1395
1476
|
.filter(([prop]) => !STUDIO_RESERVED_KEYS.has(prop))
|
|
@@ -1399,7 +1480,21 @@ export function renderSchemaFieldsTemplate(
|
|
|
1399
1480
|
|
|
1400
1481
|
let control;
|
|
1401
1482
|
const enumValues = resolveSchemaEnum(ps.enum, def);
|
|
1402
|
-
if (
|
|
1483
|
+
if (
|
|
1484
|
+
isRef(currentValue) &&
|
|
1485
|
+
ps.format !== "json-schema" &&
|
|
1486
|
+
ps.type !== "object" &&
|
|
1487
|
+
ps.type !== "array"
|
|
1488
|
+
) {
|
|
1489
|
+
control = renderBindingControl({
|
|
1490
|
+
refVal: currentValue.$ref,
|
|
1491
|
+
params,
|
|
1492
|
+
fieldKey: `${name}.${prop}`,
|
|
1493
|
+
commit: (next) =>
|
|
1494
|
+
transactDoc(activeTab.value, (t) => mutateUpdateDef(t, name, { [prop]: next })),
|
|
1495
|
+
rerender: ctx ? () => ctx.renderLeftPanel() : undefined,
|
|
1496
|
+
});
|
|
1497
|
+
} else if (enumValues) {
|
|
1403
1498
|
control = html`
|
|
1404
1499
|
<sp-picker
|
|
1405
1500
|
size="s"
|
|
@@ -1463,12 +1558,12 @@ export function renderSchemaFieldsTemplate(
|
|
|
1463
1558
|
const hasValue =
|
|
1464
1559
|
currentValue && typeof currentValue === "object" && Object.keys(currentValue).length > 0;
|
|
1465
1560
|
const cv = currentValue as Record<string, unknown>;
|
|
1466
|
-
const
|
|
1561
|
+
const isSchemaRef = hasValue && cv.$ref;
|
|
1467
1562
|
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
1468
1563
|
let debounce: ReturnType<typeof setTimeout> | undefined;
|
|
1469
1564
|
control = html`
|
|
1470
1565
|
<div class="schema-param-editor">
|
|
1471
|
-
${hasValue && !
|
|
1566
|
+
${hasValue && !isSchemaRef && cv.properties
|
|
1472
1567
|
? html`
|
|
1473
1568
|
<div style="display:flex;flex-wrap:wrap;gap:3px;margin-bottom:4px">
|
|
1474
1569
|
${Object.entries(cv.properties as Record<string, Record<string, unknown>>).map(
|
|
@@ -1580,24 +1675,43 @@ export function renderSchemaFieldsTemplate(
|
|
|
1580
1675
|
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
1581
1676
|
let debounce: ReturnType<typeof setTimeout> | undefined;
|
|
1582
1677
|
const ph = ps.default !== undefined ? String(ps.default) : (ps.examples?.[0] ?? "");
|
|
1583
|
-
control = html`<
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
()
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1678
|
+
control = html`<div style="display:flex;gap:4px;align-items:center">
|
|
1679
|
+
<sp-textfield
|
|
1680
|
+
size="s"
|
|
1681
|
+
style="flex:1"
|
|
1682
|
+
.value=${currentValue ?? ""}
|
|
1683
|
+
placeholder=${ph || nothing}
|
|
1684
|
+
title=${ps.description || nothing}
|
|
1685
|
+
@input=${(e: Event) => {
|
|
1686
|
+
clearTimeout(debounce);
|
|
1687
|
+
debounce = setTimeout(
|
|
1688
|
+
() =>
|
|
1689
|
+
transactDoc(activeTab.value, (t) =>
|
|
1690
|
+
mutateUpdateDef(t, name, {
|
|
1691
|
+
[prop]: (e.target as HTMLInputElement).value || undefined,
|
|
1692
|
+
}),
|
|
1693
|
+
),
|
|
1694
|
+
400,
|
|
1695
|
+
);
|
|
1696
|
+
}}
|
|
1697
|
+
></sp-textfield>
|
|
1698
|
+
${params.length > 0
|
|
1699
|
+
? html`<sp-action-button
|
|
1700
|
+
quiet
|
|
1701
|
+
size="s"
|
|
1702
|
+
title="Bind to route param"
|
|
1703
|
+
@click=${() => {
|
|
1704
|
+
transactDoc(activeTab.value, (t) =>
|
|
1705
|
+
mutateUpdateDef(t, name, {
|
|
1706
|
+
[prop]: { $ref: `#/$params/${params[0]}` },
|
|
1707
|
+
}),
|
|
1708
|
+
);
|
|
1709
|
+
ctx?.renderLeftPanel();
|
|
1710
|
+
}}
|
|
1711
|
+
><sp-icon-link slot="icon"></sp-icon-link
|
|
1712
|
+
></sp-action-button>`
|
|
1713
|
+
: nothing}
|
|
1714
|
+
</div>`;
|
|
1601
1715
|
}
|
|
1602
1716
|
|
|
1603
1717
|
return renderFieldRow({
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stylebook specimen-document generator — pure functions (no DOM) that turn the static element
|
|
3
|
+
* catalog (data/stylebook-meta.json) plus the project's component registry into a plain JxDocument
|
|
4
|
+
* the iframe canvas renders like any page. Each breakpoint panel renders the SAME generated doc in
|
|
5
|
+
* a width-sized iframe, so `@media` blocks evaluate for real — this replaces the legacy parent-side
|
|
6
|
+
* inline-style flatten (`buildStylebookElement`/`refreshStylebookStyles`).
|
|
7
|
+
*
|
|
8
|
+
* Two runtime facts shape {@link transposeStylebookStyle}:
|
|
9
|
+
*
|
|
10
|
+
* 1. The runtime's `applyStyle` emits `@media.selector` nesting but DROPS `selector.@media`
|
|
11
|
+
* (`emitNested` skips `@` keys) — the legacy flatten honored both orders, so tag rules' embedded
|
|
12
|
+
* `@name` blocks are HOISTED into the corresponding top-level `@name` block here.
|
|
13
|
+
* 2. Tag-keyed rules on the generated root would restyle the card chrome (`div` rules hit chrome
|
|
14
|
+
* divs), so every tag rule `T` is re-keyed to `` `& .element-card-preview ${T}` `` — applyStyle
|
|
15
|
+
* resolves `&` to the root's `[data-jx=uid]` scope, confining the cascade to specimens.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { serializeJxPath } from "../canvas/path-mapping";
|
|
19
|
+
import type { StylebookEntry } from "./stylebook-panel";
|
|
20
|
+
import type { ComponentEntry } from "../files/components";
|
|
21
|
+
import type { JxPath } from "../state";
|
|
22
|
+
import type { JxMutableNode, JxStyle } from "@jxsuite/schema/types";
|
|
23
|
+
|
|
24
|
+
export interface StylebookDocResult {
|
|
25
|
+
/** The specimen document (plain JSON — safe to post over the bridge). */
|
|
26
|
+
doc: JxMutableNode;
|
|
27
|
+
/** SerializeJxPath(path) → tag or compound ("ul li") for hit decoding. Chrome paths absent. */
|
|
28
|
+
pathToTag: Map<string, string>;
|
|
29
|
+
/** Tag/compound → the FIRST matching card's document path (selection-overlay measurement). */
|
|
30
|
+
tagToCardPath: Map<string, JxPath>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Selector-key classifier — a bare tag path like "p" or "table th" (mirrors style-panel's rule). */
|
|
34
|
+
function isTagPath(key: string): boolean {
|
|
35
|
+
return (
|
|
36
|
+
!key.startsWith(":") &&
|
|
37
|
+
!key.startsWith(".") &&
|
|
38
|
+
!key.startsWith("&") &&
|
|
39
|
+
!key.startsWith("[") &&
|
|
40
|
+
!key.startsWith("@") &&
|
|
41
|
+
!key.startsWith("--")
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
46
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The specimen-scoping selector prefix (see module doc, runtime fact 2). */
|
|
50
|
+
const SPECIMEN_SCOPE = "& .element-card-preview";
|
|
51
|
+
|
|
52
|
+
/** Deep-merge `add` into `base` (objects merge, scalars overwrite; add wins). */
|
|
53
|
+
function mergeRules(
|
|
54
|
+
base: Record<string, unknown> | undefined,
|
|
55
|
+
add: Record<string, unknown>,
|
|
56
|
+
): Record<string, unknown> {
|
|
57
|
+
const out: Record<string, unknown> = { ...base };
|
|
58
|
+
for (const [k, v] of Object.entries(add)) {
|
|
59
|
+
out[k] = isPlainObject(v) && isPlainObject(out[k]) ? mergeRules(out[k], v) : v;
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Drop nested `@` keys from a rule object (already-hoisted media can't nest deeper). */
|
|
65
|
+
function stripAtKeys(rule: Record<string, unknown>): Record<string, unknown> {
|
|
66
|
+
const out: Record<string, unknown> = {};
|
|
67
|
+
for (const [k, v] of Object.entries(rule)) {
|
|
68
|
+
if (isPlainObject(v) && k.startsWith("@")) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
out[k] = isPlainObject(v) ? stripAtKeys(v) : v;
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Transform the merged effective document style into the generated root's style block: flat scalars
|
|
78
|
+
* and `--` custom props stay at the top (inheritance into specimens), tag rules are re-keyed under
|
|
79
|
+
* {@link SPECIMEN_SCOPE}, and `selector.@media` nesting is hoisted into top-level `@name` blocks
|
|
80
|
+
* (both per the module doc). Shared by the doc generator and the live `styleUpdate` fast path so an
|
|
81
|
+
* edit round-trips through the exact same transform.
|
|
82
|
+
*/
|
|
83
|
+
export function transposeStylebookStyle(effectiveStyle: JxStyle): JxStyle {
|
|
84
|
+
const style = effectiveStyle as Record<string, unknown>;
|
|
85
|
+
const out: Record<string, unknown> = {};
|
|
86
|
+
const mediaOut: Record<string, Record<string, unknown>> = {};
|
|
87
|
+
|
|
88
|
+
/** Re-key + hoist one tag rule subtree; returns the cleaned (media-free) rule. */
|
|
89
|
+
const processTagRule = (
|
|
90
|
+
rule: Record<string, unknown>,
|
|
91
|
+
tagPath: string,
|
|
92
|
+
): Record<string, unknown> => {
|
|
93
|
+
const cleaned: Record<string, unknown> = {};
|
|
94
|
+
for (const [k, v] of Object.entries(rule)) {
|
|
95
|
+
if (!isPlainObject(v)) {
|
|
96
|
+
cleaned[k] = v;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (k.startsWith("@")) {
|
|
100
|
+
if (k === "@--") {
|
|
101
|
+
continue; // Base canvas width marker, not a real query.
|
|
102
|
+
}
|
|
103
|
+
const bucket = (mediaOut[k] ??= {});
|
|
104
|
+
const sel = `${SPECIMEN_SCOPE} ${tagPath}`;
|
|
105
|
+
bucket[sel] = mergeRules(
|
|
106
|
+
bucket[sel] as Record<string, unknown> | undefined,
|
|
107
|
+
stripAtKeys(v),
|
|
108
|
+
);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (isTagPath(k)) {
|
|
112
|
+
cleaned[k] = processTagRule(v, `${tagPath} ${k}`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
cleaned[k] = v; // Pseudo/class/& sub-rules ride along under the re-keyed parent.
|
|
116
|
+
}
|
|
117
|
+
return cleaned;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
for (const [k, v] of Object.entries(style)) {
|
|
121
|
+
if (!isPlainObject(v)) {
|
|
122
|
+
out[k] = v; // Flat scalars + -- custom props.
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (k.startsWith("@")) {
|
|
126
|
+
if (k === "@--") {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
// Media block: re-key tag rules inside; non-tag keys (scalars/pseudo) apply to the root.
|
|
130
|
+
const bucket = (mediaOut[k] ??= {});
|
|
131
|
+
for (const [mk, mv] of Object.entries(v)) {
|
|
132
|
+
if (isPlainObject(mv) && isTagPath(mk)) {
|
|
133
|
+
const sel = `${SPECIMEN_SCOPE} ${mk}`;
|
|
134
|
+
bucket[sel] = mergeRules(bucket[sel] as Record<string, unknown> | undefined, mv);
|
|
135
|
+
} else {
|
|
136
|
+
bucket[mk] = mv;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (isTagPath(k)) {
|
|
142
|
+
out[`${SPECIMEN_SCOPE} ${k}`] = processTagRule(v, k);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
out[k] = v;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const [mk, mv] of Object.entries(mediaOut)) {
|
|
149
|
+
out[mk] = isPlainObject(out[mk]) ? mergeRules(out[mk] as Record<string, unknown>, mv) : mv;
|
|
150
|
+
}
|
|
151
|
+
return out as JxStyle;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Resolve a nested tag path ("table th") in a style object; null when absent. */
|
|
155
|
+
function resolveNestedStyle(
|
|
156
|
+
style: Record<string, unknown>,
|
|
157
|
+
tagPath: string,
|
|
158
|
+
): Record<string, unknown> | null {
|
|
159
|
+
const parts = tagPath.split(" ");
|
|
160
|
+
let obj: unknown = style;
|
|
161
|
+
for (const part of parts) {
|
|
162
|
+
if (!isPlainObject(obj)) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
obj = obj[part];
|
|
166
|
+
}
|
|
167
|
+
return isPlainObject(obj) ? obj : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Whether the effective style customizes `tagPath` (directly or under any `@media` block). */
|
|
171
|
+
export function hasTagStyle(rootStyle: JxStyle, tagPath: string): boolean {
|
|
172
|
+
const style = rootStyle as Record<string, unknown>;
|
|
173
|
+
const direct = resolveNestedStyle(style, tagPath);
|
|
174
|
+
if (direct && Object.keys(direct).length > 0) {
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
for (const [key, val] of Object.entries(style)) {
|
|
178
|
+
if (!key.startsWith("@") || !isPlainObject(val)) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const nested = resolveNestedStyle(val, tagPath);
|
|
182
|
+
if (nested && Object.keys(nested).length > 0) {
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Options for {@link buildStylebookDoc}. */
|
|
190
|
+
export interface BuildStylebookDocOpts {
|
|
191
|
+
meta: { $sections: { label: string; elements: StylebookEntry[] }[] };
|
|
192
|
+
components: ComponentEntry[];
|
|
193
|
+
/** Merged document+project style (getEffectiveStyle output) — transposed here. */
|
|
194
|
+
effectiveStyle: JxStyle;
|
|
195
|
+
/** Merged $media map (getEffectiveMedia output) — becomes the doc's $media. */
|
|
196
|
+
effectiveMedia: Record<string, string>;
|
|
197
|
+
filter: string;
|
|
198
|
+
customizedOnly: boolean;
|
|
199
|
+
projectRoot: string | null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Build the specimen document + path↔tag maps. Deterministic: paths are recorded as the tree is
|
|
204
|
+
* assembled, so the parent can decode `hit` paths and measure selection cards without reading the
|
|
205
|
+
* iframe DOM.
|
|
206
|
+
*/
|
|
207
|
+
export function buildStylebookDoc(opts: BuildStylebookDocOpts): StylebookDocResult {
|
|
208
|
+
const pathToTag = new Map<string, string>();
|
|
209
|
+
const tagToCardPath = new Map<string, JxPath>();
|
|
210
|
+
const filter = opts.filter.toLowerCase();
|
|
211
|
+
|
|
212
|
+
/** Register a specimen subtree's paths: root → tag; descendants → compound (legacy semantics). */
|
|
213
|
+
const registerSpecimenPaths = (entry: StylebookEntry, path: JxPath, rootTag: string): void => {
|
|
214
|
+
const compound = entry.tag === rootTag ? rootTag : `${rootTag} ${entry.tag}`;
|
|
215
|
+
pathToTag.set(serializeJxPath(path), compound);
|
|
216
|
+
const kids = entry.children ?? [];
|
|
217
|
+
for (const [i, child] of kids.entries()) {
|
|
218
|
+
registerSpecimenPaths(child, [...path, "children", i], rootTag);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const specimenNode = (entry: StylebookEntry): JxMutableNode => {
|
|
223
|
+
const attributes: Record<string, string> = { ...entry.attributes };
|
|
224
|
+
if (entry.style) {
|
|
225
|
+
attributes.style = entry.style; // Inline CSS text — legacy `el.style.cssText` parity.
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
tagName: entry.tag,
|
|
229
|
+
...(entry.text ? { textContent: entry.text } : {}),
|
|
230
|
+
...(Object.keys(attributes).length > 0 ? { attributes } : {}),
|
|
231
|
+
...(entry.children ? { children: entry.children.map(specimenNode) } : {}),
|
|
232
|
+
} as JxMutableNode;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/** A card wrapping one specimen; registers card/preview/specimen paths under `tag`. */
|
|
236
|
+
const cardNode = (
|
|
237
|
+
tag: string,
|
|
238
|
+
label: string,
|
|
239
|
+
specimen: JxMutableNode,
|
|
240
|
+
cardPath: JxPath,
|
|
241
|
+
entryForPaths: StylebookEntry | null,
|
|
242
|
+
): JxMutableNode => {
|
|
243
|
+
pathToTag.set(serializeJxPath(cardPath), tag);
|
|
244
|
+
pathToTag.set(serializeJxPath([...cardPath, "children", 0]), tag);
|
|
245
|
+
const specimenPath = [...cardPath, "children", 0, "children", 0];
|
|
246
|
+
if (entryForPaths) {
|
|
247
|
+
registerSpecimenPaths(entryForPaths, specimenPath, tag);
|
|
248
|
+
} else {
|
|
249
|
+
pathToTag.set(serializeJxPath(specimenPath), tag);
|
|
250
|
+
}
|
|
251
|
+
if (!tagToCardPath.has(tag)) {
|
|
252
|
+
tagToCardPath.set(tag, cardPath);
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
attributes: { class: "element-card" },
|
|
256
|
+
children: [
|
|
257
|
+
{ attributes: { class: "element-card-preview" }, children: [specimen], tagName: "div" },
|
|
258
|
+
{ attributes: { class: "element-card-label" }, tagName: "div", textContent: label },
|
|
259
|
+
],
|
|
260
|
+
tagName: "div",
|
|
261
|
+
} as JxMutableNode;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const sections: JxMutableNode[] = [];
|
|
265
|
+
const sectionNode = (label: string, cards: JxMutableNode[]): JxMutableNode =>
|
|
266
|
+
({
|
|
267
|
+
attributes: { class: "sb-section" },
|
|
268
|
+
children: [
|
|
269
|
+
{ attributes: { class: "sb-label" }, tagName: "div", textContent: label },
|
|
270
|
+
{ attributes: { class: "sb-body" }, children: cards, tagName: "div" },
|
|
271
|
+
],
|
|
272
|
+
tagName: "div",
|
|
273
|
+
}) as JxMutableNode;
|
|
274
|
+
|
|
275
|
+
// Paths below mirror the assembled tree: root.children[si] = section; section.children[1] =
|
|
276
|
+
// Sb-body; body.children[ci] = card. Sections are appended only when non-empty, so indices are
|
|
277
|
+
// Computed from the OUTPUT arrays, not the input catalog.
|
|
278
|
+
const sectionBodyPath = (): JxPath => ["children", sections.length, "children", 1];
|
|
279
|
+
|
|
280
|
+
for (const section of opts.meta.$sections) {
|
|
281
|
+
let entries = section.elements;
|
|
282
|
+
if (filter) {
|
|
283
|
+
entries = entries.filter(
|
|
284
|
+
(e) => e.tag.includes(filter) || section.label.toLowerCase().includes(filter),
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
if (opts.customizedOnly) {
|
|
288
|
+
entries = entries.filter((e) => hasTagStyle(opts.effectiveStyle, e.tag));
|
|
289
|
+
}
|
|
290
|
+
if (entries.length === 0) {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const bodyPath = sectionBodyPath();
|
|
294
|
+
const cards = entries.map((entry, ci) =>
|
|
295
|
+
cardNode(
|
|
296
|
+
entry.tag,
|
|
297
|
+
`<${entry.tag}>`,
|
|
298
|
+
specimenNode(entry),
|
|
299
|
+
[...bodyPath, "children", ci],
|
|
300
|
+
entry,
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
sections.push(sectionNode(section.label, cards));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Custom components from the registry — live custom elements registered via $elements.
|
|
307
|
+
const $elements: (string | { $ref: string })[] = [];
|
|
308
|
+
{
|
|
309
|
+
let comps = opts.components;
|
|
310
|
+
if (filter) {
|
|
311
|
+
comps = comps.filter((c) => c.tagName.toLowerCase().includes(filter));
|
|
312
|
+
}
|
|
313
|
+
if (opts.customizedOnly) {
|
|
314
|
+
comps = comps.filter((c) => hasTagStyle(opts.effectiveStyle, c.tagName));
|
|
315
|
+
}
|
|
316
|
+
if (comps.length > 0) {
|
|
317
|
+
const bodyPath = sectionBodyPath();
|
|
318
|
+
const cards = comps.map((comp, ci) => {
|
|
319
|
+
const cardPath = [...bodyPath, "children", ci];
|
|
320
|
+
const renderable = comp.source !== "npm" && comp.path?.endsWith(".json");
|
|
321
|
+
if (renderable) {
|
|
322
|
+
// Root-relative $ref: registerElements resolves it against docBase (the canvas origin).
|
|
323
|
+
// Collapse leading slashes — an absolute projectRoot ("/home/…") would otherwise produce
|
|
324
|
+
// A protocol-relative "//home/…" that URL-resolves to a foreign host.
|
|
325
|
+
$elements.push({
|
|
326
|
+
$ref: `/${opts.projectRoot ? `${opts.projectRoot}/` : ""}${comp.path}`.replace(
|
|
327
|
+
/^\/+/,
|
|
328
|
+
"/",
|
|
329
|
+
),
|
|
330
|
+
});
|
|
331
|
+
const attributes: Record<string, string> = {};
|
|
332
|
+
for (const p of comp.props ?? []) {
|
|
333
|
+
if (p.default !== undefined && p.default !== "false" && p.default !== "''") {
|
|
334
|
+
attributes[p.name] = String(p.default).replaceAll(/^'|'$/g, "");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const specimen = {
|
|
338
|
+
tagName: comp.tagName,
|
|
339
|
+
...(Object.keys(attributes).length > 0 ? { attributes } : {}),
|
|
340
|
+
} as JxMutableNode;
|
|
341
|
+
return cardNode(comp.tagName, `<${comp.tagName}>`, specimen, cardPath, null);
|
|
342
|
+
}
|
|
343
|
+
// Npm/format-class components can't be $ref-registered — placeholder box (legacy parity).
|
|
344
|
+
const fallback = {
|
|
345
|
+
attributes: { class: "sb-fallback" },
|
|
346
|
+
tagName: "div",
|
|
347
|
+
textContent: `<${comp.tagName}>`,
|
|
348
|
+
} as JxMutableNode;
|
|
349
|
+
return cardNode(comp.tagName, `<${comp.tagName}>`, fallback, cardPath, null);
|
|
350
|
+
});
|
|
351
|
+
sections.push(sectionNode("Components", cards));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (sections.length === 0) {
|
|
356
|
+
sections.push({
|
|
357
|
+
attributes: { class: "sb-empty" },
|
|
358
|
+
tagName: "div",
|
|
359
|
+
textContent: opts.customizedOnly ? "No customized elements" : "No matching elements",
|
|
360
|
+
} as JxMutableNode);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const doc = {
|
|
364
|
+
...($elements.length > 0 ? { $elements } : {}),
|
|
365
|
+
$media: opts.effectiveMedia,
|
|
366
|
+
attributes: { class: "sb-root" },
|
|
367
|
+
children: sections,
|
|
368
|
+
style: transposeStylebookStyle(opts.effectiveStyle),
|
|
369
|
+
tagName: "div",
|
|
370
|
+
} as JxMutableNode;
|
|
371
|
+
|
|
372
|
+
return { doc, pathToTag, tagToCardPath };
|
|
373
|
+
}
|