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,
+ '',
+ ``,
+ '',
+ ].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',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ ];
+ expect(BASE_FRAME.render()).toBe(expectedHTML.join(''));
+ });
+
+ it('should return the correct HTML for a frame with buttons', () => {
+ const expectedHTML = [
+ '',
+ '',
+ '',
+ '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"