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,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
|
+
}
|
|
@@ -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, """)}"`);
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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
|
+
}
|