Skip to content

Commit

Permalink
feat: /preview
Browse files Browse the repository at this point in the history
  • Loading branch information
tmm committed Feb 7, 2024
1 parent db93929 commit 1ec43a9
Show file tree
Hide file tree
Showing 6 changed files with 244 additions and 60 deletions.
7 changes: 7 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,12 @@
"noUselessFragments": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingComma": "all",
"semicolons": "asNeeded"
}
}
}
Binary file modified bun.lockb
Binary file not shown.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
224 changes: 178 additions & 46 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -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<FrameReturnType>,
) {
this.get(
'/preview',
jsxRenderer(
({ children }) => {
return (
<html lang="en">
<body>{children}</body>
</html>
)
},
{ 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(
<div>
<img alt="Farcaster frame" src={frame.imageUrl} />
</div>,
)
})

this.get(path, async (c) => {
const { intents } = await handler(c);
const { intents } = await handler(c)
return c.render(
<html lang="en">
<head>
Expand All @@ -38,18 +73,18 @@ export class Framework extends Hono {
{parseIntents(intents)}
</head>
</html>,
);
});
)
})

// 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(
<html lang="en">
<head>
Expand All @@ -60,57 +95,154 @@ export class Framework extends Hono {
{parseIntents(intents)}
</head>
</html>,
);
});
)
})
}
}

////////////////////////////////////////////////////////////////////////
// Components

export type ButtonProps = {
children: string;
};
children: string
}

export function Button({ children }: ButtonProps) {
return <meta property="fc:frame:button" content={children} />;
return <meta property="fc:frame:button" content={children} />
}

////////////////////////////////////////////////////////////////////////
// Utilities

type Counter = { button: number };
type Counter = { button: number }

async function parsePostContext(ctx: Context): Promise<FrameContext> {
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<FrameMetaTagPropertyName>([
'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<number, Omit<FrameButton, 'type'>>()
const buttonActionMap = new Map<number, FrameButton['type']>()
const invalidButtons: FrameButton['index'][] = []

const properties: Partial<Record<FrameMetaTagPropertyName, string>> = {}
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
}
29 changes: 15 additions & 14 deletions src/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
43 changes: 43 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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']}`

0 comments on commit 1ec43a9

Please sign in to comment.