multiclone 0.2.0 → 0.3.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 (54) hide show
  1. package/README.md +9 -8
  2. package/agents/section-builder.md +5 -3
  3. package/commands/multiclone.md +3 -3
  4. package/dist/adapters/cli.js +14 -0
  5. package/dist/adapters/cli.js.map +1 -1
  6. package/dist/contracts/tools/method.js +12 -1
  7. package/dist/contracts/tools/method.js.map +1 -1
  8. package/dist/contracts/tools/session.js +9 -8
  9. package/dist/contracts/tools/session.js.map +1 -1
  10. package/dist/doctor.js +9 -0
  11. package/dist/doctor.js.map +1 -1
  12. package/dist/project/assets.js +15 -13
  13. package/dist/project/assets.js.map +1 -1
  14. package/dist/project/convert/attrs.js +318 -0
  15. package/dist/project/convert/attrs.js.map +1 -0
  16. package/dist/project/convert/convert.js +304 -0
  17. package/dist/project/convert/convert.js.map +1 -0
  18. package/dist/project/convert/css.js +299 -0
  19. package/dist/project/convert/css.js.map +1 -0
  20. package/dist/project/convert/html.js +425 -0
  21. package/dist/project/convert/html.js.map +1 -0
  22. package/dist/project/convert/jsx.js +335 -0
  23. package/dist/project/convert/jsx.js.map +1 -0
  24. package/dist/project/convert/locate.js +180 -0
  25. package/dist/project/convert/locate.js.map +1 -0
  26. package/dist/project/convert/scripts.js +214 -0
  27. package/dist/project/convert/scripts.js.map +1 -0
  28. package/dist/project/convert/styles.js +228 -0
  29. package/dist/project/convert/styles.js.map +1 -0
  30. package/dist/project/convert/write.js +142 -0
  31. package/dist/project/convert/write.js.map +1 -0
  32. package/dist/project/fonts.js +131 -0
  33. package/dist/project/fonts.js.map +1 -0
  34. package/dist/project/libraries.js +28 -0
  35. package/dist/project/libraries.js.map +1 -0
  36. package/dist/project/method.js +72 -6
  37. package/dist/project/method.js.map +1 -1
  38. package/dist/project/scaffold.js +33 -6
  39. package/dist/project/scaffold.js.map +1 -1
  40. package/dist/session/proc.js +29 -1
  41. package/dist/session/proc.js.map +1 -1
  42. package/dist/session/session.js +47 -4
  43. package/dist/session/session.js.map +1 -1
  44. package/dist/version.js +1 -1
  45. package/install.sh +3 -2
  46. package/package.json +4 -2
  47. package/scripts/postinstall.mjs +21 -0
  48. package/skills/multiclone/SKILL.md +17 -5
  49. package/templates/react-vite/index.html +1 -1
  50. package/templates/react-vite/src/main.tsx +2 -2
  51. package/templates/react-vite/src/styles/index.css +0 -1
  52. package/dist/project/draft.js +0 -860
  53. package/dist/project/draft.js.map +0 -1
  54. package/templates/react-vite/src/styles/draft.css +0 -1
