├── demo
├── src
│ ├── env.d.ts
│ ├── bgPattern.png
│ ├── astro-docs-logo.png
│ ├── pages
│ │ ├── local-font-test
│ │ │ ├── _fonts
│ │ │ │ └── ibm-plex-mono-latin-400-normal.ttf
│ │ │ ├── index.md
│ │ │ └── [path].ts
│ │ ├── nested
│ │ │ └── index.md
│ │ ├── formats
│ │ │ ├── index.md
│ │ │ └── [path].ts
│ │ ├── og
│ │ │ └── [...route].ts
│ │ ├── index.md
│ │ ├── font-test
│ │ │ ├── index.astro
│ │ │ ├── _pages.ts
│ │ │ ├── _pages-copy.ts
│ │ │ └── [path].ts
│ │ └── background-test
│ │ │ ├── index.md
│ │ │ └── [path].ts
│ └── layouts
│ │ └── Layout.astro
├── tsconfig.json
├── public
│ └── favicon.ico
├── astro.config.mjs
├── README.md
└── package.json
├── pnpm-workspace.yaml
├── .prettierrc
├── packages
└── astro-og-canvas
│ ├── src
│ ├── index.ts
│ ├── routing.ts
│ ├── shorthash.ts
│ ├── queue.ts
│ ├── assetLoaders.ts
│ ├── types.ts
│ └── generateOpenGraphImage.ts
│ ├── tsconfig.json
│ ├── package.json
│ ├── CHANGELOG.md
│ └── README.md
├── .gitignore
├── .changeset
├── config.json
└── README.md
├── README.md
├── package.json
├── test
└── astro-auto-import.ts
└── .github
└── workflows
├── ci.yml
└── release.yml
/demo/src/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
--------------------------------------------------------------------------------
/demo/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "astro/tsconfigs/base"
3 | }
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - 'packages/**/*'
3 | - 'demo'
4 |
--------------------------------------------------------------------------------
/demo/src/bgPattern.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/delucis/astro-og-canvas/HEAD/demo/src/bgPattern.png
--------------------------------------------------------------------------------
/demo/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/delucis/astro-og-canvas/HEAD/demo/public/favicon.ico
--------------------------------------------------------------------------------
/demo/src/astro-docs-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/delucis/astro-og-canvas/HEAD/demo/src/astro-docs-logo.png
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 100,
3 | "singleQuote": true,
4 | "tabWidth": 2,
5 | "useTabs": false
6 | }
7 |
--------------------------------------------------------------------------------
/demo/astro.config.mjs:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'astro/config';
2 |
3 | // https://astro.build/config
4 | export default defineConfig({});
5 |
--------------------------------------------------------------------------------
/packages/astro-og-canvas/src/index.ts:
--------------------------------------------------------------------------------
1 | export { generateOpenGraphImage } from './generateOpenGraphImage';
2 | export { OGImageRoute } from './routing';
3 |
--------------------------------------------------------------------------------
/demo/src/pages/local-font-test/_fonts/ibm-plex-mono-latin-400-normal.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/delucis/astro-og-canvas/HEAD/demo/src/pages/local-font-test/_fonts/ibm-plex-mono-latin-400-normal.ttf
--------------------------------------------------------------------------------
/demo/README.md:
--------------------------------------------------------------------------------
1 | # Demo Site
2 |
3 | This is a demo Astro project to test the `astro-og-canvas` package.
4 |
5 | `cd` into this directory and run `pnpm start` to run the project with a development server or `pnpm build` to build it.
6 |
--------------------------------------------------------------------------------
/demo/src/layouts/Layout.astro:
--------------------------------------------------------------------------------
1 | ---
2 | ---
3 |
4 |
5 |
6 |
7 | astro-og-canvas
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # build output
2 | dist/
3 |
4 | # dependencies
5 | node_modules/
6 |
7 | # logs
8 | npm-debug.log*
9 | yarn-debug.log*
10 | yarn-error.log*
11 | pnpm-debug.log*
12 |
13 |
14 | # environment variables
15 | .env
16 | .env.production
17 |
18 | # macOS-specific files
19 | .DS_Store
20 |
--------------------------------------------------------------------------------
/packages/astro-og-canvas/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "astro/tsconfigs/strictest",
3 | "include": ["src"],
4 | "compilerOptions": {
5 | "declaration": true,
6 | "noEmit": false,
7 | "allowImportingTsExtensions": false,
8 | "outDir": "./dist",
9 | "skipLibCheck": true
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@2.0.0/schema.json",
3 | "changelog": ["@changesets/changelog-github", { "repo": "delucis/astro-og-canvas" }],
4 | "commit": false,
5 | "fixed": [],
6 | "linked": [],
7 | "access": "public",
8 | "baseBranch": "latest",
9 | "updateInternalDependencies": "patch",
10 | "ignore": []
11 | }
12 |
--------------------------------------------------------------------------------
/demo/src/pages/local-font-test/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Local Font Test
3 | description: Test page demonstrating that a local font file can be loaded and used.
4 | layout: ../../layouts/Layout.astro
5 | ---
6 |
7 | # Astro OpenGraph Images with CanvasKit
8 |
9 | This test loads a local font file to render its text.
10 |
11 | 
12 |
13 | - [Home](/)
14 |
--------------------------------------------------------------------------------
/demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@demo/astro-og-canvas",
3 | "version": "0.0.1",
4 | "private": true,
5 | "scripts": {
6 | "dev": "astro dev",
7 | "start": "astro dev",
8 | "build": "astro build",
9 | "preview": "astro preview"
10 | },
11 | "devDependencies": {
12 | "astro": "^5.15.9",
13 | "astro-og-canvas": "workspace:*",
14 | "canvaskit-wasm": "^0.40.0"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/demo/src/pages/nested/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Nested Page
3 | description: Test page demonstrating that a generated image shows up correctly — this page is nested
4 | layout: ../../layouts/Layout.astro
5 | ---
6 |
7 | # Astro OpenGraph Images with CanvasKit
8 |
9 | This test project uses `astro-og-canvas` to generate OpenGraph images from code.
10 |
11 | 
12 |
13 | - [Home](/)
14 |
--------------------------------------------------------------------------------
/demo/src/pages/formats/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Different formats
3 | description: Test page demonstrating that images render correctly in different formats
4 | layout: ../../layouts/Layout.astro
5 | ---
6 |
7 | # Astro OpenGraph Images with CanvasKit
8 |
9 | This test renders the same image to different formats.
10 |
11 | 
12 |
13 | 
14 |
15 | 
16 |
17 | - [Home](/)
18 |
--------------------------------------------------------------------------------
/demo/src/pages/og/[...route].ts:
--------------------------------------------------------------------------------
1 | import { OGImageRoute } from 'astro-og-canvas';
2 |
3 | export const { getStaticPaths, GET } = OGImageRoute({
4 | param: 'route',
5 | pages: await import.meta.glob('/src/pages/**/*.md', { eager: true }),
6 | getImageOptions: (_path, page) => ({
7 | title: page.frontmatter.title,
8 | description: page.frontmatter.description,
9 | logo: { path: './src/astro-docs-logo.png', size: [350] },
10 | border: { width: 10 },
11 | padding: 40,
12 | }),
13 | });
14 |
--------------------------------------------------------------------------------
/.changeset/README.md:
--------------------------------------------------------------------------------
1 | # Changesets
2 |
3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4 | with multi-package repos, or single-package repos to help you version and publish your code. You can
5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6 |
7 | We have a quick list of common questions to get you started engaging with this project in
8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
9 |
--------------------------------------------------------------------------------
/demo/src/pages/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Test Page
3 | description: Test page demonstrating that a generated image shows up correctly
4 | layout: ../layouts/Layout.astro
5 | ---
6 |
7 | # Astro OpenGraph Images with CanvasKit
8 |
9 | This test project uses `astro-og-canvas` to generate OpenGraph images from code.
10 |
11 | 
12 |
13 | - [Nested](/nested)
14 | - [Font Tests](/font-test)
15 | - [Local font test](/local-font-test)
16 | - [Background image tests](/background-test)
17 | - [Different formats](/formats)
18 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Astro OG Canvas
2 |
3 | ## 🔗 Looking for the main package?
4 |
5 | [Jump to `astro-og-canvas` →](/packages/astro-og-canvas/)
6 |
7 | ## 🚀 Project Structure
8 |
9 | This project uses **workspaces** to develop a single package from `packages/astro-og-canvas`. It also includes a `demo` Astro site for testing and demonstrating the integration.
10 |
11 | ## 🧞 Commands
12 |
13 | All commands are run from the root of the project, from a terminal:
14 |
15 | | Command | Action |
16 | | :------------- | :-------------------- |
17 | | `pnpm install` | Installs dependencies |
18 | | `pnpm test` | Run unit tests |
19 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@root/astro-og-canvas",
3 | "version": "0.0.1",
4 | "private": true,
5 | "scripts": {
6 | "build:demo": "pnpm --filter \"@demo/astro-og-canvas\" build",
7 | "prepublish": "pnpm --filter \"astro-og-canvas\" build",
8 | "pretest": "pnpm prepublish && pnpm build:demo",
9 | "test": "tsm node_modules/uvu/bin.js test"
10 | },
11 | "devDependencies": {
12 | "@changesets/changelog-github": "^0.5.1",
13 | "@changesets/cli": "^2.29.7",
14 | "astro": "^5.15.9",
15 | "tsm": "^2.3.0",
16 | "typescript": "^5.0.0",
17 | "uvu": "^0.5.6"
18 | },
19 | "packageManager": "pnpm@10.18.2"
20 | }
21 |
--------------------------------------------------------------------------------
/demo/src/pages/local-font-test/[path].ts:
--------------------------------------------------------------------------------
1 | import { OGImageRoute } from 'astro-og-canvas';
2 |
3 | export const { getStaticPaths, GET } = OGImageRoute({
4 | param: 'path',
5 | pages: {
6 | 'local.md': {
7 | title: 'Local fonts',
8 | description: 'Loads IBM Plex Mono from a local font file',
9 | },
10 | },
11 | getImageOptions: (_path, page) => ({
12 | ...page,
13 | border: { color: [100, 100, 100], width: 20, side: 'block-end' },
14 | bgGradient: [[0, 0, 0]],
15 | padding: 60,
16 | font: {
17 | title: { size: 90, families: ['IBM Plex Mono'], weight: 'Normal' },
18 | description: { families: ['IBM Plex Mono'] },
19 | },
20 | fonts: ['./src/pages/local-font-test/_fonts/ibm-plex-mono-latin-400-normal.ttf'],
21 | }),
22 | });
23 |
--------------------------------------------------------------------------------
/demo/src/pages/font-test/index.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import Layout from '../../layouts/Layout.astro';
3 | import { pages } from './_pages-copy';
4 | ---
5 |
6 |
7 |
8 | {
9 | Object.entries(pages).map(([key, { title, description, dir }]) => (
10 |
11 |
12 |
13 | {description}
14 |
15 |
16 |
17 | ))
18 | }
19 |
20 |
21 |
22 |
39 |
--------------------------------------------------------------------------------
/demo/src/pages/formats/[path].ts:
--------------------------------------------------------------------------------
1 | import { OGImageRoute } from 'astro-og-canvas';
2 |
3 | export const { getStaticPaths, GET } = OGImageRoute({
4 | param: 'path',
5 | pages: {
6 | 'png.md': {
7 | title: 'Test image (PNG)',
8 | description: 'Renders an Open Graph image to a PNG file',
9 | format: 'PNG',
10 | },
11 | 'jpeg.md': {
12 | title: 'Test image (JPEG)',
13 | description: 'Renders an Open Graph image to a JPEG file',
14 | format: 'JPEG',
15 | },
16 | 'webp.md': {
17 | title: 'Test image (WEBP)',
18 | description: 'Renders an Open Graph image to a WEBP file',
19 | format: 'WEBP',
20 | },
21 | },
22 | getSlug(path, { format }) {
23 | const ext = format.toLowerCase();
24 | path = path.replace(/^\/src\/pages\//, '');
25 | path = path.replace(/\.[^.]*$/, '') + '.' + ext;
26 | return path;
27 | },
28 | getImageOptions: (_path, page) => ({
29 | ...page,
30 | }),
31 | });
32 |
--------------------------------------------------------------------------------
/test/astro-auto-import.ts:
--------------------------------------------------------------------------------
1 | import { test } from 'uvu';
2 | import * as assert from 'uvu/assert';
3 | import { readFileSync } from 'fs';
4 |
5 | function loadRoute(path: string): string {
6 | const rel = `../demo/dist/${path}/index.html`.replaceAll(/\/{2,}/g, '/');
7 | return readFileSync(new URL(rel, import.meta.url), 'utf-8');
8 | }
9 |
10 | function loadImage(path: string) {
11 | const rel = `../demo/dist/${path}`.replaceAll(/\/{2,}/g, '/');
12 | return readFileSync(new URL(rel, import.meta.url));
13 | }
14 |
15 | test('it should have created an image in the build output', () => {
16 | assert.not.throws(()=>loadImage('/og/index.png'));
17 | });
18 |
19 | test('it should have built the index page correctly', () => {
20 | const page = loadRoute('/');
21 | assert.match(page, /
/);
22 | });
23 |
24 | test('it should have rendered a JPEG correctly', () => {
25 | const buff = loadImage('/formats/jpeg.jpeg');
26 | assert.not.equal(buff.length, 0);
27 | })
28 |
29 | test.run();
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | permissions: {}
3 |
4 | on:
5 | push:
6 | branches: [latest]
7 | pull_request:
8 | branches: [latest]
9 |
10 | # Automatically cancel in-progress actions on the same branch
11 | concurrency:
12 | group: ${{ github.workflow }}-${{ github.event_name == 'pull_request_target' && github.head_ref || github.ref }}
13 | cancel-in-progress: true
14 |
15 | jobs:
16 | test:
17 | strategy:
18 | matrix:
19 | os: [ubuntu-latest, windows-latest]
20 | astro: [^3, ^4, ^5]
21 | runs-on: ${{ matrix.os }}
22 | steps:
23 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
24 | with:
25 | persist-credentials: false
26 |
27 | - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
28 |
29 | - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
30 | with:
31 | node-version: 18.x
32 |
33 | - run: pnpm i
34 |
35 | - run: pnpm i --workspace-root astro@${{ matrix.astro }}
36 | - run: pnpm i astro@${{ matrix.astro }}
37 | working-directory: packages/astro-og-canvas
38 | - run: pnpm i astro@${{ matrix.astro }}
39 | working-directory: demo
40 |
41 | - name: Test
42 | run: pnpm t
43 |
--------------------------------------------------------------------------------
/packages/astro-og-canvas/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "astro-og-canvas",
3 | "description": "Generate OpenGraph images for your Astro site",
4 | "version": "0.7.2",
5 | "type": "module",
6 | "types": "./dist/index.d.ts",
7 | "author": "Chris Swithinbank (http://chrisswithinbank.net/)",
8 | "license": "MIT",
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/delucis/astro-og-canvas.git",
12 | "directory": "packages/astro-og-canvas"
13 | },
14 | "bugs": "https://github.com/delucis/astro-og-canvas/issues",
15 | "exports": {
16 | ".": "./dist/index.js"
17 | },
18 | "files": [
19 | "dist"
20 | ],
21 | "scripts": {
22 | "build": "tsc",
23 | "prepublish": "pnpm build"
24 | },
25 | "keywords": [
26 | "withastro",
27 | "astro-integration",
28 | "open-graph",
29 | "seo",
30 | "images"
31 | ],
32 | "devDependencies": {
33 | "@types/node": "^18.19.130",
34 | "astro": "^5.15.9",
35 | "typescript": "^5.0.0"
36 | },
37 | "dependencies": {
38 | "canvaskit-wasm": "^0.40.0",
39 | "deterministic-object-hash": "^2.0.2",
40 | "entities": "^7.0.0"
41 | },
42 | "peerDependencies": {
43 | "astro": "^3.0.0 || ^4.0.0 || ^5.0.0"
44 | },
45 | "engines": {
46 | "node": ">=18.14.1"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 | permissions: {}
3 |
4 | on:
5 | push:
6 | branches:
7 | - latest
8 |
9 | concurrency: ${{ github.workflow }}-${{ github.ref }}
10 |
11 | jobs:
12 | release:
13 | name: Release
14 | if: ${{ github.repository_owner == 'delucis' }}
15 | runs-on: ubuntu-latest
16 | permissions:
17 | contents: write
18 | pull-requests: write
19 | id-token: write
20 | steps:
21 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
22 | with:
23 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
24 | fetch-depth: 0
25 | persist-credentials: false
26 |
27 | - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
28 |
29 | - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
30 | with:
31 | node-version: 24
32 |
33 | - run: pnpm i
34 |
35 | - name: Create Release Pull Request
36 | uses: changesets/action@e0145edc7d9d8679003495b11f87bd8ef63c0cba # v1.5.3
37 | with:
38 | publish: npx changeset publish
39 | env:
40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
41 | NPM_TOKEN: "" # See https://github.com/changesets/changesets/issues/1152#issuecomment-3190884868
--------------------------------------------------------------------------------
/demo/src/pages/background-test/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Background Test
3 | description: Test page demonstrating a background image.
4 | layout: ../../layouts/Layout.astro
5 | ---
6 |
7 | # Astro OpenGraph Images with CanvasKit
8 |
9 | This test loads a background image with various settings.
10 |
11 |
12 |

13 |

14 |

15 |

16 |

17 |

18 |

19 |

20 |

21 |

22 |

23 |

24 |
25 |
26 | - [Home](/)
27 |
28 |
39 |
--------------------------------------------------------------------------------
/packages/astro-og-canvas/src/routing.ts:
--------------------------------------------------------------------------------
1 | import type { APIRoute, GetStaticPaths } from 'astro';
2 | import { generateOpenGraphImage } from './generateOpenGraphImage';
3 | import type { OGImageOptions } from './types';
4 |
5 | const pathToSlug = (path: string): string => {
6 | path = path.replace(/^\/src\/pages\//, '');
7 | path = path.replace(/\.[^\.]*$/, '') + '.png';
8 | path = path.replace(/\/index\.png$/, '.png');
9 | return path;
10 | };
11 |
12 | function makeGetStaticPaths({
13 | pages,
14 | param,
15 | getSlug = pathToSlug,
16 | }: OGImageRouteConfig): GetStaticPaths {
17 | const slugs = Object.entries(pages).map((page) => getSlug(...page));
18 | const paths = slugs.map((slug) => ({ params: { [param]: slug } }));
19 | return function getStaticPaths() {
20 | return paths;
21 | };
22 | }
23 |
24 | function createOGImageEndpoint({ getSlug = pathToSlug, ...opts }: OGImageRouteConfig): APIRoute {
25 | return async function getOGImage({ params }) {
26 | const pageEntry = Object.entries(opts.pages).find(
27 | (page) => {
28 | const slug = getSlug(...page);
29 | return slug === params[opts.param] || slug.replace(/^\//, "") === params[opts.param];
30 | }
31 | );
32 | if (!pageEntry) return new Response('Page not found', { status: 404 });
33 |
34 | return new Response(await generateOpenGraphImage(
35 | await opts.getImageOptions(...pageEntry)
36 | ));
37 | };
38 | }
39 |
40 | export function OGImageRoute(opts: OGImageRouteConfig): {
41 | getStaticPaths: GetStaticPaths;
42 | GET: APIRoute;
43 | } {
44 | return {
45 | getStaticPaths: makeGetStaticPaths(opts),
46 | GET: createOGImageEndpoint(opts),
47 | };
48 | }
49 |
50 | interface OGImageRouteConfig {
51 | pages: { [path: string]: any };
52 | param: string;
53 | getSlug?: (path: string, page: any) => string;
54 | getImageOptions: (path: string, page: any) => OGImageOptions | Promise;
55 | }
56 |
--------------------------------------------------------------------------------
/demo/src/pages/font-test/_pages.ts:
--------------------------------------------------------------------------------
1 | export const pages: Record = {
2 | 'ar-editor-setup.md': {
3 | title: 'إعداد البيئة البرمجية',
4 | description: 'أعِد محرر الشفرة لبناء المشاريع مع Astro',
5 | dir: 'rtl',
6 | },
7 | 'markdown.md': {
8 | title: 'Markdown & MDX',
9 | description: 'Learn how to create content using Markdown or MDX with Astro',
10 | },
11 | 'components.md': {
12 | title: 'Components',
13 | description: 'An intro to the .astro component syntax.',
14 | },
15 | 'project-structure.md': {
16 | title: 'Project Structure',
17 | description: 'Learn how to structure a project with Astro.',
18 | },
19 | 'why-astro.md': {
20 | title: 'Why Astro?',
21 | description:
22 | 'Astro is an all-in-one web framework for building fast, content-focused websites. Learn more.',
23 | },
24 | 'install-auto.md': {
25 | title: 'Install Astro with the Automatic CLI',
26 | description: 'How to install Astro with NPM, PNPM, or Yarn via the create-astro CLI tool.',
27 | },
28 | 'zh-editor-setup.md': {
29 | title: '编辑器配置',
30 | description: '配置与 Astro 一同使用的编辑器',
31 | },
32 | 'ru-getting-started.md': {
33 | title: 'Начало Работы',
34 | description: 'Основное введение в Astro.',
35 | },
36 | 'de-config-reference.md': {
37 | title: 'Konfigurationsreferenz',
38 | description: '',
39 | },
40 | 'en-concepts-mpa-vs-spa.md': {
41 | title: 'MPAs vs. SPAs',
42 | description:
43 | 'Understanding the tradeoffs between Multi-Page Application (MPA) and Single-Page Application (SPA) architecture is key to understanding what makes Astro different from other web frameworks.',
44 | },
45 | 'es-guides-deploy-flightcontrol.md': {
46 | title: 'Despliega tu proyecto de Astro en AWS con Flightcontrol',
47 | description: 'Cómo desplegar tu proyecto de Astro en AWS con Flightcontrol',
48 | },
49 | };
50 |
--------------------------------------------------------------------------------
/demo/src/pages/font-test/_pages-copy.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * An Astro bug means importing the same `_pages.ts` into both `index.astro`
3 | * and `[path].ts` triggers an error. Duplicating the data so that each route
4 | * imports its own copy works fine.
5 | */
6 | export const pages: Record = {
7 | 'ar-editor-setup.md': {
8 | title: 'إعداد البيئة البرمجية',
9 | description: 'أعِد محرر الشفرة لبناء المشاريع مع Astro',
10 | dir: 'rtl',
11 | },
12 | 'markdown.md': {
13 | title: 'Markdown & MDX',
14 | description: 'Learn how to create content using Markdown or MDX with Astro',
15 | },
16 | 'components.md': {
17 | title: 'Components',
18 | description: 'An intro to the .astro component syntax.',
19 | },
20 | 'project-structure.md': {
21 | title: 'Project Structure',
22 | description: 'Learn how to structure a project with Astro.',
23 | },
24 | 'why-astro.md': {
25 | title: 'Why Astro?',
26 | description:
27 | 'Astro is an all-in-one web framework for building fast, content-focused websites. Learn more.',
28 | },
29 | 'install-auto.md': {
30 | title: 'Install Astro with the Automatic CLI',
31 | description: 'How to install Astro with NPM, PNPM, or Yarn via the create-astro CLI tool.',
32 | },
33 | 'zh-editor-setup.md': {
34 | title: '编辑器配置',
35 | description: '配置与 Astro 一同使用的编辑器',
36 | },
37 | 'ru-getting-started.md': {
38 | title: 'Начало Работы',
39 | description: 'Основное введение в Astro.',
40 | },
41 | 'de-config-reference.md': {
42 | title: 'Konfigurationsreferenz',
43 | description: '',
44 | },
45 | 'en-concepts-mpa-vs-spa.md': {
46 | title: 'MPAs vs. SPAs',
47 | description:
48 | 'Understanding the tradeoffs between Multi-Page Application (MPA) and Single-Page Application (SPA) architecture is key to understanding what makes Astro different from other web frameworks.',
49 | },
50 | 'es-guides-deploy-flightcontrol.md': {
51 | title: 'Despliega tu proyecto de Astro en AWS con Flightcontrol',
52 | description: 'Cómo desplegar tu proyecto de Astro en AWS con Flightcontrol',
53 | },
54 | };
55 |
--------------------------------------------------------------------------------
/demo/src/pages/font-test/[path].ts:
--------------------------------------------------------------------------------
1 | import { OGImageRoute } from 'astro-og-canvas';
2 | import { pages } from './_pages';
3 |
4 | export const { getStaticPaths, GET } = OGImageRoute({
5 | param: 'path',
6 | pages,
7 | getImageOptions: (_path, page) => ({
8 | title: page.title,
9 | description: page.description,
10 | dir: page.dir,
11 | logo: { path: './src/astro-docs-logo.png', size: [400] },
12 | border: { color: [255, 93, 1], width: 20, side: 'inline-start' },
13 | bgGradient: [
14 | [42, 35, 62],
15 | [23, 20, 36],
16 | ],
17 | padding: 60,
18 | font: {
19 | title: {
20 | size: 78,
21 | families: [
22 | 'Work Sans',
23 | 'Noto Sans Black',
24 | 'Noto Sans Arabic',
25 | 'Noto Sans SC Black',
26 | 'Noto Sans TC Black',
27 | 'Noto Sans JP Black',
28 | ],
29 | weight: 'ExtraBold',
30 | },
31 | description: {
32 | size: 45,
33 | lineHeight: 1.25,
34 | families: [
35 | 'Work Sans',
36 | 'Noto Sans',
37 | 'Noto Sans Arabic',
38 | 'Noto Sans SC',
39 | 'Noto Sans TC',
40 | 'Noto Sans JP',
41 | ],
42 | weight: 'Normal',
43 | },
44 | },
45 | fonts: [
46 | 'https://api.fontsource.org/v1/fonts/work-sans/latin-400-normal.ttf',
47 | 'https://api.fontsource.org/v1/fonts/work-sans/latin-800-normal.ttf',
48 |
49 | 'https://api.fontsource.org/v1/fonts/noto-sans/cyrillic-400-normal.ttf',
50 | 'https://api.fontsource.org/v1/fonts/noto-sans/cyrillic-900-normal.ttf',
51 |
52 | 'https://api.fontsource.org/v1/fonts/noto-sans-sc/chinese-simplified-400-normal.ttf',
53 | 'https://api.fontsource.org/v1/fonts/noto-sans-sc/chinese-simplified-900-normal.ttf',
54 |
55 | 'https://api.fontsource.org/v1/fonts/noto-sans-tc/chinese-traditional-400-normal.ttf',
56 | 'https://api.fontsource.org/v1/fonts/noto-sans-tc/chinese-traditional-900-normal.ttf',
57 |
58 | 'https://api.fontsource.org/v1/fonts/noto-sans-jp/japanese-400-normal.ttf',
59 | 'https://api.fontsource.org/v1/fonts/noto-sans-jp/japanese-900-normal.ttf',
60 |
61 | 'https://api.fontsource.org/v1/fonts/noto-sans-arabic/arabic-400-normal.ttf',
62 | 'https://api.fontsource.org/v1/fonts/noto-sans-arabic/arabic-800-normal.ttf',
63 | ],
64 | }),
65 | });
66 |
--------------------------------------------------------------------------------
/packages/astro-og-canvas/src/shorthash.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * shorthash - https://github.com/bibig/node-shorthash
3 | *
4 | * @license
5 | *
6 | * (The MIT License)
7 | *
8 | * Copyright (c) 2013 Bibig
9 | *
10 | * Permission is hereby granted, free of charge, to any person
11 | * obtaining a copy of this software and associated documentation
12 | * files (the "Software"), to deal in the Software without
13 | * restriction, including without limitation the rights to use,
14 | * copy, modify, merge, publish, distribute, sublicense, and/or sell
15 | * copies of the Software, and to permit persons to whom the
16 | * Software is furnished to do so, subject to the following
17 | * conditions:
18 | *
19 | * The above copyright notice and this permission notice shall be
20 | * included in all copies or substantial portions of the Software.
21 | *
22 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
23 | * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
24 | * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
25 | * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
26 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
27 | * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
28 | * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
29 | * OTHER DEALINGS IN THE SOFTWARE.
30 | */
31 |
32 | const dictionary = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXY';
33 | const binary = dictionary.length;
34 |
35 | // refer to: http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/
36 | function bitwise(str: string) {
37 | let hash = 0;
38 | if (str.length === 0) return hash;
39 | for (let i = 0; i < str.length; i++) {
40 | const ch = str.charCodeAt(i);
41 | hash = (hash << 5) - hash + ch;
42 | hash = hash & hash; // Convert to 32bit integer
43 | }
44 | return hash;
45 | }
46 |
47 | export function shorthash(text: string) {
48 | let num: number;
49 | let result = '';
50 |
51 | let integer = bitwise(text);
52 | const sign = integer < 0 ? 'Z' : ''; // If it's negative, start with Z, which isn't in the dictionary
53 |
54 | integer = Math.abs(integer);
55 |
56 | while (integer >= binary) {
57 | num = integer % binary;
58 | integer = Math.floor(integer / binary);
59 | result = dictionary[num] + result;
60 | }
61 |
62 | if (integer > 0) {
63 | result = dictionary[integer] + result;
64 | }
65 |
66 | return sign + result;
67 | }
68 |
--------------------------------------------------------------------------------
/packages/astro-og-canvas/src/queue.ts:
--------------------------------------------------------------------------------
1 | // Derived from https://github.com/sindresorhus/p-limit under MIT License
2 | // p-limit is copyright (c) Sindre Sorhus (https://sindresorhus.com)
3 |
4 | type ResolveCallback = (val: T | PromiseLike) => void;
5 |
6 | /**
7 | * This is a queuing/limiting system based on the `p-limit` npm package, but heavily simplified
8 | * to a minimal API surface. All it does is ensure tasks run one at a time, sequentially.
9 | *
10 | * @example
11 | * const queue = pQueue();
12 | * const input = [
13 | * limit(() => fetchSomething('foo')),
14 | * limit(() => fetchSomething('bar')),
15 | * limit(() => doSomething())
16 | * ];
17 | * // Each promise is run sequentially rather than in parallel.
18 | * await Promise.all(input);
19 | */
20 | export function pQueue() {
21 | const queue: Array<(val?: unknown) => void> = [];
22 | let activeCount = 0;
23 |
24 | /** Process the next queued function if we're under the concurrency limit. */
25 | const resumeNext = () => {
26 | if (activeCount < 1 && queue.length > 0) {
27 | activeCount++;
28 | queue.shift()!();
29 | }
30 | };
31 |
32 | const run = async (task: () => T, resolve: ResolveCallback) => {
33 | // Execute the function and capture the result promise
34 | const result = (async () => task())();
35 |
36 | // Resolve immediately with the promise (don't wait for completion)
37 | resolve(result);
38 |
39 | // Wait for the function to complete (success or failure)
40 | // We catch errors here to prevent unhandled rejections,
41 | // but the original promise rejection is preserved for the caller
42 | try {
43 | await result;
44 | } catch {}
45 |
46 | // Decrement active count and process next queued function
47 | activeCount--;
48 | resumeNext();
49 | };
50 |
51 | const enqueue = (task: () => T, resolve: ResolveCallback) => {
52 | // Queue the internal resolve function instead of the run function
53 | // to preserve the asynchronous execution context.
54 | new Promise((internalResolve) => {
55 | queue.push(internalResolve);
56 | }).then(() => run(task, resolve));
57 |
58 | // Start processing immediately if we haven't reached the concurrency limit
59 | if (activeCount < 1) resumeNext();
60 | };
61 |
62 | /** Run `task` when any previously enqueued tasks have completed. */
63 | const generator = (task: () => T) =>
64 | new Promise((resolve) => {
65 | enqueue(task, resolve);
66 | });
67 |
68 | return generator;
69 | }
70 |
--------------------------------------------------------------------------------
/demo/src/pages/background-test/[path].ts:
--------------------------------------------------------------------------------
1 | import { OGImageRoute } from 'astro-og-canvas';
2 |
3 | const logoPath = './src/astro-docs-logo.png';
4 | const bgPath = './src/bgPattern.png';
5 |
6 | export const { getStaticPaths, GET } = OGImageRoute({
7 | param: 'path',
8 | pages: {
9 | 'contain.md': {
10 | bgImage: { path: bgPath, fit: 'contain' },
11 | title: 'bgImage: { fit: "contain" }',
12 | },
13 | 'cover.md': {
14 | bgImage: { path: bgPath, fit: 'cover' },
15 | title: 'bgImage: { fit: "cover" }',
16 | },
17 | 'fill.md': {
18 | bgImage: { path: bgPath, fit: 'fill' },
19 | title: 'bgImage: { fit: "fill" }',
20 | },
21 | 'default.md': {
22 | bgImage: { path: bgPath, fit: 'none' },
23 | title: 'bgImage: { fit: "none" }',
24 | },
25 | 'logo-contain.md': {
26 | bgImage: { path: logoPath, fit: 'contain' },
27 | title: 'bgImage: { fit: "contain" }',
28 | font: { title: { color: [0, 0, 0], weight: 'Bold' } },
29 | },
30 | 'logo-cover.md': {
31 | bgImage: { path: logoPath, fit: 'cover' },
32 | title: 'bgImage: { fit: "cover" }',
33 | font: { title: { color: [0, 0, 0], weight: 'Bold' } },
34 | },
35 | 'logo-fill.md': {
36 | bgImage: { path: logoPath, fit: 'fill' },
37 | title: 'bgImage: { fit: "fill" }',
38 | font: { title: { color: [0, 0, 0], weight: 'Bold' } },
39 | },
40 | 'logo-default.md': {
41 | bgImage: { path: logoPath, fit: 'none' },
42 | title: 'bgImage: { fit: "none" }',
43 | font: { title: { color: [0, 0, 0], weight: 'Bold' } },
44 | },
45 | 'logo-start.md': {
46 | bgImage: { path: logoPath, position: 'start' },
47 | title: 'bgImage: { position: "start" }',
48 | font: { title: { color: [0, 0, 0], weight: 'Bold' } },
49 | },
50 | 'logo-end.md': {
51 | bgImage: { path: logoPath, position: 'end' },
52 | title: 'bgImage: { position: "end" }',
53 | font: { title: { color: [0, 0, 0], weight: 'Bold' } },
54 | },
55 | 'logo-center-end.md': {
56 | bgImage: { path: logoPath, position: ['center', 'end'] },
57 | title: 'bgImage: {\n position: ["center", "end"],\n}',
58 | font: { title: { color: [0, 0, 0], weight: 'Bold' } },
59 | },
60 | 'logo-end-center.md': {
61 | bgImage: { path: logoPath, position: ['end', 'center'] },
62 | title: 'bgImage: {\n position: ["end", "center"],\n}',
63 | font: { title: { color: [0, 0, 0], weight: 'Bold' } },
64 | },
65 | },
66 | getImageOptions: (_path, page) => ({
67 | title: '',
68 | description: '',
69 | logo: { path: logoPath, size: [350] },
70 | bgGradient: [
71 | [255, 0, 0],
72 | [0, 255, 0],
73 | [0, 0, 255],
74 | ],
75 | font: { title: { color: [0, 255, 0], weight: 'Bold' } },
76 | ...page,
77 | }),
78 | });
79 |
--------------------------------------------------------------------------------
/packages/astro-og-canvas/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # astro-og-canvas
2 |
3 | ## 0.7.2
4 |
5 | ### Patch Changes
6 |
7 | - [#100](https://github.com/delucis/astro-og-canvas/pull/100) [`a5f25e2`](https://github.com/delucis/astro-og-canvas/commit/a5f25e2667d42a89afc5503be9b82d8626c2bf52) Thanks [@delucis](https://github.com/delucis)! - This package is now published using OIDC trusted publishing and provenance guarantees
8 |
9 | - [#99](https://github.com/delucis/astro-og-canvas/pull/99) [`6fc6258`](https://github.com/delucis/astro-og-canvas/commit/6fc62589cac2b1d4067e7730f4afb981d79c0ac8) Thanks [@delucis](https://github.com/delucis)! - Updates `entities` and `canvaskit-wasm` internal dependencies
10 |
11 | ## 0.7.1
12 |
13 | ### Patch Changes
14 |
15 | - e8bb055: Improves handling of cases where OG images are requested to be generated in parallel
16 |
17 | ## 0.7.0
18 |
19 | ### Minor Changes
20 |
21 | - 5dcccb4: Reverts v0.6.0 changes. NPM was failing to pack bundled dependencies as expected, so this release reverts things to the v0.5.x state until we have time to figure this out.
22 |
23 | ## 0.6.0
24 |
25 | ### Minor Changes
26 |
27 | - 330c56c: Bundles `canvaskit-wasm` to avoid users with strict package managers like PNPM needing to install it directly
28 |
29 | ## 0.5.6
30 |
31 | ### Patch Changes
32 |
33 | - bb13312: Fixes a README code example
34 |
35 | ## 0.5.5
36 |
37 | ### Patch Changes
38 |
39 | - ceeecc1: Expands peer dependencies to support Astro v5
40 |
41 | ## 0.5.4
42 |
43 | ### Patch Changes
44 |
45 | - 252e840: Adds an explicit `Buffer` import for Deno compatibility
46 |
47 | ## 0.5.3
48 |
49 | ### Patch Changes
50 |
51 | - 0bea94b: Refactors CanvasKit initialization to log more helpful error in PNPM projects without `canvaskit-wasm` installed
52 |
53 | ## 0.5.2
54 |
55 | ### Patch Changes
56 |
57 | - 9fec927: Fixes image generation for slugs with a leading slash in Astro ≥4.10.2
58 |
59 | ## 0.5.1
60 |
61 | ### Patch Changes
62 |
63 | - c7d3a7a: Improves README
64 |
65 | ## 0.5.0
66 |
67 | ### Minor Changes
68 |
69 | - e66e580: Switches to using the “full” `canvaskit-wasm` build to generate images. This fixes support for rendering as JPEG or WEBP instead of the default PNG.
70 | - e66e580: Updates `canvaskit-wasm` to the latest release
71 |
72 | **Note:** pnpm users may need to manually update in their project too:
73 |
74 | ```sh
75 | pnpm i canvaskit-wasm@^0.39.1
76 | ```
77 |
78 | ## 0.4.2
79 |
80 | ### Patch Changes
81 |
82 | - be0c969: Adds missing comma in README
83 |
84 | ## 0.4.1
85 |
86 | ### Patch Changes
87 |
88 | - 0cbcfa3: Support Astro v4
89 |
90 | ## 0.4.0
91 |
92 | ### Minor Changes
93 |
94 | - c9b3dc9: Adds support for rendering a background image
95 |
96 | ## 0.3.2
97 |
98 | ### Patch Changes
99 |
100 | - 8416369: Update `deterministic-object-hash` from 1.3.1 to 2.0.2
101 |
102 | ## 0.3.1
103 |
104 | ### Patch Changes
105 |
106 | - 6432706: Update README docs
107 |
108 | ## 0.3.0
109 |
110 | ### Minor Changes
111 |
112 | - 1b83057: Add support for loading local font files
113 | - 19f025a: Cache images across builds
114 |
115 | ## 0.2.1
116 |
117 | ### Patch Changes
118 |
119 | - 467523f: Add note about `canvaskit-wasm` for pnpm users to README
120 |
121 | ## 0.2.0
122 |
123 | ### Minor Changes
124 |
125 | - 2f8952c: Add support for Astro 3.0 and remove support for Astro 1.0 and 2.0.
126 |
127 | When upgrading, update your OpenGraph routes to use `GET` instead instead of lowercase `get`:
128 |
129 | ```diff
130 | import { OGImageRoute } from 'astro-og-canvas';
131 |
132 | - export const { getStaticPaths, get } = OGImageRoute({
133 | + export const { getStaticPaths, GET } = OGImageRoute({
134 | // ...
135 | });
136 | ```
137 |
138 | ## 0.1.8
139 |
140 | ### Patch Changes
141 |
142 | - a598023: Fix unexpected slug truncation for paths without extension
143 | - ed36da8: Bump dev dependencies
144 |
145 | ## 0.1.7
146 |
147 | ### Patch Changes
148 |
149 | - 8c10732: Manually free memory after generating an image
150 |
151 | ## 0.1.6
152 |
153 | ### Patch Changes
154 |
155 | - c063c32: Allow installation in Astro v2 projects
156 |
157 | ## 0.1.5
158 |
159 | ### Patch Changes
160 |
161 | - 98be213: Handle index files in default slugifier (e.g. `/foo/index.md` now becomes `/foo.png` instead of `/foo/index.png`)
162 |
163 | ## 0.1.4
164 |
165 | ### Patch Changes
166 |
167 | - cadcdb5: Improve layout logic to better handle long text
168 |
169 | ## 0.1.3
170 |
171 | ### Patch Changes
172 |
173 | - 819977a: Support HTML entities in title & description
174 | - be5c57f: Remove unused array in font manager
175 | - cd14cbe: Fix bug causing font manager to return previous manager instance
176 |
177 | ## 0.1.2
178 |
179 | ### Patch Changes
180 |
181 | - 641bbe9: Fix debug logging prefix
182 | - 479a011: Ship compiled JavaScript output instead of uncompiled TypeScript.
183 | - 7fd6d5b: Support async `getImageOptions`
184 |
185 | ## 0.1.1
186 |
187 | ### Patch Changes
188 |
189 | - 373b227: Work around memory leak by avoiding reinstantiations of `CanvasKit.FontMgr`
190 | - e8f3952: Avoid top-level `await` for better support in different environments
191 |
192 | ## 0.1.0
193 |
194 | ### Minor Changes
195 |
196 | - 6c99108: Initial release
197 |
--------------------------------------------------------------------------------
/packages/astro-og-canvas/src/assetLoaders.ts:
--------------------------------------------------------------------------------
1 | import type { CanvasKit, FontMgr } from 'canvaskit-wasm/full';
2 | import { Buffer } from 'node:buffer';
3 | import fs from 'node:fs/promises';
4 | import { createRequire } from 'node:module';
5 | import { pQueue } from './queue';
6 | import { shorthash } from './shorthash';
7 | const { resolve } = createRequire(import.meta.url);
8 |
9 | const debug = (...args: any[]) => console.debug('[astro-og-canvas]', ...args);
10 | const error = (...args: any[]) => console.error('[astro-og-canvas]', ...args);
11 |
12 | /** CanvasKit singleton. */
13 | let canvasKitSingleton: CanvasKit;
14 | export async function getCanvasKit() {
15 | if (!canvasKitSingleton) {
16 | try {
17 | const { default: init } = await import('canvaskit-wasm/full');
18 | canvasKitSingleton = await init({
19 | // TODO: Figure how to reliably resolve this without depending on Node.
20 | locateFile: (file) => resolve(`canvaskit-wasm/bin/full/${file}`),
21 | });
22 | } catch (e) {
23 | throw formatCanvasKitInitError(e);
24 | }
25 | }
26 | return canvasKitSingleton;
27 | }
28 |
29 | function formatCanvasKitInitError(e: unknown) {
30 | if (e instanceof Error && e.message.includes('__dirname is not defined')) {
31 | e.message +=
32 | '\n\n' +
33 | 'This error is often thrown when using PNPM without installing `canvaskit-wasm` directly.\n' +
34 | 'Install this required dependency by running `pnpm add canvaskit-wasm`\n';
35 | }
36 | return e;
37 | }
38 |
39 | class FontManager {
40 | /** Font data cache to avoid repeat downloads. */
41 | readonly #cache = new Map();
42 | readonly #hashCache = new Map();
43 | /** Queue to co-ordinate `#get` calls to run sequentially. */
44 | #queue = pQueue();
45 | /** Current `CanvasKit.FontMgr` instance. */
46 | #manager?: FontMgr;
47 |
48 | /** Get a `CanvasKit.FontMgr` instance with all the currently cached fonts, creating a new one if needed. */
49 | async #getOrCreateManager(shouldUpdate: boolean): Promise {
50 | if (!shouldUpdate && this.#manager) {
51 | return this.#manager;
52 | }
53 |
54 | const CanvasKit = await getCanvasKit();
55 | const fontData = Array.from(this.#cache.values()).filter((v) => !!v) as ArrayBuffer[];
56 | this.#manager = CanvasKit.FontMgr.FromData(...fontData)!;
57 |
58 | // Log to the terminal which font families have been loaded.
59 | // Mostly useful so users can see the name of families as parsed by CanvasKit.
60 | const fontCount = this.#manager.countFamilies();
61 | const fontFamilies = [];
62 | for (let i = 0; i < fontCount; i++) fontFamilies.push(this.#manager.getFamilyName(i));
63 | debug('Loaded', fontCount, 'font families:\n' + fontFamilies.join(', '));
64 |
65 | return this.#manager;
66 | }
67 |
68 | /**
69 | * Get a font manager instance for the provided fonts.
70 | *
71 | * Fonts are backed by an in-memory cache, so fonts are only downloaded once.
72 | *
73 | * Tries to avoid repeated instantiation of `CanvasKit.FontMgr` due to a memory leak
74 | * in their implementation. Will only reinstantiate if it sees a new font in the
75 | * `fontUrls` array.
76 | *
77 | * @param fontUrls Array of URLs to remote font files (TTF recommended).
78 | * @returns A font manager for all fonts loaded up until now.
79 | */
80 | async get(fontUrls: string[]): Promise {
81 | return this.#queue(async () => {
82 | let hasNew = false;
83 | for (const url of fontUrls) {
84 | if (this.#cache.has(url)) continue;
85 | hasNew = true;
86 | debug('Loading', url);
87 | if (/^https?:\/\//.test(url)) {
88 | const response = await fetch(url);
89 | if (response.ok) {
90 | this.#cache.set(url, await response.arrayBuffer());
91 | } else {
92 | this.#cache.set(url, undefined);
93 | error(response.status, response.statusText, '—', url);
94 | }
95 | } else {
96 | const file = await fs.readFile(url);
97 | this.#cache.set(url, file);
98 | }
99 | }
100 | return this.#getOrCreateManager(hasNew);
101 | });
102 | }
103 |
104 | /** Get a short hash for a given font resource. */
105 | getHash(url: string): string {
106 | let hash = this.#hashCache.get(url) || '';
107 | if (hash) return hash;
108 | const buffer = this.#cache.get(url);
109 | hash = buffer ? shorthash(Buffer.from(buffer).toString()) : '';
110 | this.#hashCache.set(url, hash);
111 | return hash;
112 | }
113 | }
114 | export const fontManager = new FontManager();
115 |
116 | interface LoadedImage {
117 | /** Pixel buffer for the loaded image. */
118 | buffer: Buffer;
119 | /** Short hash of the image’s buffer. */
120 | hash: string;
121 | }
122 |
123 | const images = { cache: new Map(), queue: pQueue() };
124 |
125 | /**
126 | * Load an image. Backed by an in-memory cache to avoid repeat disk-reads.
127 | * @param path Path to an image file, e.g. `./src/logo.png`.
128 | * @returns Buffer containing the image contents.
129 | */
130 | export const loadImage = async (path: string): Promise =>
131 | images.queue(async () => {
132 | const cached = images.cache.get(path);
133 | if (cached) {
134 | return cached;
135 | } else {
136 | // TODO: Figure out if there’s deno-compatible way to load images.
137 | const buffer = await fs.readFile(path);
138 | const image = { buffer, hash: shorthash(buffer.toString()) };
139 | images.cache.set(path, image);
140 | return image;
141 | }
142 | });
143 |
--------------------------------------------------------------------------------
/packages/astro-og-canvas/src/types.ts:
--------------------------------------------------------------------------------
1 | import type { CanvasKit } from 'canvaskit-wasm/full';
2 |
3 | export type RGBColor = [r: number, g: number, b: number];
4 | export type XYWH = [x: number, y: number, w: number, h: number];
5 | export type LogicalSide = 'block-start' | 'inline-end' | 'block-end' | 'inline-start';
6 | export type IllogicalSide = 'top' | 'right' | 'bottom' | 'left';
7 | export type LogicalPosition = 'start' | 'center' | 'end';
8 |
9 | export interface FontConfig {
10 | /** RGB text color. Default: `[255, 255, 255]` */
11 | color?: RGBColor;
12 | /** Font size. Title default is `70`, description default is `40`. */
13 | size?: number;
14 | /** Font weight. Make sure you provide a URL for the matching font weight. */
15 | weight?: Exclude;
16 | /** Line height, a.k.a. leading. */
17 | lineHeight?: number;
18 | /**
19 | * Font families to use to render this text. These must be loaded using the
20 | * top-level `fonts` config option.
21 | *
22 | * Similar to CSS, this operates as a “font stack”. The first family in the
23 | * list will be preferred with next entries used if a glyph isn’t in earlier
24 | * families. Useful for providing fallbacks for different alphabets etc.
25 | *
26 | * Example: `['Noto Sans', 'Noto Sans Arabic']`
27 | */
28 | families?: string[];
29 | }
30 |
31 | export interface OGImageOptions {
32 | /**
33 | * Directory to use for caching images across builds.
34 | * Set to `false` to disable caching.
35 | * Default: `./node_modules/.astro-og-canvas`
36 | */
37 | cacheDir?: string | false;
38 | /** Page title. */
39 | title: string;
40 | /** Short page description. */
41 | description?: string;
42 | /** Writing direction. Default: `'ltr'`. Set to `'rtl'` for Arabic, Hebrew, etc. */
43 | dir?: 'rtl' | 'ltr';
44 | /** Optional site logo. Displayed at the top of the card. */
45 | logo?: {
46 | /** Path to the logo image file, e.g. `'./src/logo.png'` */
47 | path: string;
48 | /**
49 | * Size to display logo at.
50 | * - `undefined` — Use original image file dimensions. (Default)
51 | * - `[width]` — Resize to the specified width, height will be resize proportionally.
52 | * - `[width, height]` — Resized to the specified width and height.
53 | */
54 | size?: [width?: number, height?: number];
55 | };
56 | /**
57 | * Array of `[R, G, B]` colors to use in the background gradient,
58 | * e.g. `[[255, 0, 0], [0, 0, 255]]` (red to blue).
59 | * For a solid color, only include a single color, e.g. `[[0, 0, 0]]`
60 | */
61 | bgGradient?: RGBColor[];
62 |
63 | /**
64 | * Optional background image.
65 | */
66 | bgImage?: {
67 | /** Path to the background image file, e.g. `'./src/background.png'`. */
68 | path: string;
69 | /**
70 | * How the background image should resize to fit the container.
71 | *
72 | * Default: `'none'`
73 | *
74 | * Options:
75 | * - `'none'` — The image is displayed at original size even if that’s larger or smaller than the container
76 | * - `'cover'` — The image is sized to maintain its aspect ratio while filling the entire container.
77 | * If the image’s aspect ratio does not match the aspect ratio of the container, then the image will be clipped to fit.
78 | * - `'fill'` — The image is sized to fill the entire container.
79 | * If the image’s aspect ratio does not match the aspect ratio of the container, then the image will be stretched to fit.
80 | * - `'contain'` — The image is scaled to maintain its aspect ratio while fitting within the container.
81 | * The image will be “letter-boxed” if its aspect ratio does not match the aspect ratio of the container.
82 | */
83 | fit?: 'cover' | 'contain' | 'none' | 'fill';
84 | /**
85 | * The position of the background image.
86 | *
87 | * Default: `'center'`
88 | *
89 | * The value is either a shorthand for both block and inline directions, e.g. `'center'`,
90 | * or a tuple of `[blockPosition, inlinePosition]`, e.g. `['end', 'center']`.
91 | *
92 | * Examples:
93 | * - `'start'` — place the image at the top-left (or top-right for RTL languages)
94 | * - `'center'` (default) — center the image horizontally and vertically
95 | * - `['start', 'end']` — place the image at the top
96 | */
97 | position?: LogicalPosition | [LogicalPosition, LogicalPosition];
98 | };
99 |
100 | /** Border config. Highlights a single edge of the image. */
101 | border?: {
102 | /** RGB border color, e.g. `[0, 255, 0]`. */
103 | color?: RGBColor;
104 | /** Border width. Default: `0`. */
105 | width?: number;
106 | /** Side of the image to draw the border on. Inline start/end respects writing direction. */
107 | side?: LogicalSide;
108 | };
109 | /** Amount of padding between the image edge and text. Default: `60`. */
110 | padding?: number;
111 | /** Font styles. */
112 | font?: {
113 | /** Font style for the page title. */
114 | title?: FontConfig;
115 | /** Font style for the page description. */
116 | description?: FontConfig;
117 | };
118 | /**
119 | * Array of font URLs or file paths to load and use when rendering text.
120 | *
121 | * @example
122 | * {
123 | * fonts: [
124 | * // Local font file path relative to the project root:
125 | * './src/fonts/local-font.ttf',
126 | * // URL to a font file on a remote web server:
127 | * 'https://example.com/cdn/remote-font.ttf'
128 | * ],
129 | * }
130 | */
131 | fonts?: string[];
132 | /** Image format to save to. Default: `'PNG'` */
133 | format?: Exclude;
134 | /** Image quality between `0` (very lossy) and `100` (least lossy). Not used by all formats. */
135 | quality?: number;
136 | }
137 |
--------------------------------------------------------------------------------
/packages/astro-og-canvas/README.md:
--------------------------------------------------------------------------------
1 | # astro-og-canvas
2 |
3 | This package provides utlities to generate OpenGraph images for the pages on your Astro site.
4 |
5 | ## Installation
6 |
7 | ```shell
8 | npm i astro-og-canvas
9 | ```
10 |
11 | **Using `pnpm`?** `pnpm` users will also need to install `canvaskit-wasm` as a direct dependency:
12 |
13 | ```shell
14 | pnpm i canvaskit-wasm
15 | ```
16 |
17 | ## Version compatibility
18 |
19 | | astro | astro-og-canvas |
20 | | ------ | --------------------------------------------------------------------------------------------- |
21 | | `≤2.x` | [`0.1.x`](https://github.com/delucis/astro-og-canvas/blob/astro-og-canvas%400.1.8/README.md) |
22 | | `≥3.x` | [`≥0.2.x`](https://github.com/delucis/astro-og-canvas/blob/astro-og-canvas%400.2.0/README.md) |
23 |
24 | ## Usage
25 |
26 | ### Creating an OpenGraph image endpoint
27 |
28 | 1. Create a new file in your `src/pages/` directory. For example, `src/pages/open-graph/[...route].ts`.
29 |
30 | 2. Use the `OGImageRoute` helper to create `getStaticPaths` and `GET` functions for you:
31 |
32 | ```js
33 | // src/pages/open-graph/[...route].ts
34 |
35 | import { OGImageRoute } from 'astro-og-canvas';
36 |
37 | export const { getStaticPaths, GET } = OGImageRoute({
38 | // Tell us the name of your dynamic route segment.
39 | // In this case it’s `route`, because the file is named `[...route].ts`.
40 | param: 'route',
41 |
42 | // A collection of pages to generate images for.
43 | // The keys of this object are used to generate the path for that image.
44 | // In this example, we generate one image at `/open-graph/example.png`.
45 | pages: {
46 | 'example': {
47 | title: 'Example Page',
48 | description: 'Description of this page shown in smaller text',
49 | }
50 | },
51 |
52 | // For each page, this callback will be used to customize the OpenGraph image.
53 | getImageOptions: (path, page) => ({
54 | title: page.title,
55 | description: page.description,
56 | logo: {
57 | path: './src/astro-docs-logo.png',
58 | },
59 | // There are a bunch more options you can use here!
60 | }),
61 | });
62 | ```
63 |
64 | #### Generating `pages` from a content collection
65 |
66 | If you want to generate images for every file in a content collection, use `getCollection()` to load your content entries and convert the entries array to an object.
67 |
68 | The following example assumes a content collection schema with `title` and `description` keys in the frontmatter.
69 |
70 | ```js
71 | import { getCollection } from 'astro:content';
72 | import { OGImageRoute } from 'astro-og-canvas';
73 |
74 | const collectionEntries = await getCollection('my-collection');
75 |
76 | // Map the array of content collection entries to create an object.
77 | // Converts [{ id: 'post.md', data: { title: 'Example', description: '' } }]
78 | // to { 'post.md': { title: 'Example', description: '' } }
79 | const pages = Object.fromEntries(collectionEntries.map(({ slug, data }) => [slug, data]));
80 |
81 | export const { getStaticPaths, GET } = OGImageRoute({
82 | // Tell us the name of your dynamic route segment.
83 | // In this case it’s `route`, because the file is named `[...route].ts`.
84 | param: 'route',
85 |
86 | pages: pages,
87 |
88 | getImageOptions: (path, page) => ({
89 | title: page.title,
90 | description: page.description,
91 | }),
92 | });
93 | ```
94 |
95 | #### Generating `pages` from Markdown files
96 |
97 | If you have a folder of Markdown files with `title` and `description` fields in their frontmatter, use `import.meta.glob()` to load and pass these to the `pages` option of `OGImageRoute`.
98 |
99 | In the following example, every Markdown file in the project’s `src/pages/` directory is loaded and will have an image generated for them:
100 |
101 | ```js
102 | import { OGImageRoute } from 'astro-og-canvas';
103 |
104 | export const { getStaticPaths, GET } = OGImageRoute({
105 | // Tell us the name of your dynamic route segment.
106 | // In this case it’s `route`, because the file is named `[...route].ts`.
107 | param: 'route',
108 |
109 | // Pass the glob result to pages
110 | pages: await import.meta.glob('/src/pages/**/*.md', { eager: true }),
111 |
112 | // Extract `title` and `description` from the glob result’s `frontmatter` property
113 | getImageOptions: (path, page) => ({
114 | title: page.frontmatter.title,
115 | description: page.frontmatter.description,
116 | }),
117 |
118 | // ...
119 | });
120 | ```
121 |
122 | #### Generating `pages` from other data sources
123 |
124 | `pages` can be any object you want. Its keys are used to generate the final route, but other than this, how you use it is up to you, so you can generate images from any data you like.
125 |
126 |
127 | Pokémon API example
128 |
129 | The following example fetches some data about Pokémon using the [PokéAPI](https://pokeapi.co/) and then uses that to generate images:
130 |
131 | ```js
132 | import { OGImageRoute } from 'astro-og-canvas';
133 |
134 | // Fetch first 20 Pokémon from the PokéAPI.
135 | const { results } = await fetch('https://pokeapi.co/api/v2/pokemon/').then((res) => res.json());
136 | // Fetch details for each Pokémon.
137 | const pokemon = {};
138 | for (const { url } of results) {
139 | const details = await fetch(url).then((res) => res.json());
140 | pokemon[details.name] = details;
141 | }
142 |
143 | export const { getStaticPaths, GET } = OGImageRoute({
144 | pages: pokemon,
145 |
146 | getImageOptions: (path, page) => ({
147 | title: page.name,
148 | description: `Pokémon #${page.order}`,
149 | }),
150 | });
151 | ```
152 |
153 |
154 |
155 | ### Image Options
156 |
157 | Your `getImageOptions` callback should return an object configuring the image to render. Almost all options are optional.
158 |
159 | ```ts
160 | export interface OGImageOptions {
161 | /** Page title. */
162 | title: string;
163 |
164 | /** Short page description. */
165 | description?: string;
166 |
167 | /** Writing direction. Default: `'ltr'`. Set to `'rtl'` for Arabic, Hebrew, etc. */
168 | dir?: 'rtl' | 'ltr';
169 |
170 | /** Optional site logo. Displayed at the top of the card. */
171 | logo?: {
172 | /** Path to the logo image file, e.g. `'./src/logo.png'` */
173 | path: string;
174 |
175 | /**
176 | * Size to display logo at.
177 | * - `undefined` — Use original image file dimensions. (Default)
178 | * - `[width]` — Resize to the specified width, height will be
179 | * resized proportionally.
180 | * - `[width, height]` — Resized to the specified width and height.
181 | */
182 | size?: [width?: number, height?: number];
183 | };
184 |
185 | /**
186 | * Array of `[R, G, B]` colors to use in the background gradient,
187 | * e.g. `[[255, 0, 0], [0, 0, 255]]` (red to blue).
188 | * For a solid color, only include a single color, e.g. `[[0, 0, 0]]`
189 | */
190 | bgGradient?: RGBColor[];
191 |
192 | /** Border config. Highlights a single edge of the image. */
193 | border?: {
194 | /** RGB border color, e.g. `[0, 255, 0]`. */
195 | color?: RGBColor;
196 |
197 | /** Border width. Default: `0`. */
198 | width?: number;
199 |
200 | /** Side of the image to draw the border on. Inline start/end respects writing direction. */
201 | side?: LogicalSide;
202 | };
203 |
204 | /** Optional background image. */
205 | bgImage?: {
206 | /** Path to the background image file, e.g. `'./src/background.png'`. */
207 | path: string;
208 |
209 | /** How the background image should resize to fit the container. (Default: `'none'`) */
210 | fit?: 'cover' | 'contain' | 'none' | 'fill';
211 |
212 | /**
213 | * How the background image should be positioned in the container. (Default: `'center'`)
214 | *
215 | * The value is either a shorthand for both block and inline directions, e.g. `'center'`,
216 | * or a tuple of `[blockPosition, inlinePosition]`, e.g. `['end', 'center']`.
217 | */
218 | position?: LogicalPosition | [LogicalPosition, LogicalPosition];
219 | };
220 |
221 | /** Amount of padding between the image edge and text. Default: `60`. */
222 | padding?: number;
223 |
224 | /** Font styles. */
225 | font?: {
226 | /** Font style for the page title. */
227 | title?: FontConfig;
228 |
229 | /** Font style for the page description. */
230 | description?: FontConfig;
231 | };
232 |
233 | /**
234 | * Array of font URLs or file paths to load and use when rendering text,
235 | * e.g. `['./src/fonts/local-font.ttf', 'https://example.com/cdn/remote-font.ttf']`
236 | * Local font paths are specified relative to your project’s root.
237 | */
238 | fonts?: string[];
239 |
240 | /**
241 | * Directory to cache images in across builds.
242 | * Default: `./node_modules/.astro-og-canvas`
243 | * Set to `false` to disable caching.
244 | */
245 | cacheDir?: string | false;
246 | }
247 | ```
248 |
249 | #### `FontConfig`
250 |
251 | ```ts
252 | export interface FontConfig {
253 | /** RGB text color. Default: `[255, 255, 255]` */
254 | color?: RGBColor;
255 |
256 | /** Font size. Title default is `70`, description default is `40`. */
257 | size?: number;
258 |
259 | /** Font weight. Make sure you provide a URL for the matching font weight. */
260 | weight?: Exclude;
261 |
262 | /** Line height, a.k.a. leading. */
263 | lineHeight?: number;
264 |
265 | /**
266 | * Font families to use to render this text. These must be loaded using the
267 | * top-level `fonts` config option.
268 | *
269 | * Similar to CSS, this operates as a “font stack”. The first family in the
270 | * list will be preferred with next entries used if a glyph isn’t in earlier
271 | * families. Useful for providing fallbacks for different alphabets etc.
272 | *
273 | * Example: `['Noto Sans', 'Noto Sans Arabic']`
274 | */
275 | families?: string[];
276 | }
277 | ```
278 |
279 | ## License
280 |
281 | MIT
282 |
--------------------------------------------------------------------------------
/packages/astro-og-canvas/src/generateOpenGraphImage.ts:
--------------------------------------------------------------------------------
1 | import { deterministicString } from 'deterministic-object-hash';
2 | import { decodeHTMLStrict } from 'entities';
3 | import fs from 'node:fs/promises';
4 | import path from 'node:path';
5 | import { Buffer } from 'node:buffer';
6 | import { getCanvasKit, fontManager, loadImage } from './assetLoaders';
7 | import { shorthash } from './shorthash';
8 | import type {
9 | FontConfig,
10 | IllogicalSide,
11 | LogicalSide,
12 | OGImageOptions,
13 | RGBColor,
14 | XYWH,
15 | } from './types';
16 |
17 | const [width, height] = [1200, 630];
18 | const edges: Record = {
19 | top: [0, 0, width, 0],
20 | bottom: [0, height, width, height],
21 | left: [0, 0, 0, height],
22 | right: [width, 0, width, height],
23 | };
24 | const defaults: {
25 | border: NonNullable>;
26 | font: Record<'title' | 'description', Required>;
27 | } = {
28 | border: {
29 | color: [255, 255, 255] as RGBColor,
30 | width: 0,
31 | side: 'inline-start' as LogicalSide,
32 | },
33 | font: {
34 | title: {
35 | color: [255, 255, 255],
36 | size: 70,
37 | lineHeight: 1,
38 | weight: 'Normal',
39 | families: ['Noto Sans'],
40 | },
41 | description: {
42 | color: [255, 255, 255],
43 | size: 40,
44 | lineHeight: 1.3,
45 | weight: 'Normal',
46 | families: ['Noto Sans'],
47 | },
48 | },
49 | };
50 |
51 | class ImageCache {
52 | #dirCache = new Set();
53 | /** Ensure the requested directory exists. */
54 | async #mkdir(dir: string) {
55 | if (this.#dirCache.has(dir)) return;
56 | try {
57 | await fs.mkdir(dir, { recursive: true });
58 | this.#dirCache.add(dir);
59 | } catch {}
60 | }
61 | /** Retrieve an image from the file system cache if it exists. */
62 | async get(cachePath: string): Promise {
63 | await this.#mkdir(path.dirname(cachePath));
64 | return await fs.readFile(cachePath).catch(() => undefined);
65 | }
66 | /** Write an image to the file system cache. */
67 | async set(cachePath: string, image: Buffer): Promise {
68 | await this.#mkdir(path.dirname(cachePath));
69 | await fs.writeFile(cachePath, image).catch(() => undefined);
70 | }
71 | }
72 | const imageCache = new ImageCache();
73 |
74 | export async function generateOpenGraphImage({
75 | cacheDir = './node_modules/.astro-og-canvas',
76 | title,
77 | description = '',
78 | dir = 'ltr',
79 | bgGradient = [[0, 0, 0]],
80 | bgImage,
81 | border: borderConfig = {},
82 | padding = 60,
83 | logo,
84 | font: fontConfig = {},
85 | fonts = ['https://api.fontsource.org/v1/fonts/noto-sans/latin-400-normal.ttf'],
86 | format = 'PNG',
87 | quality = 90,
88 | }: OGImageOptions) {
89 | // Load and configure font families.
90 | const fontMgr = await fontManager.get(fonts);
91 | const loadedLogo = logo && (await loadImage(logo.path));
92 | const loadedBg = bgImage && (await loadImage(bgImage.path));
93 |
94 | /** A deterministic hash based on inputs. */
95 | const hash = shorthash(
96 | deterministicString([
97 | title,
98 | description,
99 | dir,
100 | bgGradient,
101 | bgImage,
102 | borderConfig,
103 | padding,
104 | logo,
105 | fontConfig,
106 | fonts,
107 | quality,
108 | loadedLogo?.hash,
109 | loadedBg?.hash,
110 | fonts.map((font) => fontManager.getHash(font)),
111 | ])
112 | );
113 |
114 | let cacheFilePath: string | undefined;
115 | if (cacheDir) {
116 | cacheFilePath = path.join(cacheDir, `${hash}.${format.toLowerCase()}`);
117 | const cached = await imageCache.get(cacheFilePath);
118 | if (cached) return cached;
119 | }
120 |
121 | const border = { ...defaults.border, ...borderConfig };
122 | const font = {
123 | title: { ...defaults.font.title, ...fontConfig.title },
124 | description: { ...defaults.font.description, ...fontConfig.description },
125 | };
126 |
127 | const isRtl = dir === 'rtl';
128 | const margin: Record = {
129 | 'block-start': padding,
130 | 'block-end': padding,
131 | 'inline-start': padding,
132 | 'inline-end': padding,
133 | };
134 | margin[border.side] += border.width;
135 |
136 | const CanvasKit = await getCanvasKit();
137 |
138 | const textStyle = (fontConfig: Required) => ({
139 | color: CanvasKit.Color(...fontConfig.color),
140 | fontFamilies: fontConfig.families,
141 | fontSize: fontConfig.size,
142 | fontStyle: { weight: CanvasKit.FontWeight[fontConfig.weight] },
143 | heightMultiplier: fontConfig.lineHeight,
144 | });
145 |
146 | // Set up.
147 | const surface = CanvasKit.MakeSurface(width, height)!;
148 | const canvas = surface.getCanvas();
149 |
150 | // Draw background gradient.
151 | const bgRect = CanvasKit.XYWHRect(0, 0, width, height);
152 | const bgPaint = new CanvasKit.Paint();
153 | bgPaint.setShader(
154 | CanvasKit.Shader.MakeLinearGradient(
155 | [0, 0],
156 | [0, height],
157 | bgGradient.map((rgb) => CanvasKit.Color(...rgb)),
158 | null,
159 | CanvasKit.TileMode.Clamp
160 | )
161 | );
162 | canvas.drawRect(bgRect, bgPaint);
163 |
164 | // Draw border.
165 | if (border.width) {
166 | const borderStyle = new CanvasKit.Paint();
167 | borderStyle.setStyle(CanvasKit.PaintStyle.Stroke);
168 | borderStyle.setColor(CanvasKit.Color(...border.color));
169 | borderStyle.setStrokeWidth(border.width * 2);
170 | const borders: Record = {
171 | 'block-start': edges.top,
172 | 'block-end': edges.bottom,
173 | 'inline-start': isRtl ? edges.right : edges.left,
174 | 'inline-end': isRtl ? edges.left : edges.right,
175 | };
176 | canvas.drawLine(...borders[border.side], borderStyle);
177 | }
178 |
179 | // Draw background image.
180 | if (bgImage && loadedBg?.buffer) {
181 | const bgImg = CanvasKit.MakeImageFromEncoded(loadedBg.buffer);
182 | if (bgImg) {
183 | let { position = 'center', fit = 'none' } = bgImage;
184 | if (typeof position === 'string') position = [position, position];
185 |
186 | const [bgW, bgH] = [bgImg.width(), bgImg.height()];
187 | let [targetW, targetH] = [bgW, bgH];
188 | if (fit === 'fill') {
189 | [targetW, targetH] = [width, height];
190 | } else if (fit === 'cover') {
191 | const ratio = bgW / width < bgH / height ? width / bgW : height / bgH;
192 | [targetW, targetH] = [bgW * ratio, bgH * ratio];
193 | } else if (fit === 'contain') {
194 | const ratio = bgW / width > bgH / height ? width / bgW : height / bgH;
195 | [targetW, targetH] = [bgW * ratio, bgH * ratio];
196 | }
197 |
198 | const [blockAlign, inlineAlign] = position;
199 | const targetX =
200 | inlineAlign === 'start'
201 | ? 0
202 | : inlineAlign === 'end'
203 | ? width - targetW
204 | : (width - targetW) / 2;
205 | const targetY =
206 | blockAlign === 'start'
207 | ? 0
208 | : blockAlign === 'end'
209 | ? height - targetH
210 | : (height - targetH) / 2;
211 |
212 | // Draw image
213 | const srcRect = CanvasKit.XYWHRect(0, 0, bgW, bgH);
214 | const destRect = CanvasKit.XYWHRect(targetX, targetY, targetW, targetH);
215 | canvas.drawImageRect(bgImg, srcRect, destRect, new CanvasKit.Paint());
216 | }
217 | }
218 |
219 | // Draw logo.
220 | let logoHeight = 0;
221 | if (logo && loadedLogo?.buffer) {
222 | const img = CanvasKit.MakeImageFromEncoded(loadedLogo.buffer);
223 | if (img) {
224 | const logoH = img.height();
225 | const logoW = img.width();
226 | const targetW = logo.size?.[0] ?? logoW;
227 | const targetH = logo.size?.[1] ?? (targetW / logoW) * logoH;
228 | const xRatio = targetW / logoW;
229 | const yRatio = targetH / logoH;
230 | logoHeight = targetH;
231 |
232 | // Matrix transform to scale the logo to the desired size.
233 | const imagePaint = new CanvasKit.Paint();
234 | imagePaint.setImageFilter(
235 | CanvasKit.ImageFilter.MakeMatrixTransform(
236 | CanvasKit.Matrix.scaled(xRatio, yRatio),
237 | { filter: CanvasKit.FilterMode.Linear },
238 | null
239 | )
240 | );
241 |
242 | const imageLeft = isRtl
243 | ? (1 / xRatio) * (width - margin['inline-start']) - logoW
244 | : (1 / xRatio) * margin['inline-start'];
245 |
246 | canvas.drawImage(img, imageLeft, (1 / yRatio) * margin['block-start'], imagePaint);
247 | }
248 | }
249 |
250 | if (fontMgr) {
251 | // Create paragraph with initial styles and add title.
252 | const paragraphStyle = new CanvasKit.ParagraphStyle({
253 | textAlign: isRtl ? CanvasKit.TextAlign.Right : CanvasKit.TextAlign.Left,
254 | textStyle: textStyle(font.title),
255 | textDirection: isRtl ? CanvasKit.TextDirection.RTL : CanvasKit.TextDirection.LTR,
256 | });
257 | const paragraphBuilder = CanvasKit.ParagraphBuilder.Make(paragraphStyle, fontMgr);
258 | paragraphBuilder.addText(decodeHTMLStrict(title));
259 |
260 | // Add small empty line betwen title & description.
261 | paragraphBuilder.pushStyle(
262 | new CanvasKit.TextStyle({ fontSize: padding / 3, heightMultiplier: 1 })
263 | );
264 | paragraphBuilder.addText('\n\n');
265 |
266 | // Add description.
267 | paragraphBuilder.pushStyle(new CanvasKit.TextStyle(textStyle(font.description)));
268 | paragraphBuilder.addText(decodeHTMLStrict(description));
269 |
270 | // Draw paragraph to canvas.
271 | const para = paragraphBuilder.build();
272 | const paraWidth = width - margin['inline-start'] - margin['inline-end'] - padding;
273 | para.layout(paraWidth);
274 | const paraLeft = isRtl
275 | ? width - margin['inline-start'] - para.getMaxWidth()
276 | : margin['inline-start'];
277 | const minTop = margin['block-start'] + logoHeight + (logoHeight ? padding : 0);
278 | const maxTop = minTop + (logoHeight ? padding : 0);
279 | const naturalTop = height - margin['block-end'] - para.getHeight();
280 | const paraTop = Math.max(minTop, Math.min(maxTop, naturalTop));
281 | canvas.drawParagraph(para, paraLeft, paraTop);
282 | }
283 |
284 | // Render canvas to a buffer.
285 | const image = surface.makeImageSnapshot();
286 | const imageBytes =
287 | image.encodeToBytes(CanvasKit.ImageFormat[format], quality) || new Uint8Array();
288 |
289 | // Free any memory our surface might be hanging onto.
290 | surface.dispose();
291 |
292 | const imgBuffer = Buffer.from(imageBytes);
293 |
294 | if (cacheFilePath) await imageCache.set(cacheFilePath, imgBuffer);
295 | return imgBuffer;
296 | }
297 |
--------------------------------------------------------------------------------