diff --git a/.claude/skills/github-pr-review/SKILL.md b/.claude/skills/github-pr-review/SKILL.md index b2c7535..c81e443 100644 --- a/.claude/skills/github-pr-review/SKILL.md +++ b/.claude/skills/github-pr-review/SKILL.md @@ -205,14 +205,17 @@ const example = "value"; Or use tildes: -````markdown +`````markdown ````suggestion ```javascript const example = "value"; ```` -```` +````` + +``` + +``` -```` ``` ## Common Mistakes @@ -259,6 +262,7 @@ Stop if you're thinking: First, analyze the PR and draft your comments. Then use AskUserQuestion: ``` + I've reviewed PR #123 and found 3 issues. Here's what I'll post: **Comment 1:** src/auth.ts line 20 @@ -277,7 +281,8 @@ Missing error case test... **Overall message:** "Found 3 issues that need to be addressed before merging." Ready to post this review? -``` + +```` **Step 2: After approval, post the review** @@ -305,19 +310,24 @@ gh api repos/:owner/:repo/pulls/123/reviews//events \ -X POST \ -f event="REQUEST_CHANGES" \ -f body="Found 3 issues that need to be addressed before merging." -``` +```` ## Real-World Impact **Without this pattern:** + - Multiple separate notifications spam the PR author - Can't batch feedback together - Easy to forget issues while reviewing - Inconsistent workflow based on perceived urgency **With this pattern:** + - All feedback in one coherent review - PR author gets one notification with full context - Can refine comments before posting - Professional, organized reviews -```` + +``` + +``` diff --git a/README.md b/README.md index ae1c31c..0a67ca8 100644 --- a/README.md +++ b/README.md @@ -31,14 +31,14 @@ No copy-pasting class names. No rebuilding layouts from scratch. No manual style ## Tech stack -| Layer | Choice | -|---|---| -| Framework | Next.js 14 (App Router) + TypeScript | -| Styling | Tailwind CSS v4 + CSS variables | -| Code editor | CodeMirror 6 via `@uiw/react-codemirror` | -| Fonts | Syne (display) · DM Sans (body) · JetBrains Mono (code) | -| Hosting | Vercel | -| Package manager | pnpm | +| Layer | Choice | +| --------------- | ------------------------------------------------------- | +| Framework | Next.js 14 (App Router) + TypeScript | +| Styling | Tailwind CSS v4 + CSS variables | +| Code editor | CodeMirror 6 via `@uiw/react-codemirror` | +| Fonts | Syne (display) · DM Sans (body) · JetBrains Mono (code) | +| Hosting | Vercel | +| Package manager | pnpm | --- @@ -112,14 +112,14 @@ src/ ## Roadmap -| Phase | Status | Description | -|---|---|---| -| 1 — Foundation | ✅ Done | Project scaffold, design system, XscpData types | -| 2 — Editor UI | ✅ Done | CodeMirror 6 editor, tabs, convert button | -| 3 — Conversion engine | 🔄 Next | HTML parser → CSS parser → XscpData builder → clipboard | -| 4 — Landing page | ⏳ Pending | Hero, How it works, Features, animations | -| 5 — Testing & QA | ⏳ Pending | Webflow paste tests, Lighthouse audit | -| 6 — Launch | ⏳ Pending | Domain, OG image, Product Hunt | +| Phase | Status | Description | +| --------------------- | ---------- | ------------------------------------------------------- | +| 1 — Foundation | ✅ Done | Project scaffold, design system, XscpData types | +| 2 — Editor UI | ✅ Done | CodeMirror 6 editor, tabs, convert button | +| 3 — Conversion engine | 🔄 Next | HTML parser → CSS parser → XscpData builder → clipboard | +| 4 — Landing page | ⏳ Pending | Hero, How it works, Features, animations | +| 5 — Testing & QA | ⏳ Pending | Webflow paste tests, Lighthouse audit | +| 6 — Launch | ⏳ Pending | Domain, OG image, Product Hunt | --- @@ -131,13 +131,27 @@ Codeflow outputs the `@webflow/XscpData` JSON structure: { "type": "@webflow/XscpData", "payload": { - "nodes": [{ "_id": "uuid", "type": "Block", "tag": "div", "classes": ["style-uuid"], "children": [] }], - "styles": [{ "_id": "style-uuid", "name": "section_hero", "styleLess": "display: flex; padding: 4rem 2rem;" }], + "nodes": [ + { "_id": "uuid", "type": "Block", "tag": "div", "classes": ["style-uuid"], "children": [] } + ], + "styles": [ + { + "_id": "style-uuid", + "name": "section_hero", + "styleLess": "display: flex; padding: 4rem 2rem;" + } + ], "assets": [], "ix1": [], "ix2": { "interactions": [], "events": [], "actionLists": [] } }, - "meta": { "unlinkedSymbolCount": 0, "droppedLinks": 0, "dynBindRemovedCount": 0, "dynListBindRemovedCount": 0, "paginationRemovedCount": 0 } + "meta": { + "unlinkedSymbolCount": 0, + "droppedLinks": 0, + "dynBindRemovedCount": 0, + "dynListBindRemovedCount": 0, + "paginationRemovedCount": 0 + } } ``` @@ -151,4 +165,4 @@ MIT — free to use, fork, and build on. --- -*Built with [Claude Code](https://claude.com/claude-code)* +_Built with [Claude Code](https://claude.com/claude-code)_ diff --git a/src/components/Editor/EditorPanel.tsx b/src/components/Editor/EditorPanel.tsx index 78c8972..097fc19 100644 --- a/src/components/Editor/EditorPanel.tsx +++ b/src/components/Editor/EditorPanel.tsx @@ -7,6 +7,7 @@ import dynamic from "next/dynamic"; import EditorTabs from "./EditorTabs"; import ConvertButton from "./ConvertButton"; import { STARTER_HTML, STARTER_CSS, STARTER_JS } from "@/lib/editor/starterCode"; +import { convert } from "@/lib/converter"; import type { EditorLanguage } from "./CodeEditor"; // Dynamically import CodeEditor to avoid SSR issues with CodeMirror @@ -52,12 +53,21 @@ export default function EditorPanel() { const handleConvert = async () => { setIsConverting(true); try { - // Conversion engine is built in Phase 3. - // For now, show a placeholder success to validate the clipboard flow. - await new Promise((r) => setTimeout(r, 600)); // simulate async work - showToast({ type: "success", message: "Copied! Paste into Webflow Designer (Ctrl+V)" }); + const result = await convert({ html: htmlCode, css: cssCode, js: jsCode }); + if (result.success) { + const msg = + result.warnings && result.warnings.length > 0 + ? `Copied! ${result.warnings.length} warning(s) — check Webflow` + : "Copied! Paste into Webflow Designer (Ctrl+V)"; + showToast({ type: "success", message: msg }); + } else { + showToast({ + type: "error", + message: result.error ?? "Conversion failed. Check your HTML/CSS.", + }); + } } catch { - showToast({ type: "error", message: "Conversion failed. Check your HTML/CSS syntax." }); + showToast({ type: "error", message: "Unexpected error during conversion." }); } finally { setIsConverting(false); } diff --git a/src/lib/converter/client-first.ts b/src/lib/converter/client-first.ts new file mode 100644 index 0000000..7d61ac3 --- /dev/null +++ b/src/lib/converter/client-first.ts @@ -0,0 +1,91 @@ +// src/lib/converter/client-first.ts +// Validates and transforms class names to follow Finsweet Client-First conventions. +// Reference: https://finsweet.com/client-first/docs/classes-strategy-1 +// +// Rules: +// Custom classes → underscore separator: section_hero, hero_content +// Utility classes → hyphen only (no underscore): text-size-large, margin-top-medium +// General → specific: hero_heading-large (NOT large-heading-hero) +// No abbreviations: section_header (NOT sec-h) + +// HTML tag → default Client-First base name +const TAG_CF_DEFAULTS: Record = { + section: "section", + header: "section_header", + footer: "section_footer", + nav: "navbar_component", + main: "page_main", + article: "article", + aside: "sidebar", + form: "form_component", + ul: "list", + ol: "list", + li: "list_item", + figure: "figure", + figcaption: "figure_caption", +}; + +// Webflow utility class names that should not get the folder prefix +const UTILITY_PATTERNS = [ + /^text-/, + /^margin-/, + /^padding-/, + /^background-/, + /^border-/, + /^display-/, + /^flex-/, + /^grid-/, + /^gap-/, + /^width-/, + /^height-/, + /^overflow-/, + /^position-/, + /^z-index-/, + /^opacity-/, + /^container-/, + /^button$/, + /^is-/, + /^hide$/, + /^show$/, +]; + +export function isUtilityClass(name: string): boolean { + return UTILITY_PATTERNS.some((pattern) => pattern.test(name)); +} + +export function isClientFirstCompliant(name: string): boolean { + // Utility classes: hyphens only, no underscore + if (isUtilityClass(name)) return !name.includes("_"); + // Custom classes: must have exactly one underscore (folder_name) + const parts = name.split("_"); + return parts.length === 2 && parts[0].length > 0 && parts[1].length > 0; +} + +// Given a raw class name, return the most Client-First version we can infer. +// If the name is already CF-compliant, return it unchanged. +export function toClientFirst(rawName: string): string { + // Already has underscore folder structure — leave as-is + if (rawName.includes("_")) return rawName; + + // Known utility pattern — leave as-is + if (isUtilityClass(rawName)) return rawName; + + // Generic single word (e.g. "hero", "wrapper", "content") — treat as + // a component name and let the context add the folder in the builder. + return rawName; +} + +// Generate a sensible CF class name for an element that has no classes. +// Uses tag name and an optional context hint (parent class name). +export function generateCFName(tag: string, context?: string): string { + const base = TAG_CF_DEFAULTS[tag]; + if (base) return base; + + // Use context (parent class) as the folder prefix + if (context) { + const folder = context.split("_")[0] || context; + return `${folder}_${tag}`; + } + + return `${tag}_component`; +} diff --git a/src/lib/converter/clipboard.ts b/src/lib/converter/clipboard.ts new file mode 100644 index 0000000..8996cc9 --- /dev/null +++ b/src/lib/converter/clipboard.ts @@ -0,0 +1,41 @@ +// src/lib/converter/clipboard.ts +// Copies Webflow XscpData JSON to the clipboard. +// Webflow Designer reads the application/json MIME slot specifically. +// navigator.clipboard.write() blocks non-safe MIME types, but +// execCommand('copy') + clipboardData.setData() can write application/json +// when called within a user gesture (button click). + +import type { WebflowXscpData } from "./types"; + +export async function copyToWebflowClipboard(data: WebflowXscpData): Promise { + const json = JSON.stringify(data); + + // Primary: execCommand copy — can write application/json MIME type + // (navigator.clipboard.write() blocks non-safe MIME types in all browsers) + const execResult = await new Promise((resolve) => { + const handler = (e: ClipboardEvent) => { + try { + e.clipboardData?.setData("application/json", json); + e.clipboardData?.setData("text/plain", json); // belt-and-suspenders + e.preventDefault(); + resolve(true); + } catch { + resolve(false); + } + }; + document.addEventListener("copy", handler, { once: true }); + const success = document.execCommand("copy"); + if (!success) { + document.removeEventListener("copy", handler); + resolve(false); + } + }); + + if (execResult) return; + + // Fallback: writeText — works everywhere but only writes text/plain. + // Webflow may not read this, but it's better than a hard failure. + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(json); + } +} diff --git a/src/lib/converter/css-parser.ts b/src/lib/converter/css-parser.ts new file mode 100644 index 0000000..32d252a --- /dev/null +++ b/src/lib/converter/css-parser.ts @@ -0,0 +1,180 @@ +// src/lib/converter/css-parser.ts +// Parses a CSS string into a structured rule map. +// Handles @media queries → Webflow responsive variants. + +import { WEBFLOW_BREAKPOINTS, type WebflowVariant } from "./types"; + +// Per-class parsed data: base styles + responsive variant overrides +export interface ParsedClassData { + base: Record; + variants: { + medium?: Record; + small?: Record; + tiny?: Record; + }; + // If this was parsed from a compound selector (e.g. .button.is-primary), + // baseClass holds the first class name (the "parent" combo class) + baseClass?: string; +} + +// Map from CSS class name (or "base.modifier" for combos) → parsed data +export type CSSRuleMap = Map; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function stripComments(css: string): string { + return css.replace(/\/\*[\s\S]*?\*\//g, ""); +} + +function parseDeclarations(block: string): Record { + const props: Record = {}; + for (const decl of block.split(";")) { + const colonIdx = decl.indexOf(":"); + if (colonIdx === -1) continue; + const prop = decl.slice(0, colonIdx).trim(); + const value = decl.slice(colonIdx + 1).trim(); + if (prop && value) props[prop] = value; + } + return props; +} + +// Extract class names from a selector. +// ".hero_content" → [["hero_content"]] single class +// ".button.is-primary" → [["button", "is-primary"]] compound (combo) +// ".hero, .card" → [["hero"], ["card"]] grouped selectors +function extractClassGroups(selector: string): string[][] { + return selector.split(",").map((part) => { + const matches = part.trim().match(/\.(-?[a-zA-Z_][a-zA-Z0-9_-]*)/g); + return matches ? matches.map((m) => m.slice(1)) : []; + }); +} + +// Use bracket-counting to reliably extract @media block content and condition. +function extractMediaBlocks(css: string): Array<{ condition: string; content: string }> { + const blocks: Array<{ condition: string; content: string }> = []; + let i = 0; + + while (i < css.length) { + const atIdx = css.indexOf("@media", i); + if (atIdx === -1) break; + + const openBrace = css.indexOf("{", atIdx); + if (openBrace === -1) break; + + const condition = css.slice(atIdx + 6, openBrace).trim(); + + // Count brackets to find the matching closing brace + let depth = 1; + let j = openBrace + 1; + while (j < css.length && depth > 0) { + if (css[j] === "{") depth++; + else if (css[j] === "}") depth--; + j++; + } + + blocks.push({ condition, content: css.slice(openBrace + 1, j - 1) }); + i = j; + } + + return blocks; +} + +// Strip all @media blocks from a CSS string, leaving only top-level rules. +function stripMediaBlocks(css: string): string { + let result = css; + let i = 0; + + while (i < result.length) { + const atIdx = result.indexOf("@media", i); + if (atIdx === -1) break; + + const openBrace = result.indexOf("{", atIdx); + if (openBrace === -1) break; + + let depth = 1; + let j = openBrace + 1; + while (j < result.length && depth > 0) { + if (result[j] === "{") depth++; + else if (result[j] === "}") depth--; + j++; + } + + result = result.slice(0, atIdx) + result.slice(j); + i = atIdx; + } + + return result; +} + +// ─── Rule parser ───────────────────────────────────────────────────────────── + +function applyRules(cssBlock: string, map: CSSRuleMap, variant: WebflowVariant | undefined): void { + // Simple rule regex: captures selector and declaration block + const ruleRegex = /([^{@][^{]*)\{([^}]*)\}/g; + let match: RegExpExecArray | null; + + while ((match = ruleRegex.exec(cssBlock)) !== null) { + const selector = match[1].trim(); + const declarations = parseDeclarations(match[2]); + + if (!selector || Object.keys(declarations).length === 0) continue; + + const classGroups = extractClassGroups(selector); + + for (const group of classGroups) { + if (group.length === 0) continue; + + // Single class: ".hero" → key = "hero" + // Compound: ".button.is-primary" → key = "button.is-primary" + const key = group.join("."); + + if (!map.has(key)) { + const entry: ParsedClassData = { base: {}, variants: {} }; + if (group.length > 1) entry.baseClass = group[0]; + map.set(key, entry); + } + + const entry = map.get(key)!; + + if (!variant) { + Object.assign(entry.base, declarations); + } else { + if (!entry.variants[variant]) entry.variants[variant] = {}; + Object.assign(entry.variants[variant]!, declarations); + } + } + } +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +export function parseCss(css: string): CSSRuleMap { + const map: CSSRuleMap = new Map(); + const cleaned = stripComments(css); + + // 1. Parse top-level (non-media) rules + const baseOnly = stripMediaBlocks(cleaned); + applyRules(baseOnly, map, undefined); + + // 2. Parse @media blocks, map max-width → Webflow variant + const mediaBlocks = extractMediaBlocks(cleaned); + for (const { condition, content } of mediaBlocks) { + const maxWidthMatch = condition.match(/max-width:\s*(\d+)px/); + if (!maxWidthMatch) continue; + + const variant = WEBFLOW_BREAKPOINTS[maxWidthMatch[1]] as WebflowVariant | undefined; + if (variant) applyRules(content, map, variant); + } + + return map; +} + +// Serialize a properties object to Webflow's styleLess string format. +// e.g. { display: "flex", padding: "2rem" } → "display: flex; padding: 2rem;" +export function toStyleLess(props: Record): string { + return ( + Object.entries(props) + .map(([p, v]) => `${p}: ${v}`) + .join("; ") + ";" + ); +} diff --git a/src/lib/converter/html-parser.ts b/src/lib/converter/html-parser.ts new file mode 100644 index 0000000..4ab32d1 --- /dev/null +++ b/src/lib/converter/html-parser.ts @@ -0,0 +1,174 @@ +// src/lib/converter/html-parser.ts +// Parses an HTML string into a flat list of Webflow nodes. +// Uses the browser's DOMParser — must run client-side only. + +import { generateUUID } from "@/lib/utils/uuid"; +import type { WebflowNode, WebflowNodeType, WebflowNodeData } from "./types"; + +// ─── Tag → Webflow node type ───────────────────────────────────────────────── + +const TAG_TYPE_MAP: Record = { + div: "Block", + section: "Block", + article: "Block", + header: "Block", + footer: "Block", + main: "Block", + nav: "Block", + aside: "Block", + span: "Block", + figure: "Block", + figcaption: "Block", + h1: "Heading", + h2: "Heading", + h3: "Heading", + h4: "Heading", + h5: "Heading", + h6: "Heading", + p: "Paragraph", + a: "Link", + img: "Image", + ul: "List", + ol: "List", + li: "ListItem", + form: "FormForm", + button: "FormButton", + input: "FormTextInput", +}; + +const HEADING_LEVEL: Record = { + h1: 1, + h2: 2, + h3: 3, + h4: 4, + h5: 5, + h6: 6, +}; + +// ─── Recursive DOM walker ──────────────────────────────────────────────────── + +export interface ParsedHTMLResult { + nodes: WebflowNode[]; + rootIds: string[]; // IDs of top-level nodes (for XscpData assembly) + warnings: string[]; +} + +function walkElement(el: Element, allNodes: WebflowNode[], warnings: string[]): string { + const tag = el.tagName.toLowerCase(); + const id = generateUUID(); + const type: WebflowNodeType = TAG_TYPE_MAP[tag] ?? "Block"; + + // Collect class names — an element may have multiple classes + const classNames = Array.from(el.classList); + + // Build node data — keep only fields Webflow expects + const data: WebflowNodeData = { + tag, + xattr: [], + search: { exclude: false }, + visibility: { conditions: [] }, + }; + + if (type === "Heading") { + data.level = HEADING_LEVEL[tag]; + } + + if (type === "Link") { + const href = el.getAttribute("href") ?? ""; + const target = el.getAttribute("target") ?? ""; + // "section" mode requires a real element ID — bare "#" is external + const mode = href.startsWith("#") && href.length > 1 ? "section" : "external"; + data.link = { + url: href, + mode, + ...(target ? { target } : {}), + }; + // Do NOT duplicate href into data.attr — data.link already owns it + } + + if (type === "Image") { + const src = el.getAttribute("src") ?? ""; + const alt = el.getAttribute("alt") ?? ""; + data.img = { url: src, alt }; + } + + // Custom attributes only (id, data-*, aria-*, etc.) — never standard HTML attrs + const genericAttrs: Record = {}; + for (const attr of Array.from(el.attributes)) { + if (!["class", "href", "src", "alt", "target"].includes(attr.name)) { + genericAttrs[attr.name] = attr.value; + } + } + if (Object.keys(genericAttrs).length > 0) { + data.attr = genericAttrs; + } + + // Walk children: collect element children as child IDs, + // and accumulate direct text content into the parent's v field. + const childIds: string[] = []; + const textParts: string[] = []; + + for (const child of Array.from(el.childNodes)) { + if (child.nodeType === Node.ELEMENT_NODE) { + const childId = walkElement(child as Element, allNodes, warnings); + childIds.push(childId); + } else if (child.nodeType === Node.TEXT_NODE) { + const text = child.textContent?.trim() ?? ""; + if (text) textParts.push(text); + } + } + + const inlineText = textParts.join(" ") || undefined; + + // classes array is populated later by webflow-builder (needs style UUIDs) + // We temporarily store class names in displayName for builder lookup + const node: WebflowNode = { + _id: id, + type, + tag, + classes: [], // filled by webflow-builder + children: childIds, + data: { + ...data, + // Store original class names for builder to look up style UUIDs + displayName: classNames.join(" "), + }, + ...(inlineText !== undefined ? { v: inlineText } : {}), + }; + + if (type === "Heading" && data.level !== undefined) { + node.data.level = data.level; + } + + allNodes.push(node); + return id; +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +export function parseHtml(html: string): ParsedHTMLResult { + const warnings: string[] = []; + const allNodes: WebflowNode[] = []; + + if (!html.trim()) { + return { nodes: [], rootIds: [], warnings: ["HTML input is empty"] }; + } + + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + const body = doc.body; + + const rootIds: string[] = []; + for (const child of Array.from(body.childNodes)) { + if (child.nodeType === Node.ELEMENT_NODE) { + const id = walkElement(child as Element, allNodes, warnings); + rootIds.push(id); + } + } + + if (rootIds.length === 0) { + warnings.push("No valid HTML elements found"); + } + + return { nodes: allNodes, rootIds, warnings }; +} diff --git a/src/lib/converter/index.ts b/src/lib/converter/index.ts new file mode 100644 index 0000000..d686901 --- /dev/null +++ b/src/lib/converter/index.ts @@ -0,0 +1,49 @@ +// src/lib/converter/index.ts +// Main orchestrator: HTML + CSS + JS → @webflow/XscpData → clipboard + +import { parseCss } from "./css-parser"; +import { parseHtml } from "./html-parser"; +import { buildXscpData } from "./webflow-builder"; +import { copyToWebflowClipboard } from "./clipboard"; +import type { ConvertInput, ConvertResult } from "./types"; + +export async function convert(input: ConvertInput): Promise { + const warnings: string[] = []; + + try { + // 1. Parse CSS → rule map (breakpoints, properties per class) + const cssMap = parseCss(input.css); + + // 2. Parse HTML → flat node list + root IDs + const { nodes, rootIds, warnings: htmlWarnings } = parseHtml(input.html); + warnings.push(...htmlWarnings); + + if (nodes.length === 0) { + return { + success: false, + error: "No HTML elements found to convert.", + warnings, + }; + } + + // 3. Build XscpData JSON (assigns style UUIDs, handles JS embed) + const data = buildXscpData(nodes, rootIds, cssMap, input.js, warnings); + + // Debug: inspect the generated JSON in DevTools before clipboard write + console.log("[Codeflow] XscpData JSON:", JSON.stringify(data, null, 2)); + + // 4. Copy to clipboard with application/json MIME type + await copyToWebflowClipboard(data); + + return { success: true, data, warnings }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Conversion failed", + warnings, + }; + } +} + +// Re-export types for consumers +export type { ConvertInput, ConvertResult } from "./types"; diff --git a/src/lib/converter/types.ts b/src/lib/converter/types.ts index a2af001..cb6f0c1 100644 --- a/src/lib/converter/types.ts +++ b/src/lib/converter/types.ts @@ -31,7 +31,6 @@ export type WebflowNodeType = export interface WebflowNodeData { tag?: string; - text?: boolean; xattr?: Array<{ name: string; value: string }>; search?: { exclude: boolean }; visibility?: { conditions: unknown[] }; @@ -67,6 +66,7 @@ export interface WebflowNode { classes: string[]; // Array of style _id UUIDs children: string[]; // Array of child node _id UUIDs data: WebflowNodeData; + v?: string; // Inline text content (Webflow's format for text inside elements) } // ============================================================ diff --git a/src/lib/converter/webflow-builder.ts b/src/lib/converter/webflow-builder.ts new file mode 100644 index 0000000..01639e1 --- /dev/null +++ b/src/lib/converter/webflow-builder.ts @@ -0,0 +1,225 @@ +// src/lib/converter/webflow-builder.ts +// Assembles a complete @webflow/XscpData structure from parsed HTML nodes +// and the CSS rule map. + +import { generateUUID } from "@/lib/utils/uuid"; +import { toStyleLess, type CSSRuleMap } from "./css-parser"; +import { generateCFName } from "./client-first"; +import type { WebflowNode, WebflowStyle, WebflowXscpData, WebflowStyleVariants } from "./types"; + +// ─── Style registry ─────────────────────────────────────────────────────────── +// Tracks which CSS class names have already been converted to Webflow styles, +// so shared classes (e.g. "button" used on multiple elements) only appear once. + +interface StyleRegistry { + // cssKey (e.g. "button" or "button.is-primary") → Webflow style _id + byKey: Map; + // className (single name) → Webflow style _id + byName: Map; + styles: WebflowStyle[]; +} + +function makeRegistry(): StyleRegistry { + return { byKey: new Map(), byName: new Map(), styles: [] }; +} + +// Build a WebflowStyle from a CSS rule map entry. +function buildStyle( + name: string, + cssKey: string, + cssMap: CSSRuleMap, + registry: StyleRegistry, + comboBaseId?: string +): WebflowStyle { + const id = generateUUID(); + const entry = cssMap.get(cssKey); + + const styleLess = entry ? toStyleLess(entry.base) : ""; + + const variants: WebflowStyleVariants = {}; + if (entry?.variants.medium && Object.keys(entry.variants.medium).length > 0) { + variants.medium = { styleLess: toStyleLess(entry.variants.medium) }; + } + if (entry?.variants.small && Object.keys(entry.variants.small).length > 0) { + variants.small = { styleLess: toStyleLess(entry.variants.small) }; + } + if (entry?.variants.tiny && Object.keys(entry.variants.tiny).length > 0) { + variants.tiny = { styleLess: toStyleLess(entry.variants.tiny) }; + } + + return { + _id: id, + fake: false, + type: "class", + name, + namespace: "", + comb: comboBaseId ?? "", + styleLess, + variants, + children: [], + selector: null, + }; +} + +// Given an element's class list (e.g. ["button", "is-primary"]), +// return the ordered list of Webflow style _ids to assign to that node. +// Creates styles as needed and registers them. +function resolveStyleIds( + classNames: string[], + cssMap: CSSRuleMap, + registry: StyleRegistry +): string[] { + if (classNames.length === 0) return []; + + const ids: string[] = []; + + // First class is always the "base" class + const baseClassName = classNames[0]; + const baseCssKey = baseClassName; + + let baseId = registry.byName.get(baseClassName); + if (!baseId) { + const style = buildStyle(baseClassName, baseCssKey, cssMap, registry); + baseId = style._id; + registry.byKey.set(baseCssKey, baseId); + registry.byName.set(baseClassName, baseId); + registry.styles.push(style); + } + ids.push(baseId); + + // Subsequent classes are combo classes (modifiers on the base) + for (let i = 1; i < classNames.length; i++) { + const comboName = classNames[i]; + // Compound CSS key for lookup: "button.is-primary" + const compoundKey = `${baseClassName}.${comboName}`; + // Also try just the modifier name as a key + const simpleKey = comboName; + + const registryKey = `${baseClassName}__${comboName}`; + let comboId = registry.byKey.get(registryKey); + + if (!comboId) { + // Look for compound CSS rule first, then fall back to simple key + const cssKey = cssMap.has(compoundKey) ? compoundKey : simpleKey; + const style = buildStyle(comboName, cssKey, cssMap, registry, baseId); + comboId = style._id; + registry.byKey.set(registryKey, comboId); + registry.styles.push(style); + } + + ids.push(comboId); + } + + return ids; +} + +// ─── Node finalizer ─────────────────────────────────────────────────────────── +// html-parser stores class names in displayName temporarily. +// This pass assigns real style UUIDs and clears displayName. + +function finalizeNodes( + nodes: WebflowNode[], + cssMap: CSSRuleMap, + registry: StyleRegistry, + warnings: string[] +): void { + for (const node of nodes) { + const rawDisplayName = node.data.displayName ?? ""; + const classNames = rawDisplayName ? rawDisplayName.split(/\s+/).filter(Boolean) : []; + + if (classNames.length === 0) { + // No classes — generate a Client-First name based on tag + const cfName = generateCFName(node.tag); + const style = buildStyle(cfName, cfName, cssMap, registry); + registry.byName.set(cfName, style._id); + registry.styles.push(style); + node.classes = [style._id]; + warnings.push(`<${node.tag}> had no classes — assigned "${cfName}" (edit in Webflow)`); + } else { + node.classes = resolveStyleIds(classNames, cssMap, registry); + } + + // Remove the temporary class name storage — don't leave empty string on the node + delete node.data.displayName; + } +} + +// ─── JS embed ──────────────────────────────────────────────────────────────── + +function buildJsEmbedNode(js: string): WebflowNode { + return { + _id: generateUUID(), + type: "CodeBlock", + tag: "div", + classes: [], + children: [], + data: { + tag: "div", + xattr: [], + search: { exclude: false }, + visibility: { conditions: [] }, + displayName: "Custom Code", + attr: {}, + script: { + head: "", + body: ``, + }, + }, + }; +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +export function buildXscpData( + nodes: WebflowNode[], + rootIds: string[], + cssMap: CSSRuleMap, + js: string, + warnings: string[] +): WebflowXscpData { + const registry = makeRegistry(); + + // First pass: build all CSS classes referenced in nodes that exist in cssMap. + // Pre-register any CSS classes not referenced by any element + // (in case the user wrote CSS for classes not in their HTML snippet). + cssMap.forEach((_value, key) => { + const firstName = key.split(".")[0]; + if (!registry.byName.has(firstName) && !key.includes(".")) { + const style = buildStyle(firstName, key, cssMap, registry); + registry.byKey.set(key, style._id); + registry.byName.set(firstName, style._id); + registry.styles.push(style); + } + }); + + // Second pass: assign style UUIDs to all parsed nodes + finalizeNodes(nodes, cssMap, registry, warnings); + + // Optionally append JS as a custom code embed node + const finalNodes = [...nodes]; + const finalRootIds = [...rootIds]; + + if (js.trim()) { + const jsNode = buildJsEmbedNode(js); + finalNodes.push(jsNode); + finalRootIds.push(jsNode._id); + } + + return { + type: "@webflow/XscpData", + payload: { + nodes: finalNodes, + styles: registry.styles, + assets: [], + ix1: [], + ix2: { interactions: [], events: [], actionLists: [] }, + }, + meta: { + unlinkedSymbolCount: 0, + droppedLinks: 0, + dynBindRemovedCount: 0, + dynListBindRemovedCount: 0, + paginationRemovedCount: 0, + }, + }; +} diff --git a/src/lib/utils/uuid.ts b/src/lib/utils/uuid.ts new file mode 100644 index 0000000..cdfa11f --- /dev/null +++ b/src/lib/utils/uuid.ts @@ -0,0 +1,14 @@ +// src/lib/utils/uuid.ts +// Generates Webflow-style UUIDs (standard v4 format) + +export function generateUUID(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + // Fallback for environments without crypto.randomUUID + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +}