diff --git a/.eslintrc.js b/.eslintrc.js index 8531d2b7..ab836fa6 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -41,6 +41,7 @@ module.exports = { './packages/providers/tsconfig.json', './packages/utils/tsconfig.json', './packages/poaps/tsconfig.json', + './packages/frames/tsconfig.json', './tsconfig.json', ], }, @@ -53,4 +54,4 @@ module.exports = { }, }, ], -}; \ No newline at end of file +}; diff --git a/README.md b/README.md index 08b88d8d..001cff9f 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,17 @@ Please ensure that your code adheres to the project's code style and passes all
💻 + + + +
+ + Robin Gagnon + +
+
+ 💻 + diff --git a/examples/frames/.nvmrc b/examples/frames/.nvmrc new file mode 100644 index 00000000..0828ab79 --- /dev/null +++ b/examples/frames/.nvmrc @@ -0,0 +1 @@ +v18 \ No newline at end of file diff --git a/examples/frames/package.json b/examples/frames/package.json new file mode 100644 index 00000000..7b13467f --- /dev/null +++ b/examples/frames/package.json @@ -0,0 +1,28 @@ +{ + "name": "backend-frames-example", + "version": "1.0.0", + "description": "Backend example for using the library", + "main": "src/index.ts", + "scripts": { + "start": "ts-node ./src/index.ts", + "build": "tsc", + "test": "echo \"No tests specified\" && exit 0" + }, + "dependencies": { + "@poap-xyz/frames": "*", + "@poap-xyz/providers": "*", + "@poap-xyz/utils": "*", + "@types/node": "^18.16.0", + "axios": "^1.3.5", + "dotenv": "^16.0.3", + "stream": "^0.0.2" + }, + "devDependencies": { + "@types/node-fetch": "^2.6.3", + "ts-node": "^10.4.0", + "typescript": "^4.5.5" + }, + "engines": { + "node": ">=18" + } +} diff --git a/examples/frames/src/frames/base.ts b/examples/frames/src/frames/base.ts new file mode 100644 index 00000000..dd62c8de --- /dev/null +++ b/examples/frames/src/frames/base.ts @@ -0,0 +1,8 @@ +import { Frame, FrameAspectRatio } from '@poap-xyz/frames'; + +export const BASE_FRAME = new Frame({ + title: 'Hello World', + image: 'https://placehold.co/600x600', + aspectRatio: FrameAspectRatio.SQUARE, + postUrl: 'https://poap.xyz', +}); diff --git a/examples/frames/src/frames/buttons.ts b/examples/frames/src/frames/buttons.ts new file mode 100644 index 00000000..c80efb9a --- /dev/null +++ b/examples/frames/src/frames/buttons.ts @@ -0,0 +1,21 @@ +import { Frame, FrameAspectRatio, FrameButtonAction } from '@poap-xyz/frames'; + +export const FRAME_WITH_BUTTONS = new Frame({ + title: 'Hello World', + image: 'https://placehold.co/600x600', + aspectRatio: FrameAspectRatio.SQUARE, + postUrl: 'https://poap.xyz', + buttons: [ + { label: 'Button 1' }, + { + label: 'Button 2', + action: FrameButtonAction.POST, + target: 'https://poap.xyz', + }, + { + label: 'Button 3', + action: FrameButtonAction.LINK, + target: 'https://poap.xyz', + }, + ], +}); diff --git a/examples/frames/src/index.ts b/examples/frames/src/index.ts new file mode 100644 index 00000000..78a6bd34 --- /dev/null +++ b/examples/frames/src/index.ts @@ -0,0 +1,22 @@ +import dotenv from 'dotenv'; +import { Frame } from '@poap-xyz/frames'; +import { BASE_FRAME } from './frames/base'; +import { FRAME_WITH_BUTTONS } from './frames/buttons'; + +dotenv.config(); + +function main(): void { + runFrameExample('Base frame', BASE_FRAME); + runFrameExample('Frame with buttons', FRAME_WITH_BUTTONS); +} + +function runFrameExample(name: string, frame: Frame): void { + console.log(name); + console.log('meta tags:'); + console.log(frame.toMetaTags()); + console.log('html:'); + console.log(frame.render()); + console.log('---------'); +} + +main(); diff --git a/examples/frames/tsconfig.json b/examples/frames/tsconfig.json new file mode 100644 index 00000000..8c8a9317 --- /dev/null +++ b/examples/frames/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "lib": ["es6"], + "target": "es6", + "module": "commonjs", + "moduleResolution": "node", + "outDir": "dist", + "resolveJsonModule": true, + "emitDecoratorMetadata": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "sourceMap": true, + "allowSyntheticDefaultImports": true, + }, + "include": ["src/**/*.ts", "src/assets/*.png"], + "exclude": ["node_modules", "**/*.spec.ts"], +} diff --git a/package-order.sh b/package-order.sh index 30b2e38b..f2af9a33 100644 --- a/package-order.sh +++ b/package-order.sh @@ -7,4 +7,5 @@ DIRS=( "packages/drops" "packages/poaps" "packages/moments" + "packages/frames" ) diff --git a/packages/frames/README.md b/packages/frames/README.md new file mode 100644 index 00000000..00ccd8c9 --- /dev/null +++ b/packages/frames/README.md @@ -0,0 +1,65 @@ +# @poap-xyz/frames + +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) + +@poap-xyz/frames is a package to help with the development of Farcaster Frames. + +## Features + +- Generate the frame meta tags +- Generate the frame HTML markup + +## Installation + +### NPM + +```bash +npm install @poap-xyz/frames +``` + +### Yarn + +```bash +yarn add @poap-xyz/frames +``` + +## Usage + +### Meta tags + +```javascript +const frame = useMemo(() => new Frame({ ... }); +return ( + <> + +
...
+ +) +``` + +### HTML render + +```javascript +// /api/frame.ts +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const frame = new Frame({ ... }); + return res.status(200).send(frame.render()); +} +``` + +## Examples + +For example scripts and usage, please check the [examples](https://github.com/poap-xyz/poap.js/tree/main/examples). + +## Contributing + +We welcome contributions! Please see the `CONTRIBUTING.md` file for guidelines. + +## License + +@poap-xyz/frames is released under the [MIT License](https://opensource.org/licenses/MIT). diff --git a/packages/frames/package.json b/packages/frames/package.json new file mode 100644 index 00000000..56dc52ad --- /dev/null +++ b/packages/frames/package.json @@ -0,0 +1,34 @@ +{ + "name": "@poap-xyz/frames", + "version": "1.0.0", + "description": "Frames module for the poap.js library", + "main": "dist/cjs/index.cjs", + "module": "dist/esm/index.mjs", + "typings": "dist/cjs/index.d.ts", + "browser": "dist/umd/index.js", + "exports": { + "require": "./dist/cjs/index.cjs", + "import": "./dist/esm/index.mjs", + "browser": "./dist/umd/index.js" + }, + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/poap-xyz/poap.js.git" + }, + "author": "POAP", + "license": "MIT", + "bugs": { + "url": "https://github.com/poap-xyz/poap.js/issues" + }, + "homepage": "https://github.com/poap-xyz/poap.js#readme", + "scripts": { + "build": "rollup -c --bundleConfigAsCjs" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "next-seo": "^6.4.0" + } +} diff --git a/packages/frames/rollup.config.js b/packages/frames/rollup.config.js new file mode 100644 index 00000000..5c310463 --- /dev/null +++ b/packages/frames/rollup.config.js @@ -0,0 +1,3 @@ +import configs from '../../rollup.base.config'; + +export default configs; diff --git a/packages/frames/src/Frame.ts b/packages/frames/src/Frame.ts new file mode 100644 index 00000000..872efa3e --- /dev/null +++ b/packages/frames/src/Frame.ts @@ -0,0 +1,211 @@ +import { MetaTag } from 'next-seo/lib/types'; + +export enum FrameAspectRatio { + SQUARE = '1:1', + WIDE = '1.91:1', +} + +export enum FrameButtonAction { + /** + * The button will trigger a post request to the postUrl. + * By default, the postUrl is used as the target URL if target is not provided. + */ + POST = 'post', + /** + * The button will redirect to the target URL. + */ + LINK = 'link', +} + +export interface FrameButton { + /** + * The label to be displayed in the button. + */ + label: string; + /** + * - post: The button will trigger a post request to the postUrl. + * - link: The button will redirect to the target URL. + * @default post + */ + action?: FrameButtonAction; + /** + * A target URL for the button. It uses the postUrl if not provided. + */ + target?: string; +} + +interface FrameConstructorProps { + /** + * The title to be displayed in the frame. Required. + */ + title: string; + /** + * The URL of the image to be displayed in the frame. It should be a URL. Required. + */ + image: string; + /** + * The URL to be used in the post request. + */ + postUrl?: string; + /** + * Aspect-ratio of the image. Required. + */ + aspectRatio: FrameAspectRatio; + /** + * An ordered list of buttons to be displayed in the frame. + */ + buttons?: FrameButton[]; +} + +/** + * Represents a Farcaster frame. + * Documentation: https://docs.farcaster.xyz/learn/what-is-farcaster/frames + * Specification: https://docs.farcaster.xyz/reference/frames/spec + */ +export class Frame { + /** + * The title to be displayed in the frame. Required. + */ + title: string; + /** + * The URL of the image to be displayed in the frame. It should be a URL. Required. + */ + image: string; + /** + * The URL to be used in the post request. + */ + postUrl?: string; + /** + * Aspect-ratio of the image. Required. + */ + aspectRatio: FrameAspectRatio; + /** + * An ordered list of buttons to be displayed in the frame. + */ + buttons?: FrameButton[]; + + constructor(props: FrameConstructorProps) { + this.title = props.title; + this.image = props.image; + this.postUrl = props.postUrl; + this.aspectRatio = props.aspectRatio; + this.buttons = props.buttons; + } + + /** + * Renders the frame as complete HTML markup. + * + * @example + * export default async function handler(req: NextApiRequest, res: NextApiResponse) { + * const frame = new Frame({ ... }); + * return res.status(200).send(frame.render()); + * } + * + * @returns The HTML representation of the frame. + */ + public render(): string { + const metaTags = this.toMetaTags().map((tag) => Frame.renderTag(tag)); + + return [ + '', + '', + '', + `${this.title}`, + ...metaTags, + '', + `${this.title}`, + '', + ].join(''); + } + + /** + * Generates the meta tags used for the frame. + * + * @example + * const frame = useMemo(() => new Frame({ ... }); + * return ( + * <> + * + *
...
+ * + * ) + * + * @returns The meta tags. + */ + public toMetaTags(): MetaTag[] { + const tags: MetaTag[] = [ + { property: 'og:title', content: this.title }, + { property: 'og:image', content: this.image }, + { name: 'fc:frame', content: 'vNext' }, + { name: 'fc:frame:image', content: this.image }, + { name: 'fc:frame:image:aspect_ratio', content: this.aspectRatio }, + ]; + + if (this.postUrl) { + tags.push({ name: 'fc:frame:post_url', content: this.postUrl }); + } + + if (!this.buttons) { + return tags; + } + + tags.push( + ...this.buttons.flatMap((button, index) => + Frame.buttonToMetaTag(button, index + 1), + ), + ); + + return tags; + } + + private static buttonToMetaTag( + button: FrameButton, + index: number, + ): MetaTag[] { + const tags = [ + { + name: `fc:frame:button:${index}`, + content: button.label, + }, + ]; + + if (button.action) { + tags.push({ + name: `fc:frame:button:${index}:action`, + content: button.action, + }); + } + + if (button.target) { + tags.push({ + name: `fc:frame:button:${index}:target`, + content: button.target, + }); + } + + return tags; + } + + private static renderTag(tag: MetaTag): string { + const parts: string[] = [''); + + return parts.join(' '); + } +} diff --git a/packages/frames/src/index.ts b/packages/frames/src/index.ts new file mode 100644 index 00000000..70ff7252 --- /dev/null +++ b/packages/frames/src/index.ts @@ -0,0 +1 @@ +export * from './Frame'; diff --git a/packages/frames/test/Frame.spec.ts b/packages/frames/test/Frame.spec.ts new file mode 100644 index 00000000..dd4c823f --- /dev/null +++ b/packages/frames/test/Frame.spec.ts @@ -0,0 +1,106 @@ +import { Frame, FrameAspectRatio, FrameButtonAction } from '../src'; + +const BASE_FRAME = new Frame({ + title: 'Hello World', + image: 'https://placehold.co/600x600', + aspectRatio: FrameAspectRatio.SQUARE, + postUrl: 'https://poap.xyz', +}); + +const FRAME_WITH_BUTTONS = new Frame({ + title: 'Hello World', + image: 'https://placehold.co/600x600', + aspectRatio: FrameAspectRatio.SQUARE, + postUrl: 'https://poap.xyz', + buttons: [ + { label: 'Button 1' }, + { + label: 'Button 2', + action: FrameButtonAction.POST, + target: 'https://poap.xyz', + }, + { + label: 'Button 3', + action: FrameButtonAction.LINK, + target: 'https://poap.xyz', + }, + ], +}); + +describe('Frame.toMetaTags', () => { + it('should return the correct meta tags for a base frame', () => { + expect(BASE_FRAME.toMetaTags()).toEqual([ + { property: 'og:title', content: 'Hello World' }, + { property: 'og:image', content: 'https://placehold.co/600x600' }, + { name: 'fc:frame', content: 'vNext' }, + { name: 'fc:frame:image', content: 'https://placehold.co/600x600' }, + { name: 'fc:frame:image:aspect_ratio', content: '1:1' }, + { name: 'fc:frame:post_url', content: 'https://poap.xyz' }, + ]); + }); + + it('should return the correct meta tags for a frame with buttons', () => { + expect(FRAME_WITH_BUTTONS.toMetaTags()).toEqual([ + { property: 'og:title', content: 'Hello World' }, + { property: 'og:image', content: 'https://placehold.co/600x600' }, + { name: 'fc:frame', content: 'vNext' }, + { name: 'fc:frame:image', content: 'https://placehold.co/600x600' }, + { name: 'fc:frame:image:aspect_ratio', content: '1:1' }, + { name: 'fc:frame:post_url', content: 'https://poap.xyz' }, + { name: 'fc:frame:button:1', content: 'Button 1' }, + { name: 'fc:frame:button:2', content: 'Button 2' }, + { name: 'fc:frame:button:2:action', content: FrameButtonAction.POST }, + { name: 'fc:frame:button:2:target', content: 'https://poap.xyz' }, + { name: 'fc:frame:button:3', content: 'Button 3' }, + { name: 'fc:frame:button:3:action', content: FrameButtonAction.LINK }, + { name: 'fc:frame:button:3:target', content: 'https://poap.xyz' }, + ]); + }); +}); + +describe('Frame.render', () => { + it('should return the correct HTML for a base frame', () => { + const expectedHTML = [ + '', + '', + '', + 'Hello World', + '', + '', + '', + '', + '', + '', + '', + 'Hello World', + '', + ]; + expect(BASE_FRAME.render()).toBe(expectedHTML.join('')); + }); + + it('should return the correct HTML for a frame with buttons', () => { + const expectedHTML = [ + '', + '', + '', + 'Hello World', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + 'Hello World', + '', + ]; + expect(FRAME_WITH_BUTTONS.render()).toBe(expectedHTML.join('')); + }); +}); diff --git a/packages/frames/tsconfig.json b/packages/frames/tsconfig.json new file mode 100644 index 00000000..494b742b --- /dev/null +++ b/packages/frames/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true, + }, + "include": ["./src/**/*.ts"], +} diff --git a/tsconfig.json b/tsconfig.json index 28910951..88587e28 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,8 +29,8 @@ "providers": ["providers/src/*"], "drops": ["drops/src"], "utils": ["utils/src"], - "poaps": ["poaps/src"] - } + "poaps": ["poaps/src"], + }, }, "exclude": ["node_modules", "dist"], "references": [ @@ -38,6 +38,7 @@ { "path": "./packages/drops" }, { "path": "./packages/providers" }, { "path": "./packages/utils" }, - { "path": "./packages/poaps" } - ] + { "path": "./packages/poaps" }, + { "path": "./packages/frames" }, + ], } diff --git a/yarn.lock b/yarn.lock index 06f8d245..12730823 100644 --- a/yarn.lock +++ b/yarn.lock @@ -889,6 +889,14 @@ __metadata: languageName: unknown linkType: soft +"@poap-xyz/frames@*, @poap-xyz/frames@workspace:packages/frames": + version: 0.0.0-use.local + resolution: "@poap-xyz/frames@workspace:packages/frames" + dependencies: + next-seo: ^6.4.0 + languageName: unknown + linkType: soft + "@poap-xyz/moments@*, @poap-xyz/moments@workspace:packages/moments": version: 0.0.0-use.local resolution: "@poap-xyz/moments@workspace:packages/moments" @@ -1894,6 +1902,23 @@ __metadata: languageName: unknown linkType: soft +"backend-frames-example@workspace:examples/frames": + version: 0.0.0-use.local + resolution: "backend-frames-example@workspace:examples/frames" + dependencies: + "@poap-xyz/frames": "*" + "@poap-xyz/providers": "*" + "@poap-xyz/utils": "*" + "@types/node": ^18.16.0 + "@types/node-fetch": ^2.6.3 + axios: ^1.3.5 + dotenv: ^16.0.3 + stream: ^0.0.2 + ts-node: ^10.4.0 + typescript: ^4.5.5 + languageName: unknown + linkType: soft + "backend-moments-example@workspace:examples/moments/backend": version: 0.0.0-use.local resolution: "backend-moments-example@workspace:examples/moments/backend" @@ -5304,6 +5329,17 @@ __metadata: languageName: node linkType: hard +"next-seo@npm:^6.4.0": + version: 6.5.0 + resolution: "next-seo@npm:6.5.0" + peerDependencies: + next: ^8.1.1-canary.54 || >=9.0.0 + react: ">=16.0.0" + react-dom: ">=16.0.0" + checksum: 744e49cae31debc5117f35132b6929edd68b3469eed238fbd8d4888cd22a32a67e780688265311d27311936d4d9dab5c1408536ad87c73e4c293701e5213130c + languageName: node + linkType: hard + "node-domexception@npm:^1.0.0": version: 1.0.0 resolution: "node-domexception@npm:1.0.0"