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.
Files changed (60) hide show
  1. package/README.md +49 -2
  2. package/core/board-layout.js +71 -0
  3. package/core/class-groups.js +210 -0
  4. package/core/design-spec.js +303 -0
  5. package/core/exporters.js +156 -0
  6. package/core/pages.js +39 -0
  7. package/core/prop-tailwind.js +158 -0
  8. package/core/split-sections.js +99 -0
  9. package/core/theme.js +87 -0
  10. package/design/agent.js +2118 -0
  11. package/design/classes-section.js +103 -0
  12. package/design/code-editor.js +293 -0
  13. package/design/code-view.js +158 -0
  14. package/design/color-picker.js +107 -0
  15. package/design/config.js +5 -0
  16. package/design/controls.js +132 -0
  17. package/design/css-props.js +111 -0
  18. package/design/design-tab.js +120 -0
  19. package/design/device-frame.js +40 -0
  20. package/design/editor/add-menu.js +151 -0
  21. package/design/editor/api.js +47 -0
  22. package/design/editor/base.js +55 -0
  23. package/design/editor/code-panel.js +206 -0
  24. package/design/editor/color-ui.js +135 -0
  25. package/design/editor/designs.js +606 -0
  26. package/design/editor/docs.js +121 -0
  27. package/design/editor/frame-handles.js +223 -0
  28. package/design/editor/frame-messages.js +195 -0
  29. package/design/editor/inspector.js +461 -0
  30. package/design/editor/layers.js +61 -0
  31. package/design/editor/pages-panel.js +161 -0
  32. package/design/editor/panel-resize.js +47 -0
  33. package/design/editor/plane.js +132 -0
  34. package/design/editor/present.js +52 -0
  35. package/design/editor/toolbar.js +267 -0
  36. package/design/editor.js +47 -0
  37. package/design/figma-sections.js +440 -0
  38. package/design/float-toolbar.js +163 -0
  39. package/design/group-controls.js +52 -0
  40. package/design/host.js +22 -0
  41. package/design/panel-model.js +130 -0
  42. package/design/panel-state.js +40 -0
  43. package/design/panel.js +212 -0
  44. package/design/presenter.js +312 -0
  45. package/design/prop-sections.js +18 -0
  46. package/design/selection-overlay.js +93 -0
  47. package/design/store.js +29 -0
  48. package/design/style-sections.js +315 -0
  49. package/design/tailwind-classes.js +65 -0
  50. package/embed-frame.js +43 -0
  51. package/embed.html +15 -0
  52. package/embed.js +60 -0
  53. package/index.js +62 -0
  54. package/package.json +50 -5
  55. package/playground/component-source.js +124 -0
  56. package/styles.css +402 -0
  57. package/template.js +247 -0
  58. package/vendor/codemirror.js +24 -0
  59. package/vendor/preact-htm.js +1 -0
  60. package/vendor/prettier.js +171 -0
