diff --git a/biome.json b/biome.json index 917e99be..75f761c5 100644 --- a/biome.json +++ b/biome.json @@ -25,5 +25,12 @@ "noUselessFragments": "off" } } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingComma": "all", + "semicolons": "asNeeded" + } } } diff --git a/bun.lockb b/bun.lockb index 73e1e616..d26d007e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index dde3b3ce..8ef17fda 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "workspaces": ["example", "src"], "scripts": { + "dev": "bun run --hot ./example/src/index.tsx", "build": "bun run clean && tsc --project ./tsconfig.build.json", "changeset": "changeset", "changeset:release": "bun run build && changeset publish", diff --git a/src/index.tsx b/src/index.tsx index 06f3fddf..d9e12389 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,33 +1,68 @@ -import { type Context, Hono } from "hono"; -import { ImageResponse } from "hono-og"; -import { type JSXNode } from "hono/jsx"; +import { type Context, Hono } from 'hono' +import { ImageResponse } from 'hono-og' +import { type JSXNode } from 'hono/jsx' +import { Window } from 'happy-dom' +import { jsxRenderer } from 'hono/jsx-renderer' + +import { + type Frame, + type FrameButton, + type FrameMetaTagPropertyName, + type FrameVersion, +} from './types.js' type FrameContext = Context & { - trustedData?: { messageBytes: string }; + trustedData?: { messageBytes: string } untrustedData?: { - fid: number; - url: string; - messageHash: string; - timestamp: number; - network: number; - buttonIndex?: 1 | 2 | 3 | 4; - castId: { fid: number; hash: string }; - inputText?: string; - }; -}; + fid: number + url: string + messageHash: string + timestamp: number + network: number + buttonIndex?: 1 | 2 | 3 | 4 + castId: { fid: number; hash: string } + inputText?: string + } +} type FrameReturnType = { - image: JSX.Element; - intents: JSX.Element; -}; + image: JSX.Element + intents: JSX.Element +} export class Framework extends Hono { frame( path: string, handler: (c: FrameContext) => FrameReturnType | Promise, ) { + this.get( + '/preview', + jsxRenderer( + ({ children }) => { + return ( + + {children} + + ) + }, + { docType: true }, + ), + ) + + this.get('/preview/*', async (c) => { + const baseUrl = c.req.url.replace('/preview', '') + const response = await fetch(baseUrl) + const html = await response.text() + const frame = htmlToFrame(html) + return c.render( +
+ Farcaster frame +
, + ) + }) + this.get(path, async (c) => { - const { intents } = await handler(c); + const { intents } = await handler(c) return c.render( @@ -38,18 +73,18 @@ export class Framework extends Hono { {parseIntents(intents)} , - ); - }); + ) + }) // TODO: don't slice this.get(`${path.slice(1)}_og`, async (c) => { - const { image } = await handler(c); - return new ImageResponse(image); - }); + const { image } = await handler(c) + return new ImageResponse(image) + }) this.post(path, async (c) => { - const context = await parsePostContext(c); - const { intents } = await handler(context); + const context = await parsePostContext(c) + const { intents } = await handler(context) return c.render( @@ -60,8 +95,8 @@ export class Framework extends Hono { {parseIntents(intents)} , - ); - }); + ) + }) } } @@ -69,48 +104,145 @@ export class Framework extends Hono { // Components export type ButtonProps = { - children: string; -}; + children: string +} export function Button({ children }: ButtonProps) { - return ; + return } //////////////////////////////////////////////////////////////////////// // Utilities -type Counter = { button: number }; +type Counter = { button: number } async function parsePostContext(ctx: Context): Promise { const { trustedData, untrustedData } = - (await ctx.req.json().catch(() => {})) || {}; - return Object.assign(ctx, { trustedData, untrustedData }); + (await ctx.req.json().catch(() => {})) || {} + return Object.assign(ctx, { trustedData, untrustedData }) } function parseIntents(intents_: JSX.Element) { - const intents = intents_ as unknown as JSXNode; + const intents = intents_ as unknown as JSXNode const counter: Counter = { - button: 0, - }; + button: 1, + } - if (typeof intents.children[0] === "object") + if (typeof intents.children[0] === 'object') return Object.assign(intents, { children: intents.children.map((e) => parseIntent(e as JSXNode, counter)), - }); - return parseIntent(intents, counter); + }) + return parseIntent(intents, counter) } function parseIntent(node: JSXNode, counter: Counter) { const intent = ( - typeof node.tag === "function" ? node.tag({}) : node - ) as JSXNode; + typeof node.tag === 'function' ? node.tag({}) : node + ) as JSXNode + + const props = intent.props || {} + + if (props.property === 'fc:frame:button') { + props.property = `fc:frame:button:${counter.button++}` + props.content = node.children + } - const props = intent.props || {}; + return Object.assign(intent, { props }) +} - if (props.property === "fc:frame:button") { - props.property = `fc:frame:button:${counter.button++}`; - props.content = node.children; +function htmlToFrame(html: string) { + const window = new Window() + window.document.write(html) + const document = window.document + const metaTags = document.querySelectorAll( + 'meta', + ) as unknown as readonly HTMLMetaElement[] + + const validPropertyNames = new Set([ + 'fc:frame', + 'fc:frame:image', + 'fc:frame:input:text', + 'fc:frame:post_url', + 'og:image', + 'og:title', + ]) + // https://regexr.com/7rlm0 + const buttonRegex = /fc:frame:button:(1|2|3|4)(?::(action|target))?$/ + + let currentButtonIndex = 0 + let buttonsAreMissing = false + let buttonsAreOutOfOrder = false + const buttonMap = new Map>() + const buttonActionMap = new Map() + const invalidButtons: FrameButton['index'][] = [] + + const properties: Partial> = {} + for (const metaTag of metaTags) { + const property = metaTag.getAttribute( + 'property', + ) as FrameMetaTagPropertyName | null + if (!property) continue + + const content = metaTag.getAttribute('content') ?? '' + if (validPropertyNames.has(property)) properties[property] = content + else if (buttonRegex.test(property)) { + const matchArray = property.match(buttonRegex) as [ + string, + string, + string | undefined, + ] + const index = parseInt(matchArray[1], 10) as FrameButton['index'] + const type = matchArray[2] as FrameButton['type'] | undefined + + if (type) buttonActionMap.set(index, content as FrameButton['type']) + else { + if (currentButtonIndex >= index) buttonsAreOutOfOrder = true + if (currentButtonIndex + 1 === index) currentButtonIndex = index + else buttonsAreMissing = true + + if (buttonsAreOutOfOrder || buttonsAreMissing) + invalidButtons.push(index) + + const title = content ?? index + buttonMap.set(index, { index, title }) + } + } } - return Object.assign(intent, { props }); + const image = properties['og:image'] ?? '' + const imageUrl = properties['fc:frame:image'] ?? '' + const postUrl = properties['fc:frame:post_url'] ?? '' + const title = properties['og:title'] ?? '' + const version = (properties['fc:frame'] as FrameVersion) ?? 'vNext' + + let buttons = [] as FrameButton[] + for (const [index, button] of buttonMap) { + buttons.push({ + ...button, + type: buttonActionMap.get(index) ?? 'post', + }) + } + buttons = buttons.toSorted((a, b) => a.index - b.index) + + const fallbackImageToUrl = !imageUrl + const postUrlTooLong = postUrl.length > 2_048 + // TODO: Figure out how this is determined + // https://warpcast.com/~/developers/frames + const valid = true + + const frame = { buttons, imageUrl, postUrl, version } + return { + ...frame, + debug: { + ...frame, + buttonsAreOutOfOrder: buttonsAreMissing || buttonsAreOutOfOrder, + fallbackImageToUrl, + htmlTags: metaTags.map((x) => x.outerHTML), + image, + invalidButtons, + postUrlTooLong, + valid, + }, + title, + } satisfies Frame } diff --git a/src/package.json b/src/package.json index 9df81242..c77d0c0e 100644 --- a/src/package.json +++ b/src/package.json @@ -1,18 +1,19 @@ { - "name": "@wevm/framework", - "version": "0.0.0", - "type": "module", - "module": "_lib/index.js", - "types": "_lib/index.d.ts", - "typings": "_lib/index.d.ts", - "sideEffects": false, - "exports": { + "name": "@wevm/framework", + "version": "0.0.0", + "type": "module", + "module": "_lib/index.js", + "types": "_lib/index.d.ts", + "typings": "_lib/index.d.ts", + "sideEffects": false, + "exports": { ".": "./_lib/index.js" }, - "peerDependencies": { - "hono": "^3" - }, - "dependencies": { - "hono-og": "~0.0.2" - } + "peerDependencies": { + "hono": "^3" + }, + "dependencies": { + "happy-dom": "^13.3.8", + "hono-og": "~0.0.2" + } } diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..9e0bb623 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,43 @@ +// TODO: TSDoc + +export type Frame = { + buttons?: readonly FrameButton[] | undefined + debug?: FrameDebug | undefined + imageUrl: string + postUrl: string + title: string + version: FrameVersion +} + +export type FrameDebug = { + buttons?: readonly FrameButton[] | undefined + buttonsAreOutOfOrder: boolean + fallbackImageToUrl: boolean + htmlTags: readonly string[] + image: string + imageUrl: string + invalidButtons: readonly FrameButton['index'][] + postUrl: string + postUrlTooLong: boolean + valid: boolean + version: FrameVersion +} + +export type FrameButton = { + index: 1 | 2 | 3 | 4 + title: string + type: 'post' | 'post_redirect' +} + +export type FrameVersion = 'vNext' + +export type FrameMetaTagPropertyName = + | 'fc:frame' + | 'fc:frame:image' + | 'fc:frame:input:text' + | 'fc:frame:post_url' + | 'og:image' + | 'og:title' + | `fc:frame:button:${FrameButton['index']}:action` + | `fc:frame:button:${FrameButton['index']}:target` + | `fc:frame:button:${FrameButton['index']}`