${escapeHtml(title)}
+ ${subtitle ? `${escapeHtml(subtitle)}
` : ''} + +Palette
+Typography
+Components
+Production-quality artifact
+Sample card showing how surfaces, borders, and accent text behave in this system.
+commit a46764fb1bc800d0b40687be5903cac7419decf6
Author: Zakaria
${escapeHtml(subtitle)}
` : ''} + +Sample card showing how surfaces, borders, and accent text behave in this system.
+`); + } + return `${html}${tag}`; +} + +export function normalizeDeployHookScriptUrl(raw) { + if (typeof raw !== 'string') return ''; + const trimmed = raw.trim(); + if (!trimmed) return ''; + try { + const url = new URL(trimmed); + if (url.protocol !== 'https:' && url.protocol !== 'http:') return ''; + return url.toString(); + } catch { + return ''; + } +} + +function escapeHtmlAttribute(value) { + return String(value) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} + +function rewriteSrcset(raw, baseDir) { + return String(raw) + .split(',') + .map((part) => { + const trimmed = part.trim(); + if (!trimmed) return part; + const pieces = trimmed.split(/\s+/); + const nextUrl = rewriteHtmlReference(pieces[0], baseDir); + return [nextUrl, ...pieces.slice(1)].join(' '); + }) + .join(', '); +} + +function parseHtmlTags(html) { + const tags = []; + const rawTextRanges = htmlRawTextRanges(html); + const tagRe = /<([A-Za-z][A-Za-z0-9:-]*)([^<>]*?)>/g; + let match; + while ((match = tagRe.exec(String(html)))) { + if (isOffsetInRanges(match.index, rawTextRanges)) continue; + tags.push({ + name: String(match[1]).toLowerCase(), + attrs: match[2] || '', + }); + } + return tags; +} + +function htmlRawTextRanges(html) { + const source = String(html); + const ranges = []; + + const commentRe = //g; + let match; + while ((match = commentRe.exec(source))) { + ranges.push([match.index, match.index + match[0].length]); + } + + const rawTagRe = /<(script|style)\b[^<>]*>/gi; + while ((match = rawTagRe.exec(source))) { + const tagName = String(match[1]).toLowerCase(); + const contentStart = match.index + match[0].length; + const closeRe = new RegExp(`${tagName}\\s*>`, 'gi'); + closeRe.lastIndex = contentStart; + const close = closeRe.exec(source); + const contentEnd = close ? close.index : source.length; + if (contentEnd > contentStart) ranges.push([contentStart, contentEnd]); + rawTagRe.lastIndex = close ? close.index + close[0].length : source.length; + } + + return ranges; +} + +function isOffsetInRanges(offset, ranges) { + return ranges.some(([start, end]) => offset >= start && offset < end); +} + +function parseHtmlAttributes(rawAttrs) { + const attrs = new Map(); + const attrRe = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g; + let match; + while ((match = attrRe.exec(String(rawAttrs)))) { + attrs.set(String(match[1]).toLowerCase(), match[2] ?? match[3] ?? match[4] ?? ''); + } + return attrs; +} + +function rewriteHtmlAttributes(rawAttrs, tagName, attrs, baseDir) { + const shouldRewriteHref = shouldCollectHref(tagName, attrs); + return String(rawAttrs).replace( + /([^\s"'<>/=]+)(\s*=\s*)("([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g, + (full, rawName, equals, rawValue, doubleQuoted, singleQuoted, unquoted) => { + const name = String(rawName).toLowerCase(); + if ( + name !== 'src' && + name !== 'poster' && + name !== 'srcset' && + name !== 'href' && + name !== 'style' + ) { + return full; + } + if (name === 'href' && !shouldRewriteHref) return full; + + const value = doubleQuoted ?? singleQuoted ?? unquoted ?? ''; + let nextValue; + if (name === 'srcset') nextValue = rewriteSrcset(value, baseDir); + else if (name === 'style') nextValue = rewriteCssReferences(value, baseDir); + else nextValue = rewriteHtmlReference(value, baseDir); + if (doubleQuoted !== undefined) return `${rawName}${equals}"${nextValue}"`; + if (singleQuoted !== undefined) return `${rawName}${equals}'${nextValue}'`; + return `${rawName}${equals}${nextValue}`; + }, + ); +} + +function shouldCollectHref(tagName, attrs) { + if (tagName !== 'link') return false; + const rel = String(attrs.get('rel') || '').toLowerCase(); + if (!rel) return false; + return rel.split(/\s+/).some((item) => ( + item === 'stylesheet' || + item === 'icon' || + item === 'apple-touch-icon' || + item === 'manifest' || + item === 'preload' || + item === 'modulepreload' || + item === 'prefetch' + )); +} + +function rewriteHtmlReference(raw, baseDir) { + if (typeof raw !== 'string') return raw; + const trimmed = raw.trim(); + if (!trimmed || trimmed.startsWith('/') || trimmed.startsWith('#')) return raw; + const resolved = resolveReferencedPath(raw, baseDir); + if (!resolved) return raw; + const suffix = referenceSuffix(trimmed); + return `${resolved}${suffix}`; +} + +function referenceSuffix(raw) { + const queryIdx = raw.indexOf('?'); + const hashIdx = raw.indexOf('#'); + const suffixIdx = + queryIdx === -1 ? hashIdx : hashIdx === -1 ? queryIdx : Math.min(queryIdx, hashIdx); + return suffixIdx === -1 ? '' : raw.slice(suffixIdx); +} + +async function pollVercelDeployment(config, id) { + let last = null; + for (let i = 0; i < 30; i += 1) { + await new Promise((resolve) => setTimeout(resolve, i < 5 ? 1000 : 2000)); + const resp = await fetch( + `${VERCEL_API}/v13/deployments/${encodeURIComponent(id)}${vercelTeamQuery(config)}`, + { headers: { Authorization: `Bearer ${config.token}` } }, + ); + const json = await readVercelJson(resp); + if (!resp.ok) throw vercelError(json, resp.status); + last = json; + if (json.readyState === 'READY' || json.readyState === 'ERROR') return json; + } + return last; +} + +export async function waitForReachableDeploymentUrl( + urls, + { timeoutMs = 60_000, intervalMs = 2_000 } = {}, +) { + const candidates = [...new Set((urls || []).map(normalizeDeploymentUrl).filter(Boolean))]; + const fallbackUrl = candidates[0] || ''; + if (!fallbackUrl) { + return { + status: 'link-delayed', + url: '', + statusMessage: 'Vercel did not return a public deployment URL.', + }; + } + + const startedAt = Date.now(); + let lastMessage = ''; + while (Date.now() - startedAt <= timeoutMs) { + for (const url of candidates) { + const result = await checkDeploymentUrl(url); + if (result.reachable) { + return { + status: 'ready', + url, + statusMessage: 'Public link is ready.', + reachableAt: Date.now(), + }; + } + if (result.status === 'protected') { + return { + status: 'protected', + url, + statusMessage: result.statusMessage || VERCEL_PROTECTED_MESSAGE, + }; + } + lastMessage = result.statusMessage || lastMessage; + } + if (Date.now() - startedAt >= timeoutMs) break; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + return { + status: 'link-delayed', + url: fallbackUrl, + statusMessage: + lastMessage || 'Vercel returned a deployment URL, but it is not reachable yet.', + }; +} + +export async function checkDeploymentUrl(url, { timeoutMs = 8_000 } = {}) { + const normalized = normalizeDeploymentUrl(url); + if (!normalized) { + return { reachable: false, statusMessage: 'Deployment URL is empty.' }; + } + const head = await requestDeploymentUrl(normalized, 'HEAD', timeoutMs); + if (head.reachable) return head; + if (head.status === 'protected') return head; + if (head.statusCode && (head.statusCode === 405 || head.statusCode === 403 || head.statusCode >= 400)) { + const get = await requestDeploymentUrl(normalized, 'GET', timeoutMs); + if (get.reachable) return get; + if (get.status === 'protected') return get; + return get.statusMessage ? get : head; + } + const get = await requestDeploymentUrl(normalized, 'GET', timeoutMs); + return get.reachable ? get : (get.statusMessage ? get : head); +} + +async function requestDeploymentUrl(url, method, timeoutMs) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const resp = await fetch(url, { + method, + redirect: 'manual', + signal: controller.signal, + }); + if (resp.status >= 200 && resp.status < 400) { + return { reachable: true, statusCode: resp.status }; + } + const body = method === 'GET' || resp.status === 401 + ? await resp.text().catch(() => '') + : ''; + if (resp.status === 401 && isVercelProtectedResponse(resp, body)) { + return { + reachable: false, + status: 'protected', + statusCode: resp.status, + statusMessage: VERCEL_PROTECTED_MESSAGE, + }; + } + return { + reachable: false, + statusCode: resp.status, + statusMessage: `Public link returned HTTP ${resp.status}.`, + }; + } catch (err) { + return { + reachable: false, + statusMessage: `Public link is not reachable yet: ${err?.message || String(err)}`, + }; + } finally { + clearTimeout(timer); + } +} + +export function isVercelProtectedResponse(resp, body = '') { + const server = resp.headers?.get?.('server') || ''; + const setCookie = resp.headers?.get?.('set-cookie') || ''; + const text = String(body || ''); + return ( + /vercel/i.test(server) || + /_vercel_sso_nonce/i.test(setCookie) || + /Authentication Required/i.test(text) || + /Vercel Authentication/i.test(text) || + /vercel\.com\/sso-api/i.test(text) + ); +} + +export function deploymentUrlCandidates(...responses) { + const urls = []; + for (const json of responses) { + if (!json) continue; + if (json.url) urls.push(json.url); + for (const alias of json.alias ?? []) urls.push(alias); + for (const alias of json.aliases ?? []) { + if (typeof alias === 'string') urls.push(alias); + else if (alias?.domain) urls.push(alias.domain); + else if (alias?.url) urls.push(alias.url); + } + } + return [...new Set(urls.map(normalizeDeploymentUrl).filter(Boolean))]; +} + +export function normalizeDeploymentUrl(url) { + if (typeof url !== 'string') return ''; + const trimmed = url.trim(); + if (!trimmed) return ''; + return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; +} + +function vercelTeamQuery(config) { + const params = new URLSearchParams(); + if (config.teamId) params.set('teamId', config.teamId); + else if (config.teamSlug) params.set('slug', config.teamSlug); + const s = params.toString(); + return s ? `?${s}` : ''; +} + +async function readVercelJson(resp) { + try { + return await resp.json(); + } catch { + return {}; + } +} + +function vercelError(json, status) { + const code = json?.error?.code; + const message = json?.error?.message || json?.message || `Vercel request failed (${status}).`; + if (code === 'forbidden' || /permission/i.test(message)) { + return new DeployError("You don't have permission to create a project.", status, json); + } + return new DeployError(message, status, json); +} + +function deploymentUrl(json) { + const url = json?.url || json?.alias?.[0] || ''; + if (!url) return ''; + return /^https?:\/\//i.test(url) ? url : `https://${url}`; +} + +function safeVercelProjectName(raw) { + return String(raw) + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80) || `od-${randomUUID().slice(0, 8)}`; +} diff --git a/apps/daemon/src/design-system-preview.ts b/apps/daemon/src/design-system-preview.ts new file mode 100644 index 0000000..74cf160 --- /dev/null +++ b/apps/daemon/src/design-system-preview.ts @@ -0,0 +1,620 @@ +// @ts-nocheck +/** + * Build a showcase HTML page from a DESIGN.md so the user can see what each + * design system looks like *before* generating anything. We don't try to + * render a unique product mockup — we extract the palette, typography, and + * a couple of component conventions, then drop them into one fixed + * template. The full DESIGN.md is rendered below as prose for reference. + * + * Parsing is deliberately permissive: imported systems vary in section + * naming and bullet style, so we use loose regexes and fall back to sane + * defaults when a token isn't found. + */ + +export function renderDesignSystemPreview(id, raw) { + const titleMatch = /^#\s+(.+?)\s*$/m.exec(raw); + const title = cleanTitle(titleMatch?.[1] ?? id); + const subtitle = extractSubtitle(raw); + const colors = extractColors(raw); + const fonts = extractFonts(raw); + + const bg = + pickColor(colors, ['page background', 'background', 'canvas', 'paper', 'bg ', 'page bg']) + ?? pickColor(colors, ['white']) + ?? '#ffffff'; + const fg = + pickColor(colors, ['heading', 'foreground', 'ink', 'fg', 'text', 'navy', 'graphite']) + ?? '#111111'; + // Accent: brand/primary names first, then fall back to the first color + // that doesn't look like a neutral white/black/grey so we always show + // something punchy in the showcase header. + const accent = + pickColor(colors, ['primary brand', 'brand primary', 'primary', 'brand', 'accent']) + ?? firstNonNeutral(colors) + ?? '#2f6feb'; + const muted = pickColor(colors, ['muted', 'secondary', 'neutral', 'subtle', 'caption']) ?? '#777777'; + const border = pickColor(colors, ['border', 'divider', 'rule', 'stroke']) ?? '#e5e5e5'; + const surface = + pickColor(colors, ['surface', 'card', 'background-secondary', 'panel', 'elevated']) + ?? '#ffffff'; + + const display = fonts.display + ?? fonts.heading + ?? "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"; + const body = fonts.body ?? display; + const mono = fonts.mono ?? "ui-monospace, 'JetBrains Mono', monospace"; + + const renderedMarkdown = renderMarkdownLite(raw); + + return ` + +
+ + +
+ + +
+