/ clis / uiverse / _shared.js
_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  }