windcraft-editor 0.0.0-stage → 0.1.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 +49 -2
- package/core/board-layout.js +71 -0
- package/core/class-groups.js +210 -0
- package/core/design-spec.js +303 -0
- package/core/exporters.js +156 -0
- package/core/pages.js +39 -0
- package/core/prop-tailwind.js +158 -0
- package/core/split-sections.js +99 -0
- package/core/theme.js +87 -0
- package/design/agent.js +2118 -0
- package/design/classes-section.js +103 -0
- package/design/code-editor.js +293 -0
- package/design/code-view.js +158 -0
- package/design/color-picker.js +107 -0
- package/design/config.js +5 -0
- package/design/controls.js +132 -0
- package/design/css-props.js +111 -0
- package/design/design-tab.js +120 -0
- package/design/device-frame.js +40 -0
- package/design/editor/add-menu.js +151 -0
- package/design/editor/api.js +47 -0
- package/design/editor/base.js +55 -0
- package/design/editor/code-panel.js +206 -0
- package/design/editor/color-ui.js +135 -0
- package/design/editor/designs.js +606 -0
- package/design/editor/docs.js +121 -0
- package/design/editor/frame-handles.js +223 -0
- package/design/editor/frame-messages.js +195 -0
- package/design/editor/inspector.js +461 -0
- package/design/editor/layers.js +61 -0
- package/design/editor/pages-panel.js +161 -0
- package/design/editor/panel-resize.js +47 -0
- package/design/editor/plane.js +132 -0
- package/design/editor/present.js +52 -0
- package/design/editor/toolbar.js +267 -0
- package/design/editor.js +47 -0
- package/design/figma-sections.js +440 -0
- package/design/float-toolbar.js +163 -0
- package/design/group-controls.js +52 -0
- package/design/host.js +22 -0
- package/design/panel-model.js +130 -0
- package/design/panel-state.js +40 -0
- package/design/panel.js +212 -0
- package/design/presenter.js +312 -0
- package/design/prop-sections.js +18 -0
- package/design/selection-overlay.js +93 -0
- package/design/store.js +29 -0
- package/design/style-sections.js +315 -0
- package/design/tailwind-classes.js +65 -0
- package/embed-frame.js +43 -0
- package/embed.html +15 -0
- package/embed.js +60 -0
- package/index.js +62 -0
- package/package.json +50 -5
- package/playground/component-source.js +124 -0
- package/styles.css +402 -0
- package/template.js +247 -0
- package/vendor/codemirror.js +24 -0
- package/vendor/preact-htm.js +1 -0
- package/vendor/prettier.js +171 -0
package/README.md
CHANGED
|
@@ -1,3 +1,50 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @windcraft/editor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Windcraft visual editor (HTML + Tailwind): canvas, layers, Properties panel, code panel and pages. It has no backend of its own: the page that mounts it gives it a **host** that loads and saves.
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
import { mountEditor } from "@windcraft/editor";
|
|
7
|
+
|
|
8
|
+
await mountEditor(document.getElementById("root"), {
|
|
9
|
+
host, // saving, projects, AI and the address: see design/host.js
|
|
10
|
+
data, // the design to open: { id, title, htmlCode, ... }
|
|
11
|
+
});
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
| Piece | Where |
|
|
15
|
+
|---|---|
|
|
16
|
+
| What the app must provide | `design/host.js` (the `Host` type) |
|
|
17
|
+
| A host with no backend (localStorage) | `demo/demo.js` |
|
|
18
|
+
| Run the demo | `npm run demo`, then open http://localhost:4000/editor/demo/ |
|
|
19
|
+
| Windcraft's own host | `src/public/js/app-host.js` |
|
|
20
|
+
|
|
21
|
+
The page must map `@windcraft/core/` and `@windcraft/editor/` to where they are served (an import map, as in `demo/index.html`, or a bundler). `mountEditor` loads Tailwind's play CDN (v3) unless you pass `tailwind: false`. One editor per page; it takes over the page's size, keys and zoom.
|
|
22
|
+
|
|
23
|
+
The AI panel and "Generate" need `host.generate`; without a working one they show its error.
|
|
24
|
+
|
|
25
|
+
## Embedding in a container
|
|
26
|
+
|
|
27
|
+
`embedEditor` puts the editor in an element of your page, isolated in an iframe (its own keys, zoom and styles), so several can share a page and it fills whatever size you give it. Leave out the parts you don't need with `ui`; keep the code box and the details form in your own page.
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
import { embedEditor } from "@windcraft/editor";
|
|
31
|
+
|
|
32
|
+
const ed = embedEditor(el, {
|
|
33
|
+
host, // saving, as above
|
|
34
|
+
data: { htmlCode }, // what to open
|
|
35
|
+
ui: { panels: false, nav: false, tools: ["move", "hand", "interact", "frame", "shape", "text", "add"] },
|
|
36
|
+
onDetails: (fields) => showMyForm(fields), // the first save of a new design needs a name: you ask, then call saveDetails
|
|
37
|
+
});
|
|
38
|
+
ed.onChange(({ html, dirty, status, source }) => {}); // source: "host" (your setHtml), "editor" (a change there), "status" (save state only)
|
|
39
|
+
ed.setHtml(html); ed.getHtml(); ed.isDirty(); ed.save();
|
|
40
|
+
await ed.saveDetails({ title, category, description, tags, isPublished, useAsReference, project });
|
|
41
|
+
ed.destroy();
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
| `ui` | Effect |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `panels: false` | no Layers, Properties or Code panels |
|
|
47
|
+
| `nav: false` | no Back, page list or project |
|
|
48
|
+
| `tools: [...]` | only these tools (`move hand interact frame shape text add`); `null` keeps all |
|
|
49
|
+
|
|
50
|
+
The iframe loads `embed.html` (next to `embed.js`; pass `src` if you serve it elsewhere). It needs the same import map as any editor page. Run `npm run demo` and open `/editor/demo/embed.html` for a working example: a code box and the editor following each other.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Where each design sits on a project's infinite plane. Shared by the project board and the editor.
|
|
2
|
+
// Saved positions win. Everything else is laid out automatically: pages in a row, each page's sections stacked beneath it.
|
|
3
|
+
// Pages that have a group ("Public site", "Customer area") get a row of their own per group, the rows one under the other in
|
|
4
|
+
// the order the groups first appear; without groups there is one row, as there always was.
|
|
5
|
+
export const GAP_X = 320;
|
|
6
|
+
export const SECTION_GAP = 200;
|
|
7
|
+
export const ROW_GAP = 360; // between the bottom of one group's row and the labels of the next
|
|
8
|
+
export const PAGE_H = 900;
|
|
9
|
+
export const SEC_H = 480;
|
|
10
|
+
|
|
11
|
+
const LABEL_H = 40; // the name above each frame belongs to it
|
|
12
|
+
const CLEAR = 60; // the least air between two frames
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* items: [{ id, kind, parent, group, board }]. Returns { [id]: { x, y } }. A frame may be wider than frameW (widthOf).
|
|
16
|
+
* Nothing placed automatically lands on another frame: a spot that is taken (by a frame with a saved position, or one placed before)
|
|
17
|
+
* is moved past it, a page to the right and a section below. Saved positions are never moved.
|
|
18
|
+
*/
|
|
19
|
+
export function computeLayout(items, { frameW, heightOf, widthOf = () => frameW }) {
|
|
20
|
+
const pos = {};
|
|
21
|
+
const box = (it, p) => ({ x: p.x, y: p.y - LABEL_H, r: p.x + widthOf(it), b: p.y + heightOf(it) });
|
|
22
|
+
const taken = []; // the boxes of what has a place: the saved ones first, then each one placed
|
|
23
|
+
items.forEach((i) => { if (i.board) taken.push(box(i, i.board)); });
|
|
24
|
+
const hit = (a, b) => a.x < b.r + CLEAR && a.r + CLEAR > b.x && a.y < b.b + CLEAR && a.b + CLEAR > b.y;
|
|
25
|
+
/** The first spot from `p` that touches nothing, moving right (down: false) or down. */
|
|
26
|
+
const clear = (it, p, down) => {
|
|
27
|
+
let q = { ...p };
|
|
28
|
+
for (let n = 0; n < 400; n++) {
|
|
29
|
+
const me = box(it, q), other = taken.find((t) => hit(me, t));
|
|
30
|
+
if (!other) return q;
|
|
31
|
+
q = down ? { x: q.x, y: other.b + CLEAR + LABEL_H } : { x: other.r + CLEAR, y: q.y };
|
|
32
|
+
}
|
|
33
|
+
return q;
|
|
34
|
+
};
|
|
35
|
+
const place = (it, want, down) => {
|
|
36
|
+
const p = it.board ? { x: it.board.x, y: it.board.y } : clear(it, want, down);
|
|
37
|
+
if (!it.board) taken.push(box(it, p));
|
|
38
|
+
return p;
|
|
39
|
+
};
|
|
40
|
+
const pages = items.filter((i) => i.kind !== "section" || !items.some((p) => p.id === i.parent));
|
|
41
|
+
/** @type {{ group: string, pages: typeof pages }[]} */
|
|
42
|
+
const rows = [];
|
|
43
|
+
pages.forEach((p) => {
|
|
44
|
+
const group = p.group || "";
|
|
45
|
+
let row = rows.find((r) => r.group === group);
|
|
46
|
+
if (!row) rows.push((row = { group, pages: [] }));
|
|
47
|
+
row.pages.push(p);
|
|
48
|
+
});
|
|
49
|
+
let rowY = 0;
|
|
50
|
+
rows.forEach((row) => {
|
|
51
|
+
let nextX = 0;
|
|
52
|
+
// the lowest edge of this row, so the next row starts below it. A page moved by hand keeps its slot, its height included:
|
|
53
|
+
// dragging a page never moves another one (a row below doesn't move up when a tall page is picked up)
|
|
54
|
+
let bottom = rowY;
|
|
55
|
+
row.pages.forEach((p) => {
|
|
56
|
+
pos[p.id] = place(p, { x: nextX, y: rowY }, false);
|
|
57
|
+
nextX = (p.board ? nextX : pos[p.id].x) + widthOf(p) + GAP_X;
|
|
58
|
+
let y = pos[p.id].y + heightOf(p) + 260;
|
|
59
|
+
let slot = rowY + heightOf(p) + 260; // the same stack, as it stands in the slot
|
|
60
|
+
bottom = Math.max(bottom, rowY + heightOf(p));
|
|
61
|
+
items.filter((i) => i.kind === "section" && i.parent === p.id).forEach((s) => {
|
|
62
|
+
pos[s.id] = place(s, { x: pos[p.id].x, y }, true);
|
|
63
|
+
bottom = Math.max(bottom, slot + heightOf(s));
|
|
64
|
+
y = pos[s.id].y + heightOf(s) + SECTION_GAP;
|
|
65
|
+
slot += heightOf(s) + SECTION_GAP;
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
rowY = bottom + ROW_GAP;
|
|
69
|
+
});
|
|
70
|
+
return pos;
|
|
71
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// A registry of Tailwind utility groups. Each group is a set of mutually exclusive classes
|
|
3
|
+
// (for example every font size). Controls read and write a group for a chosen variant
|
|
4
|
+
// (breakpoint and state), e.g. "md:hover:".
|
|
5
|
+
export const HUES = ["slate", "gray", "zinc", "neutral", "stone", "red", "orange", "amber", "yellow", "lime", "green", "emerald", "teal", "cyan", "sky", "blue", "indigo", "violet", "purple", "fuchsia", "pink", "rose", "brand"]; // brand: every project has its own scale (brand-50 to brand-950, see theme.js)
|
|
6
|
+
export const SHADES = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
|
|
7
|
+
export const SPECIAL_COLORS = ["black", "white", "transparent", "current"];
|
|
8
|
+
|
|
9
|
+
export const STEPS = ["0", "px", "0.5", "1", "1.5", "2", "2.5", "3", "3.5", "4", "5", "6", "7", "8", "9", "10", "11", "12", "14", "16", "20", "24", "28", "32", "36", "40", "44", "48", "52", "56", "60", "64", "72", "80", "96"];
|
|
10
|
+
export const SIZES = [...STEPS, "auto", "full", "screen", "min", "max", "fit", "1/2", "1/3", "2/3", "1/4", "3/4", "1/5", "2/5", "3/5", "4/5"];
|
|
11
|
+
export const MAX_W = ["none", "xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl", "5xl", "6xl", "7xl", "full", "prose", "screen-sm", "screen-md", "screen-lg", "screen-xl"];
|
|
12
|
+
|
|
13
|
+
const STEP = "\\d+(?:\\.5)?|px";
|
|
14
|
+
const SIZE = `${STEP}|auto|full|screen|min|max|fit|\\d+/\\d+`;
|
|
15
|
+
const ARB = "\\[[^\\]]+\\]";
|
|
16
|
+
const HUE_RE = HUES.join("|");
|
|
17
|
+
const COLOR = `(?:(?:${HUE_RE})-\\d{2,3}|black|white|transparent|current|inherit|\\[(?:#|rgb|hsl|var)[^\\]]*\\])(?:/\\d+|/\\[[^\\]]+\\])?`;
|
|
18
|
+
|
|
19
|
+
const normArb = (v) => {
|
|
20
|
+
v = String(v).trim();
|
|
21
|
+
if (/^\[.*\]$/.test(v)) return v;
|
|
22
|
+
if (/^-?\d*\.?\d+(px|rem|em|%|vh|vw|vmin|vmax|ch|deg|ms|s|fr)$/.test(v) || /^(calc|var|min|max|clamp)\(/.test(v)) return `[${v}]`;
|
|
23
|
+
return v;
|
|
24
|
+
};
|
|
25
|
+
const normColor = (v) => {
|
|
26
|
+
v = String(v).trim();
|
|
27
|
+
if (/^(#[0-9a-fA-F]{3,8}|rgba?\(|hsla?\()/.test(v)) return `[${v.replace(/\s+/g, "_")}]`;
|
|
28
|
+
return v;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// prefix-value utility (w-4, -mt-2, gap-x-[10px])
|
|
32
|
+
const P = (prefix, vals, { neg = false, arb = true } = {}) => ({
|
|
33
|
+
re: new RegExp(`^(${neg ? "-?" : ""})${prefix}-(${vals}${arb ? `|${ARB}` : ""})$`),
|
|
34
|
+
read: (m) => (m[1] || "") + m[2],
|
|
35
|
+
build: (v) => {
|
|
36
|
+
let sign = "";
|
|
37
|
+
v = String(v).trim();
|
|
38
|
+
if (neg && v.startsWith("-")) { sign = "-"; v = v.slice(1); }
|
|
39
|
+
return `${sign}${prefix}-${normArb(v)}`;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
// exact words (display: flex)
|
|
43
|
+
const E = (alts) => ({ re: new RegExp(`^(${alts})$`), read: (m) => m[1], build: (v) => v });
|
|
44
|
+
// color utility with optional /opacity
|
|
45
|
+
const C = (prefix) => ({
|
|
46
|
+
re: new RegExp(`^${prefix}-(${COLOR})$`),
|
|
47
|
+
read: (m) => m[1],
|
|
48
|
+
build: (v) => `${prefix}-${normColor(v)}`,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const GROUPS = {
|
|
52
|
+
// layout
|
|
53
|
+
display: E("block|inline-block|inline|flex|inline-flex|grid|inline-grid|hidden|contents|table|flow-root"),
|
|
54
|
+
position: E("static|fixed|absolute|relative|sticky"),
|
|
55
|
+
overflow: E("overflow-(?:auto|hidden|visible|scroll|clip)"),
|
|
56
|
+
overflowX: E("overflow-x-(?:auto|hidden|visible|scroll|clip)"),
|
|
57
|
+
overflowY: E("overflow-y-(?:auto|hidden|visible|scroll|clip)"),
|
|
58
|
+
z: P("z", "\\d+|auto", { neg: true }),
|
|
59
|
+
visibility: E("visible|invisible"),
|
|
60
|
+
// flex and grid
|
|
61
|
+
flexDir: E("flex-(?:row|col|row-reverse|col-reverse)"),
|
|
62
|
+
flexWrap: E("flex-(?:wrap|nowrap|wrap-reverse)"),
|
|
63
|
+
justify: E("justify-(?:start|center|end|between|around|evenly|stretch|normal)"),
|
|
64
|
+
items: E("items-(?:start|center|end|stretch|baseline)"),
|
|
65
|
+
content: E("content-(?:start|center|end|between|around|evenly|stretch)"),
|
|
66
|
+
self: E("self-(?:auto|start|center|end|stretch|baseline)"),
|
|
67
|
+
grow: E("grow|grow-0"),
|
|
68
|
+
shrink: E("shrink|shrink-0"),
|
|
69
|
+
flexNum: E("flex-(?:1|auto|initial|none)"),
|
|
70
|
+
gap: P("gap", STEP),
|
|
71
|
+
gapX: P("gap-x", STEP),
|
|
72
|
+
gapY: P("gap-y", STEP),
|
|
73
|
+
gridCols: P("grid-cols", "\\d+|none|subgrid"),
|
|
74
|
+
gridRows: P("grid-rows", "\\d+|none|subgrid"),
|
|
75
|
+
colSpan: P("col-span", "\\d+|full"),
|
|
76
|
+
rowSpan: P("row-span", "\\d+|full"),
|
|
77
|
+
order: P("order", "\\d+|first|last|none", { neg: true }),
|
|
78
|
+
// size
|
|
79
|
+
w: P("w", SIZE),
|
|
80
|
+
h: P("h", SIZE),
|
|
81
|
+
minW: P("min-w", `${STEP}|full|min|max|fit`),
|
|
82
|
+
maxW: P("max-w", `${STEP}|${MAX_W.join("|")}|fit|min|max`),
|
|
83
|
+
minH: P("min-h", `${STEP}|full|screen|min|max|fit`),
|
|
84
|
+
maxH: P("max-h", `${STEP}|full|screen|min|max|fit|none`),
|
|
85
|
+
aspect: P("aspect", "auto|square|video"),
|
|
86
|
+
objectFit: E("object-(?:contain|cover|fill|none|scale-down)"),
|
|
87
|
+
// spacing
|
|
88
|
+
p: P("p", STEP), px: P("px", STEP), py: P("py", STEP), pt: P("pt", STEP), pr: P("pr", STEP), pb: P("pb", STEP), pl: P("pl", STEP),
|
|
89
|
+
m: P("m", `${STEP}|auto`, { neg: true }), mx: P("mx", `${STEP}|auto`, { neg: true }), my: P("my", `${STEP}|auto`, { neg: true }),
|
|
90
|
+
mt: P("mt", `${STEP}|auto`, { neg: true }), mr: P("mr", `${STEP}|auto`, { neg: true }), mb: P("mb", `${STEP}|auto`, { neg: true }), ml: P("ml", `${STEP}|auto`, { neg: true }),
|
|
91
|
+
spaceX: P("space-x", STEP, { neg: true }),
|
|
92
|
+
spaceY: P("space-y", STEP, { neg: true }),
|
|
93
|
+
// position offsets
|
|
94
|
+
inset: P("inset", `${STEP}|auto|full|\\d+/\\d+`, { neg: true }),
|
|
95
|
+
top: P("top", `${STEP}|auto|full|\\d+/\\d+`, { neg: true }),
|
|
96
|
+
right: P("right", `${STEP}|auto|full|\\d+/\\d+`, { neg: true }),
|
|
97
|
+
bottom: P("bottom", `${STEP}|auto|full|\\d+/\\d+`, { neg: true }),
|
|
98
|
+
left: P("left", `${STEP}|auto|full|\\d+/\\d+`, { neg: true }),
|
|
99
|
+
// typography
|
|
100
|
+
fontFamily: E("font-(?:sans|serif|mono)"),
|
|
101
|
+
fontSize: { re: /^text-(xs|sm|base|lg|xl|[2-9]xl|\[\d[^\]]*\])$/, read: (m) => m[1], build: (v) => `text-${normArb(v)}` },
|
|
102
|
+
fontWeight: E("font-(?:thin|extralight|light|normal|medium|semibold|bold|extrabold|black)"),
|
|
103
|
+
fontStyle: E("italic|not-italic"),
|
|
104
|
+
leading: P("leading", "none|tight|snug|normal|relaxed|loose|\\d+"),
|
|
105
|
+
tracking: P("tracking", "tighter|tight|normal|wide|wider|widest"),
|
|
106
|
+
textAlign: E("text-(?:left|center|right|justify|start|end)"),
|
|
107
|
+
textColor: C("text"),
|
|
108
|
+
textDecoration: E("underline|overline|line-through|no-underline"),
|
|
109
|
+
textTransform: E("uppercase|lowercase|capitalize|normal-case"),
|
|
110
|
+
textOverflow: E("truncate|text-ellipsis|text-clip"),
|
|
111
|
+
whitespace: E("whitespace-(?:normal|nowrap|pre|pre-line|pre-wrap|break-spaces)"),
|
|
112
|
+
lineClamp: P("line-clamp", "\\d+|none"),
|
|
113
|
+
// background
|
|
114
|
+
bgColor: C("bg"),
|
|
115
|
+
bgImage: E("bg-gradient-to-(?:t|tr|r|br|b|bl|l|tl)|bg-none"),
|
|
116
|
+
gradFrom: C("from"), gradVia: C("via"), gradTo: C("to"),
|
|
117
|
+
bgSize: E("bg-(?:auto|cover|contain)"),
|
|
118
|
+
bgPosition: E("bg-(?:center|top|right|bottom|left|left-top|left-bottom|right-top|right-bottom)"),
|
|
119
|
+
bgRepeat: E("bg-(?:repeat|no-repeat|repeat-x|repeat-y)"),
|
|
120
|
+
fill: C("fill"), stroke: C("stroke"),
|
|
121
|
+
// border
|
|
122
|
+
borderWidth: { re: /^border(?:-([0248]|\[\d[^\]]*\]))?$/, read: (m) => m[1] || "1", build: (v) => (v === "1" || v === "" ? "border" : `border-${normArb(v)}`) },
|
|
123
|
+
borderT: P("border-t", "[0248]"), borderR: P("border-r", "[0248]"), borderB: P("border-b", "[0248]"), borderL: P("border-l", "[0248]"),
|
|
124
|
+
borderStyle: E("border-(?:solid|dashed|dotted|double|none)"),
|
|
125
|
+
borderColor: C("border"),
|
|
126
|
+
radius: { re: /^rounded(?:-(none|sm|md|lg|xl|2xl|3xl|full|\[[^\]]+\]))?$/, read: (m) => m[1] || "", build: (v) => (v === "" || v === "default" ? "rounded" : `rounded-${normArb(v)}`) },
|
|
127
|
+
radiusTL: P("rounded-tl", "none|sm|md|lg|xl|2xl|3xl|full"), radiusTR: P("rounded-tr", "none|sm|md|lg|xl|2xl|3xl|full"),
|
|
128
|
+
radiusBR: P("rounded-br", "none|sm|md|lg|xl|2xl|3xl|full"), radiusBL: P("rounded-bl", "none|sm|md|lg|xl|2xl|3xl|full"),
|
|
129
|
+
ring: { re: /^ring(?:-([0124-8]))?$/, read: (m) => m[1] || "3", build: (v) => (v === "3" ? "ring" : `ring-${v}`) },
|
|
130
|
+
ringColor: C("ring"),
|
|
131
|
+
// effects
|
|
132
|
+
shadow: { re: /^shadow(?:-(sm|md|lg|xl|2xl|inner|none))?$/, read: (m) => m[1] || "default", build: (v) => (v === "default" || v === "" ? "shadow" : `shadow-${v}`) },
|
|
133
|
+
shadowColor: C("shadow"),
|
|
134
|
+
opacity: P("opacity", "\\d+"),
|
|
135
|
+
blur: { re: /^blur(?:-(none|sm|md|lg|xl|2xl|3xl))?$/, read: (m) => m[1] || "default", build: (v) => (v === "default" ? "blur" : `blur-${v}`) },
|
|
136
|
+
backdropBlur: { re: /^backdrop-blur(?:-(none|sm|md|lg|xl|2xl|3xl))?$/, read: (m) => m[1] || "default", build: (v) => (v === "default" ? "backdrop-blur" : `backdrop-blur-${v}`) },
|
|
137
|
+
brightness: P("brightness", "\\d+"),
|
|
138
|
+
grayscale: { re: /^grayscale(?:-(0))?$/, read: (m) => m[1] || "100", build: (v) => (v === "0" ? "grayscale-0" : "grayscale") },
|
|
139
|
+
mixBlend: E("mix-blend-(?:normal|multiply|screen|overlay|darken|lighten|difference)"),
|
|
140
|
+
rotate: P("rotate", "\\d+", { neg: true }),
|
|
141
|
+
scale: P("scale", "\\d+"),
|
|
142
|
+
// an outline: its style (outline = solid), width, colour and offset are four classes
|
|
143
|
+
outlineStyle: E("outline|outline-none|outline-dashed|outline-dotted|outline-double"),
|
|
144
|
+
outlineW: P("outline", "\\d+|\\[\\d+(?:\\.\\d+)?px\\]", { arb: false }),
|
|
145
|
+
outlineColor: C("outline"),
|
|
146
|
+
outlineOffset: P("outline-offset", "\\d+|\\[\\d+(?:\\.\\d+)?px\\]", { arb: false }),
|
|
147
|
+
flipX: E("-scale-x-100"), // a mirror image: the one scale class that works in Tailwind 3 and 4 alike
|
|
148
|
+
flipY: E("-scale-y-100"),
|
|
149
|
+
translateX: P("translate-x", `${STEP}|full|\\d+/\\d+`, { neg: true }),
|
|
150
|
+
translateY: P("translate-y", `${STEP}|full|\\d+/\\d+`, { neg: true }),
|
|
151
|
+
// motion and interaction
|
|
152
|
+
transition: { re: /^transition(?:-(none|all|colors|opacity|shadow|transform))?$/, read: (m) => m[1] || "default", build: (v) => (v === "default" ? "transition" : `transition-${v}`) },
|
|
153
|
+
duration: P("duration", "\\d+"),
|
|
154
|
+
ease: E("ease-(?:linear|in|out|in-out)"),
|
|
155
|
+
delay: P("delay", "\\d+"),
|
|
156
|
+
cursor: E("cursor-(?:pointer|default|text|move|not-allowed|wait|grab|grabbing|crosshair|help|auto)"),
|
|
157
|
+
pointerEvents: E("pointer-events-(?:none|auto)"),
|
|
158
|
+
userSelect: E("select-(?:none|text|all|auto)"),
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export const BREAKPOINTS = ["", "sm", "md", "lg", "xl", "2xl"];
|
|
162
|
+
export const STATES = ["", "hover", "focus", "active", "disabled", "dark", "group-hover", "focus-within", "first", "last", "odd", "even"];
|
|
163
|
+
export const BREAKPOINT_WIDTH = { "": 390, sm: 640, md: 768, lg: 1024, xl: 1280, "2xl": 1536 };
|
|
164
|
+
|
|
165
|
+
const split = (str) => String(str || "").split(/\s+/).filter(Boolean);
|
|
166
|
+
|
|
167
|
+
/** "md:hover:bg-red-500" -> { variant: "md:hover:", base: "bg-red-500" } (ignores colons inside [...]) */
|
|
168
|
+
export function parseToken(tok) {
|
|
169
|
+
let depth = 0, cut = -1;
|
|
170
|
+
for (let i = 0; i < tok.length; i++) {
|
|
171
|
+
if (tok[i] === "[") depth++;
|
|
172
|
+
else if (tok[i] === "]") depth--;
|
|
173
|
+
else if (tok[i] === ":" && depth === 0) cut = i;
|
|
174
|
+
}
|
|
175
|
+
return cut === -1 ? { variant: "", base: tok } : { variant: tok.slice(0, cut + 1), base: tok.slice(cut + 1) };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Current value of a group for one variant, or null. */
|
|
179
|
+
export function getGroup(classes, key, variant = "") {
|
|
180
|
+
const g = GROUPS[key];
|
|
181
|
+
for (const tok of split(classes)) {
|
|
182
|
+
const { variant: v, base } = parseToken(tok);
|
|
183
|
+
if (v !== variant) continue;
|
|
184
|
+
const m = base.match(g.re);
|
|
185
|
+
if (m) return { token: tok, value: g.read(m) };
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Sets (or, with an empty value, clears) a group for one variant. Returns the new class string, or null if the value is invalid. */
|
|
191
|
+
export function setGroup(classes, key, value, variant = "") {
|
|
192
|
+
const g = GROUPS[key];
|
|
193
|
+
let built = "";
|
|
194
|
+
if (value !== "" && value !== null && value !== undefined) {
|
|
195
|
+
built = g.build(value);
|
|
196
|
+
if (!g.re.test(built)) return null;
|
|
197
|
+
}
|
|
198
|
+
// the new class takes the old one's place (or goes at the end), so the rest of the list keeps its order
|
|
199
|
+
const out = [];
|
|
200
|
+
let at = -1;
|
|
201
|
+
for (const tok of split(classes)) {
|
|
202
|
+
const { variant: v, base } = parseToken(tok);
|
|
203
|
+
if (v === variant && g.re.test(base)) { if (at === -1) at = out.length; continue; }
|
|
204
|
+
out.push(tok);
|
|
205
|
+
}
|
|
206
|
+
if (built) out.splice(at === -1 ? out.length : at, 0, variant + built);
|
|
207
|
+
return out.join(" ");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export const ALL_GROUP_KEYS = Object.keys(GROUPS);
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The design spec: a design as JSON an AI can read and target reliably, and back to HTML. It replaces the old element
|
|
3
|
+
// description (element-description/index.js), which only ran in the browser and lost spaces, comments and most SVG.
|
|
4
|
+
//
|
|
5
|
+
// It works on a tree the HTML was already read into: the browser's own (DOMParser) or the server's (node-html-parser).
|
|
6
|
+
// Both are read through the few helpers below, so the browser and the server number elements the same way.
|
|
7
|
+
//
|
|
8
|
+
// Elements are numbered in document order (n1, n2...) every time a spec is made. The ids belong to that version of the
|
|
9
|
+
// design: a patch says which version it was read from (design-spec.service.js), so an id never lands on the wrong element.
|
|
10
|
+
import { GROUPS, parseToken } from "./class-groups.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Class properties by variant: { base: { p: "6" }, md: { p: "8" }, "md:hover": { bgColor: "blue-600" } }.
|
|
14
|
+
* The keys inside are class groups (class-groups.js), the values what the group reads and builds.
|
|
15
|
+
* @typedef {Record<string, Record<string, string>>} Props
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* One element of a spec, or a run of text between elements ({ text } alone).
|
|
19
|
+
* @typedef {object} SpecNode
|
|
20
|
+
* @property {string} [id] n1, n2... in document order; only for elements
|
|
21
|
+
* @property {string} [tag]
|
|
22
|
+
* @property {string} [role] title, button, card... (simple rules)
|
|
23
|
+
* @property {Props} [props] classes read as properties
|
|
24
|
+
* @property {string[]} [raw] classes no group reads back exactly
|
|
25
|
+
* @property {string} [css] the style attribute
|
|
26
|
+
* @property {Record<string, string>} [attrs]
|
|
27
|
+
* @property {string} [text]
|
|
28
|
+
* @property {SpecNode[]} [children]
|
|
29
|
+
* @property {string} [svg] an <svg>'s inner markup
|
|
30
|
+
* @property {number} [svgChars] its length, when the drawing is left out
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {object} Assets
|
|
34
|
+
* @property {string} [css]
|
|
35
|
+
* @property {string} [js]
|
|
36
|
+
* @property {string[]} [stylesheets]
|
|
37
|
+
* @property {string[]} [scripts]
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {object} Spec
|
|
41
|
+
* @property {Record<string, string[]>} tokens
|
|
42
|
+
* @property {Assets} assets
|
|
43
|
+
* @property {SpecNode[]} tree
|
|
44
|
+
* @property {string} [id]
|
|
45
|
+
* @property {string} [title]
|
|
46
|
+
* @property {number} [version]
|
|
47
|
+
* @property {string} [kind]
|
|
48
|
+
* @property {string} [warning]
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
// ── Reading either tree ──────────────────────────────────────────────────────
|
|
52
|
+
const tagOf = (n) => String(n.localName || n.rawTagName || "").toLowerCase();
|
|
53
|
+
const attrsOf = (n) => {
|
|
54
|
+
const a = n.attributes;
|
|
55
|
+
if (!a) return {};
|
|
56
|
+
if (typeof a.length === "number") return Object.fromEntries(Array.from(a, (x) => [x.name, x.value])); // the browser's NamedNodeMap
|
|
57
|
+
return { ...a }; // node-html-parser: already { name: value }
|
|
58
|
+
};
|
|
59
|
+
const textOf = (n) => (n.data !== undefined ? n.data : n.text); // a text node's decoded text
|
|
60
|
+
const rawOf = (n) => (n.rawText !== undefined ? n.rawText : n.textContent); // <style>/<script> content, as written
|
|
61
|
+
const innerOf = (n) => n.innerHTML;
|
|
62
|
+
const kidsOf = (n) => Array.from(n.childNodes || []);
|
|
63
|
+
const ELEMENT = 1, TEXT = 3;
|
|
64
|
+
|
|
65
|
+
/** Head-only and asset elements: never part of the tree (their content goes to `assets`). */
|
|
66
|
+
const SKIP = new Set(["head", "style", "script", "link", "meta", "title", "base", "noscript", "template"]);
|
|
67
|
+
const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]);
|
|
68
|
+
/** Elements whose text is kept exactly as written (spaces and line breaks matter). */
|
|
69
|
+
const EXACT = new Set(["pre", "textarea", "code"]);
|
|
70
|
+
const INLINE = new Set(["a", "abbr", "b", "br", "button", "code", "em", "i", "img", "input", "kbd", "label", "mark", "q", "s", "select", "small", "span", "strong", "sub", "sup", "svg", "time", "u", "textarea"]);
|
|
71
|
+
|
|
72
|
+
/** The nodes a design's content starts at: the <body>'s children, or the top level for a fragment. */
|
|
73
|
+
export function contentRoots(root) {
|
|
74
|
+
const body = root.body || (root.querySelector && root.querySelector("body"));
|
|
75
|
+
return kidsOf(body || root);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Visits every element of the tree in document order with its id (n1, n2...). An <svg> is one element: what's inside it
|
|
80
|
+
* has no ids. The spec builder and the patch engine both number elements with this, so their ids always agree.
|
|
81
|
+
*/
|
|
82
|
+
export function walkElements(roots, visit) {
|
|
83
|
+
let count = 0;
|
|
84
|
+
const go = (n, parentId) => {
|
|
85
|
+
if (n.nodeType !== ELEMENT || SKIP.has(tagOf(n))) return;
|
|
86
|
+
const id = `n${++count}`;
|
|
87
|
+
const into = visit(n, id, parentId) !== false;
|
|
88
|
+
if (into && tagOf(n) !== "svg") kidsOf(n).forEach((k) => go(k, id));
|
|
89
|
+
};
|
|
90
|
+
roots.forEach((r) => go(r, null));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Classes as properties ────────────────────────────────────────────────────
|
|
94
|
+
const GROUP_KEYS = Object.keys(GROUPS);
|
|
95
|
+
/** "md:hover:" -> "md:hover", "" -> "base" */
|
|
96
|
+
const atOf = (variant) => (variant ? variant.slice(0, -1) : "base");
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Splits a class list into properties by class group and variant ({ base: { p: "6" }, md: { p: "8" } }) and the classes
|
|
100
|
+
* no group reads back exactly (`raw`). Nothing is lost: every class is in one or the other.
|
|
101
|
+
*/
|
|
102
|
+
/** @param {string | null | undefined} classes @returns {{ props: Props, raw: string[] }} */
|
|
103
|
+
export function readClasses(classes) {
|
|
104
|
+
/** @type {Props} */
|
|
105
|
+
const props = {};
|
|
106
|
+
/** @type {string[]} */
|
|
107
|
+
const raw = [];
|
|
108
|
+
for (const tok of String(classes || "").split(/\s+/).filter(Boolean)) {
|
|
109
|
+
const { variant, base } = parseToken(tok);
|
|
110
|
+
let placed = false;
|
|
111
|
+
for (const key of GROUP_KEYS) {
|
|
112
|
+
const m = base.match(GROUPS[key].re);
|
|
113
|
+
if (!m) continue;
|
|
114
|
+
const value = GROUPS[key].read(m);
|
|
115
|
+
const at = atOf(variant);
|
|
116
|
+
// only when writing it back gives the same class, and the variant doesn't already have one from this group
|
|
117
|
+
if (GROUPS[key].build(value) === base && !(props[at] && key in props[at])) {
|
|
118
|
+
(props[at] = props[at] || {})[key] = value;
|
|
119
|
+
placed = true;
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
if (!placed) raw.push(tok);
|
|
124
|
+
}
|
|
125
|
+
return { props, raw };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The class list for properties and raw classes: the reverse of readClasses. */
|
|
129
|
+
/** @param {Props} [props] @param {string[]} [raw] @returns {string} */
|
|
130
|
+
export function writeClasses(props = {}, raw = []) {
|
|
131
|
+
const out = [];
|
|
132
|
+
const ats = Object.keys(props).sort((a, b) => (a === "base" ? -1 : b === "base" ? 1 : 0));
|
|
133
|
+
for (const at of ats) {
|
|
134
|
+
const variant = at === "base" ? "" : `${at}:`;
|
|
135
|
+
for (const [key, value] of Object.entries(props[at] || {})) {
|
|
136
|
+
if (!GROUPS[key]) throw new Error(`Unknown property "${key}"`);
|
|
137
|
+
const built = GROUPS[key].build(value);
|
|
138
|
+
if (!GROUPS[key].re.test(built)) throw new Error(`"${value}" is not a valid ${key}`);
|
|
139
|
+
out.push(variant + built);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return [...out, ...raw].join(" ");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Roles: what an element is for, by simple rules ───────────────────────────
|
|
146
|
+
const hasAny = (props, keys) => Object.values(props).some((p) => keys.some((k) => k in p));
|
|
147
|
+
function roleOf(tag, attrs, props) {
|
|
148
|
+
if (/^h[1-6]$/.test(tag)) return "title";
|
|
149
|
+
if (tag === "svg") return "icon";
|
|
150
|
+
if (tag === "img" || tag === "picture" || tag === "video") return "media";
|
|
151
|
+
if (tag === "button" || (tag === "input" && /^(submit|button|reset)$/i.test(attrs.type || ""))) return "button";
|
|
152
|
+
if (tag === "a") return hasAny(props, ["bgColor", "borderWidth"]) && hasAny(props, ["px", "py", "p"]) ? "button" : "link";
|
|
153
|
+
if (tag === "input" || tag === "select" || tag === "textarea") return "field";
|
|
154
|
+
if (tag === "p" || tag === "blockquote") return "text";
|
|
155
|
+
if (tag === "ul" || tag === "ol") return "list";
|
|
156
|
+
if (tag === "li") return "item";
|
|
157
|
+
if (["nav", "header", "footer", "main", "form", "section", "article", "aside", "label", "table"].includes(tag)) return tag;
|
|
158
|
+
if (tag === "div") {
|
|
159
|
+
if (hasAny(props, ["radius", "borderWidth", "shadow"]) && hasAny(props, ["p", "px", "py"])) return "card";
|
|
160
|
+
if (props.base && props.base.mx === "auto" && "maxW" in props.base) return "container";
|
|
161
|
+
if (hasAny(props, ["display"]) && Object.values(props).some((p) => /flex|grid/.test(p.display || ""))) return "layout";
|
|
162
|
+
}
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── HTML -> spec ─────────────────────────────────────────────────────────────
|
|
167
|
+
const squash = (s) => s.replace(/\s+/g, " ");
|
|
168
|
+
|
|
169
|
+
function describe(el, id, nextId, opts) {
|
|
170
|
+
const tag = tagOf(el);
|
|
171
|
+
const attrs = attrsOf(el);
|
|
172
|
+
const { props, raw } = readClasses(attrs.class);
|
|
173
|
+
const node = { id, tag };
|
|
174
|
+
const role = roleOf(tag, attrs, props);
|
|
175
|
+
if (role) node.role = role;
|
|
176
|
+
delete attrs.class;
|
|
177
|
+
if (attrs.style !== undefined) { node.css = attrs.style; delete attrs.style; }
|
|
178
|
+
if (Object.keys(attrs).length) node.attrs = attrs;
|
|
179
|
+
if (Object.keys(props).length) node.props = props;
|
|
180
|
+
if (raw.length) node.raw = raw;
|
|
181
|
+
if (tag === "svg") {
|
|
182
|
+
// an icon's drawing: in full, or (for reading only) just its size, since nothing targets what's inside it
|
|
183
|
+
if (opts.svg === false) node.svgChars = innerOf(el).length;
|
|
184
|
+
else node.svg = innerOf(el);
|
|
185
|
+
return node;
|
|
186
|
+
}
|
|
187
|
+
if (VOID.has(tag)) return node;
|
|
188
|
+
|
|
189
|
+
const kids = kidsOf(el).filter((k) => k.nodeType === ELEMENT || k.nodeType === TEXT);
|
|
190
|
+
const exact = EXACT.has(tag);
|
|
191
|
+
if (kids.every((k) => k.nodeType === TEXT)) {
|
|
192
|
+
const t = kids.map(textOf).join("");
|
|
193
|
+
const shown = exact ? t : squash(t).trim();
|
|
194
|
+
if (shown) node.text = shown;
|
|
195
|
+
return node;
|
|
196
|
+
}
|
|
197
|
+
const children = [];
|
|
198
|
+
kids.forEach((k, i) => {
|
|
199
|
+
if (k.nodeType === ELEMENT) {
|
|
200
|
+
if (!SKIP.has(tagOf(k))) children.push(describe(k, nextId(), nextId, opts));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const t = exact ? textOf(k) : squash(textOf(k));
|
|
204
|
+
if (t.trim()) children.push({ text: t }); // its edge spaces stay: "Hello <b>you</b>" needs the one before <b>
|
|
205
|
+
else {
|
|
206
|
+
// a space between two inline elements shows on the page ("<b>Hello</b> <i>world</i>"): keep it
|
|
207
|
+
const prev = kids[i - 1], next = kids[i + 1];
|
|
208
|
+
if (prev && next && prev.nodeType === ELEMENT && next.nodeType === ELEMENT && INLINE.has(tagOf(prev)) && INLINE.has(tagOf(next))) children.push({ text: " " });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
if (children.length) node.children = children;
|
|
212
|
+
return node;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The design tokens it uses: the values that recur, most used first. */
|
|
216
|
+
function tokensOf(tree) {
|
|
217
|
+
const buckets = { colors: new Map(), fontSizes: new Map(), radius: new Map(), spacing: new Map(), shadows: new Map() };
|
|
218
|
+
const add = (b, v) => buckets[b].set(v, (buckets[b].get(v) || 0) + 1);
|
|
219
|
+
const visit = (n) => {
|
|
220
|
+
for (const p of Object.values(n.props || {})) {
|
|
221
|
+
for (const [k, v] of Object.entries(p)) {
|
|
222
|
+
if (/Color$|^grad(From|Via|To)$|^fill$|^stroke$/.test(k)) add("colors", v);
|
|
223
|
+
else if (k === "fontSize") add("fontSizes", v);
|
|
224
|
+
else if (/^radius/.test(k)) add("radius", v);
|
|
225
|
+
else if (/^(p|px|py|pt|pr|pb|pl|m|mx|my|mt|mr|mb|ml|gap|gapX|gapY|spaceX|spaceY)$/.test(k)) add("spacing", v);
|
|
226
|
+
else if (k === "shadow") add("shadows", v);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
(n.children || []).forEach(visit);
|
|
230
|
+
};
|
|
231
|
+
tree.forEach(visit);
|
|
232
|
+
/** @type {Record<string, string[]>} */
|
|
233
|
+
const out = {};
|
|
234
|
+
for (const [k, m] of Object.entries(buckets)) {
|
|
235
|
+
const top = [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12).map(([v]) => v);
|
|
236
|
+
if (top.length) out[k] = top;
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** <style>, inline <script>, and the stylesheets and scripts it loads, as written. */
|
|
242
|
+
function assetsOf(root) {
|
|
243
|
+
const all = (sel) => Array.from(root.querySelectorAll(sel));
|
|
244
|
+
const css = all("style").map(rawOf).map((s) => s.trim()).filter(Boolean).join("\n\n");
|
|
245
|
+
const js = all("script").filter((s) => !attrsOf(s).src).map(rawOf).map((s) => s.trim()).filter(Boolean).join("\n\n");
|
|
246
|
+
const stylesheets = all("link[href]").filter((l) => /stylesheet/i.test(attrsOf(l).rel || "")).map((l) => attrsOf(l).href);
|
|
247
|
+
const scripts = all("script[src]").map((s) => attrsOf(s).src);
|
|
248
|
+
/** @type {Assets} */
|
|
249
|
+
const out = {};
|
|
250
|
+
if (css) out.css = css;
|
|
251
|
+
if (js) out.js = js;
|
|
252
|
+
if (stylesheets.length) out.stylesheets = stylesheets;
|
|
253
|
+
if (scripts.length) out.scripts = scripts;
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** In the browser: HTML text read by the browser's own parser, ready for toSpec. (The server uses node-html-parser.) */
|
|
258
|
+
export const readHtmlInBrowser = (html) => new DOMParser().parseFromString(String(html || ""), "text/html");
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* A design (already read into a tree) as a spec: { tokens, assets, tree }. With { svg: false }, icons carry only their
|
|
262
|
+
* size (svgChars) instead of their drawing: smaller to read, but toHtml can't rebuild the icons from it.
|
|
263
|
+
*/
|
|
264
|
+
/** @param {any} root a read tree @param {{ svg?: boolean }} [opts] @returns {Spec} */
|
|
265
|
+
export function toSpec(root, opts = {}) {
|
|
266
|
+
let count = 0;
|
|
267
|
+
const nextId = () => `n${++count}`;
|
|
268
|
+
const tree = contentRoots(root).filter((n) => n.nodeType === ELEMENT && !SKIP.has(tagOf(n))).map((n) => describe(n, nextId(), nextId, opts));
|
|
269
|
+
return { tokens: tokensOf(tree), assets: assetsOf(root), tree };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── spec -> HTML ─────────────────────────────────────────────────────────────
|
|
273
|
+
const escText = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
274
|
+
const escAttr = (s) => String(s).replace(/&/g, "&").replace(/"/g, """);
|
|
275
|
+
const TAG_RE = /^[a-z][a-z0-9-]*$/;
|
|
276
|
+
|
|
277
|
+
function nodeHtml(n) {
|
|
278
|
+
if (n.text !== undefined && !n.tag) return escText(n.text);
|
|
279
|
+
if (!TAG_RE.test(n.tag || "")) throw new Error(`Invalid tag "${n.tag}"`);
|
|
280
|
+
const cls = writeClasses(n.props, n.raw);
|
|
281
|
+
const attrs = { ...(n.attrs || {}) };
|
|
282
|
+
if (cls) attrs.class = cls;
|
|
283
|
+
if (n.css) attrs.style = n.css;
|
|
284
|
+
const open = `<${n.tag}${Object.entries(attrs).map(([k, v]) => (v === "" ? ` ${k}` : ` ${k}="${escAttr(v)}"`)).join("")}>`;
|
|
285
|
+
if (VOID.has(n.tag)) return open;
|
|
286
|
+
const inner = n.tag === "svg" ? n.svg || "" : n.text !== undefined ? escText(n.text) : (n.children || []).map(nodeHtml).join("");
|
|
287
|
+
return `${open}${inner}</${n.tag}>`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** A spec (or a list of spec nodes) as HTML, with its <style> and <script> when it has them. */
|
|
291
|
+
/** @param {Spec | SpecNode[]} spec @returns {string} */
|
|
292
|
+
export function toHtml(spec) {
|
|
293
|
+
const tree = Array.isArray(spec) ? spec : spec.tree || [];
|
|
294
|
+
const body = tree.map(nodeHtml).join("\n");
|
|
295
|
+
const a = Array.isArray(spec) ? {} : spec.assets || {};
|
|
296
|
+
return [
|
|
297
|
+
...(a.stylesheets || []).map((h) => `<link rel="stylesheet" href="${escAttr(h)}">`),
|
|
298
|
+
a.css ? `<style>\n${a.css}\n</style>` : "",
|
|
299
|
+
body,
|
|
300
|
+
...(a.scripts || []).map((s) => `<script src="${escAttr(s)}"></script>`),
|
|
301
|
+
a.js ? `<script>\n${a.js}\n</script>` : "",
|
|
302
|
+
].filter(Boolean).join("\n");
|
|
303
|
+
}
|