@ampless/plugin-og-image 0.2.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ampless contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # @ampless/plugin-og-image
2
+
3
+ Dynamic Open Graph image generation for [ampless](https://github.com/heavymoons/ampless). SNS crawlers hit `https://<your-site>/og/<slug>`, the Next.js route renders a JSX card containing the post title + excerpt + site name + an optional image (theme banner or the first image in the post body), and Next.js `ImageResponse` returns a PNG. WebP / AVIF source images are decoded to PNG on the fly via [@jsquash](https://github.com/jamsinclair/jSquash) so Satori can paint them.
4
+
5
+ > **Pre-release / alpha.** Breaking changes possible in any minor version until v1.0.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @ampless/plugin-og-image@alpha
11
+ ```
12
+
13
+ ## Configure
14
+
15
+ In `cms.config.ts`:
16
+
17
+ ```ts
18
+ import { defineConfig } from 'ampless'
19
+ import ogImagePlugin, { loadFontFromUrl } from '@ampless/plugin-og-image'
20
+
21
+ export default defineConfig({
22
+ // ...
23
+ plugins: [
24
+ ogImagePlugin({
25
+ // Required: at least one font. Satori cannot render without one.
26
+ fonts: [
27
+ {
28
+ name: 'Inter',
29
+ // Lazy loader — runs once per route invocation, then cached
30
+ // in-process. Ship the .ttf / .otf from your CDN or public/.
31
+ data: loadFontFromUrl('https://example.com/fonts/Inter-Regular.ttf'),
32
+ weight: 400,
33
+ },
34
+ ],
35
+ // Image strategy: 'content' (first image in post body) | 'theme'
36
+ // (use `themeImageUrl`) | 'none' | (post) => url | null
37
+ image: 'content',
38
+ // Used when image === 'theme'
39
+ // themeImageUrl: 'https://example.com/og-banner.png',
40
+ }),
41
+ ],
42
+ })
43
+ ```
44
+
45
+ Add the dispatcher route in your Next.js app (the `_shared` template ships one at `app/site/[siteId]/og/[slug]/route.ts`).
46
+
47
+ ## Options
48
+
49
+ | Option | Default | Notes |
50
+ |---|---|---|
51
+ | `fonts` | required | At least one font must be provided |
52
+ | `size` | `{ width: 1200, height: 630 }` | OG card dimensions |
53
+ | `image` | `'content'` | `'theme'` / `'content'` / `'none'` / `(post) => url \| null` |
54
+ | `themeImageUrl` | none | Used when `image === 'theme'` |
55
+ | `render` | built-in card | Override to fully customize the JSX |
56
+
57
+ ## What it produces
58
+
59
+ - A `metadata()` hook that injects `openGraph.images: [{ url: '<site>/og/<slug>', width, height }]` into per-post metadata, so SNS crawlers know where to fetch the card.
60
+ - An `ogImage.render(ctx)` that the dispatcher route turns into a PNG via `next/og`.
61
+
62
+ ## Trust level
63
+
64
+ `untrusted` — runs only in the Next.js request path (no Lambda hooks). No AWS credentials are touched; fonts and post images are fetched over plain HTTPS.
65
+
66
+ ## License
67
+
68
+ [MIT](../../LICENSE)
@@ -0,0 +1,60 @@
1
+ // src/load-image.ts
2
+ function toBase64(bytes) {
3
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
4
+ }
5
+ function toArrayBuffer(bytes) {
6
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
7
+ }
8
+ async function decodeWebp(bytes) {
9
+ try {
10
+ const webp = await import("@jsquash/webp");
11
+ const png = await import("@jsquash/png");
12
+ const imageData = await webp.decode(toArrayBuffer(bytes));
13
+ const pngBytes = await png.encode(imageData);
14
+ return `data:image/png;base64,${toBase64(new Uint8Array(pngBytes))}`;
15
+ } catch (err) {
16
+ console.error("[plugin-og-image] webp decode failed:", err);
17
+ return null;
18
+ }
19
+ }
20
+ async function decodeAvif(bytes) {
21
+ try {
22
+ const avif = await import("@jsquash/avif");
23
+ const png = await import("@jsquash/png");
24
+ const imageData = await avif.decode(toArrayBuffer(bytes));
25
+ const pngBytes = await png.encode(imageData);
26
+ return `data:image/png;base64,${toBase64(new Uint8Array(pngBytes))}`;
27
+ } catch (err) {
28
+ console.error("[plugin-og-image] avif decode failed:", err);
29
+ return null;
30
+ }
31
+ }
32
+ async function loadImageForOg(url) {
33
+ let res;
34
+ try {
35
+ res = await fetch(url);
36
+ } catch (err) {
37
+ console.error("[plugin-og-image] fetch failed:", err);
38
+ return null;
39
+ }
40
+ if (!res.ok) {
41
+ console.error("[plugin-og-image] fetch non-ok:", res.status, url);
42
+ return null;
43
+ }
44
+ const contentType = (res.headers.get("content-type") ?? "").toLowerCase().split(";")[0].trim();
45
+ const bytes = new Uint8Array(await res.arrayBuffer());
46
+ if (contentType === "image/png" || contentType === "image/jpeg" || contentType === "image/jpg") {
47
+ return `data:${contentType};base64,${toBase64(bytes)}`;
48
+ }
49
+ if (contentType === "image/webp") {
50
+ return decodeWebp(bytes);
51
+ }
52
+ if (contentType === "image/avif") {
53
+ return decodeAvif(bytes);
54
+ }
55
+ return null;
56
+ }
57
+
58
+ export {
59
+ loadImageForOg
60
+ };
@@ -0,0 +1,57 @@
1
+ import { OgImageFont, Post, Config, OgImageRenderContext, AmplessPlugin } from 'ampless';
2
+ import { ReactElement } from 'react';
3
+ export { loadImageForOg } from './load-image.js';
4
+
5
+ /**
6
+ * Return a font loader that fetches `url` lazily and caches the result.
7
+ * Use in `cms.config.ts`:
8
+ *
9
+ * ogImagePlugin({
10
+ * fonts: [
11
+ * { name: 'Inter', data: loadFontFromUrl('https://.../Inter.ttf') },
12
+ * ],
13
+ * })
14
+ */
15
+ declare function loadFontFromUrl(url: string): () => Promise<ArrayBuffer>;
16
+
17
+ interface DefaultCardProps {
18
+ title: string;
19
+ excerpt?: string;
20
+ siteName: string;
21
+ /** Optional pre-decoded data URL — produced by loadImageForOg(). */
22
+ imageDataUrl?: string | null;
23
+ /** Font name registered with ogImagePlugin's `fonts` array. */
24
+ fontFamily: string;
25
+ }
26
+ declare function DefaultCard(props: DefaultCardProps): ReactElement;
27
+
28
+ /**
29
+ * Image strategy:
30
+ * - 'theme' : use the URL passed in `themeImageUrl`
31
+ * - 'content' : use the first image found in the post body
32
+ * - 'none' : never include an image
33
+ * - function : pick the URL yourself (return null to omit)
34
+ */
35
+ type OgImageStrategy = 'theme' | 'content' | 'none' | ((post: Post, site: Config['site']) => string | null | undefined);
36
+ interface OgImagePluginOptions {
37
+ fonts: OgImageFont[];
38
+ size?: {
39
+ width: number;
40
+ height: number;
41
+ };
42
+ image?: OgImageStrategy;
43
+ themeImageUrl?: string;
44
+ /** Override the entire card render. Receives the same context the route hands the plugin. */
45
+ render?: (ctx: OgImageRenderContext) => Promise<ReactElement> | ReactElement;
46
+ }
47
+ /**
48
+ * OG image plugin. Contributes:
49
+ * - `metadata(post)` → tells SNS crawlers the per-post OG image URL.
50
+ * - `ogImage.render(ctx)` → invoked by the dispatcher route at request time.
51
+ *
52
+ * The dispatcher route picks the first plugin in `cms.config.plugins`
53
+ * that has an `ogImage` config, so only register one OG image plugin per site.
54
+ */
55
+ declare function ogImagePlugin(options: OgImagePluginOptions): AmplessPlugin;
56
+
57
+ export { DefaultCard, type OgImagePluginOptions, type OgImageStrategy, ogImagePlugin as default, loadFontFromUrl };
package/dist/index.js ADDED
@@ -0,0 +1,196 @@
1
+ import {
2
+ loadImageForOg
3
+ } from "./chunk-BDDYGEJD.js";
4
+
5
+ // src/index.ts
6
+ import {
7
+ definePlugin,
8
+ extractFirstImageUrl
9
+ } from "ampless";
10
+
11
+ // src/default-card.tsx
12
+ import { jsx, jsxs } from "react/jsx-runtime";
13
+ function DefaultCard(props) {
14
+ const { title, excerpt, siteName, imageDataUrl, fontFamily } = props;
15
+ return /* @__PURE__ */ jsxs(
16
+ "div",
17
+ {
18
+ style: {
19
+ display: "flex",
20
+ width: "100%",
21
+ height: "100%",
22
+ backgroundColor: "#ffffff",
23
+ padding: 64,
24
+ fontFamily
25
+ },
26
+ children: [
27
+ /* @__PURE__ */ jsxs(
28
+ "div",
29
+ {
30
+ style: {
31
+ display: "flex",
32
+ flexDirection: "column",
33
+ justifyContent: "space-between",
34
+ flex: 1,
35
+ // Leave room for the image on the right when present, otherwise
36
+ // let the text occupy the whole card.
37
+ paddingRight: imageDataUrl ? 48 : 0
38
+ },
39
+ children: [
40
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", flexDirection: "column" }, children: [
41
+ /* @__PURE__ */ jsx(
42
+ "div",
43
+ {
44
+ style: {
45
+ fontSize: 64,
46
+ fontWeight: 700,
47
+ color: "#111111",
48
+ lineHeight: 1.2,
49
+ // Satori has no text-overflow:ellipsis — but it does clip to
50
+ // the box. A high lineClamp is the recommended hint.
51
+ display: "flex"
52
+ },
53
+ children: title
54
+ }
55
+ ),
56
+ excerpt ? /* @__PURE__ */ jsx(
57
+ "div",
58
+ {
59
+ style: {
60
+ fontSize: 28,
61
+ color: "#555555",
62
+ lineHeight: 1.4,
63
+ marginTop: 24,
64
+ display: "flex"
65
+ },
66
+ children: excerpt
67
+ }
68
+ ) : null
69
+ ] }),
70
+ /* @__PURE__ */ jsx(
71
+ "div",
72
+ {
73
+ style: {
74
+ fontSize: 24,
75
+ color: "#888888",
76
+ display: "flex"
77
+ },
78
+ children: siteName
79
+ }
80
+ )
81
+ ]
82
+ }
83
+ ),
84
+ imageDataUrl ? /* @__PURE__ */ jsx(
85
+ "div",
86
+ {
87
+ style: {
88
+ display: "flex",
89
+ alignItems: "center",
90
+ justifyContent: "center",
91
+ width: 400,
92
+ height: 400
93
+ },
94
+ children: /* @__PURE__ */ jsx(
95
+ "img",
96
+ {
97
+ src: imageDataUrl,
98
+ width: 400,
99
+ height: 400,
100
+ style: {
101
+ width: 400,
102
+ height: 400,
103
+ borderRadius: 16,
104
+ objectFit: "cover"
105
+ },
106
+ alt: ""
107
+ }
108
+ )
109
+ }
110
+ ) : null
111
+ ]
112
+ }
113
+ );
114
+ }
115
+
116
+ // src/load-font.ts
117
+ var cache = /* @__PURE__ */ new Map();
118
+ function loadFontFromUrl(url) {
119
+ return () => {
120
+ const hit = cache.get(url);
121
+ if (hit) return hit;
122
+ const promise = fetch(url).then(async (res) => {
123
+ if (!res.ok) {
124
+ cache.delete(url);
125
+ throw new Error(`[plugin-og-image] failed to load font ${url}: HTTP ${res.status}`);
126
+ }
127
+ return res.arrayBuffer();
128
+ });
129
+ cache.set(url, promise);
130
+ return promise;
131
+ };
132
+ }
133
+
134
+ // src/index.ts
135
+ function pickImageUrl(strategy, themeImageUrl, post, site) {
136
+ if (typeof strategy === "function") {
137
+ return strategy(post, site) ?? null;
138
+ }
139
+ switch (strategy) {
140
+ case "theme":
141
+ return themeImageUrl ?? null;
142
+ case "content":
143
+ return extractFirstImageUrl(post);
144
+ case "none":
145
+ default:
146
+ return null;
147
+ }
148
+ }
149
+ function ogImagePlugin(options) {
150
+ if (!options.fonts || options.fonts.length === 0) {
151
+ throw new Error("[plugin-og-image] at least one font must be provided in `fonts`");
152
+ }
153
+ const size = options.size ?? { width: 1200, height: 630 };
154
+ const strategy = options.image ?? "content";
155
+ const fontFamily = options.fonts[0].name;
156
+ const defaultRender = async (ctx) => {
157
+ const url = pickImageUrl(strategy, options.themeImageUrl, ctx.post, ctx.site);
158
+ const imageDataUrl = url ? await ctx.image(url) : null;
159
+ return DefaultCard({
160
+ title: ctx.post.title,
161
+ excerpt: ctx.post.excerpt,
162
+ siteName: ctx.site.name,
163
+ imageDataUrl,
164
+ fontFamily
165
+ });
166
+ };
167
+ return definePlugin({
168
+ name: "og-image",
169
+ apiVersion: 1,
170
+ trust_level: "untrusted",
171
+ metadata(post, site) {
172
+ const baseUrl = site.url.replace(/\/$/, "");
173
+ const url = `${baseUrl}/og/${post.slug}`;
174
+ return {
175
+ openGraph: {
176
+ images: [{ url, width: size.width, height: size.height }]
177
+ },
178
+ twitter: {
179
+ card: "summary_large_image",
180
+ images: [url]
181
+ }
182
+ };
183
+ },
184
+ ogImage: {
185
+ fonts: options.fonts,
186
+ size,
187
+ render: options.render ?? defaultRender
188
+ }
189
+ });
190
+ }
191
+ export {
192
+ DefaultCard,
193
+ ogImagePlugin as default,
194
+ loadFontFromUrl,
195
+ loadImageForOg
196
+ };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Fetch `url` and return a data URL usable inside Satori JSX.
3
+ *
4
+ * Behavior:
5
+ * - PNG / JPEG: pass through as a data URL (no re-encode).
6
+ * - WebP: decode to ImageData, re-encode to PNG, return data URL.
7
+ * - AVIF: same as WebP.
8
+ * - GIF / SVG / other: returns null (Satori can't render animated GIFs,
9
+ * and SVG isn't supported via the image() helper — embed SVG with the
10
+ * Satori `<img src="data:image/svg+xml;...">` form if you need it).
11
+ * - Any fetch / decode error: returns null so the OG card still renders
12
+ * without an image instead of 500ing the route.
13
+ */
14
+ declare function loadImageForOg(url: string): Promise<string | null>;
15
+
16
+ export { loadImageForOg };
@@ -0,0 +1,6 @@
1
+ import {
2
+ loadImageForOg
3
+ } from "./chunk-BDDYGEJD.js";
4
+ export {
5
+ loadImageForOg
6
+ };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@ampless/plugin-og-image",
3
+ "version": "0.2.0-alpha.0",
4
+ "description": "Dynamic Open Graph image generation plugin for ampless",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js",
10
+ "types": "./dist/index.d.ts"
11
+ },
12
+ "./load-image": {
13
+ "import": "./dist/load-image.js",
14
+ "types": "./dist/load-image.d.ts"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/heavymoons/ampless.git",
27
+ "directory": "packages/plugin-og-image"
28
+ },
29
+ "homepage": "https://github.com/heavymoons/ampless/tree/main/packages/plugin-og-image#readme",
30
+ "bugs": "https://github.com/heavymoons/ampless/issues",
31
+ "dependencies": {
32
+ "@jsquash/avif": "^1.2.0",
33
+ "@jsquash/png": "^3.0.0",
34
+ "@jsquash/webp": "^1.4.0",
35
+ "ampless": "0.2.0-alpha.0"
36
+ },
37
+ "peerDependencies": {
38
+ "react": "^18 || ^19"
39
+ },
40
+ "devDependencies": {
41
+ "@types/react": "^19.0.0"
42
+ },
43
+ "keywords": [
44
+ "ampless",
45
+ "plugin",
46
+ "og-image",
47
+ "opengraph"
48
+ ],
49
+ "scripts": {
50
+ "build": "tsup",
51
+ "dev": "tsup --watch",
52
+ "lint": "tsc --noEmit",
53
+ "test": "vitest run --passWithNoTests"
54
+ }
55
+ }