@@ -0,0 +1,156 @@
1
+ import { parseDocument } from "./split-sections.js";
2
+
3
+ const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]);
4
+
5
+ const ATTR_MAP = {
6
+ class: "className", for: "htmlFor", tabindex: "tabIndex", readonly: "readOnly", maxlength: "maxLength",
7
+ minlength: "minLength", autocomplete: "autoComplete", autofocus: "autoFocus", colspan: "colSpan",
8
+ rowspan: "rowSpan", viewbox: "viewBox", "stroke-width": "strokeWidth", "stroke-linecap": "strokeLinecap",
9
+ "stroke-linejoin": "strokeLinejoin", "fill-rule": "fillRule", "clip-rule": "clipRule", "stroke-miterlimit": "strokeMiterlimit",
10
+ "stop-color": "stopColor", "stop-opacity": "stopOpacity", "fill-opacity": "fillOpacity", "stroke-opacity": "strokeOpacity",
11
+ "xlink:href": "xlinkHref", crossorigin: "crossOrigin", srcset: "srcSet", datetime: "dateTime", enctype: "encType",
12
+ contenteditable: "contentEditable", spellcheck: "spellCheck", "accept-charset": "acceptCharset",
13
+ };
14
+
15
+ const EVENT_RE = /^on[a-z]+$/;
16
+
17
+ /** Splits pasted source into markup, css and js, mirroring the browser-side parser. */
18
+ export function splitSource(code = "") {
19
+ const styles = [];
20
+ const scripts = [];
21
+ const markup = code
22
+ .replace(/<style\b[^>]*>([\s\S]*?)<\/style>/gi, (_, css) => (styles.push(css.trim()), ""))
23
+ .replace(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi, (_, attrs, js) => {
24
+ if (!/\bsrc\s*=/.test(attrs)) scripts.push(js.trim());
25
+ return "";
26
+ })
27
+ .trim();
28
+ return { markup, css: styles.join("\n\n"), js: scripts.join("\n\n") };
29
+ }
30
+
31
+ const kebabToCamel = (s) => (s.startsWith("--") ? s : s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()));
32
+
33
+ function styleToObject(style) {
34
+ const entries = style
35
+ .split(";")
36
+ .map((d) => d.trim())
37
+ .filter(Boolean)
38
+ .map((d) => {
39
+ const i = d.indexOf(":");
40
+ const key = kebabToCamel(d.slice(0, i).trim());
41
+ const val = d.slice(i + 1).trim().replace(/'/g, "\\'");
42
+ return `${/^[a-zA-Z_$][\w$]*$/.test(key) ? key : `'${key}'`}: '${val}'`;
43
+ });
44
+ return `{{ ${entries.join(", ")} }}`;
45
+ }
46
+
47
+ const escapeText = (t) => t.replace(/[{}]/g, (c) => `{'${c}'}`);
48
+
49
+ function tagToJsx(tag) {
50
+ const m = tag.match(/^<(\/?)([a-zA-Z][\w:-]*)([\s\S]*?)(\/?)>$/);
51
+ if (!m) return tag;
52
+ const [, closing, name, rest, selfClose] = m;
53
+ if (closing) return VOID_TAGS.has(name.toLowerCase()) ? "" : `</${name}>`;
54
+ const attrs = [];
55
+ const attrRe = /([^\s=/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
56
+ let a;
57
+ while ((a = attrRe.exec(rest))) {
58
+ const raw = a[1];
59
+ const value = a[2] ?? a[3] ?? a[4];
60
+ const lower = raw.toLowerCase();
61
+ if (EVENT_RE.test(lower)) continue;
62
+ const key = ATTR_MAP[lower] || (raw.startsWith("data-") || raw.startsWith("aria-") ? raw : raw);
63
+ if (value === undefined) attrs.push(key);
64
+ else if (lower === "style") attrs.push(`style=${styleToObject(value)}`);
65
+ else attrs.push(`${key}="${value.replace(/"/g, "&quot;")}"`);
66
+ }
67
+ const open = `<${name}${attrs.length ? " " + attrs.join(" ") : ""}`;
68
+ return VOID_TAGS.has(name.toLowerCase()) || selfClose ? `${open} />` : `${open}>`;
69
+ }
70
+
71
+ function htmlToJsx(markup) {
72
+ return markup
73
+ .split(/(<!--[\s\S]*?-->|<[^>]+>)/g)
74
+ .map((part) => {
75
+ if (!part) return "";
76
+ if (part.startsWith("<!--")) return `{/*${part.slice(4, -3)}*/}`;
77
+ if (part.startsWith("<")) return tagToJsx(part);
78
+ return escapeText(part);
79
+ })
80
+ .join("");
81
+ }
82
+
83
+ const indent = (s, n = 2) => s.split("\n").map((l) => (l ? " ".repeat(n) + l : l)).join("\n");
84
+
85
+ export function toReact(code, name = "Component") {
86
+ const { markup, css, js } = splitSource(code);
87
+ const jsx = htmlToJsx(markup) + (css ? `\n<style>{\`${css.replace(/`/g, "\\`")}\`}</style>` : "");
88
+ const imports = js ? "import { useEffect } from 'react';\n\n" : "";
89
+ const effect = js ? ` useEffect(() => {\n${indent(js, 4)}\n }, []);\n\n` : "";
90
+ return `${imports}export default function ${name}() {\n${effect} return (\n <>\n${indent(jsx, 6)}\n </>\n );\n}\n`;
91
+ }
92
+
93
+ export function toVue(code) {
94
+ const { markup, css, js } = splitSource(code);
95
+ const parts = [`<template>\n${indent(markup)}\n</template>`];
96
+ if (js) parts.push(`<script setup>\nimport { onMounted } from 'vue';\n\nonMounted(() => {\n${indent(js)}\n});\n</script>`);
97
+ if (css) parts.push(`<style scoped>\n${css}\n</style>`);
98
+ return parts.join("\n\n") + "\n";
99
+ }
100
+
101
+ export function toSvelte(code) {
102
+ const { markup, css, js } = splitSource(code);
103
+ const parts = [];
104
+ if (js) parts.push(`<script>\n import { onMount } from 'svelte';\n\n onMount(() => {\n${indent(js, 4)}\n });\n</script>`);
105
+ parts.push(escapeText(markup));
106
+ if (css) parts.push(`<style>\n${css}\n</style>`);
107
+ return parts.join("\n\n") + "\n";
108
+ }
109
+
110
+ const TAILWIND_CDN = '<script src="https://cdn.tailwindcss.com"></script>';
111
+ const escHtml = (t) => String(t).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
112
+ const mentionsTailwind = (list) => list.some((tag) => /\bsrc\s*=\s*["'][^"']*tailwind/i.test(tag));
113
+
114
+ /**
115
+ * The component as a complete, openable .html file (doctype, head, Tailwind, its own CSS and JS),
116
+ * like the file Claude Design exports. Full documents are kept as they are, only adding Tailwind if missing.
117
+ */
118
+ export function toStandaloneHtml(code, { title = "Page" } = {}) {
119
+ const src = String(code || "");
120
+ if (/<html[\s>]/i.test(src)) {
121
+ if (/<script\b[^>]*\bsrc\s*=\s*["'][^"']*tailwind/i.test(src)) return src;
122
+ if (/<\/head>/i.test(src)) return src.replace(/<\/head>/i, `${TAILWIND_CDN}\n</head>`);
123
+ return src.replace(/<html[^>]*>/i, (m) => `${m}<head>${TAILWIND_CDN}</head>`);
124
+ }
125
+ const d = parseDocument(src);
126
+ return `<!DOCTYPE html>
127
+ <html lang="en">
128
+ <head>
129
+ <meta charset="utf-8">
130
+ <meta name="viewport" content="width=device-width, initial-scale=1">
131
+ <title>${escHtml(title)}</title>
132
+ ${mentionsTailwind(d.scripts) ? "" : TAILWIND_CDN}
133
+ ${d.links.join("\n")}
134
+ ${d.styles.join("\n")}
135
+ </head>
136
+ <body>
137
+ ${d.body}
138
+ ${d.scripts.join("\n")}
139
+ </body>
140
+ </html>
141
+ `;
142
+ }
143
+
144
+ export const FORMATS = ["html", "standalone", "react", "vue", "svelte"];
145
+
146
+ export function exportComponent(code, format = "html", name = "Component") {
147
+ const safeName = String(name).replace(/[^a-zA-Z0-9]+(.)?/g, (_, c) => (c ? c.toUpperCase() : "")).replace(/^[^a-zA-Z]+/, "") || "Component";
148
+ const cap = safeName[0].toUpperCase() + safeName.slice(1);
149
+ switch (format) {
150
+ case "react": return toReact(code, cap);
151
+ case "vue": return toVue(code);
152
+ case "svelte": return toSvelte(code);
153
+ case "standalone": return toStandaloneHtml(code, { title: name });
154
+ default: return code;
155
+ }
156
+ }
package/core/pages.js ADDED
@@ -0,0 +1,39 @@
1
+ // What saving several pages of one design needs, as plain functions: the head they share, their order, and their group.
2
+
3
+ export const MAX_GROUP = 60;
4
+ /** A page's group name ("Public site", "Customer area"): trimmed and kept short; empty means none. */
5
+ export const normalizeGroup = (g) => String(g ?? "").trim().replace(/\s+/g, " ").slice(0, MAX_GROUP);
6
+
7
+ /**
8
+ * Puts `shared` (fonts, the Tailwind script and config, a stylesheet...) at the top of a page's <head>, so every page is
9
+ * complete on its own. The head is made when the page has none (after <html>, else after a doctype, else before everything).
10
+ * A page that already contains it is left as it is, so saving a page again doesn't add it twice.
11
+ */
12
+ export function withSharedHead(html, shared) {
13
+ const add = String(shared ?? "").trim();
14
+ const page = String(html ?? "");
15
+ if (!add || page.includes(add)) return page;
16
+ const insert = (at, text) => page.slice(0, at) + text + page.slice(at);
17
+ const head = /<head\b[^>]*>/i.exec(page);
18
+ if (head) return insert(head.index + head[0].length, `\n${add}`);
19
+ const root = /<html\b[^>]*>/i.exec(page);
20
+ if (root) return insert(root.index + root[0].length, `\n<head>\n${add}\n</head>`);
21
+ const doctype = /^\s*<!doctype[^>]*>/i.exec(page);
22
+ if (doctype) return insert(doctype[0].length, `\n${add}`);
23
+ return `${add}\n${page}`;
24
+ }
25
+
26
+ /**
27
+ * The project's pages in their new order. `sequence`: every page id in the order they are in now (pages new in this save last).
28
+ * `named`: the ids in the order they should come. The named pages take, in that order, the places the named pages hold now;
29
+ * the others do not move. So saving pages again in another order reorders just those, and saving a few of them cannot throw
30
+ * the rest out of place.
31
+ */
32
+ export function placeInOrder(sequence, named) {
33
+ const wanted = [...new Set(named)].filter((id) => sequence.includes(id));
34
+ const set = new Set(wanted);
35
+ const out = [...sequence];
36
+ let n = 0;
37
+ sequence.forEach((id, i) => { if (set.has(id)) out[i] = wanted[n++]; });
38
+ return out;
39
+ }
@@ -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(/&nbsp;/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
+ }