diff --git a/packages/shrinkwrap/app/api/shrinkwrap/route.ts b/packages/shrinkwrap/app/api/shrinkwrap/route.ts index e3f0807..67dfc99 100644 --- a/packages/shrinkwrap/app/api/shrinkwrap/route.ts +++ b/packages/shrinkwrap/app/api/shrinkwrap/route.ts @@ -14,6 +14,8 @@ export const maxDuration = 300; let cachedInjectSource: string | undefined; +const SKIP_AI = true; // Set to true to skip AI requests + const loadSources = async () => { if (process.env.NODE_ENV === 'development') { const [injectSource] = await Promise.all([ @@ -41,7 +43,7 @@ const loadSources = async () => { return { injectSource }; }; -const shouldCloseBrowser = true; +const shouldCloseBrowser = false; const CHROMIUM_PATH = 'https://fs.bippy.dev/chromium.tar'; const CHROMIUM_ARGS = [ @@ -109,6 +111,12 @@ const getBrowser = async (): Promise => { })) as unknown as Browser; }; +const debug = (...args: unknown[]) => { + if (process.env.NODE_ENV === 'development') { + console.info(...args); + } +}; + export const POST = async (request: NextRequest) => { const { injectSource } = await loadSources(); @@ -137,9 +145,20 @@ export const POST = async (request: NextRequest) => { request.continue(); }); + // Add navigation handling + let navigationOccurred = false; + page.on('framenavigated', async (frame) => { + if (frame === page.mainFrame()) { + navigationOccurred = true; + debug('Navigation occurred to:', frame.url()); + } + }); + await page.setUserAgent( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36', ); + + await page.evaluateOnNewDocument(injectSource); await page.evaluateOnNewDocument(() => { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); Object.defineProperty(navigator, 'languages', { @@ -151,12 +170,14 @@ export const POST = async (request: NextRequest) => { Object.defineProperty(navigator, 'headless', { get: () => undefined }); }); - await page.evaluateOnNewDocument(injectSource); - - await page.goto(url, { waitUntil: ['domcontentloaded', 'load'] }); + debug('Navigating to:', url); + await page.goto(url, { + waitUntil: ['networkidle0', 'domcontentloaded'], + timeout: 30000 + }); + // Process CSS selectors first const cssSelectors: Record = {}; - const rules = Array.from(stylesheets.values()); await Promise.all( @@ -171,12 +192,54 @@ export const POST = async (request: NextRequest) => { }), ); + // Inject CSS selectors before component collection await page.evaluate((cssSelectors) => { // biome-ignore lint/suspicious/noExplicitAny: OK const ShrinkwrapData = (globalThis as any).ShrinkwrapData; ShrinkwrapData.cssSelectors = cssSelectors; }, cssSelectors); + // Force a component map creation for each fiber root + await page.evaluate(() => { + // biome-ignore lint/suspicious/noExplicitAny: OK + const ShrinkwrapData = (globalThis as any).ShrinkwrapData; + for (const fiberRoot of ShrinkwrapData.fiberRoots) { + ShrinkwrapData.createComponentMap(fiberRoot); + } + }); + + // Give React a moment to process everything + await delay(1000); + + // Now collect the component data + let safeComponentMap: Record; + tailwindClasses: string[]; + }>; + try { + safeComponentMap = await page.evaluate(() => { + // biome-ignore lint/suspicious/noExplicitAny: OK + const ShrinkwrapData = (globalThis as any).ShrinkwrapData; + return ShrinkwrapData.safeComponentMap || {}; + }); + + debug('Collected component data:', { + componentCount: Object.keys(safeComponentMap).length, + hasHtml: Object.values(safeComponentMap).some(comp => comp.html.length > 0) + }); + } catch (error) { + debug('Error collecting component data:', error); + if (navigationOccurred) { + return NextResponse.json( + { error: 'Page navigation occurred before data collection completed' }, + { status: 500 }, + ); + } + throw error; + } + const title = await page.title(); const description = await page.evaluate(() => { return document @@ -251,6 +314,43 @@ export const POST = async (request: NextRequest) => { const hexColors = palette.map(({ hex }: { hex: string }) => hex); + if (SKIP_AI) { + // Ensure we have the latest component data + const finalComponentData = await page.evaluate(() => { + // biome-ignore lint/suspicious/noExplicitAny: OK + const ShrinkwrapData = (window as any).ShrinkwrapData; + return { + components: ShrinkwrapData.safeComponentMap || {}, + fiberRoots: Array.from(ShrinkwrapData.fiberRoots || []).length, + elementMapSize: ShrinkwrapData.elementMap?.size || 0, + componentTypeMapSize: ShrinkwrapData.componentTypeMap?.size || 0 + }; + }); + + debug('Component collection stats:', finalComponentData); + + if (shouldCloseBrowser) { + await browser.close(); + } + + return NextResponse.json({ + debug: { + url, + title, + description, + stylesheetCount: stylesheets.size, + selectorCount: Object.keys(cssSelectors).length, + components: finalComponentData.components, + cssSelectors, + stats: { + fiberRoots: finalComponentData.fiberRoots, + elementMapSize: finalComponentData.elementMapSize, + componentTypeMapSize: finalComponentData.componentTypeMapSize + } + } + }); + } + const summaryChunks = await Promise.all( bodyChunks.map((bodyChunk) => generateText({ @@ -317,59 +417,59 @@ ${r.text}`, type: 'jpeg', }); - const safeComponentMap = await page.evaluate(() => { - // biome-ignore lint/suspicious/noExplicitAny: - const ShrinkwrapData = (globalThis as any).ShrinkwrapData; + // const safeComponentMap = await page.evaluate(() => { + // // biome-ignore lint/suspicious/noExplicitAny: + // const ShrinkwrapData = (globalThis as any).ShrinkwrapData; - const safeComponentMap: Record< - string, - { html: string; childrenComponents: number[] } - > = {}; + // const safeComponentMap: Record< + // string, + // { html: string; childrenComponents: number[] } + // > = {}; - for (const fiberRoot of ShrinkwrapData.fiberRoots) { - const { componentMap, componentKeyMap } = - ShrinkwrapData.createComponentMap(fiberRoot); + // for (const fiberRoot of ShrinkwrapData.fiberRoots) { + // const { componentMap, componentKeyMap } = + // ShrinkwrapData.createComponentMap(fiberRoot); - // biome-ignore lint/suspicious/noExplicitAny: - const invertedComponentTypeMap = new WeakMap(); + // // biome-ignore lint/suspicious/noExplicitAny: + // const invertedComponentTypeMap = new WeakMap(); - for (const [key, value] of ShrinkwrapData.componentTypeMap.entries()) { - for (const type of value) { - invertedComponentTypeMap.set(type, key); - } - } + // for (const [key, value] of ShrinkwrapData.componentTypeMap.entries()) { + // for (const type of value) { + // invertedComponentTypeMap.set(type, key); + // } + // } - for (const [key, value] of ShrinkwrapData.componentTypeMap.entries()) { - for (const type of value) { - const { elements, childrenComponents: rawChildrenComponents } = - componentMap.get(type); - if (!elements.size) continue; - let html = ''; - - for (const element of elements) { - html += element.outerHTML; - } - - const childrenComponents: number[] = []; - for (const rawChildComponent of rawChildrenComponents) { - if (invertedComponentTypeMap.has(rawChildComponent)) { - // basically need to get the key of the value: - childrenComponents.push( - invertedComponentTypeMap.get(rawChildComponent) as number, - ); - } - } - safeComponentMap[key] = { - html, - childrenComponents, - }; - } - } - } - return safeComponentMap; - }); + // for (const [key, value] of ShrinkwrapData.componentTypeMap.entries()) { + // for (const type of value) { + // const { elements, childrenComponents: rawChildrenComponents } = + // componentMap.get(type); + // if (!elements.size) continue; + // let html = ''; + + // for (const element of elements) { + // html += element.outerHTML; + // } + + // const childrenComponents: number[] = []; + // for (const rawChildComponent of rawChildrenComponents) { + // if (invertedComponentTypeMap.has(rawChildComponent)) { + // // basically need to get the key of the value: + // childrenComponents.push( + // invertedComponentTypeMap.get(rawChildComponent) as number, + // ); + // } + // } + // safeComponentMap[key] = { + // html, + // childrenComponents, + // }; + // } + // } + // } + // return safeComponentMap; + // }); - console.log(safeComponentMap); + // console.log(safeComponentMap); // const stringifiedElementMap = await page.evaluate(() => { // // https://x.com/theo/status/1889972653785764084 diff --git a/packages/shrinkwrap/inject/index.ts b/packages/shrinkwrap/inject/index.ts index 392c235..e83c5e3 100644 --- a/packages/shrinkwrap/inject/index.ts +++ b/packages/shrinkwrap/inject/index.ts @@ -14,19 +14,71 @@ import { isInstrumentationActive, } from 'bippy'; import { registerGlobal } from './puppeteer-utils'; +import { getClassStyles, stylesToCSS, type StylesMap } from './styles'; +import { TailwindConverter } from 'css-to-tailwindcss'; + +const converter = new TailwindConverter({ + remInPx: 16, + tailwindConfig: { + content: ['./src/**/*.{js,jsx,ts,tsx}'], + theme: { + extend: {}, + }, + } +}); + +const debug = (...args: unknown[]) => { + console.info('[Shrinkwrap]', ...args); +}; + +export const findNearestCompositeFibers = (startFiber: Fiber, results: Set) => { + debug('Finding nearest composite fibers for:', startFiber); + const stack: Array<{ fiber: Fiber; visited: boolean }> = [ + { fiber: startFiber, visited: false }, + ]; + + while (stack.length > 0) { + const { fiber, visited } = stack[stack.length - 1]; + + if (!visited) { + stack[stack.length - 1].visited = true; + + if (isCompositeFiber(fiber)) { + results.add(fiber); + stack.pop(); + continue; + } + + if (fiber.child) { + stack.push({ fiber: fiber.child, visited: false }); + continue; + } + } + + stack.pop(); + if (fiber.sibling) { + stack.push({ fiber: fiber.sibling, visited: false }); + } + } +}; const ShrinkwrapData: { - isActive: boolean; elementMap: Map>; componentTypeMap: Map>; fiberRoots: Set; createComponentMap: typeof createComponentMap | undefined; + cssSelectors: Record; + findNearestCompositeFibers: typeof findNearestCompositeFibers; + safeComponentMap?: Record; } = { - isActive: false, elementMap: new Map(), componentTypeMap: new Map(), fiberRoots, createComponentMap: undefined, + cssSelectors: {}, + findNearestCompositeFibers, }; registerGlobal('ShrinkwrapData', ShrinkwrapData); @@ -208,6 +260,7 @@ export const draw = async ( ShrinkwrapData.elementMap.clear(); ShrinkwrapData.componentTypeMap.clear(); + ShrinkwrapData.safeComponentMap = {}; const getTypeIndex = (type: string | object) => { if (typeof type === 'string') { @@ -231,6 +284,8 @@ export const draw = async ( const viewportHeight = window.innerHeight; const COVERAGE_THRESHOLD = 0.97; + const visibleElements = new Set(); + for (let i = 0, len = elements.length; i < len; i++) { const element = elements[i]; const rect = rectMap.get(element); @@ -280,25 +335,13 @@ export const draw = async ( if (!hasCollision) { drawnLabelBounds.push(labelBounds); visibleIndices.set(element, typeIndex + 1); + visibleElements.add(element); const elementId = typeIndex + 1; const elementSet = ShrinkwrapData.elementMap.get(elementId) || new Set(); elementSet.add(element); ShrinkwrapData.elementMap.set(elementId, elementSet); - const componentTypeSet = - ShrinkwrapData.componentTypeMap.get(elementId) || new Set(); - - const compositeFiber = traverseFiber(fiber, (innerFiber) => { - return isCompositeFiber(innerFiber); - }); - const compositeFiberType = - getType(compositeFiber) || compositeFiber?.type; - - if (compositeFiber) { - componentTypeSet.add(compositeFiberType); - } - ShrinkwrapData.componentTypeMap.set(elementId, componentTypeSet); ctx.beginPath(); ctx.rect(x, y, width, height); @@ -315,96 +358,167 @@ export const draw = async ( } } + for (const element of visibleElements) { + const fiber = getFiberFromHostInstance(element); + if (!fiber?.type) continue; + + const fiberType = getType(fiber) || fiber.type; + const typeIndex = getTypeIndex(fiberType); + const elementId = typeIndex + 1; + const componentId = elementId.toString(); + + if (!ShrinkwrapData.safeComponentMap[componentId]) { + const processElement = async (el: Element): Promise => { + const tailwindClasses = new Set(); + + const normalStyles = getClassStyles(el); + const cssString = `.converted-element{${stylesToCSS(normalStyles)}}`; + + try { + const { nodes } = await converter.convertCSS(cssString); + debug('Normal styles:', { + element: el.tagName, + styles: normalStyles, + convertedClasses: nodes?.[0]?.tailwindClasses || [] + }); + + if (nodes?.[0]?.tailwindClasses) { + for (const cls of nodes[0].tailwindClasses) { + if (!cls.includes('undefined') && !cls.includes('NaN')) { + tailwindClasses.add(cls); + } + } + } + + const hoverStyles = getClassStyles(el, ':hover'); + const diffHoverStyles: StylesMap = {}; + + for (const [prop, value] of Object.entries(hoverStyles)) { + if (value !== normalStyles[prop]) { + diffHoverStyles[prop] = value; + } + } + + if (Object.keys(diffHoverStyles).length > 0) { + const hoverCss = `.converted-element:hover{${stylesToCSS(diffHoverStyles)}}`; + const { nodes: hoverNodes } = await converter.convertCSS(hoverCss); + debug('Hover styles:', { + element: el.tagName, + styles: diffHoverStyles, + classes: hoverNodes?.[0]?.tailwindClasses || [] + }); + + if (hoverNodes?.[0]?.tailwindClasses) { + for (const cls of hoverNodes[0].tailwindClasses) { + if (cls.startsWith('hover:') && !cls.includes('undefined') && !cls.includes('NaN')) { + tailwindClasses.add(cls); + } + } + } + } + } catch (error) { + debug('Error converting styles to Tailwind:', error); + } + + const childrenHTML = await Promise.all( + Array.from(el.children).map(child => processElement(child)) + ); + + const textContent = Array.from(el.childNodes) + .filter(node => node.nodeType === Node.TEXT_NODE) + .map(node => node.textContent) + .join('') + .trim(); + + const tag = el.tagName.toLowerCase(); + const attrs = Array.from(el.attributes) + .filter(attr => attr.name !== 'class') + .map(attr => `${attr.name}="${attr.value}"`) + .join(' '); + + const classString = Array.from(tailwindClasses).join(' '); + return `<${tag}${classString ? ` class="${classString}"` : ''}${attrs ? ` ${attrs}` : ''}>${textContent}${childrenHTML.join('')}`; + }; + + const html = await processElement(element); + ShrinkwrapData.safeComponentMap[componentId] = { html }; + + debug('Processed component:', { + id: componentId, + tag: element.tagName.toLowerCase() + }); + } + } + return visibleIndices; }; export const createComponentMap = (fiberRoot: FiberRoot) => { - // biome-ignore lint/suspicious/noExplicitAny: OK - const componentKeyMap = new Map(); + debug('Starting component map creation for fiber root:', fiberRoot); + + const componentKeyMap = new Map(); const componentMap = new Map< - // biome-ignore lint/suspicious/noExplicitAny: OK - any, + object, { elements: WeakSet; - // biome-ignore lint/suspicious/noExplicitAny: OK - childrenComponents: WeakSet; + childrenComponents: WeakSet; } >(); - const findNearestCompositeFibers = ( - startFiber: Fiber, - results: Set, - ) => { - const stack: Array<{ fiber: Fiber; visited: boolean }> = [ - { fiber: startFiber, visited: false }, - ]; - - while (stack.length > 0) { - const { fiber, visited } = stack[stack.length - 1]; - - if (!visited) { - stack[stack.length - 1].visited = true; - - if (isCompositeFiber(fiber)) { - results.add(fiber); - stack.pop(); - continue; - } - - if (fiber.child) { - stack.push({ fiber: fiber.child, visited: false }); - continue; - } - } - - stack.pop(); - if (fiber.sibling) { - stack.push({ fiber: fiber.sibling, visited: false }); - } - } - }; + let componentCount = 0; traverseFiber(fiberRoot.current, (fiber) => { - if (!isCompositeFiber(fiber)) { - return; - } + if (!isCompositeFiber(fiber)) return; const type = getType(fiber) || fiber.type; - if (!type) return; + componentCount++; + debug('Found component:', { + count: componentCount, + type: typeof type === 'function' ? type.name : type, + hasElements: fiber.stateNode instanceof Element + }); + componentKeyMap.set(componentKeyMap.size, type); if (!componentMap.has(type)) { componentMap.set(type, { - elements: new Set(), - childrenComponents: new Set(), + elements: new WeakSet(), + childrenComponents: new WeakSet(), }); } - const component = componentMap.get(type); + const component = componentMap.get(type); if (!component) return; - const compositeFibers = new Set(); + const hostFibers = getNearestHostFibers(fiber); + debug('Found host fibers:', hostFibers.length); + for (const hostFiber of hostFibers) { + if (hostFiber.stateNode instanceof Element) { + component.elements.add(hostFiber.stateNode); + } + } + + const compositeFibers = new Set(); if (fiber.child) { findNearestCompositeFibers(fiber.child, compositeFibers); } + debug('Found child components:', compositeFibers.size); + for (const compositeFiber of compositeFibers) { const childType = getType(compositeFiber) || compositeFiber.type; if (childType) { component.childrenComponents.add(childType); } } + }); - const hostFibers = getNearestHostFibers(fiber); - for (let i = 0; i < hostFibers.length; i++) { - const hostFiber = hostFibers[i]; - if (hostFiber.stateNode instanceof Element) { - component.elements.add(hostFiber.stateNode); - } - } + debug('Component map creation complete:', { + totalComponents: componentCount, + componentsWithElements: componentMap.size }); return { componentMap, componentKeyMap }; @@ -435,8 +549,6 @@ const handleFiberRoot = (root: FiberRoot) => { }; const init = () => { - if (ShrinkwrapData.isActive) return; - ShrinkwrapData.isActive = true; const host = document.createElement('div'); host.setAttribute('data-shrinkwrap', 'true'); const root = host.attachShadow({ mode: 'open' }); diff --git a/packages/shrinkwrap/inject/styles.ts b/packages/shrinkwrap/inject/styles.ts index a122e78..1ef78dc 100644 --- a/packages/shrinkwrap/inject/styles.ts +++ b/packages/shrinkwrap/inject/styles.ts @@ -1,4 +1,4 @@ -type StylesMap = Record; +export type StylesMap = Record; let blankIframe: HTMLIFrameElement | undefined; @@ -17,8 +17,9 @@ export const getStylesIframe = (): HTMLIFrameElement => { export const getStylesObject = ( node: Element, parentWindow: Window, + pseudoClass?: string ): StylesMap => { - const styles = parentWindow.getComputedStyle(node); + const styles = parentWindow.getComputedStyle(node, pseudoClass || null); const stylesObject: StylesMap = {}; for (let i = 0; i < styles.length; i++) { @@ -52,20 +53,132 @@ export const getDefaultStyles = (node: Element): StylesMap => { return defaultStyles; }; -export const getUserStyles = (node: Element): StylesMap => { +export const getInlineStyles = (node: Element): StylesMap => { + const stylesObject: StylesMap = {}; + if (node instanceof HTMLElement && node.style) { + const style = node.style; + for (let i = 0; i < style.length; i++) { + const property = style[i]; + const value = style.getPropertyValue(property); + if (value) { + stylesObject[property] = value; + } + } + } + return stylesObject; +}; + +export const getUserStyles = (node: Element, pseudoClass?: string): StylesMap => { const defaultStyles = getDefaultStyles(node); - const styles = getStylesObject(node, window); + const computedStyles = pseudoClass + ? getStylesObject(node, window, pseudoClass) + : getStylesObject(node, window); + const inlineStyles = getInlineStyles(node); const userStyles: StylesMap = {}; - for (const property in defaultStyles) { - if (styles[property] !== defaultStyles[property]) { - userStyles[property] = styles[property]; + for (const property in computedStyles) { + const value = computedStyles[property]; + const defaultValue = defaultStyles[property]; + + if (value === defaultValue || + value === 'none' || + value === 'auto' || + value === '0px' || + value === 'normal' || + value === 'rgb(0, 0, 0)' || + value === 'rgba(0, 0, 0, 0)') { + continue; } + + userStyles[property] = value; } + Object.assign(userStyles, inlineStyles); + return userStyles; }; +export const stylesToCSS = (styles: StylesMap): string => { + return Object.entries(styles) + .map(([property, value]) => `${property}: ${value};`) + .join(' '); +}; + export const splitClassName = (className: string) => { return className.match(/\S+/g) || []; }; + +export const getWindowDefaultStyles = (node: Element): StylesMap => { + const tempElement = document.createElement(node.tagName); + document.body.appendChild(tempElement); + + const windowStyles = window.getComputedStyle(tempElement); + const defaultStyles: StylesMap = {}; + + for (let i = 0; i < windowStyles.length; i++) { + const property = windowStyles[i]; + const value = windowStyles.getPropertyValue(property); + defaultStyles[property] = value; + } + + tempElement.remove(); + return defaultStyles; +}; + +const INHERITABLE_PROPERTIES = new Set([ + 'border-collapse', + 'border-spacing', + 'caption-side', + 'color', + 'cursor', + 'direction', + 'empty-cells', + 'font-family', + 'font-size', + 'font-style', + 'font-variant', + 'font-weight', + 'font', + 'letter-spacing', + 'line-height', + 'list-style-image', + 'list-style-position', + 'list-style-type', + 'list-style', + 'text-align', + 'text-indent', + 'text-transform', + 'visibility', + 'white-space', + 'word-spacing' +]); + +export const getClassStyles = (node: Element, pseudoClass?: string): StylesMap => { + const windowDefaults = getWindowDefaultStyles(node); + + const computedStyles = window.getComputedStyle(node, pseudoClass || null); + const classStyles: StylesMap = {}; + + for (let i = 0; i < computedStyles.length; i++) { + const property = computedStyles[i]; + const value = computedStyles.getPropertyValue(property); + const defaultValue = windowDefaults[property]; + + if (!INHERITABLE_PROPERTIES.has(property) && + value === defaultValue || + (value !== 'inherit' && ( + value === 'none' || + value === 'auto' || + value === '0px' || + value === 'normal' || + value === 'rgb(0, 0, 0)' || + value === 'rgba(0, 0, 0, 0)' + ))) { + continue; + } + + classStyles[property] = value; + } + + return classStyles; +};