@@ -1,860 +0,0 @@
1
- import { existsSync } from 'node:fs';
2
- import { readFile, rm, writeFile } from 'node:fs/promises';
3
- import { format } from 'prettier';
4
- import { join, resolve } from 'node:path';
5
- import { ensureDir, readJson, writeJson } from '../core/files.js';
6
- import { log } from '../core/log.js';
7
- import { mcError, TaggedError } from '../domain/result.js';
8
- import { nodeStyles } from '../domain/snapshot.js';
9
- import { RunFolder } from '../store/run.js';
10
- const MAX_ELEMENTS = 1200;
11
- const MOBILE_QUERY = '(max-width: 767px)';
12
- /** Values a property has when nothing sets it (after the draft reset), so they can be omitted. */
13
- const DEFAULTS = {
14
- position: 'static',
15
- 'z-index': 'auto',
16
- float: 'none',
17
- 'box-sizing': 'border-box',
18
- 'min-width': 'auto',
19
- 'max-width': 'none',
20
- 'min-height': 'auto',
21
- 'max-height': 'none',
22
- 'margin-top': '0px',
23
- 'margin-right': '0px',
24
- 'margin-bottom': '0px',
25
- 'margin-left': '0px',
26
- 'padding-top': '0px',
27
- 'padding-right': '0px',
28
- 'padding-bottom': '0px',
29
- 'padding-left': '0px',
30
- 'border-top-left-radius': '0px',
31
- 'border-top-right-radius': '0px',
32
- 'border-bottom-right-radius': '0px',
33
- 'border-bottom-left-radius': '0px',
34
- 'flex-direction': 'row',
35
- 'flex-wrap': 'nowrap',
36
- 'flex-grow': '0',
37
- 'flex-shrink': '1',
38
- 'flex-basis': 'auto',
39
- 'justify-content': 'normal',
40
- 'align-items': 'normal',
41
- 'align-self': 'auto',
42
- 'align-content': 'normal',
43
- order: '0',
44
- 'row-gap': 'normal',
45
- 'column-gap': 'normal',
46
- 'grid-template-columns': 'none',
47
- 'grid-template-rows': 'none',
48
- 'grid-template-areas': 'none',
49
- 'grid-column': 'auto',
50
- 'grid-row': 'auto',
51
- 'grid-auto-flow': 'row',
52
- 'aspect-ratio': 'auto',
53
- 'object-fit': 'fill',
54
- 'object-position': '50% 50%',
55
- 'text-decoration-line': 'none',
56
- 'text-overflow': 'clip',
57
- opacity: '1',
58
- 'background-color': 'rgba(0, 0, 0, 0)',
59
- 'background-image': 'none',
60
- 'background-size': 'auto',
61
- 'background-position': '0% 0%',
62
- 'background-repeat': 'repeat',
63
- 'background-clip': 'border-box',
64
- 'box-shadow': 'none',
65
- transform: 'none',
66
- 'transform-style': 'flat',
67
- perspective: 'none',
68
- translate: 'none',
69
- rotate: 'none',
70
- scale: 'none',
71
- filter: 'none',
72
- 'backdrop-filter': 'none',
73
- 'clip-path': 'none',
74
- 'mask-image': 'none',
75
- 'mix-blend-mode': 'normal',
76
- isolation: 'auto',
77
- 'overflow-x': 'visible',
78
- 'overflow-y': 'visible',
79
- transition: 'all 0s ease 0s',
80
- 'will-change': 'auto',
81
- };
82
- /** Inherited properties: emitted only where they differ from the parent. */
83
- const INHERITED = [
84
- 'color',
85
- 'font-family',
86
- 'font-size',
87
- 'font-weight',
88
- 'font-style',
89
- 'font-stretch',
90
- 'font-variation-settings',
91
- 'font-feature-settings',
92
- 'line-height',
93
- 'letter-spacing',
94
- 'word-spacing',
95
- 'text-align',
96
- 'text-transform',
97
- 'text-indent',
98
- 'text-shadow',
99
- 'white-space',
100
- 'word-break',
101
- 'visibility',
102
- 'cursor',
103
- 'pointer-events',
104
- 'list-style-type',
105
- ];
106
- const INHERITED_SET = new Set(INHERITED);
107
- const BLOCK_TAGS = new Set('div section header footer main nav article aside h1 h2 h3 h4 h5 h6 p ul ol dl dd dt figure figcaption blockquote form fieldset address details summary dialog pre hr img video canvas svg'.split(' '));
108
- const DEFAULT_DISPLAY = {
109
- li: 'list-item',
110
- button: 'inline-block',
111
- input: 'inline-block',
112
- select: 'inline-block',
113
- textarea: 'inline-block',
114
- table: 'table',
115
- thead: 'table-header-group',
116
- tbody: 'table-row-group',
117
- tr: 'table-row',
118
- td: 'table-cell',
119
- th: 'table-cell',
120
- iframe: 'inline',
121
- summary: 'list-item',
122
- };
123
- const HTML_TAGS = new Set('div span section header footer main nav article aside h1 h2 h3 h4 h5 h6 p a img video picture figure figcaption ul ol li dl dt dd button form input label textarea select option blockquote strong em b i u small sup sub br hr code pre table thead tbody tfoot tr td th iframe canvas svg time details summary dialog mark abbr cite q s del ins address fieldset legend'.split(' '));
124
- const SVG_TAGS = new Set('svg g path circle ellipse line polyline polygon rect defs linearGradient radialGradient stop clipPath mask use symbol text tspan pattern title desc'.split(' '));
125
- const VOID_TAGS = new Set(['img', 'br', 'hr', 'input']);
126
- const MEDIA_TAGS = new Set(['img', 'video', 'canvas', 'svg', 'iframe', 'picture']);
127
- const SKIP_TAGS = new Set([
128
- 'script',
129
- 'style',
130
- 'noscript',
131
- 'template',
132
- 'link',
133
- 'meta',
134
- 'source',
135
- 'track',
136
- 'head',
137
- 'title',
138
- ]);
139
- const GLOBAL_ATTRS = {
140
- id: 'id',
141
- title: 'title',
142
- role: 'role',
143
- lang: 'lang',
144
- 'aria-label': 'aria-label',
145
- 'aria-labelledby': 'aria-labelledby',
146
- 'aria-describedby': 'aria-describedby',
147
- 'aria-controls': 'aria-controls',
148
- };
149
- const TAG_ATTRS = {
150
- a: { href: 'href', rel: 'rel', target: 'target' },
151
- img: { alt: 'alt', width: 'width', height: 'height' },
152
- video: { width: 'width', height: 'height' },
153
- iframe: { title: 'title', width: 'width', height: 'height' },
154
- input: { name: 'name', placeholder: 'placeholder', value: 'defaultValue' },
155
- textarea: { name: 'name', placeholder: 'placeholder' },
156
- select: { name: 'name' },
157
- button: { name: 'name' },
158
- label: { for: 'htmlFor' },
159
- time: { datetime: 'dateTime' },
160
- td: { colspan: 'colSpan', rowspan: 'rowSpan' },
161
- th: { colspan: 'colSpan', rowspan: 'rowSpan' },
162
- };
163
- const SVG_ATTRS = {
164
- viewBox: 'viewBox',
165
- viewbox: 'viewBox',
166
- d: 'd',
167
- fill: 'fill',
168
- stroke: 'stroke',
169
- width: 'width',
170
- height: 'height',
171
- cx: 'cx',
172
- cy: 'cy',
173
- r: 'r',
174
- rx: 'rx',
175
- ry: 'ry',
176
- x: 'x',
177
- y: 'y',
178
- x1: 'x1',
179
- x2: 'x2',
180
- y1: 'y1',
181
- y2: 'y2',
182
- points: 'points',
183
- transform: 'transform',
184
- offset: 'offset',
185
- opacity: 'opacity',
186
- mask: 'mask',
187
- href: 'href',
188
- 'xlink:href': 'href',
189
- preserveAspectRatio: 'preserveAspectRatio',
190
- gradientUnits: 'gradientUnits',
191
- gradientTransform: 'gradientTransform',
192
- 'stroke-width': 'strokeWidth',
193
- 'stroke-linecap': 'strokeLinecap',
194
- 'stroke-linejoin': 'strokeLinejoin',
195
- 'stroke-dasharray': 'strokeDasharray',
196
- 'stroke-dashoffset': 'strokeDashoffset',
197
- 'stroke-opacity': 'strokeOpacity',
198
- 'fill-rule': 'fillRule',
199
- 'fill-opacity': 'fillOpacity',
200
- 'clip-rule': 'clipRule',
201
- 'clip-path': 'clipPath',
202
- 'stop-color': 'stopColor',
203
- 'stop-opacity': 'stopOpacity',
204
- };
205
- /** Attributes React types as unions: only these values are emitted. */
206
- const UNION_VALUES = {
207
- 'button.type': new Set(['button', 'submit', 'reset']),
208
- 'img.loading': new Set(['lazy', 'eager']),
209
- 'iframe.loading': new Set(['lazy', 'eager']),
210
- 'aria-hidden': new Set(['true', 'false']),
211
- 'aria-expanded': new Set(['true', 'false']),
212
- };
213
- /** Element reset that makes "computed value equals the default" mean the same in the clone. */
214
- const DRAFT_RESET = `/* Draft reset by multiclone: the generated sections assume these defaults. */
215
- h1, h2, h3, h4, h5, h6, p, ul, ol, dl, dd, figure, blockquote, pre { margin: 0; padding: 0; }
216
- h1, h2, h3, h4, h5, h6, b, strong, small { font-size: inherit; font-weight: inherit; }
217
- em, i { font-style: inherit; }
218
- a { color: inherit; text-decoration: none; }
219
- button { background: none; border: 0; padding: 0; font: inherit; color: inherit; text-align: inherit; }
220
- input, textarea, select { font: inherit; color: inherit; }
221
- `;
222
- /**
223
- * Writes a first draft of one captured page into a scaffolded project: one React component and
224
- * CSS module per captured section, built from the styled DOM snapshot (desktop, with mobile
225
- * overrides), real media paths and captured hover states. The agent polishes from there.
226
- */
227
- export async function generateDraft(options) {
228
- const opened = await RunFolder.open(options.capture);
229
- if (!opened.ok)
230
- throw new TaggedError(opened.error);
231
- const run = opened.value;
232
- const project = resolve(options.project);
233
- const route = normalizeRoute(options.route ?? '/');
234
- const warnings = [];
235
- const primary = run.viewports[0] ?? 'desktop';
236
- const mobileName = run.viewports.find((name) => name !== primary && /mobile/i.test(name));
237
- const snapshot = await run.snapshot(primary);
238
- if (!snapshot?.documents[0])
239
- throw new Error(`No ${primary} snapshot in ${run.dir}.`);
240
- const mobile = mobileName ? await run.snapshot(mobileName) : null;
241
- const assetMap = await readJson(join(project, 'src', 'content', 'asset-map.json')).catch(() => ({}));
242
- const hovers = hoverMap(await run.json(join('interactions', `${primary}.json`), []));
243
- const page = new SnapshotIndex(snapshot);
244
- const mobileIndex = mobile?.documents[0] ? new SnapshotIndex(mobile) : null;
245
- const origin = run.manifest.finalUrl || run.manifest.url;
246
- let index = await run.json('sections/index.json', []);
247
- if (!index.length) {
248
- const body = page.byTag('body');
249
- if (!body?.path)
250
- throw new Error('The snapshot has no body element.');
251
- index = [
252
- {
253
- id: 'page',
254
- label: 'page',
255
- viewports: { [primary]: { top: 0, bottom: 0, path: body.path } },
256
- },
257
- ];
258
- warnings.push('No section split in the capture (run analyze); drafted the whole body as one section.');
259
- }
260
- const pageName = componentName(route === '/' ? 'home' : route, 'Page');
261
- const slug = route === '/'
262
- ? 'home'
263
- : route
264
- .slice(1)
265
- .replace(/[^a-z0-9]+/gi, '-')
266
- .toLowerCase();
267
- const sectionsDir = join(project, 'src', 'sections', slug);
268
- if (existsSync(sectionsDir)) {
269
- if (options.overwrite !== true)
270
- throw new TaggedError(mcError('invalid-input', `${route} is already drafted in src/sections/${slug}.`, 'Pass overwrite to replace it (this discards edits to those files).'));
271
- await rm(sectionsDir, { recursive: true, force: true });
272
- }
273
- const body = page.byTag('body');
274
- const base = body ? nodeStyles(snapshot, body) : {};
275
- const used = new Set();
276
- const sections = [];
277
- for (const entry of index) {
278
- const placement = entry.viewports[primary];
279
- const root = placement ? page.byPath(placement.path) : undefined;
280
- if (!placement || !root) {
281
- warnings.push(`Section "${entry.label}" has no ${primary} element; skipped.`);
282
- continue;
283
- }
284
- let name = componentName(entry.label, 'Section');
285
- for (let n = 2; used.has(name); n++)
286
- name = `${componentName(entry.label, 'Section')}${String(n)}`;
287
- used.add(name);
288
- const mobilePlacement = mobileName ? entry.viewports[mobileName] : undefined;
289
- const builder = new SectionBuilder({
290
- page,
291
- mobile: mobileIndex,
292
- base,
293
- assetMap,
294
- hovers,
295
- origin,
296
- mobileRootHeight: mobilePlacement ? mobilePlacement.bottom - mobilePlacement.top : null,
297
- });
298
- const jsx = builder.render(root);
299
- const dir = join(sectionsDir, name);
300
- await ensureDir(dir);
301
- await writeFile(join(dir, `${name}.tsx`), await pretty(sectionSource(name, entry.label, origin, jsx), 'typescript'));
302
- await writeFile(join(dir, `${name}.module.css`), await pretty(builder.css(), 'css'));
303
- sections.push({
304
- name,
305
- label: entry.label,
306
- file: `src/sections/${slug}/${name}/${name}.tsx`,
307
- elements: builder.elements,
308
- truncated: builder.truncated,
309
- });
310
- if (builder.truncated)
311
- warnings.push(`${name}: stopped at ${String(MAX_ELEMENTS)} elements; finish it from sections/${entry.id}/dom-${primary}.txt.`);
312
- }
313
- const pageFile = join(project, 'src', 'pages', `${pageName}.tsx`);
314
- await ensureDir(join(project, 'src', 'pages'));
315
- await writeFile(pageFile, await pretty(pageSource(pageName, slug, sections, origin), 'typescript'));
316
- await writeFile(join(project, 'src', 'styles', 'draft.css'), await pretty(draftCss(base, assetMap), 'css'));
317
- const routes = [...new Set([route, ...(options.aliases ?? []).map(normalizeRoute)])];
318
- await registerPage(project, routes.map((path) => ({ route: path, component: pageName, file: `@/pages/${pageName}` })));
319
- log(`drafted ${String(sections.length)} sections → src/sections/${slug} (route ${route})`);
320
- return { route, page: `src/pages/${pageName}.tsx`, sections, warnings };
321
- }
322
- /** Lookup helpers over the first document of a snapshot. */
323
- class SnapshotIndex {
324
- snapshot;
325
- nodes;
326
- children = new Map();
327
- paths = new Map();
328
- constructor(snapshot) {
329
- this.snapshot = snapshot;
330
- this.nodes = snapshot.documents[0]?.nodes ?? [];
331
- for (const node of this.nodes) {
332
- const list = this.children.get(node.p) ?? [];
333
- list.push(node);
334
- this.children.set(node.p, list);
335
- if (node.path && !this.paths.has(node.path))
336
- this.paths.set(node.path, node);
337
- }
338
- }
339
- byPath(path) {
340
- return this.paths.get(path);
341
- }
342
- byTag(tag) {
343
- return this.nodes.find((node) => node.t === 1 && node.name.toLowerCase() === tag);
344
- }
345
- styles(node) {
346
- return nodeStyles(this.snapshot, node);
347
- }
348
- }
349
- /** Turns one section subtree into JSX plus a deduplicated CSS module. */
350
- class SectionBuilder {
351
- input;
352
- elements = 0;
353
- truncated = false;
354
- rules = new Map();
355
- counters = new Map();
356
- constructor(input) {
357
- this.input = input;
358
- }
359
- render(root) {
360
- return this.element(root, 2, this.input.base, null, true, false) ?? ' <div />';
361
- }
362
- css() {
363
- const blocks = [];
364
- const mobile = [];
365
- for (const { name, rule } of this.rules.values()) {
366
- blocks.push(block(`.${name}`, rule.own));
367
- if (rule.before)
368
- blocks.push(block(`.${name}::before`, rule.before));
369
- if (rule.after)
370
- blocks.push(block(`.${name}::after`, rule.after));
371
- if (rule.hover)
372
- blocks.push(block(`.${name}:hover`, rule.hover));
373
- if (rule.mobile?.size)
374
- mobile.push(block(` .${name}`, rule.mobile, ' '));
375
- }
376
- const media = mobile.length ? `\n@media ${MOBILE_QUERY} {\n${mobile.join('\n')}}\n` : '';
377
- return `${blocks.filter(Boolean).join('\n')}${media}`;
378
- }
379
- element(node, depth, parentStyles, parentNode, isRoot, inSvg) {
380
- const raw = node.name;
381
- const lower = raw.toLowerCase();
382
- if (SKIP_TAGS.has(lower) || node.pseudo)
383
- return null;
384
- const svg = inSvg || lower === 'svg';
385
- if (!node.box && !svg)
386
- return null;
387
- if (this.elements >= MAX_ELEMENTS) {
388
- this.truncated = true;
389
- return null;
390
- }
391
- this.elements++;
392
- const pad = ' '.repeat(depth);
393
- const styles = this.input.page.styles(node);
394
- const tag = svg ? (SVG_TAGS.has(raw) ? raw : null) : jsxTag(lower, styles, isRoot);
395
- if (tag === null)
396
- return null;
397
- const attrs = this.attributes(node, tag, svg);
398
- if (!svg || lower === 'svg') {
399
- const className = this.classFor(node, styles, parentStyles, parentNode, isRoot, tag);
400
- if (className)
401
- attrs.unshift(`className={styles.${className}}`);
402
- }
403
- if (VOID_TAGS.has(tag) || (tag === 'video' && !svg) || tag === 'canvas' || tag === 'iframe') {
404
- return `${pad}<${tag}${attrs.length ? ` ${attrs.join(' ')}` : ''} />`;
405
- }
406
- const kids = [];
407
- const children = this.input.page.children.get(node.i) ?? [];
408
- let previousText = false;
409
- for (const child of children) {
410
- if (child.t === 3) {
411
- if (tag === 'textarea' || tag === 'select')
412
- continue;
413
- if (kids.length && !previousText)
414
- kids.push(`${pad} {' '}`);
415
- kids.push(`${pad} {${JSON.stringify(child.text ?? '')}}`);
416
- previousText = true;
417
- continue;
418
- }
419
- if (child.t !== 1)
420
- continue;
421
- const rendered = this.element(child, depth + 1, styles, node, false, svg);
422
- if (rendered === null)
423
- continue;
424
- if (previousText && isInline(child, this.input.page))
425
- kids.push(`${pad} {' '}`);
426
- kids.push(rendered);
427
- previousText = false;
428
- }
429
- const open = `${pad}<${tag}${attrs.length ? ` ${attrs.join(' ')}` : ''}`;
430
- return kids.length ? `${open}>\n${kids.join('\n')}\n${pad}</${tag}>` : `${open} />`;
431
- }
432
- attributes(node, tag, svg) {
433
- const out = [];
434
- const attrs = node.attrs ?? {};
435
- const allowed = svg
436
- ? SVG_ATTRS
437
- : { ...GLOBAL_ATTRS, ...(TAG_ATTRS[tag] ?? {}) };
438
- for (const [key, value] of Object.entries(attrs).sort(([a], [b]) => a.localeCompare(b))) {
439
- if (key.startsWith('data-')) {
440
- out.push(`${key}={${JSON.stringify(value)}}`);
441
- continue;
442
- }
443
- const union = UNION_VALUES[key] ?? UNION_VALUES[`${tag}.${key}`];
444
- if (union) {
445
- if (union.has(value))
446
- out.push(`${key}={${JSON.stringify(value)}}`);
447
- continue;
448
- }
449
- const name = allowed[key];
450
- if (!name)
451
- continue;
452
- if (key === 'href' && tag === 'a') {
453
- out.push(`href={${JSON.stringify(this.link(value))}}`);
454
- continue;
455
- }
456
- if ((key === 'width' || key === 'height') && !svg && !/^\d+$/.test(value))
457
- continue;
458
- out.push(`${name}={${JSON.stringify(value)}}`);
459
- }
460
- if (!svg && (tag === 'img' || tag === 'video' || tag === 'iframe')) {
461
- const source = node.src ?? (attrs.src ? this.absolute(attrs.src) : '');
462
- if (source)
463
- out.push(`src={${JSON.stringify(this.asset(source))}}`);
464
- }
465
- if (!svg && tag === 'img' && !('alt' in attrs))
466
- out.push('alt=""');
467
- if (!svg && tag === 'video') {
468
- if (attrs.poster)
469
- out.push(`poster={${JSON.stringify(this.asset(this.absolute(attrs.poster)))}}`);
470
- out.push('autoPlay', 'muted', 'loop', 'playsInline');
471
- }
472
- if (!svg && tag === 'input' && attrs.type)
473
- out.push(`type={${JSON.stringify(attrs.type)}}`);
474
- if (!svg && tag === 'input' && 'checked' in attrs)
475
- out.push('defaultChecked');
476
- if (!svg && (tag === 'details' || tag === 'dialog') && 'open' in attrs)
477
- out.push('open');
478
- if (!svg && tag === 'canvas')
479
- out.push(`data-mc-canvas="rebuild: WebGL/2D canvas, see the capture's REPORT.md"`);
480
- return out;
481
- }
482
- classFor(node, styles, parentStyles, parentNode, isRoot, tag) {
483
- const { page, mobile } = this.input;
484
- const own = declarations(node, styles, parentStyles, parentNode, page, isRoot, tag);
485
- this.rewriteUrls(own);
486
- const pseudo = (kind) => {
487
- const child = (page.children.get(node.i) ?? []).find((c) => c.pseudo === kind);
488
- if (!child?.s)
489
- return null;
490
- const cs = page.styles(child);
491
- const decls = declarations(child, cs, styles, node, page, false, 'span');
492
- const content = cs.content;
493
- if (!content || content === 'none' || content === 'normal')
494
- return null;
495
- decls.set('content', content);
496
- if (child.box) {
497
- decls.set('width', `${String(child.box[2])}px`);
498
- decls.set('height', `${String(child.box[3])}px`);
499
- }
500
- this.rewriteUrls(decls);
501
- return decls;
502
- };
503
- let mobileDecls = null;
504
- if (mobile && node.path) {
505
- const twin = mobile.byPath(node.path);
506
- if (!twin?.box)
507
- mobileDecls = new Map([['display', 'none']]);
508
- else {
509
- const twinParent = mobile.nodes.find((n) => n.i === twin.p) ?? null;
510
- const twinParentStyles = isRoot || !twinParent ? this.input.base : mobile.styles(twinParent);
511
- const decls = declarations(twin, mobile.styles(twin), twinParentStyles, twinParent, mobile, isRoot, tag);
512
- if (isRoot && this.input.mobileRootHeight !== null)
513
- decls.set('min-height', `${String(Math.round(this.input.mobileRootHeight))}px`);
514
- this.rewriteUrls(decls);
515
- mobileDecls = overrides(own, decls);
516
- }
517
- }
518
- const rule = {
519
- own,
520
- before: pseudo('before'),
521
- after: pseudo('after'),
522
- hover: node.path ? (this.input.hovers.get(node.path) ?? null) : null,
523
- mobile: mobileDecls,
524
- };
525
- if (!isRoot &&
526
- !rule.own.size &&
527
- !rule.before &&
528
- !rule.after &&
529
- !rule.hover &&
530
- !rule.mobile?.size)
531
- return null;
532
- const key = isRoot ? '#root' : ruleKey(rule);
533
- const existing = this.rules.get(key);
534
- if (existing)
535
- return existing.name;
536
- const base = isRoot ? 'root' : tag.replace(/[^a-z0-9]/gi, '');
537
- const count = (this.counters.get(base) ?? 0) + 1;
538
- this.counters.set(base, count);
539
- const name = isRoot ? 'root' : `${base}${String(count)}`;
540
- this.rules.set(key, { name, rule });
541
- return name;
542
- }
543
- rewriteUrls(decls) {
544
- for (const [prop, value] of decls) {
545
- if (!value.includes('url('))
546
- continue;
547
- decls.set(prop, value.replace(/url\((['"]?)(.*?)\1\)/g, (_m, _q, url) => `url("${this.asset(url)}")`));
548
- }
549
- }
550
- asset(url) {
551
- return this.input.assetMap[url] ?? url;
552
- }
553
- absolute(value) {
554
- try {
555
- return new URL(value, this.input.origin).toString();
556
- }
557
- catch {
558
- return value;
559
- }
560
- }
561
- /** Same-site links become paths in the clone; others stay absolute. */
562
- link(value) {
563
- try {
564
- const url = new URL(value, this.input.origin);
565
- if (!['http:', 'https:', 'mailto:', 'tel:'].includes(url.protocol))
566
- return '#';
567
- if (url.origin === new URL(this.input.origin).origin)
568
- return `${url.pathname}${url.search}${url.hash}`;
569
- return url.toString();
570
- }
571
- catch {
572
- return value;
573
- }
574
- }
575
- }
576
- /** The CSS a node needs on top of its parent and the draft reset. */
577
- function declarations(node, styles, parentStyles, parentNode, index, isRoot, tag) {
578
- const out = new Map();
579
- const get = (prop) => styles[prop] ?? '';
580
- const position = get('position') || 'static';
581
- const display = get('display');
582
- const defaultDisplay = DEFAULT_DISPLAY[tag] ?? (BLOCK_TAGS.has(tag) ? 'block' : 'inline');
583
- if (display && display !== defaultDisplay)
584
- out.set('display', display);
585
- if (position !== 'static') {
586
- out.set('position', position);
587
- for (const side of ['top', 'right', 'bottom', 'left']) {
588
- const value = get(side);
589
- if (value && value !== 'auto')
590
- out.set(side, value);
591
- }
592
- const z = get('z-index');
593
- if (z && z !== 'auto')
594
- out.set('z-index', z);
595
- }
596
- for (const [prop, fallback] of Object.entries(DEFAULTS)) {
597
- if (prop === 'position' || prop === 'z-index')
598
- continue;
599
- const value = get(prop);
600
- if (!value || value === fallback)
601
- continue;
602
- if (prop === 'row-gap' || prop === 'column-gap') {
603
- if (!/flex|grid/.test(display))
604
- continue;
605
- }
606
- out.set(prop, value);
607
- }
608
- for (const prop of INHERITED) {
609
- const value = get(prop);
610
- if (value && value !== (parentStyles[prop] ?? ''))
611
- out.set(prop, value);
612
- }
613
- const widths = ['top', 'right', 'bottom', 'left'].map((side) => get(`border-${side}-width`));
614
- if (widths.some((w) => w && w !== '0px')) {
615
- const style = get('border-top-style') || 'solid';
616
- for (const [k, side] of ['top', 'right', 'bottom', 'left'].entries()) {
617
- const width = widths[k] ?? '0px';
618
- if (width === '0px')
619
- continue;
620
- out.set(`border-${side}`, `${width} ${style} ${get(`border-${side}-color`)}`);
621
- }
622
- }
623
- const transformed = out.has('transform') || out.has('rotate') || out.has('scale');
624
- const origin = get('transform-origin');
625
- if (transformed && origin)
626
- out.set('transform-origin', origin);
627
- const animation = get('animation-name');
628
- if (animation && animation !== 'none') {
629
- out.set('animation-name', animation);
630
- out.set('animation-duration', get('animation-duration'));
631
- out.set('animation-timing-function', get('animation-timing-function'));
632
- }
633
- for (const prop of ['grid-template-areas', 'grid-column', 'grid-row']) {
634
- const value = out.get(prop);
635
- if (value?.startsWith('auto'))
636
- out.delete(prop);
637
- }
638
- // Reveal-on-scroll content still hidden at capture time: show it; the agent re-adds motion.
639
- const opacity = Number(get('opacity') || '1');
640
- if (opacity < 0.05 && position !== 'fixed' && (index.children.get(node.i)?.length ?? 0) > 0) {
641
- out.delete('opacity');
642
- out.delete('transform');
643
- out.delete('translate');
644
- out.delete('filter');
645
- }
646
- const hasChildren = (index.children.get(node.i) ?? []).some((child) => child.t === 1 || child.t === 3);
647
- sizing(out, node, styles, parentStyles, parentNode, { isRoot, tag, position, hasChildren });
648
- if (isRoot) {
649
- if (get('background-color') === DEFAULTS['background-color'] &&
650
- node.bg &&
651
- node.bg !== DEFAULTS['background-color'])
652
- out.set('background-color', node.bg);
653
- for (const prop of ['margin-top', 'margin-bottom'])
654
- out.delete(prop);
655
- }
656
- return out;
657
- }
658
- /** Fixed sizes only where they are part of the design; flowing content keeps fluid widths. */
659
- function sizing(out, node, styles, parentStyles, parentNode, shape) {
660
- const { isRoot, tag, position, hasChildren } = shape;
661
- const box = node.box;
662
- if (!box)
663
- return;
664
- const [x, , width, height] = box;
665
- if (isRoot) {
666
- out.set('min-height', `${String(height)}px`);
667
- const viewportWidth = parentNode?.box?.[2] ?? 0;
668
- if (position === 'static' && x > 4 && viewportWidth === 0) {
669
- out.set('max-width', `${String(width)}px`);
670
- out.set('margin-left', 'auto');
671
- out.set('margin-right', 'auto');
672
- }
673
- return;
674
- }
675
- const media = MEDIA_TAGS.has(tag);
676
- const detached = position === 'absolute' || position === 'fixed';
677
- const parentDisplay = parentStyles.display ?? '';
678
- const flexRow = parentDisplay.includes('flex') && !(parentStyles['flex-direction'] ?? 'row').includes('column');
679
- const inline = (styles.display ?? '').includes('inline') && !media;
680
- if (inline && styles.display === 'inline')
681
- return;
682
- if (media || detached || !hasChildren) {
683
- out.set('width', `${String(width)}px`);
684
- out.set('height', `${String(height)}px`);
685
- if (media && !detached)
686
- out.set('max-width', '100%');
687
- return;
688
- }
689
- if (flexRow && (styles['flex-grow'] ?? '0') === '0') {
690
- out.set('width', `${String(width)}px`);
691
- out.set('max-width', '100%');
692
- }
693
- }
694
- function overrides(desktop, mobile) {
695
- const out = new Map();
696
- for (const key of [...new Set([...desktop.keys(), ...mobile.keys()])].sort()) {
697
- const d = desktop.get(key);
698
- const m = mobile.get(key) ?? resetValue(key);
699
- if (m !== d && !(d === undefined && mobile.get(key) === undefined))
700
- out.set(key, m);
701
- }
702
- return out;
703
- }
704
- function resetValue(prop) {
705
- if (INHERITED_SET.has(prop))
706
- return 'inherit';
707
- if (prop === 'width' || prop === 'height' || /^(top|right|bottom|left)$/.test(prop))
708
- return 'auto';
709
- if (prop === 'display')
710
- return 'revert';
711
- if (prop.startsWith('border-'))
712
- return 'none';
713
- return DEFAULTS[prop] ?? 'initial';
714
- }
715
- function ruleKey(rule) {
716
- const flat = (decls) => decls ? [...decls].map(([k, v]) => `${k}:${v}`).join(';') : '';
717
- return [rule.own, rule.before, rule.after, rule.hover, rule.mobile].map(flat).join('|');
718
- }
719
- function block(selector, decls, indent = ' ') {
720
- if (!decls.size)
721
- return '';
722
- const close = indent.length > 2 ? ' }' : '}';
723
- return `${selector} {\n${[...decls].map(([k, v]) => `${indent}${k}: ${v};`).join('\n')}\n${close}\n`;
724
- }
725
- function hoverMap(records) {
726
- const out = new Map();
727
- for (const record of records) {
728
- const diff = record.hover?.diff['.'] ?? record.forced.hover?.['.'];
729
- if (!diff)
730
- continue;
731
- const decls = new Map();
732
- for (const [prop, [, to]] of Object.entries(diff).sort(([a], [b]) => a.localeCompare(b)))
733
- decls.set(prop, to);
734
- if (decls.size)
735
- out.set(record.path, decls);
736
- }
737
- return out;
738
- }
739
- function jsxTag(lower, styles, isRoot) {
740
- if (lower === 'main' || lower === 'body' || lower === 'html')
741
- return 'div';
742
- if (isRoot && lower === 'span')
743
- return 'div';
744
- if (HTML_TAGS.has(lower))
745
- return lower;
746
- return (styles.display ?? '').startsWith('inline') ? 'span' : 'div';
747
- }
748
- function isInline(node, index) {
749
- return (index.styles(node).display ?? '').startsWith('inline');
750
- }
751
- function normalizeRoute(route) {
752
- const path = route.startsWith('/') ? route : `/${route}`;
753
- return path.length > 1 ? path.replace(/\/+$/, '') : '/';
754
- }
755
- /** PascalCase name with a suffix (`HeroSection`, `PricingPage`); never starts with a digit. */
756
- function componentName(label, suffix) {
757
- const name = label
758
- .replace(/[^a-zA-Z0-9]+/g, ' ')
759
- .trim()
760
- .split(/\s+/)
761
- .filter(Boolean)
762
- .slice(0, 4)
763
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
764
- .join('');
765
- if (!name)
766
- return suffix;
767
- return /^[A-Z]/.test(name) ? `${name}${suffix}` : `${suffix}${name}`;
768
- }
769
- function sectionSource(name, label, origin, jsx) {
770
- return `// Draft generated by multiclone from ${origin} (section "${label.replace(/\n/g, ' ')}"). Polish freely.
771
- import styles from './${name}.module.css';
772
-
773
- export function ${name}() {
774
- return (
775
- ${jsx}
776
- );
777
- }
778
- `;
779
- }
780
- function pageSource(pageName, slug, sections, origin) {
781
- const imports = sections
782
- .map((s) => `import { ${s.name} } from '@/sections/${slug}/${s.name}/${s.name}';`)
783
- .join('\n');
784
- const body = sections.map((s) => ` <${s.name} />`).join('\n');
785
- return `// Draft page generated by multiclone from ${origin}. Sections are in src/sections/${slug}.
786
- ${imports}
787
-
788
- export function ${pageName}() {
789
- return (
790
- <main id="main">
791
- ${body}
792
- </main>
793
- );
794
- }
795
- `;
796
- }
797
- function draftCss(base, assetMap) {
798
- const props = [
799
- 'font-family',
800
- 'font-size',
801
- 'font-weight',
802
- 'line-height',
803
- 'letter-spacing',
804
- 'color',
805
- 'background-color',
806
- ];
807
- const lines = props.flatMap((prop) => {
808
- const value = base[prop];
809
- return value && value !== DEFAULTS[prop] ? [` ${prop}: ${value};`] : [];
810
- });
811
- const image = base['background-image'];
812
- if (image && image !== 'none')
813
- lines.push(` background-image: ${image.replace(/url\((['"]?)(.*?)\1\)/g, (_m, _q, url) => `url("${assetMap[url] ?? url}")`)};`);
814
- return `${DRAFT_RESET}\nbody {\n${lines.join('\n')}\n}\n`;
815
- }
816
- /** Same settings as the template's .prettierrc.json, so `npm run check` passes on drafts. */
817
- async function pretty(source, parser) {
818
- try {
819
- return await format(source, {
820
- parser,
821
- singleQuote: true,
822
- semi: true,
823
- trailingComma: 'all',
824
- printWidth: 100,
825
- });
826
- }
827
- catch {
828
- return source;
829
- }
830
- }
831
- /** Keeps the list of drafted pages and regenerates src/app/routes.ts from it. */
832
- async function registerPage(project, entries) {
833
- const file = join(project, '.multiclone', 'pages.json');
834
- const taken = new Set(entries.map((entry) => entry.route));
835
- const pages = (await readJson(file).catch(() => [])).filter((page) => !taken.has(page.route));
836
- pages.push(...entries);
837
- pages.sort((a, b) => a.route.localeCompare(b.route));
838
- await writeJson(file, pages);
839
- const components = new Map(pages.map((page) => [page.component, page.file]));
840
- const imports = [...components]
841
- .sort(([a], [b]) => a.localeCompare(b))
842
- .map(([component, path]) => `import { ${component} } from '${path}';`)
843
- .join('\n');
844
- const routes = pages
845
- .map((page) => ` ${JSON.stringify(page.route)}: ${page.component},`)
846
- .join('\n');
847
- await writeFile(join(project, 'src', 'app', 'routes.ts'), await pretty(`// Generated by multiclone draft: one entry per drafted page.
848
- import type { ComponentType } from 'react';
849
- ${imports}
850
-
851
- export const routes: Partial<Record<string, ComponentType>> = {
852
- ${routes}
853
- };
854
- `, 'typescript'));
855
- const index = join(project, 'src', 'styles', 'index.css');
856
- const css = await readFile(index, 'utf8').catch(() => '');
857
- if (css && !css.includes('draft.css'))
858
- await writeFile(index, `${css.trimEnd()}\n@import './draft.css';\n`);
859
- }
860
- //# sourceMappingURL=draft.js.map