windcraft-editor 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/README.md +53 -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/editor/design/agent.js +2118 -0
- package/editor/design/classes-section.js +103 -0
- package/editor/design/code-editor.js +293 -0
- package/editor/design/code-view.js +158 -0
- package/editor/design/color-picker.js +107 -0
- package/editor/design/config.js +5 -0
- package/editor/design/controls.js +132 -0
- package/editor/design/css-props.js +111 -0
- package/editor/design/design-tab.js +120 -0
- package/editor/design/device-frame.js +40 -0
- package/editor/design/editor/add-menu.js +151 -0
- package/editor/design/editor/api.js +47 -0
- package/editor/design/editor/base.js +55 -0
- package/editor/design/editor/code-panel.js +206 -0
- package/editor/design/editor/color-ui.js +135 -0
- package/editor/design/editor/designs.js +606 -0
- package/editor/design/editor/docs.js +121 -0
- package/editor/design/editor/frame-handles.js +223 -0
- package/editor/design/editor/frame-messages.js +195 -0
- package/editor/design/editor/inspector.js +461 -0
- package/editor/design/editor/layers.js +61 -0
- package/editor/design/editor/pages-panel.js +161 -0
- package/editor/design/editor/panel-resize.js +47 -0
- package/editor/design/editor/plane.js +132 -0
- package/editor/design/editor/present.js +52 -0
- package/editor/design/editor/toolbar.js +267 -0
- package/editor/design/editor.js +47 -0
- package/editor/design/figma-sections.js +440 -0
- package/editor/design/float-toolbar.js +163 -0
- package/editor/design/group-controls.js +52 -0
- package/editor/design/host.js +22 -0
- package/editor/design/panel-model.js +130 -0
- package/editor/design/panel-state.js +40 -0
- package/editor/design/panel.js +212 -0
- package/editor/design/presenter.js +312 -0
- package/editor/design/prop-sections.js +18 -0
- package/editor/design/selection-overlay.js +93 -0
- package/editor/design/store.js +29 -0
- package/editor/design/style-sections.js +315 -0
- package/editor/design/tailwind-classes.js +65 -0
- package/editor/embed-frame.js +43 -0
- package/editor/embed.html +15 -0
- package/editor/embed.js +60 -0
- package/editor/index.js +62 -0
- package/editor/playground/component-source.js +124 -0
- package/editor/styles.css +402 -0
- package/editor/template.js +247 -0
- package/editor/vendor/codemirror.js +24 -0
- package/editor/vendor/preact-htm.js +1 -0
- package/editor/vendor/prettier.js +171 -0
- package/package.json +39 -5
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// The Properties panel works in CSS properties (width, background-color...). When an edit goes to Tailwind, this turns the
|
|
2
|
+
// property and the typed value into a class group of class-groups.js and the value to build it with. Anything without a
|
|
3
|
+
// clean Tailwind form (a transform, a filter, a font stack) returns null, and the edit is written as CSS instead.
|
|
4
|
+
import { GROUPS, parseToken, STEPS, getGroup } from "./class-groups.js";
|
|
5
|
+
|
|
6
|
+
const same = (prefix) => (v) => `${prefix}${v}`;
|
|
7
|
+
const pick = (map) => (v) => map[v] || null;
|
|
8
|
+
|
|
9
|
+
// properties whose values are words: CSS value -> the whole Tailwind class
|
|
10
|
+
const WORDS = {
|
|
11
|
+
display: ["display", pick({ block: "block", "inline-block": "inline-block", inline: "inline", flex: "flex", "inline-flex": "inline-flex", grid: "grid", "inline-grid": "inline-grid", contents: "contents", none: "hidden", table: "table", "flow-root": "flow-root" })],
|
|
12
|
+
position: ["position", pick({ static: "static", relative: "relative", absolute: "absolute", fixed: "fixed", sticky: "sticky" })],
|
|
13
|
+
overflow: ["overflow", same("overflow-")],
|
|
14
|
+
"flex-direction": ["flexDir", pick({ row: "flex-row", column: "flex-col", "row-reverse": "flex-row-reverse", "column-reverse": "flex-col-reverse" })],
|
|
15
|
+
"flex-wrap": ["flexWrap", same("flex-")],
|
|
16
|
+
"justify-content": ["justify", pick({ "flex-start": "justify-start", start: "justify-start", center: "justify-center", "flex-end": "justify-end", end: "justify-end", "space-between": "justify-between", "space-around": "justify-around", "space-evenly": "justify-evenly", stretch: "justify-stretch", normal: "justify-normal" })],
|
|
17
|
+
"align-items": ["items", pick({ "flex-start": "items-start", start: "items-start", center: "items-center", "flex-end": "items-end", end: "items-end", baseline: "items-baseline", stretch: "items-stretch" })],
|
|
18
|
+
"align-self": ["self", pick({ auto: "self-auto", "flex-start": "self-start", start: "self-start", center: "self-center", "flex-end": "self-end", end: "self-end", stretch: "self-stretch", baseline: "self-baseline" })],
|
|
19
|
+
"text-align": ["textAlign", same("text-")],
|
|
20
|
+
"flex-grow": ["grow", pick({ 0: "grow-0", 1: "grow" })],
|
|
21
|
+
"text-decoration-line": ["textDecoration", pick({ none: "no-underline", underline: "underline", "line-through": "line-through", overline: "overline" })],
|
|
22
|
+
"text-transform": ["textTransform", pick({ none: "normal-case", uppercase: "uppercase", lowercase: "lowercase", capitalize: "capitalize" })],
|
|
23
|
+
"font-style": ["fontStyle", pick({ italic: "italic", normal: "not-italic" })],
|
|
24
|
+
"white-space": ["whitespace", same("whitespace-")],
|
|
25
|
+
"font-weight": ["fontWeight", (v) => ({ 100: "font-thin", 200: "font-extralight", 300: "font-light", 400: "font-normal", 500: "font-medium", 600: "font-semibold", 700: "font-bold", 800: "font-extrabold", 900: "font-black" })[v] || (/^font-/.test(v) ? v : `font-${v}`)],
|
|
26
|
+
"font-family": ["fontFamily", pick({ "font-sans": "font-sans", sans: "font-sans", "sans-serif": "font-sans", serif: "font-serif", "font-serif": "font-serif", mono: "font-mono", monospace: "font-mono", "font-mono": "font-mono" })],
|
|
27
|
+
"background-size": ["bgSize", same("bg-")],
|
|
28
|
+
"background-repeat": ["bgRepeat", same("bg-")],
|
|
29
|
+
"border-top-style": ["borderStyle", same("border-")],
|
|
30
|
+
cursor: ["cursor", same("cursor-")],
|
|
31
|
+
"mix-blend-mode": ["mixBlend", same("mix-blend-")],
|
|
32
|
+
"pointer-events": ["pointerEvents", same("pointer-events-")],
|
|
33
|
+
"object-fit": ["objectFit", same("object-")],
|
|
34
|
+
visibility: ["visibility", pick({ visible: "visible", hidden: "invisible" })],
|
|
35
|
+
"user-select": ["userSelect", same("select-")],
|
|
36
|
+
"box-shadow": ["shadow", (v) => (/^(sm|md|lg|xl|2xl|inner|none)$/.test(v) ? v : v === "default" || v === "shadow" ? "default" : null)], // the shadow group takes the size, not the class
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// properties whose values are sizes: the group, and whether a negative value is allowed
|
|
40
|
+
const LENGTHS = {
|
|
41
|
+
width: "w", height: "h", "min-width": "minW", "max-width": "maxW", "min-height": "minH", "max-height": "maxH",
|
|
42
|
+
gap: "gap", "column-gap": "gapX", "row-gap": "gapY", top: "top", right: "right", bottom: "bottom", left: "left",
|
|
43
|
+
"padding-top": "pt", "padding-right": "pr", "padding-bottom": "pb", "padding-left": "pl",
|
|
44
|
+
"margin-top": "mt", "margin-right": "mr", "margin-bottom": "mb", "margin-left": "ml",
|
|
45
|
+
"font-size": "fontSize", "line-height": "leading", "letter-spacing": "tracking", "border-radius": "radius", "z-index": "z",
|
|
46
|
+
};
|
|
47
|
+
const COLORS = { color: "textColor", "background-color": "bgColor", "border-top-color": "borderColor" };
|
|
48
|
+
const LENGTH_WORDS = /^(auto|full|screen|min|max|fit|px|none|xs|sm|md|lg|xl|[2-9]xl|base|prose|tight|snug|normal|relaxed|loose|tighter|wide|wider|widest|\d+\/\d+)$/;
|
|
49
|
+
|
|
50
|
+
// groups on Tailwind's spacing scale: an exact pixel size becomes the scale step (16px -> p-4), anything else an arbitrary value
|
|
51
|
+
const SCALED = new Set(["w", "h", "minW", "maxW", "minH", "maxH", "gap", "gapX", "gapY", "inset", "top", "right", "bottom", "left", "p", "px", "py", "pt", "pr", "pb", "pl", "m", "mx", "my", "mt", "mr", "mb", "ml", "spaceX", "spaceY", "translateX", "translateY"]);
|
|
52
|
+
// sizes with a name, where the name means the same in Tailwind v3 and v4 (v3's rounded-sm is 2px, v4's 4px: left out)
|
|
53
|
+
const RADIUS_NAMES = { 6: "md", 8: "lg", 12: "xl", 16: "2xl", 24: "3xl" };
|
|
54
|
+
const FONT_NAMES = { 12: "xs", 14: "sm", 16: "base", 18: "lg", 20: "xl", 24: "2xl", 30: "3xl", 36: "4xl", 48: "5xl", 60: "6xl", 72: "7xl", 96: "8xl", 128: "9xl" };
|
|
55
|
+
/** A pixel size as the value of a group: 16 -> "4", 1 -> "px", -88 -> "-88px", 13 -> "13px". */
|
|
56
|
+
export function fromPx(key, n) {
|
|
57
|
+
const a = Math.abs(n), sign = n < 0 ? "-" : "";
|
|
58
|
+
if (SCALED.has(key)) return sign + (a === 1 ? "px" : STEPS.includes(String(a / 4)) ? String(a / 4) : `${a}px`);
|
|
59
|
+
if (key === "borderWidth") return [0, 1, 2, 4, 8].includes(a) ? String(a) : `${a}px`;
|
|
60
|
+
if (/^border[TRBL]$/.test(key)) return [0, 2, 4, 8].includes(a) ? String(a) : `${a}px`;
|
|
61
|
+
if (/^radius/.test(key)) return a === 0 ? "none" : a >= 9999 ? "full" : RADIUS_NAMES[a] || `${a}px`;
|
|
62
|
+
if (key === "fontSize") return FONT_NAMES[a] || `${a}px`;
|
|
63
|
+
return `${sign}${a}px`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Whether a bare number is a step of the group's own scale (w-64, leading-6) rather than pixels (font size has no number scale). */
|
|
67
|
+
export const hasScale = (key) => SCALED.has(key) || key === "leading" || key === "z" || key === "opacity";
|
|
68
|
+
|
|
69
|
+
// the shorter classes that also set a property (px-8 sets padding-left, rounded-lg every corner), looked at after its own
|
|
70
|
+
const SHORTER = { pl: ["px", "p"], pr: ["px", "p"], pt: ["py", "p"], pb: ["py", "p"], ml: ["mx", "m"], mr: ["mx", "m"], mt: ["my", "m"], mb: ["my", "m"],
|
|
71
|
+
top: ["inset"], right: ["inset"], bottom: ["inset"], left: ["inset"], gapX: ["gap"], gapY: ["gap"],
|
|
72
|
+
radiusTL: ["radius"], radiusTR: ["radius"], radiusBR: ["radius"], radiusBL: ["radius"], borderT: ["borderWidth"], borderR: ["borderWidth"], borderB: ["borderWidth"], borderL: ["borderWidth"] };
|
|
73
|
+
// properties whose own group is a side or corner one (the general groupOf writes them with the all-sides class)
|
|
74
|
+
const SIDE_GROUP = { "column-gap": "gapX", "row-gap": "gapY", "border-top-left-radius": "radiusTL", "border-top-right-radius": "radiusTR", "border-bottom-right-radius": "radiusBR",
|
|
75
|
+
"border-bottom-left-radius": "radiusBL", "border-top-width": "borderT", "border-right-width": "borderR", "border-bottom-width": "borderB", "border-left-width": "borderL" };
|
|
76
|
+
/** The class in `classes` that gives `prop` at `variant` (md:, hover:), also through a shorter one (px-8 for padding-left), or null. */
|
|
77
|
+
export function tokenFor(classes, prop, variant = "") {
|
|
78
|
+
const g = SIDE_GROUP[prop] || groupOf(prop);
|
|
79
|
+
if (!g) return null;
|
|
80
|
+
for (const k of [g, ...(SHORTER[g] || [])]) { const r = GROUPS[k] && getGroup(classes, k, variant); if (r) return { ...r, group: k }; }
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The class group a property is written with in Tailwind, or null when it has none. */
|
|
85
|
+
export function groupOf(prop) {
|
|
86
|
+
if (WORDS[prop]) return WORDS[prop][0];
|
|
87
|
+
if (LENGTHS[prop]) return LENGTHS[prop];
|
|
88
|
+
if (COLORS[prop]) return COLORS[prop];
|
|
89
|
+
if (prop === "border-top-width") return "borderWidth";
|
|
90
|
+
if (prop === "opacity") return "opacity";
|
|
91
|
+
if (prop === "rotate") return "rotate";
|
|
92
|
+
if (prop === "outline-offset") return "outlineOffset";
|
|
93
|
+
if (prop === "grid-template-columns") return "gridCols";
|
|
94
|
+
if (prop === "aspect-ratio") return "aspect";
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The value to hand setGroup() for this property, or null to write CSS instead.
|
|
100
|
+
* `scale`: on a row a Tailwind class already drives, a bare number is Tailwind's scale (w-64); elsewhere it means pixels.
|
|
101
|
+
*/
|
|
102
|
+
export function twValue(prop, raw, scale) {
|
|
103
|
+
const v = String(raw).trim();
|
|
104
|
+
if (!v) return null;
|
|
105
|
+
if (prop === "outline-offset") { // Tailwind's steps are classes (outline-offset-2), any other size an arbitrary value
|
|
106
|
+
const m = /^(\d*\.?\d+)(px)?$/.exec(v);
|
|
107
|
+
return !m ? null : [0, 1, 2, 4, 8].includes(Number(m[1])) ? String(Number(m[1])) : `[${Number(m[1])}px]`;
|
|
108
|
+
}
|
|
109
|
+
if (prop === "rotate") { // degrees: Tailwind's own steps are classes (rotate-45), any other angle an arbitrary value (rotate-[15deg])
|
|
110
|
+
const m = /^(-?\d*\.?\d+)(deg)?$/.exec(v);
|
|
111
|
+
return !m ? null : [0, 1, 2, 3, 6, 12, 45, 90, 180].includes(Math.abs(Number(m[1]))) ? String(Number(m[1])) : `${Number(m[1])}deg`;
|
|
112
|
+
}
|
|
113
|
+
if (WORDS[prop]) return WORDS[prop][1](v);
|
|
114
|
+
// z-index: only 0-50 in steps of 10 are classes in every Tailwind version; any other whole number is an arbitrary value (z-[9999], -z-[1])
|
|
115
|
+
if (prop === "z-index" && /^-?\d+$/.test(v)) return /^(-?)(0|10|20|30|40|50)$/.test(v) ? v : `${v.startsWith("-") ? "-" : ""}[${Math.abs(Number(v))}]`;
|
|
116
|
+
if (LENGTHS[prop]) {
|
|
117
|
+
if (LENGTH_WORDS.test(v) || /^\[.*\]$/.test(v)) return v;
|
|
118
|
+
if (/^-?\d*\.?\d+$/.test(v)) return (scale && hasScale(LENGTHS[prop])) || prop === "z-index" ? v : fromPx(LENGTHS[prop], Number(v));
|
|
119
|
+
if (/^-?\d*\.?\d+px$/.test(v)) return fromPx(LENGTHS[prop], parseFloat(v));
|
|
120
|
+
if (/^-?\d*\.?\d+(px|rem|em|%|vh|vw|ch)$/.test(v) || /^(calc|var|min|max|clamp)\(/.test(v)) return v; // the group wraps it as [value]
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
if (COLORS[prop]) {
|
|
124
|
+
if (/^(#|rgba?\(|hsla?\()/.test(v)) return v;
|
|
125
|
+
if (/^(black|white|transparent|current|inherit)$/.test(v) || /^[a-z]+-\d{2,3}(\/\d+)?$/.test(v)) return v;
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
if (prop === "border-top-width") { const n = v.replace(/px$/, ""); return /^(0|1|2|4|8)$/.test(n) ? n : /^\d+(\.\d+)?(px)?$/.test(v) ? `${n}px` : null; }
|
|
129
|
+
if (prop === "opacity") { // Tailwind's own steps are classes (opacity-50), anything else an arbitrary fraction (opacity-[0.33])
|
|
130
|
+
const n = Number(v);
|
|
131
|
+
if (!Number.isFinite(n)) return null;
|
|
132
|
+
const pct = n <= 1 ? Math.round(n * 100) : Math.round(n);
|
|
133
|
+
return [0, 5, 10, 20, 25, 30, 40, 50, 60, 70, 75, 80, 90, 95, 100].includes(pct) ? String(pct) : `[${pct / 100}]`;
|
|
134
|
+
} // the panel sends a fraction (0.9), a typed class number is above 1
|
|
135
|
+
if (prop === "grid-template-columns") return /^\d+$/.test(v) || v === "none" ? v : null;
|
|
136
|
+
if (prop === "aspect-ratio") {
|
|
137
|
+
// 16 / 9 -> video, 1 / 1 -> square, any other two whole numbers -> [w/h]
|
|
138
|
+
const m = v.match(/^(\d+)\s*\/\s*(\d+)$/) || (/^\d+$/.test(v) ? [v, v, "1"] : null);
|
|
139
|
+
if (v === "auto") return "auto";
|
|
140
|
+
if (!m) return null;
|
|
141
|
+
const [w, h] = [Number(m[1]), Number(m[2])];
|
|
142
|
+
return w === h ? "square" : w * 9 === h * 16 ? "video" : `[${w}/${h}]`;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** What a Tailwind-driven row shows: the class's own value (64, 1/2, red-500, 500px), not the computed pixels. */
|
|
148
|
+
export function twShown(prop, token) {
|
|
149
|
+
const key = groupOf(prop);
|
|
150
|
+
if (!key || !token) return null;
|
|
151
|
+
const m = parseToken(token).base.match(GROUPS[key].re);
|
|
152
|
+
if (!m) return null;
|
|
153
|
+
const v = GROUPS[key].read(m);
|
|
154
|
+
return String(v).replace(/^\[(.*)\]$/, "$1").replace(/_/g, " ");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** "md:hover:w-64" -> "md:hover:" */
|
|
158
|
+
export const variantOf = (token) => (token ? parseToken(token).variant : "");
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Pure string logic (no DOM) so the same code runs on the server and in the browser.
|
|
2
|
+
const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]);
|
|
3
|
+
const RAW = new Set(["script", "style"]);
|
|
4
|
+
const SKIP_TOP = new Set(["script", "style", "link", "meta", "title", "noscript", "template"]);
|
|
5
|
+
const TAG_RE = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][\w:-]*)((?:"[^"]*"|'[^']*'|[^'">])*)>/g;
|
|
6
|
+
|
|
7
|
+
const stripTags = (s) => s.replace(/<[^>]*>/g, " ").replace(/ /g, " ").replace(/\s+/g, " ").trim();
|
|
8
|
+
|
|
9
|
+
/** Top-level elements of a markup string, as { tag, html, start, end }. */
|
|
10
|
+
export function topLevelElements(markup) {
|
|
11
|
+
const out = [];
|
|
12
|
+
let depth = 0;
|
|
13
|
+
let current = null;
|
|
14
|
+
TAG_RE.lastIndex = 0;
|
|
15
|
+
let m;
|
|
16
|
+
while ((m = TAG_RE.exec(markup))) {
|
|
17
|
+
if (m[0].startsWith("<!--")) continue;
|
|
18
|
+
const closing = m[1] === "/";
|
|
19
|
+
const tag = m[2].toLowerCase();
|
|
20
|
+
const selfClosing = VOID.has(tag) || /\/\s*$/.test(m[3]);
|
|
21
|
+
|
|
22
|
+
if (!closing && RAW.has(tag)) {
|
|
23
|
+
const close = markup.toLowerCase().indexOf(`</${tag}`, TAG_RE.lastIndex);
|
|
24
|
+
const end = close === -1 ? markup.length : markup.indexOf(">", close) + 1;
|
|
25
|
+
if (depth === 0) out.push({ tag, html: markup.slice(m.index, end), start: m.index, end });
|
|
26
|
+
TAG_RE.lastIndex = end;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!closing) {
|
|
31
|
+
if (depth === 0) current = { tag, start: m.index };
|
|
32
|
+
if (selfClosing) {
|
|
33
|
+
if (depth === 0) out.push({ tag, html: m[0], start: m.index, end: TAG_RE.lastIndex });
|
|
34
|
+
} else depth++;
|
|
35
|
+
} else if (depth > 0) {
|
|
36
|
+
depth--;
|
|
37
|
+
if (depth === 0 && current) {
|
|
38
|
+
out.push({ tag: current.tag, html: markup.slice(current.start, TAG_RE.lastIndex), start: current.start, end: TAG_RE.lastIndex });
|
|
39
|
+
current = null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function innerHtml(el) {
|
|
47
|
+
const open = el.html.indexOf(">") + 1;
|
|
48
|
+
const close = el.html.toLowerCase().lastIndexOf(`</${el.tag}`);
|
|
49
|
+
return close > open ? el.html.slice(open, close) : "";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Splits a page body into its main sections, or [] when it should stay one piece. */
|
|
53
|
+
const SEMANTIC = new Set(["section", "header", "footer", "nav", "article"]);
|
|
54
|
+
const SIDE_BY_SIDE = /\bclass\s*=\s*["'][^"']*\b(?:flex|inline-flex|grid)\b/;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Splits a page body into its main sections, or [] when it should stay one piece.
|
|
58
|
+
* Only vertically stacked, semantic sections qualify. App shells (a sidebar next to a main area,
|
|
59
|
+
* flex or grid wrappers) and plain div soup are left whole.
|
|
60
|
+
*/
|
|
61
|
+
export function splitSections(markup, maxSections = 20) {
|
|
62
|
+
let els = topLevelElements(markup).filter((e) => !SKIP_TOP.has(e.tag));
|
|
63
|
+
for (let i = 0; i < 3 && els.length === 1; i++) {
|
|
64
|
+
const opening = els[0].html.slice(0, els[0].html.indexOf(">") + 1);
|
|
65
|
+
if (SIDE_BY_SIDE.test(opening) && !/\bflex-col\b/.test(opening)) return [];
|
|
66
|
+
const kids = topLevelElements(innerHtml(els[0])).filter((e) => !SKIP_TOP.has(e.tag));
|
|
67
|
+
if (!kids.length) break;
|
|
68
|
+
els = kids;
|
|
69
|
+
}
|
|
70
|
+
if (els.length < 2) return [];
|
|
71
|
+
if (els.some((e) => e.tag === "aside")) return [];
|
|
72
|
+
if (els.filter((e) => SEMANTIC.has(e.tag)).length < 2) return [];
|
|
73
|
+
return els.slice(0, maxSections).map((e, i) => {
|
|
74
|
+
const heading = stripTags((e.html.match(/<h[1-6]\b[^>]*>([\s\S]*?)<\/h[1-6]>/i) || [])[1] || "");
|
|
75
|
+
const aria = (e.html.match(/^<[^>]*\baria-label\s*=\s*"([^"]+)"/i) || [])[1];
|
|
76
|
+
const label = { header: "Header", nav: "Navigation", footer: "Footer", main: "Main", section: `Section ${i + 1}`, article: `Article ${i + 1}`, div: `Block ${i + 1}` }[e.tag] || `Block ${i + 1}`;
|
|
77
|
+
return { name: (heading || aria || label).slice(0, 60), kind: e.tag, html: e.html };
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Separates a full document into title, assets (styles/links/scripts) and body markup. */
|
|
82
|
+
export function parseDocument(html = "") {
|
|
83
|
+
const title = stripTags((html.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1] || "");
|
|
84
|
+
const description = (html.match(/<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)["']/i) || [])[1] || "";
|
|
85
|
+
const styles = [...html.matchAll(/<style\b[^>]*>[\s\S]*?<\/style>/gi)].map((m) => m[0]);
|
|
86
|
+
const links = [...html.matchAll(/<link\b[^>]*rel\s*=\s*["']stylesheet["'][^>]*>/gi)].map((m) => m[0]);
|
|
87
|
+
const scripts = [...html.matchAll(/<script\b[^>]*>[\s\S]*?<\/script>/gi)].map((m) => m[0]);
|
|
88
|
+
|
|
89
|
+
const bodyMatch = html.match(/<body\b[^>]*>([\s\S]*?)<\/body>/i);
|
|
90
|
+
let body = bodyMatch ? bodyMatch[1] : html.replace(/<head\b[\s\S]*?<\/head>/i, "").replace(/<\/?(?:html|body)\b[^>]*>/gi, "").replace(/<!doctype[^>]*>/i, "");
|
|
91
|
+
body = body
|
|
92
|
+
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "")
|
|
93
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
|
|
94
|
+
.replace(/<link\b[^>]*>/gi, "")
|
|
95
|
+
.trim();
|
|
96
|
+
|
|
97
|
+
const heading = stripTags((body.match(/<h[1-2]\b[^>]*>([\s\S]*?)<\/h[1-2]>/i) || [])[1] || "");
|
|
98
|
+
return { title, description, heading, styles, links, scripts, body };
|
|
99
|
+
}
|
package/core/theme.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// A project's theme: a short list of choices that every part (the canvas, the previews, the catalog, the exports) reads.
|
|
2
|
+
// brand one colour (hex); the 11 steps of its scale (brand-50 … brand-950) are made from it, with the colour itself as brand-600
|
|
3
|
+
// gray which of Tailwind's neutral palettes `gray-*` means in this project (slate, gray, zinc, neutral, stone)
|
|
4
|
+
// font the sans font (a Google Fonts family, or "System")
|
|
5
|
+
// radius how round corners are (none, sm, md, lg, xl): the rounded-* sizes follow it
|
|
6
|
+
// tailwindConfig() is the object for `tailwind.config` (the play CDN, or a project's own tailwind.config.js); themeHead() is the HTML
|
|
7
|
+
// a preview document needs for it (the config, the font link). Pure functions: nothing here touches a page or a database.
|
|
8
|
+
|
|
9
|
+
export const GRAYS = ["slate", "gray", "zinc", "neutral", "stone"];
|
|
10
|
+
export const RADII = ["none", "sm", "md", "lg", "xl"];
|
|
11
|
+
export const SYSTEM_FONT = "System";
|
|
12
|
+
export const DEFAULT_THEME = Object.freeze({ brand: "#4f46e5", gray: "slate", font: "Inter", radius: "md" });
|
|
13
|
+
|
|
14
|
+
const STEPS = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"];
|
|
15
|
+
const PALETTES = {
|
|
16
|
+
slate: ["f8fafc", "f1f5f9", "e2e8f0", "cbd5e1", "94a3b8", "64748b", "475569", "334155", "1e293b", "0f172a", "020617"],
|
|
17
|
+
gray: ["f9fafb", "f3f4f6", "e5e7eb", "d1d5db", "9ca3af", "6b7280", "4b5563", "374151", "1f2937", "111827", "030712"],
|
|
18
|
+
zinc: ["fafafa", "f4f4f5", "e4e4e7", "d4d4d8", "a1a1aa", "71717a", "52525b", "3f3f46", "27272a", "18181b", "09090b"],
|
|
19
|
+
neutral: ["fafafa", "f5f5f5", "e5e5e5", "d4d4d4", "a3a3a3", "737373", "525252", "404040", "262626", "171717", "0a0a0a"],
|
|
20
|
+
stone: ["fafaf9", "f5f5f4", "e7e5e4", "d6d3d1", "a8a29e", "78716c", "57534e", "44403c", "292524", "1c1917", "0c0a09"],
|
|
21
|
+
};
|
|
22
|
+
const RADIUS_PX = { none: 0, sm: 3, md: 6, lg: 10, xl: 16 };
|
|
23
|
+
const SYSTEM_STACK = ["ui-sans-serif", "system-ui", "-apple-system", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "sans-serif"];
|
|
24
|
+
|
|
25
|
+
const HEX = /^#?([0-9a-f]{6})$/i;
|
|
26
|
+
const FONT_NAME = /^[A-Za-z0-9][A-Za-z0-9 ]{0,39}$/;
|
|
27
|
+
|
|
28
|
+
/** A theme from anything: every field checked, anything invalid replaced by the default. */
|
|
29
|
+
export function normalizeTheme(input) {
|
|
30
|
+
const t = input && typeof input === "object" ? input : {};
|
|
31
|
+
const hex = HEX.exec(String(t.brand || ""));
|
|
32
|
+
return {
|
|
33
|
+
brand: hex ? `#${hex[1].toLowerCase()}` : DEFAULT_THEME.brand,
|
|
34
|
+
gray: GRAYS.includes(t.gray) ? t.gray : DEFAULT_THEME.gray,
|
|
35
|
+
font: typeof t.font === "string" && (t.font === SYSTEM_FONT || FONT_NAME.test(t.font.trim())) ? t.font.trim() : DEFAULT_THEME.font,
|
|
36
|
+
radius: RADII.includes(t.radius) ? t.radius : DEFAULT_THEME.radius,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const toRgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
|
41
|
+
const toHex = (rgb) => `#${rgb.map((v) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, "0")).join("")}`;
|
|
42
|
+
const mix = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);
|
|
43
|
+
|
|
44
|
+
/** The 11 steps of the brand colour: the colour is 600; lighter steps mix it with white, darker ones with black. */
|
|
45
|
+
export function brandScale(hex) {
|
|
46
|
+
const base = toRgb(normalizeTheme({ brand: hex }).brand), white = [255, 255, 255], black = [0, 0, 0];
|
|
47
|
+
const mixes = { 50: [white, 0.92], 100: [white, 0.84], 200: [white, 0.68], 300: [white, 0.5], 400: [white, 0.28], 500: [white, 0.12], 700: [black, 0.18], 800: [black, 0.36], 900: [black, 0.52], 950: [black, 0.68] };
|
|
48
|
+
return Object.fromEntries(STEPS.map((s) => [s, s === "600" ? toHex(base) : toHex(mix(base, ...mixes[s]))]));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One of Tailwind's neutral palettes as { 50: "#…", … 950: "#…" }. */
|
|
52
|
+
export const grayScale = (name) => Object.fromEntries(STEPS.map((s, i) => [s, `#${PALETTES[GRAYS.includes(name) ? name : DEFAULT_THEME.gray][i]}`]));
|
|
53
|
+
|
|
54
|
+
/** The rounded-* sizes for a radius choice, in px. */
|
|
55
|
+
export function radiusScale(radius) {
|
|
56
|
+
const r = RADIUS_PX[RADII.includes(radius) ? radius : DEFAULT_THEME.radius];
|
|
57
|
+
const px = (v) => `${Math.round(v * 100) / 100}px`;
|
|
58
|
+
return { none: "0px", sm: px(r * 0.5), DEFAULT: px(r * 0.66), md: px(r), lg: px(r * 1.5), xl: px(r * 2), "2xl": px(r * 2.67), "3xl": px(r * 4), full: "9999px" };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The `tailwind.config` object for a theme (dark mode follows a `dark` class, so a page can switch it). */
|
|
62
|
+
export function tailwindConfig(theme) {
|
|
63
|
+
const t = normalizeTheme(theme);
|
|
64
|
+
return {
|
|
65
|
+
darkMode: "class",
|
|
66
|
+
theme: {
|
|
67
|
+
extend: {
|
|
68
|
+
colors: { brand: brandScale(t.brand), gray: grayScale(t.gray) },
|
|
69
|
+
fontFamily: { sans: t.font === SYSTEM_FONT ? SYSTEM_STACK : [t.font, ...SYSTEM_STACK] },
|
|
70
|
+
borderRadius: radiusScale(t.radius),
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The Google Fonts stylesheet URL for the theme's font, or null for the system font. */
|
|
77
|
+
export const fontUrl = (theme) => {
|
|
78
|
+
const f = normalizeTheme(theme).font;
|
|
79
|
+
return f === SYSTEM_FONT ? null : `https://fonts.googleapis.com/css2?family=${encodeURIComponent(f).replace(/%20/g, "+")}:wght@400;500;600;700&display=swap`;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** What a preview document's <head> needs after the Tailwind script: the font and the config. */
|
|
83
|
+
export function themeHead(theme) {
|
|
84
|
+
const url = fontUrl(theme);
|
|
85
|
+
const config = JSON.stringify(tailwindConfig(theme)).replace(/</g, "\\u003c");
|
|
86
|
+
return `${url ? `<link rel="stylesheet" href="${url}">\n` : ""}<script data-wc-theme>tailwind.config = ${config};<\/script>`;
|
|
87
|
+
}
|