_shared.js
1 import * as fs from 'node:fs/promises'; 2 import * as os from 'node:os'; 3 import * as path from 'node:path'; 4 5 export const UIVERSE_BASE_URL = 'https://uiverse.io'; 6 7 const ROUTE_DATA_KEY = 'routes/$username.$friendlyId'; 8 const CODE_DATA_KEY = 'routes/resource.post.code.$id'; 9 const EXPORT_TARGET_BUTTON_LABELS = ['React', 'Vue', 'Svelte', 'Lit']; 10 11 function trimPathSegment(value) { 12 return String(value || '').trim().replace(/^\/+|\/+$/g, ''); 13 } 14 15 export function parseComponentInput(input) { 16 const raw = String(input || '').trim(); 17 if (!raw) { 18 throw new Error('Missing component input. Pass a full Uiverse URL or an author/slug identifier.'); 19 } 20 21 let pathname = raw; 22 if (/^https?:\/\//i.test(raw)) { 23 const url = new URL(raw); 24 if (url.hostname !== 'uiverse.io' && url.hostname !== 'www.uiverse.io') { 25 throw new Error(`Unsupported non-Uiverse URL: ${raw}`); 26 } 27 pathname = url.pathname; 28 } 29 30 const cleaned = trimPathSegment(pathname); 31 const segments = cleaned.split('/').filter(Boolean); 32 if (segments.length !== 2) { 33 throw new Error(`Could not parse author/slug from input: ${raw}`); 34 } 35 36 const [username, slug] = segments; 37 if (!username || !slug) { 38 throw new Error(`Invalid component identifier: ${raw}. Expected author/slug.`); 39 } 40 41 return { 42 raw, 43 username, 44 slug, 45 url: `${UIVERSE_BASE_URL}/${username}/${slug}`, 46 }; 47 } 48 49 async function fetchJsonInBrowser(page, url) { 50 const raw = await page.evaluate(`(async () => { 51 const url = ${JSON.stringify(url)}; 52 const response = await fetch(url, { 53 credentials: 'include', 54 headers: { 55 accept: 'application/json, text/plain, */*', 56 }, 57 }); 58 const text = await response.text(); 59 return JSON.stringify({ 60 ok: response.ok, 61 status: response.status, 62 statusText: response.statusText, 63 text, 64 url, 65 }); 66 })()`); 67 68 const result = JSON.parse(raw); 69 if (!result?.ok) { 70 throw new Error(`Request failed: ${result?.status} ${result?.statusText} (${result?.url || url})`); 71 } 72 73 try { 74 return JSON.parse(result.text); 75 } catch { 76 throw new Error(`Response was not valid JSON: ${url}`); 77 } 78 } 79 80 export async function getPostDetails(page, input) { 81 const normalized = parseComponentInput(input); 82 await page.goto(normalized.url); 83 84 const raw = await page.evaluate(`(async () => { 85 const key = ${JSON.stringify(ROUTE_DATA_KEY)}; 86 const loaderData = window.__remixContext?.state?.loaderData || {}; 87 const routeData = loaderData[key]; 88 return JSON.stringify({ routeData: routeData || null, keys: Object.keys(loaderData) }); 89 })()`); 90 91 const parsed = JSON.parse(raw); 92 let routeData = parsed?.routeData; 93 if (!routeData?.post?.id) { 94 const routeUrl = `${normalized.url}?_data=${encodeURIComponent(ROUTE_DATA_KEY)}`; 95 routeData = await fetchJsonInBrowser(page, routeUrl); 96 } 97 98 if (!routeData?.post?.id) { 99 throw new Error(`Could not resolve post.id from the component page: ${normalized.url}`); 100 } 101 102 return { 103 ...normalized, 104 post: routeData.post, 105 routeData, 106 }; 107 } 108 109 export async function getRawCode(page, postId) { 110 const codeUrl = `${UIVERSE_BASE_URL}/resource/post/code/${postId}?v=1&_data=${encodeURIComponent(CODE_DATA_KEY)}`; 111 const payload = await fetchJsonInBrowser(page, codeUrl); 112 if (typeof payload?.html !== 'string' || typeof payload?.css !== 'string') { 113 throw new Error(`Unexpected code payload shape: ${codeUrl}`); 114 } 115 return payload; 116 } 117 118 export function inferLanguage(target, post) { 119 if (target === 'react') return 'tsx'; 120 if (target === 'vue') return 'vue'; 121 if (target === 'html') return post?.isTailwind ? 'html+tailwind' : 'html'; 122 if (target === 'css') return 'css'; 123 return 'text'; 124 } 125 126 export function getCodeLength(code) { 127 return String(code || '').length; 128 } 129 130 function normalizeExportTarget(target) { 131 return String(target || '').trim().toLowerCase() === 'vue' ? 'Vue' : 'React'; 132 } 133 134 export async function extractExportCode(page, target = 'react') { 135 const targetLabel = normalizeExportTarget(target); 136 const raw = await page.evaluate(`(async () => { 137 const targetLabel = ${JSON.stringify(targetLabel)}; 138 const exportButtonLabel = 'Export'; 139 const exportTargetButtonLabels = ${JSON.stringify(EXPORT_TARGET_BUTTON_LABELS)}; 140 141 const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 142 143 const triggerClick = (element) => { 144 if (!element) return; 145 element.focus?.(); 146 const pointer = { bubbles: true, cancelable: true, composed: true, view: window }; 147 const mouse = { bubbles: true, cancelable: true, composed: true, view: window, button: 0, buttons: 1 }; 148 element.dispatchEvent(new PointerEvent('pointerdown', pointer)); 149 element.dispatchEvent(new MouseEvent('mousedown', mouse)); 150 element.dispatchEvent(new PointerEvent('pointerup', pointer)); 151 element.dispatchEvent(new MouseEvent('mouseup', mouse)); 152 element.dispatchEvent(new MouseEvent('click', mouse)); 153 }; 154 155 const isCompleteExportCode = (code) => { 156 if (!code) return false; 157 if (targetLabel === 'Vue') { 158 return code.includes('<template>') 159 && code.includes('</template>') 160 && code.includes('<style') 161 && code.includes('</style>'); 162 } 163 return code.includes('export default') 164 && (code.includes('styled-components') || code.includes('StyledWrapper') || code.includes('styled.')); 165 }; 166 167 const readCode = () => { 168 const dialog = document.querySelector('[role="dialog"]'); 169 if (!dialog) return null; 170 const heading = dialog.querySelector('h1,h2,h3,h4,h5,h6'); 171 if (heading && (heading.textContent || '').trim() !== targetLabel) return null; 172 const textarea = dialog.querySelector('textarea'); 173 if (textarea && textarea.value) return textarea.value; 174 return null; 175 }; 176 177 const exportButton = [...document.querySelectorAll('button')].find((element) => (element.textContent || '').trim() === exportButtonLabel); 178 const currentTargetButton = [...document.querySelectorAll('button')].find((element) => { 179 const text = (element.textContent || '').trim(); 180 return exportTargetButtonLabels.includes(text); 181 }); 182 183 const existing = readCode(); 184 if (!existing && (!exportButton || !currentTargetButton)) { 185 return JSON.stringify({ ok: false, error: 'Could not find the export controls on the page.' }); 186 } 187 188 if (!existing) { 189 const currentLabel = (currentTargetButton.textContent || '').trim(); 190 if (currentLabel === targetLabel) { 191 triggerClick(exportButton); 192 } else { 193 triggerClick(currentTargetButton); 194 let menuItem = null; 195 for (let index = 0; index < 20; index += 1) { 196 menuItem = [...document.querySelectorAll('[role="menuitem"]')].find((element) => (element.textContent || '').trim() === targetLabel); 197 if (menuItem) break; 198 await sleep(100); 199 } 200 if (!menuItem) { 201 return JSON.stringify({ ok: false, error: 'Could not find target in export menu: ' + targetLabel }); 202 } 203 triggerClick(menuItem); 204 } 205 } 206 207 let longest = existing || ''; 208 let longestLooksComplete = isCompleteExportCode(longest); 209 let stableCount = 0; 210 211 for (let index = 0; index < 40; index += 1) { 212 await sleep(200); 213 const code = readCode(); 214 if (!code) continue; 215 216 if (code.length > longest.length) { 217 longest = code; 218 longestLooksComplete = isCompleteExportCode(code); 219 stableCount = 0; 220 continue; 221 } 222 223 if (code === longest) { 224 if (longestLooksComplete) { 225 stableCount += 1; 226 if (stableCount >= 2) { 227 return JSON.stringify({ ok: true, code: longest, length: longest.length }); 228 } 229 } else { 230 stableCount = 0; 231 } 232 } 233 } 234 235 const dialog = document.querySelector('[role="dialog"]'); 236 if (longest && longestLooksComplete) { 237 return JSON.stringify({ ok: true, code: longest, length: longest.length, fallback: true }); 238 } 239 240 return JSON.stringify({ 241 ok: false, 242 error: dialog 243 ? (targetLabel + ' dialog appeared, but the exported code never reached a stable complete state.') 244 : (targetLabel + ' export dialog did not appear after clicking the export controls.'), 245 dialogFound: Boolean(dialog), 246 dialogText: dialog ? (dialog.innerText || '').slice(0, 200) : null, 247 longestLength: longest.length, 248 }); 249 })()`); 250 251 const data = JSON.parse(raw); 252 if (!data?.ok || typeof data.code !== 'string') { 253 throw new Error(data?.error || `Failed to extract ${targetLabel} export code.`); 254 } 255 return data.code; 256 } 257 258 export function parseHtmlRootSignature(html) { 259 const source = String(html || '').trim(); 260 const match = source.match(/^<([a-zA-Z0-9-]+)([^>]*)>/); 261 if (!match) { 262 return { tag: null, id: null, classes: [] }; 263 } 264 265 const [, tag, attrs] = match; 266 const idMatch = attrs.match(/\sid=["']([^"']+)["']/i); 267 const classMatch = attrs.match(/\sclass=["']([^"']+)["']/i); 268 const classes = classMatch ? classMatch[1].split(/\s+/).filter(Boolean) : []; 269 return { 270 tag: tag.toLowerCase(), 271 id: idMatch ? idMatch[1] : null, 272 classes, 273 }; 274 } 275 276 export async function locatePreviewElement(page, html) { 277 const signature = parseHtmlRootSignature(html); 278 const raw = await page.evaluate(`(async () => { 279 const sig = ${JSON.stringify(signature)}; 280 const viewportWidth = window.innerWidth; 281 const viewportHeight = window.innerHeight; 282 const fallbackTags = sig.tag ? [sig.tag] : ['label', 'button', 'a', 'div']; 283 284 const isVisible = (element) => { 285 if (!element || !(element instanceof Element)) return false; 286 if (element.closest('[role="dialog"]')) return false; 287 const style = window.getComputedStyle(element); 288 if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; 289 const rect = element.getBoundingClientRect(); 290 return rect.width > 0 && rect.height > 0; 291 }; 292 293 const scoreCandidate = (element, source) => { 294 const rect = element.getBoundingClientRect(); 295 const centerX = rect.x + rect.width / 2; 296 const centerY = rect.y + rect.height / 2; 297 const area = rect.width * rect.height; 298 let score = 0; 299 if (sig.tag && element.tagName.toLowerCase() === sig.tag) score += 30; 300 if (sig.id && element.id === sig.id) score += 120; 301 if (sig.classes.length && sig.classes.every((className) => element.classList.contains(className))) score += 120; 302 if (centerX <= viewportWidth * 0.65) score += 40; 303 if (centerY <= viewportHeight * 0.6) score += 40; 304 if (area <= viewportWidth * viewportHeight * 0.2) score += 30; 305 if (area <= viewportWidth * viewportHeight * 0.05) score += 20; 306 return { 307 source, 308 tag: element.tagName.toLowerCase(), 309 className: element.className || '', 310 id: element.id || '', 311 score, 312 rect: { 313 x: rect.x, 314 y: rect.y, 315 width: rect.width, 316 height: rect.height, 317 }, 318 }; 319 }; 320 321 const candidates = []; 322 const seen = new Set(); 323 const collect = (element, source) => { 324 if (!isVisible(element)) return; 325 if (seen.has(element)) return; 326 seen.add(element); 327 candidates.push(scoreCandidate(element, source)); 328 }; 329 330 if (sig.id) collect(document.getElementById(sig.id), 'id'); 331 if (sig.classes.length) collect(document.querySelector('.' + sig.classes.join('.')), 'classes'); 332 333 for (const tagName of fallbackTags) { 334 const tagNodes = Array.from(document.querySelectorAll(tagName)); 335 for (const node of tagNodes.slice(0, 200)) { 336 collect(node, 'tag:' + tagName); 337 } 338 } 339 340 candidates.sort((left, right) => { 341 if (right.score !== left.score) return right.score - left.score; 342 if (left.rect.y !== right.rect.y) return left.rect.y - right.rect.y; 343 if (left.rect.x !== right.rect.x) return left.rect.x - right.rect.x; 344 return (left.rect.width * left.rect.height) - (right.rect.width * right.rect.height); 345 }); 346 347 return JSON.stringify({ signature: sig, best: candidates[0] || null, candidates: candidates.slice(0, 5) }); 348 })()`); 349 350 const result = JSON.parse(raw); 351 if (!result?.best?.rect?.width || !result?.best?.rect?.height) { 352 throw new Error(`Could not locate a Uiverse preview element. Candidate data: ${JSON.stringify(result)}`); 353 } 354 return result; 355 } 356 357 export function getDefaultOutputPath({ username, slug, suffix, extension }) { 358 const safeUsername = trimPathSegment(username).replace(/[^a-zA-Z0-9-_]/g, '-'); 359 const safeSlug = trimPathSegment(slug).replace(/[^a-zA-Z0-9-_]/g, '-'); 360 return path.join(os.tmpdir(), `opencli-uiverse-${safeUsername}-${safeSlug}-${suffix}.${extension}`); 361 } 362 363 export async function saveBase64File(base64, outputPath) { 364 const resolved = path.resolve(outputPath); 365 await fs.mkdir(path.dirname(resolved), { recursive: true }); 366 await fs.writeFile(resolved, Buffer.from(base64, 'base64')); 367 return resolved; 368 }