astro-cards 0.0.0 → 1.0.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 Adam Bouqdib
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 CHANGED
@@ -1,45 +1,231 @@
1
- # astro-cards
1
+ # astro-cards 🏞️
2
2
 
3
- ## ⚠️ IMPORTANT NOTICE ⚠️
3
+ An Astro integration that renders `.astro` components to images. Open Graph cards, badges, tickets,
4
+ or any fixed-size visual composed in markup.
4
5
 
5
- **This package is created solely for the purpose of setting up OIDC (OpenID Connect) trusted publishing with npm.**
6
+ ## Why Astro Cards?
6
7
 
7
- This is **NOT** a functional package and contains **NO** code or functionality beyond the OIDC setup configuration.
8
+ Every site needs images it cannot author by hand: an Open Graph card per post, a badge per release,
9
+ a ticket per attendee. Astro has no built-in way to produce them, so the usual answer is a separate
10
+ templating layer, either a fixed template you fill with a title and a description, or JSX rendered
11
+ by a library that is not the one your site is built with.
8
12
 
9
- ## Purpose
13
+ With **Astro Cards** the template is an ordinary `.astro` component. You write markup and CSS, take
14
+ props, import images with `astro:assets`, and use the fonts you already configured. The integration
15
+ renders it with [takumi](https://takumi.kane.tw) and hands back the URL of an image.
10
16
 
11
- This package exists to:
12
- 1. Configure OIDC trusted publishing for the package name `astro-cards`
13
- 2. Enable secure, token-less publishing from CI/CD workflows
14
- 3. Establish provenance for packages published under this name
17
+ It works both on prerendered pages and on pages rendered on demand.
15
18
 
16
- ## What is OIDC Trusted Publishing?
19
+ ## Installation
17
20
 
18
- OIDC trusted publishing allows package maintainers to publish packages directly from their CI/CD workflows without needing to manage npm access tokens. Instead, it uses OpenID Connect to establish trust between the CI/CD provider (like GitHub Actions) and npm.
21
+ ```sh
22
+ npx astro add astro-cards
23
+ ```
19
24
 
20
- ## Setup Instructions
25
+ ### Manual Install
21
26
 
22
- To properly configure OIDC trusted publishing for this package:
27
+ Install `astro-cards` using your package manager.
23
28
 
24
- 1. Go to [npmjs.com](https://www.npmjs.com/) and navigate to your package settings
25
- 2. Configure the trusted publisher (e.g., GitHub Actions)
26
- 3. Specify the repository and workflow that should be allowed to publish
27
- 4. Use the configured workflow to publish your actual package
29
+ ```sh
30
+ npm install astro-cards
31
+ ```
28
32
 
29
- ## DO NOT USE THIS PACKAGE
33
+ Add the integration to your `astro.config.mjs`:
30
34
 
31
- This package is a placeholder for OIDC configuration only. It:
32
- - Contains no executable code
33
- - Provides no functionality
34
- - Should not be installed as a dependency
35
- - Exists only for administrative purposes
35
+ ```js
36
+ import { defineConfig } from 'astro/config';
37
+ import cards from 'astro-cards';
36
38
 
37
- ## More Information
39
+ export default defineConfig({
40
+ // ...
41
+ integrations: [cards()],
42
+ });
43
+ ```
38
44
 
39
- For more details about npm's trusted publishing feature, see:
40
- - [npm Trusted Publishing Documentation](https://docs.npmjs.com/generating-provenance-statements)
41
- - [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
45
+ ## Usage
42
46
 
47
+ ### Creating a card
48
+
49
+ Cards are `.astro` components in `src/cards`. The file name is the card's name, so
50
+ `src/cards/post.astro` is the card `post`, and a card in a subdirectory keeps its path,
51
+ `src/cards/blog/post.astro` being `blog/post`.
52
+
53
+ ```astro
54
+ ---
55
+ import type { CardOptions } from 'astro-cards';
56
+
57
+ interface Props {
58
+ title: string;
59
+ }
60
+
61
+ export const card = { width: 1200, height: 630 } satisfies CardOptions;
62
+
63
+ const { title } = Astro.props;
64
+ ---
65
+
66
+ <div class="card">{title}</div>
67
+
68
+ <style is:inline>
69
+ .card {
70
+ width: 100%;
71
+ height: 100%;
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: center;
75
+ background: #17171a;
76
+ color: #ffffff;
77
+ font-size: 64px;
78
+ }
79
+ </style>
80
+ ```
81
+
82
+ > [!IMPORTANT]
83
+ > Style blocks in a card must be `is:inline`. Astro hoists a plain `<style>` into a bundled
84
+ > stylesheet that the renderer never sees, so the card would come out unstyled.
85
+
86
+ ### Rendering a card
87
+
88
+ Call `renderCard` with the card's name and its props. It returns the URL to embed, plus the
89
+ dimensions and MIME type, which is what the Open Graph tags want.
90
+
91
+ ```astro
92
+ ---
93
+ import { renderCard } from 'astro-cards/runtime';
94
+
95
+ const card = await renderCard('post', { title: Astro.props.title });
96
+ ---
97
+
98
+ <meta property="og:image" content={card.src} />
99
+ <meta property="og:image:width" content={card.width} />
100
+ <meta property="og:image:height" content={card.height} />
101
+ <meta property="og:image:type" content={card.type} />
102
+ ```
103
+
104
+ Card names are typed once `astro sync` has run, so an unknown name is a type error and the props are
105
+ checked against the card's own `Props`.
106
+
107
+ > [!WARNING]
108
+ > On a page rendered on demand the props travel in the image URL, encoded but not encrypted, and the
109
+ > response is publicly cacheable. Do not pass secrets or personal data.
110
+
111
+ ### Size, format and quality
112
+
113
+ Each option can be set in three places, and the nearest one wins: the call site beats the card,
114
+ which beats the integration config.
115
+
116
+ ```js
117
+ // astro.config.mjs: the default for every card
118
+ cards({ width: 1200, height: 630, format: 'jpeg', quality: 90 });
119
+ ```
120
+
121
+ ```astro
122
+ ---
123
+ // the card: a default for this template
124
+ export const card = { width: 400, height: 200, format: 'png' };
125
+ ---
126
+ ```
127
+
128
+ ```js
129
+ // the call site: this one image
130
+ await renderCard('post', { title }, { width: 800, height: 418 });
131
+ ```
132
+
133
+ Formats are `png`, `jpeg` and `webp`. `quality` runs from 0 to 100 and applies to the lossy formats
134
+ only.
135
+
136
+ ### Fonts
137
+
138
+ A card's fonts come from the `@font-face` rules in its own markup. A card that declares none renders
139
+ its text in Noto Sans, fetched from Google Fonts.
140
+
141
+ The easiest source is Astro's fonts API, whose `<Font>` component writes the `@font-face` rules
142
+ straight into the card:
143
+
144
+ ```astro
43
145
  ---
146
+ import { Font } from 'astro:assets';
147
+ ---
148
+
149
+ <Font cssVariable="--font-heading" />
150
+
151
+ <div class="card">Hello</div>
152
+
153
+ <style is:inline>
154
+ .card {
155
+ font-family: var(--font-heading);
156
+ }
157
+ </style>
158
+ ```
159
+
160
+ Hand-written `@font-face` rules work as well, whether the file sits in `public/` or on another host:
161
+
162
+ ```astro
163
+ <style is:inline>
164
+ @font-face {
165
+ font-family: Inter;
166
+ src: url(/fonts/inter-latin-400.woff2) format('woff2');
167
+ unicode-range: U+0000-00FF;
168
+ }
169
+ </style>
170
+ ```
171
+
172
+ Declaring `unicode-range` is worth doing for a family split into subsets, because the renderer then
173
+ loads only the files the text actually needs.
174
+
175
+ > [!NOTE]
176
+ > Stylesheet `<link>` elements are not fetched. Faces have to be declared in the card's markup.
177
+
178
+ ### Images
179
+
180
+ Images imported through `astro:assets` work as they do anywhere else, and so do files in `public/`:
181
+
182
+ ```astro
183
+ ---
184
+ import { Image } from 'astro:assets';
185
+ import photo from '../assets/photo.png';
186
+ ---
187
+
188
+ <Image src={photo} width={200} height={200} alt="" />
189
+
190
+ <img src="/logo.png" width="100" height="100" alt="" />
191
+
192
+ <style is:inline>
193
+ .backdrop {
194
+ background-image: url(/backdrop.png);
195
+ }
196
+ </style>
197
+ ```
198
+
199
+ Images on other hosts are fetched, and are subject to the same
200
+ [`image.domains`](https://docs.astro.build/en/reference/configuration-reference/#imagedomains) and
201
+ [`image.remotePatterns`](https://docs.astro.build/en/reference/configuration-reference/#imageremotepatterns)
202
+ rules as the rest of your site.
203
+
204
+ ## Options
205
+
206
+ | Option | Type | Default | Description |
207
+ | --------- | --------------------------- | ------------- | ------------------------------------------------- |
208
+ | `dir` | `string` | `'src/cards'` | Where card components live, relative to the root. |
209
+ | `width` | `number` | `1200` | Default pixel width. |
210
+ | `height` | `number` | `630` | Default pixel height. |
211
+ | `format` | `'png' \| 'jpeg' \| 'webp'` | `'jpeg'` | Default encoding. |
212
+ | `quality` | `number` | `90` | Default encoder quality, 0 to 100, lossy formats. |
213
+
214
+ ## Limitations
215
+
216
+ A card rendered on demand receives its props through the URL, so they must be
217
+ [serialisable](https://developer.mozilla.org/en-US/docs/Glossary/Serialization): able to be
218
+ translated into a format suitable for transfer over a network. Not every structure is, so there are
219
+ some limitations on what can be passed to `renderCard`.
220
+
221
+ The following prop types are supported: plain object, `number`, `string`, `boolean`, `null`,
222
+ `undefined`, `Array`, `Map`, `Set`, `RegExp`, `Date`, `BigInt`, `URL`, `Uint8Array`, `Uint16Array`,
223
+ `Uint32Array`, and `Infinity`. Repeated and circular references are preserved. The decoded on-demand
224
+ payload is limited to 128 KiB.
225
+
226
+ Notably, functions and class instances cannot be passed, as they cannot be serialised.
44
227
 
45
- **Maintained for OIDC setup purposes only**
228
+ > [!NOTE]
229
+ > This applies whenever a card is rendered on demand, which includes every card in `astro dev`. A
230
+ > prerendered build hands props straight to the component, so an unsupported prop can pass
231
+ > `astro build` and fail in `astro dev`.
@@ -0,0 +1,5 @@
1
+ import { APIRoute } from "astro";
2
+ //#region src/endpoint.d.ts
3
+ export declare const prerender = false;
4
+ export declare const GET: APIRoute;
5
+ //#endregion
@@ -0,0 +1,33 @@
1
+ import { r as loadCard, t as decodePayload } from "./payload-CizLlC8p.js";
2
+ import { r as rasterise } from "./render-CzpiUNuk.js";
3
+ import { cards, config } from "virtual:astro-cards";
4
+ //#region src/endpoint.ts
5
+ const prerender = false;
6
+ const GET = async ({ request }) => {
7
+ const url = new URL(request.url);
8
+ const origin = config.site ? new URL(config.site).origin : url.origin;
9
+ const payload = await decodePayload(url.searchParams.get("p") ?? "").catch(() => void 0);
10
+ if (!payload) return new Response("Bad Request", { status: 400 });
11
+ const { name, props, ...overrides } = payload;
12
+ if (!Object.hasOwn(cards, name)) return new Response("Not Found", { status: 404 });
13
+ const { options, type, render } = await loadCard(name, overrides);
14
+ const bytes = await rasterise({
15
+ name,
16
+ markup: await render(props),
17
+ ...options
18
+ }, {
19
+ origin,
20
+ base: config.base,
21
+ imageConfig: config.image,
22
+ assetsPrefix: typeof config.assetsPrefix === "string" ? [config.assetsPrefix] : Object.values(config.assetsPrefix ?? {})
23
+ });
24
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
25
+ const etag = Array.from(new Uint8Array(digest, 0, 10), (byte) => byte.toString(16).padStart(2, "0")).join("");
26
+ return new Response(bytes, { headers: {
27
+ "Content-Type": type,
28
+ "Cache-Control": "public, max-age=31536000",
29
+ ETag: `"${etag}"`
30
+ } });
31
+ };
32
+ //#endregion
33
+ export { GET, prerender };
@@ -0,0 +1,10 @@
1
+ import { a as Format, i as CardOptions, t as CardResult } from "./runtime-BIgJTgv9.js";
2
+ import { AstroIntegration } from "astro";
3
+ //#region src/index.d.ts
4
+ export interface CardsOptions extends CardOptions {
5
+ /** Where the card components live, relative to the project root. Defaults to `src/cards`. */
6
+ dir?: string;
7
+ }
8
+ export default function cards(options?: CardsOptions): AstroIntegration;
9
+ //#endregion
10
+ export type { CardOptions, CardResult, Format };
package/dist/index.js ADDED
@@ -0,0 +1,213 @@
1
+ import { createHash } from "node:crypto";
2
+ import { once } from "node:events";
3
+ import { writeFileSync } from "node:fs";
4
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
5
+ import { createServer } from "node:http";
6
+ import { availableParallelism } from "node:os";
7
+ import { join, relative, sep } from "node:path";
8
+ import { json } from "node:stream/consumers";
9
+ import { fileURLToPath } from "node:url";
10
+ import { styleText } from "node:util";
11
+ import { isParentDirectory, stripRequestBase } from "@astrojs/internal-helpers/path";
12
+ //#region src/index.ts
13
+ const ID = "virtual:astro-cards";
14
+ const FALLBACK_ORIGIN = "https://localhost";
15
+ const COLLECTOR = Symbol.for("astro-cards.collector");
16
+ const globals = globalThis;
17
+ const sha = (bytes) => createHash("sha256").update(bytes).digest("hex");
18
+ function cards(options = {}) {
19
+ let found = [];
20
+ let astroConfig;
21
+ let assetsPrefix;
22
+ let collector;
23
+ let cardsDir;
24
+ let trigger;
25
+ const pending = /* @__PURE__ */ new Map();
26
+ return {
27
+ name: "astro-cards",
28
+ hooks: {
29
+ async "astro:config:setup"({ addWatchFile, command, config, createCodegenDir, injectRoute, updateConfig }) {
30
+ cardsDir = fileURLToPath(new URL(`${options.dir ?? "src/cards"}/`, config.root));
31
+ found = (await readdir(cardsDir, {
32
+ recursive: true,
33
+ withFileTypes: true
34
+ }).catch((error) => {
35
+ if (error.code !== "ENOENT") throw error;
36
+ return [];
37
+ })).filter((entry) => entry.isFile() && entry.name.endsWith(".astro")).map((entry) => {
38
+ const path = join(entry.parentPath, entry.name);
39
+ return {
40
+ path,
41
+ name: relative(cardsDir, path).replace(/\.astro$/, "").split(sep).join("/")
42
+ };
43
+ }).sort((a, b) => a.name.localeCompare(b.name));
44
+ let url;
45
+ if (command === "build") {
46
+ pending.clear();
47
+ collector = createServer(async (request, response) => {
48
+ const file = decodeURIComponent((request.url ?? "/").slice(1));
49
+ pending.set(file, await json(request));
50
+ response.end();
51
+ });
52
+ collector.listen(0, "127.0.0.1").unref();
53
+ await once(collector, "listening");
54
+ url = `http://127.0.0.1:${collector.address().port}/`;
55
+ globals[COLLECTOR] = url;
56
+ }
57
+ if (command === "dev") {
58
+ trigger = fileURLToPath(new URL("restart", createCodegenDir()));
59
+ addWatchFile(trigger);
60
+ }
61
+ assetsPrefix = config.build.assetsPrefix;
62
+ updateConfig({ vite: { plugins: [{
63
+ name: "astro-cards:virtual",
64
+ enforce: "pre",
65
+ resolveId: (id) => id === ID ? `\0${ID}` : void 0,
66
+ load(id) {
67
+ if (id !== `\0${ID}`) return;
68
+ const cardsConfig = {
69
+ width: options.width ?? 1200,
70
+ height: options.height ?? 630,
71
+ format: options.format ?? "jpeg",
72
+ quality: options.quality ?? 90,
73
+ site: command === "dev" ? void 0 : config.site,
74
+ base: config.base,
75
+ assetsDir: config.build.assets,
76
+ assetsPrefix,
77
+ image: {
78
+ domains: config.image.domains,
79
+ remotePatterns: config.image.remotePatterns
80
+ },
81
+ collector: this.environment?.name === "prerender" ? url : void 0
82
+ };
83
+ return [
84
+ "export const cards = {",
85
+ ...found.map(({ name, path }) => ` ${JSON.stringify(name)}: () => import(${JSON.stringify(path)}),`),
86
+ "}",
87
+ `export const config = ${JSON.stringify(cardsConfig)}`
88
+ ].join("\n");
89
+ }
90
+ }] } });
91
+ if (command === "dev" || config.output === "server" || config.adapter) injectRoute({
92
+ pattern: "/_cards",
93
+ entrypoint: new URL("./endpoint.js", import.meta.url),
94
+ prerender: false
95
+ });
96
+ },
97
+ async "astro:config:done"({ config, injectTypes }) {
98
+ astroConfig = config;
99
+ const content = `export {}\ndeclare module 'astro-cards/runtime' {\n interface Cards {\n${found.map(({ name, path }) => ` ${JSON.stringify(name)}: typeof import(${JSON.stringify(path)}).default`).join("\n")}\n }\n}\n`;
100
+ const types = injectTypes({
101
+ filename: "cards.d.ts",
102
+ content
103
+ });
104
+ if (trigger) await writeFile(types, content);
105
+ },
106
+ "astro:server:setup"({ server }) {
107
+ const touch = (file) => {
108
+ if (trigger && file.endsWith(".astro") && isParentDirectory(cardsDir, file)) writeFileSync(trigger, String(Date.now()));
109
+ };
110
+ server.watcher.on("add", touch).on("unlink", touch);
111
+ },
112
+ async "astro:build:done"({ dir, logger }) {
113
+ collector?.close();
114
+ delete globals[COLLECTOR];
115
+ if (!pending.size) return;
116
+ logger.info(styleText(["bgGreen", "black"], " generating cards "));
117
+ const { rasterise } = await import("./render-BJFZWw2w.js");
118
+ const root = fileURLToPath(dir);
119
+ const readLocal = (pathname) => {
120
+ const file = join(root, stripRequestBase(pathname, astroConfig.base));
121
+ if (!isParentDirectory(root, file)) throw new Error(`refusing to read outside the output directory: ${pathname}`);
122
+ return readFile(file);
123
+ };
124
+ const env = {
125
+ origin: astroConfig.site ? new URL(astroConfig.site).origin : FALLBACK_ORIGIN,
126
+ base: astroConfig.base,
127
+ imageConfig: astroConfig.image,
128
+ assetsPrefix: typeof assetsPrefix === "string" ? [assetsPrefix] : Object.values(assetsPrefix ?? {}),
129
+ readLocal
130
+ };
131
+ const outDir = new URL(`./${astroConfig.build.assets}/`, dir);
132
+ const cacheDir = new URL("astro-cards/", astroConfig.cacheDir);
133
+ await mkdir(outDir, { recursive: true });
134
+ await mkdir(cacheDir, { recursive: true });
135
+ const unchanged = async (key, dep) => {
136
+ if (!URL.canParse(key)) return sha(await readLocal(key)) === dep.sha;
137
+ if (dep.expires && dep.expires > Date.now()) return true;
138
+ return (await fetch(key, {
139
+ method: "HEAD",
140
+ headers: {
141
+ ...dep.etag && { "if-none-match": dep.etag },
142
+ ...dep.modified && { "if-modified-since": dep.modified }
143
+ },
144
+ signal: AbortSignal.timeout(3e4),
145
+ redirect: "manual"
146
+ })).status === 304;
147
+ };
148
+ const reuse = async (file) => {
149
+ const deps = await readFile(new URL(`./${file}.json`, cacheDir), "utf-8").then((json) => JSON.parse(json)).catch(() => void 0);
150
+ if (!deps) return void 0;
151
+ const checks = Object.entries(deps).map(([key, dep]) => unchanged(key, dep));
152
+ if (!(await Promise.all(checks).catch(() => [false])).every(Boolean)) return void 0;
153
+ return readFile(new URL(`./${file}`, cacheDir)).catch(() => void 0);
154
+ };
155
+ let done = 0;
156
+ const emit = async (file, card) => {
157
+ const started = performance.now();
158
+ const log = (bytes, cached) => {
159
+ const ms = performance.now() - started;
160
+ const took = ms < 1e3 ? `${Math.round(ms)}ms` : `${(ms / 1e3).toFixed(2)}s`;
161
+ const detail = `(${`${Math.round(bytes.length / 1024)}kB`}) ${cached ? "(cached) " : ""}(${took}) (${++done}/${pending.size})`;
162
+ logger.info(` ${styleText("green", "▶")} /${astroConfig.build.assets}/${file} ${styleText("dim", detail)}`);
163
+ };
164
+ const hit = await reuse(file);
165
+ if (hit) {
166
+ await writeFile(new URL(`./${file}`, outDir), hit);
167
+ return log(hit, true);
168
+ }
169
+ const deps = /* @__PURE__ */ new Map();
170
+ const bytes = await rasterise(card, {
171
+ ...env,
172
+ readLocal: async (pathname) => {
173
+ const read = await readLocal(pathname);
174
+ if (!stripRequestBase(pathname, astroConfig.base).startsWith(`/${astroConfig.build.assets}/`)) deps.set(pathname, { sha: sha(read) });
175
+ return read;
176
+ },
177
+ fetch: async (url, init) => {
178
+ const response = await fetch(url, init);
179
+ if (!response.ok) return response;
180
+ const body = await response.bytes();
181
+ const maxAge = response.headers.get("cache-control")?.match(/max-age=(\d+)/)?.[1];
182
+ deps.set(String(url), {
183
+ sha: sha(body),
184
+ etag: response.headers.get("etag") ?? void 0,
185
+ modified: response.headers.get("last-modified") ?? void 0,
186
+ expires: maxAge ? Date.now() + Number(maxAge) * 1e3 : void 0
187
+ });
188
+ return new Response(body, {
189
+ status: response.status,
190
+ statusText: response.statusText,
191
+ headers: response.headers
192
+ });
193
+ }
194
+ });
195
+ await writeFile(new URL(`./${file}`, outDir), bytes);
196
+ await writeFile(new URL(`./${file}`, cacheDir), bytes);
197
+ await writeFile(new URL(`./${file}.json`, cacheDir), JSON.stringify(Object.fromEntries(deps)));
198
+ log(bytes);
199
+ };
200
+ const queue = pending.entries();
201
+ const start = performance.now();
202
+ await Promise.all(Array.from({ length: Math.min(availableParallelism(), pending.size) }, async () => {
203
+ for (const [file, card] of queue) await emit(file, card);
204
+ }));
205
+ const total = performance.now() - start;
206
+ const took = total < 1e3 ? `${Math.round(total)}ms` : `${(total / 1e3).toFixed(2)}s`;
207
+ logger.info(styleText("green", `✓ Completed in ${took}`));
208
+ }
209
+ }
210
+ };
211
+ }
212
+ //#endregion
213
+ export { cards as default };
@@ -0,0 +1,67 @@
1
+ import { cards, config } from "virtual:astro-cards";
2
+ import { experimental_AstroContainer } from "astro/container";
3
+ import { z } from "astro/zod";
4
+ import { parse, stringifyAsync } from "devalue";
5
+ //#region src/card.ts
6
+ const FORMATS = [
7
+ "png",
8
+ "jpeg",
9
+ "webp"
10
+ ];
11
+ const EXT = {
12
+ png: "png",
13
+ jpeg: "jpg",
14
+ webp: "webp"
15
+ };
16
+ const container = await experimental_AstroContainer.create();
17
+ async function loadCard(name, overrides) {
18
+ const module = await cards[name]();
19
+ const card = module.card ?? {};
20
+ const format = overrides.format ?? card.format ?? config.format;
21
+ return {
22
+ options: {
23
+ width: overrides.width ?? card.width ?? config.width,
24
+ height: overrides.height ?? card.height ?? config.height,
25
+ format,
26
+ quality: overrides.quality ?? card.quality ?? config.quality
27
+ },
28
+ ext: EXT[format],
29
+ type: `image/${format}`,
30
+ render: (props) => container.renderToString(module.default, { props })
31
+ };
32
+ }
33
+ //#endregion
34
+ //#region src/payload.ts
35
+ const schema = z.strictObject({
36
+ name: z.string(),
37
+ props: z.record(z.string(), z.unknown()),
38
+ width: z.number().int().positive().optional(),
39
+ height: z.number().int().positive().optional(),
40
+ format: z.enum(FORMATS).optional(),
41
+ quality: z.number().min(0).max(100).optional()
42
+ });
43
+ const MAX_DECODED = 131072;
44
+ async function encodePayload(payload) {
45
+ const serialised = await stringifyAsync(payload).catch((cause) => {
46
+ const message = cause instanceof Error ? cause.message : String(cause);
47
+ throw new Error(`astro-cards: card "${payload.name}" got a prop it cannot serialise: ${message}`, { cause });
48
+ });
49
+ const source = new Blob([serialised]).stream();
50
+ const deflated = await new Response(source.pipeThrough(new CompressionStream("deflate-raw"))).bytes();
51
+ let binary = "";
52
+ for (const byte of deflated) binary += String.fromCharCode(byte);
53
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
54
+ }
55
+ async function decodePayload(value) {
56
+ const binary = atob(value.replaceAll("-", "+").replaceAll("_", "/"));
57
+ const source = new Blob([Uint8Array.from(binary, (char) => char.charCodeAt(0))]);
58
+ let size = 0;
59
+ const decoded = await new Response(source.stream().pipeThrough(new DecompressionStream("deflate-raw")).pipeThrough(new TransformStream({ transform(chunk, controller) {
60
+ size += chunk.byteLength;
61
+ if (size > MAX_DECODED) throw new Error("payload is too large");
62
+ controller.enqueue(chunk);
63
+ } }))).text();
64
+ return schema.parse(parse(decoded));
65
+ }
66
+ //#endregion
67
+ export { encodePayload as n, loadCard as r, decodePayload as t };
@@ -0,0 +1,2 @@
1
+ import { r as rasterise } from "./render-CzpiUNuk.js";
2
+ export { rasterise };
@@ -0,0 +1,178 @@
1
+ import { appendForwardSlash } from "@astrojs/internal-helpers/path";
2
+ import { isRemoteAllowed } from "@astrojs/internal-helpers/remote";
3
+ import { defaultMaxFetchBytes, fetchOk, googleFonts, readBodyLimited } from "@takumi-rs/helpers";
4
+ import { fromHtml } from "@takumi-rs/helpers/html";
5
+ import { render } from "takumi-js";
6
+ //#region src/render.ts
7
+ const URL_FUNCTION = /url\(\s*(['"]?)(.*?)\1\s*\)/g;
8
+ const COMMENT = /\/\*[\s\S]*?\*\//g;
9
+ const FONT_FACE = /@font-face\s*\{([^}]*)\}/g;
10
+ /** Finds every image a card references, mapping the raw source to an absolute URL. */
11
+ function collectSources(node, css, base) {
12
+ const found = /* @__PURE__ */ new Map();
13
+ const add = (raw, value = raw) => {
14
+ if (value.startsWith("data:") || value.startsWith("#")) return;
15
+ const url = new URL(value, base);
16
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`unsupported image URL "${value}"`);
17
+ found.set(raw, url.href);
18
+ };
19
+ const scan = (value) => {
20
+ if (typeof value !== "string") return;
21
+ for (const match of value.matchAll(URL_FUNCTION)) {
22
+ const url = match[2]?.trim();
23
+ if (url) add(url);
24
+ }
25
+ };
26
+ const visit = (node) => {
27
+ for (const value of Object.values(node.style ?? {})) scan(value);
28
+ for (const value of Object.values(node.preset ?? {})) scan(value);
29
+ scan(node.tw);
30
+ if (node.type === "image" && typeof node.src === "string") add(node.src, node.attributes?.src);
31
+ if (node.type === "container") for (const child of node.children ?? []) visit(child);
32
+ };
33
+ visit(node);
34
+ for (const sheet of css) scan(sheet.replace(COMMENT, "").replace(FONT_FACE, ""));
35
+ return found;
36
+ }
37
+ const hasText = (node) => {
38
+ if (node.type === "text") return Boolean(node.text?.trim());
39
+ return node.type === "container" ? (node.children ?? []).some(hasText) : false;
40
+ };
41
+ function parseUnicodeRange(value) {
42
+ const ranges = [];
43
+ for (const raw of value.split(",")) {
44
+ const token = raw.trim().replace(/^U\+/i, "");
45
+ if (!token) continue;
46
+ if (token.includes("-")) {
47
+ const [lo, hi] = token.split("-");
48
+ if (lo && hi) ranges.push([parseInt(lo, 16), parseInt(hi, 16)]);
49
+ } else if (token.includes("?")) ranges.push([parseInt(token.replaceAll("?", "0"), 16), parseInt(token.replaceAll("?", "F"), 16)]);
50
+ else {
51
+ const codepoint = parseInt(token, 16);
52
+ ranges.push([codepoint, codepoint]);
53
+ }
54
+ }
55
+ return ranges;
56
+ }
57
+ function hash(value) {
58
+ let h = 2166136261;
59
+ for (let i = 0; i < value.length; i++) {
60
+ h ^= value.charCodeAt(i);
61
+ h = Math.imul(h, 16777619);
62
+ }
63
+ return (h >>> 0).toString(16).padStart(8, "0");
64
+ }
65
+ /** Adapted from the parser inside `googleFonts` in `@takumi-rs/helpers` 2.13.5 (MIT). */
66
+ function collectFonts(css, base) {
67
+ const faces = [];
68
+ for (const sheet of css) for (const [, body] of sheet.replace(COMMENT, "").matchAll(FONT_FACE)) {
69
+ const url = body?.match(/src:[^;]*?url\(([^)]+)\)/)?.[1]?.replace(/['"]/g, "").trim();
70
+ const family = body?.match(/font-family:\s*['"]?([^'";]+)['"]?/)?.[1]?.trim();
71
+ if (!body || !url || !family) continue;
72
+ const range = body.match(/unicode-range:\s*([^;]+)/)?.[1];
73
+ const weight = body.match(/font-weight:\s*(\d+)(?:\s+(\d+))?/);
74
+ const ranges = parseUnicodeRange(range ?? "");
75
+ faces.push({
76
+ family,
77
+ subset: `s${hash(ranges.map(([lo, hi]) => `${lo}-${hi}`).join(","))}`,
78
+ url: new URL(url, base).href,
79
+ weight: weight && !weight[2] ? Number(weight[1]) : void 0,
80
+ style: body.match(/font-style:\s*([a-z]+)/i)?.[1],
81
+ ranges
82
+ });
83
+ }
84
+ const identity = (face) => `${face.family} ${face.subset}:${face.style ?? ""}:${face.url}`;
85
+ const weights = /* @__PURE__ */ new Map();
86
+ for (const face of faces) {
87
+ const id = identity(face);
88
+ weights.set(id, (weights.get(id) ?? /* @__PURE__ */ new Set()).add(face.weight));
89
+ }
90
+ const seen = /* @__PURE__ */ new Set();
91
+ const merged = [];
92
+ for (const face of faces) {
93
+ const id = identity(face);
94
+ if (seen.has(id)) continue;
95
+ seen.add(id);
96
+ merged.push((weights.get(id)?.size ?? 0) > 1 ? {
97
+ ...face,
98
+ weight: void 0
99
+ } : face);
100
+ }
101
+ return merged.map(({ family, subset, ...face }) => ({
102
+ name: `${family} ${subset}`,
103
+ subsetOf: family,
104
+ subsetRank: face.ranges.length ? Math.min(...face.ranges.map(([lo]) => lo)) : 4294967295,
105
+ key: `${family} ${subset}:${face.weight ?? ""}:${face.style ?? ""}:${face.url}`,
106
+ ...face
107
+ }));
108
+ }
109
+ async function rasterise(card, env) {
110
+ const cdns = (env.assetsPrefix ?? []).map((prefix) => new URL(appendForwardSlash(prefix)));
111
+ const findCdn = (url) => cdns.find((cdn) => url.href.startsWith(cdn.href));
112
+ const resolve = async (url, init) => {
113
+ const absolute = new URL(url, env.origin);
114
+ if (env.readLocal) {
115
+ const cdn = findCdn(absolute);
116
+ let pathname;
117
+ if (cdn) pathname = absolute.pathname.slice(cdn.pathname.length - 1);
118
+ else if (absolute.origin === env.origin) pathname = absolute.pathname;
119
+ if (pathname !== void 0) return new Response(await env.readLocal(decodeURIComponent(pathname)));
120
+ }
121
+ return (env.fetch ?? fetch)(absolute.href, init);
122
+ };
123
+ const allowUrl = (url) => {
124
+ const parsed = URL.parse(url);
125
+ if (!parsed) return false;
126
+ if (parsed.origin === env.origin || findCdn(parsed)) return true;
127
+ return isRemoteAllowed(url, env.imageConfig);
128
+ };
129
+ try {
130
+ const { node, css } = fromHtml(card.markup);
131
+ const base = new URL(appendForwardSlash(env.base), env.origin).href;
132
+ const fonts = collectFonts(css, base).map(({ url, ...face }) => ({
133
+ ...face,
134
+ data: () => fetchOk(url, { fetch: resolve }).then((r) => readBodyLimited(r, defaultMaxFetchBytes))
135
+ }));
136
+ if (!fonts.length && hasText(node)) {
137
+ const name = "Noto Sans";
138
+ const fallback = await googleFonts([{
139
+ name,
140
+ weight: "100..900"
141
+ }]).catch((cause) => {
142
+ const message = cause instanceof Error ? cause.message : String(cause);
143
+ throw new Error(`error fetching ${name} from Google Fonts: ${message}`, { cause });
144
+ });
145
+ if (!fallback.length) throw new Error(`Google Fonts returned no faces for ${name}`);
146
+ fonts.push(...fallback);
147
+ }
148
+ const sources = [];
149
+ for (const [src, url] of collectSources(node, css, base)) {
150
+ const data = async () => readBodyLimited(await fetchOk(url, {
151
+ fetch: resolve,
152
+ allowUrl
153
+ }), defaultMaxFetchBytes);
154
+ sources.push({
155
+ src,
156
+ data
157
+ });
158
+ }
159
+ return await render(node, {
160
+ width: card.width,
161
+ height: card.height,
162
+ format: card.format,
163
+ quality: card.quality,
164
+ fonts,
165
+ css,
166
+ images: {
167
+ sources,
168
+ fetch: resolve,
169
+ allowUrl
170
+ }
171
+ });
172
+ } catch (cause) {
173
+ const message = cause instanceof Error ? cause.message : String(cause);
174
+ throw new Error(`astro-cards: card "${card.name}" failed: ${message}`, { cause });
175
+ }
176
+ }
177
+ //#endregion
178
+ export { collectSources as n, rasterise as r, collectFonts as t };
@@ -0,0 +1,28 @@
1
+ import { ComponentProps } from "astro/types";
2
+ //#region src/card.d.ts
3
+ declare const FORMATS: readonly ["png", "jpeg", "webp"];
4
+ type Format = (typeof FORMATS)[number];
5
+ interface CardOptions {
6
+ /** Pixel width of the image. Defaults to 1200. */
7
+ width?: number;
8
+ /** Pixel height of the image. Defaults to 630. */
9
+ height?: number;
10
+ /** Image encoding. Defaults to `jpeg`. */
11
+ format?: Format;
12
+ /** Encoder quality from 0 to 100 for lossy formats. Defaults to 90. */
13
+ quality?: number;
14
+ }
15
+ //#endregion
16
+ //#region src/runtime.d.ts
17
+ interface CardResult {
18
+ src: string;
19
+ width: number;
20
+ height: number;
21
+ type: string;
22
+ }
23
+ /** The project's cards, filled in by the declaration `astro sync` generates. */
24
+ interface Cards {}
25
+ /** Resolves a card to what a page embeds: its `src`, size and MIME type. */
26
+ declare function renderCard<K extends keyof Cards & string>(name: K, props: ComponentProps<Cards[K]>, options?: CardOptions): Promise<CardResult>;
27
+ //#endregion
28
+ export { Format as a, CardOptions as i, Cards as n, renderCard as r, CardResult as t };
@@ -0,0 +1,2 @@
1
+ import { n as Cards, r as renderCard, t as CardResult } from "./runtime-BIgJTgv9.js";
2
+ export { CardResult, Cards, renderCard };
@@ -0,0 +1,56 @@
1
+ import { n as encodePayload, r as loadCard } from "./payload-CizLlC8p.js";
2
+ import { joinPaths, prependForwardSlash, removeTrailingForwardSlash } from "@astrojs/internal-helpers/path";
3
+ import { cards, config } from "virtual:astro-cards";
4
+ //#region src/runtime.ts
5
+ const INVALID_CHAR_REGEX = /[\u0000-\u001F"#$%&*+,:;<=>?[\]^`{|}\u007F]/g;
6
+ const COLLECTOR = Symbol.for("astro-cards.collector");
7
+ const globals = globalThis;
8
+ /** Resolves a card to what a page embeds: its `src`, size and MIME type. */
9
+ async function renderCard(name, props, options = {}) {
10
+ if (!Object.hasOwn(cards, name)) {
11
+ const known = Object.keys(cards).join(", ");
12
+ throw new Error(`astro-cards: no card named "${name}". Known cards: ${known || "(none)"}`);
13
+ }
14
+ const { options: resolved, ext, type, render } = await loadCard(name, options);
15
+ const collector = config.collector ?? globals[COLLECTOR];
16
+ if (collector) {
17
+ const markup = await render(props);
18
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify({
19
+ markup,
20
+ ...resolved
21
+ })));
22
+ const hash = Array.from(new Uint8Array(digest, 0, 10), (byte) => byte.toString(16).padStart(2, "0")).join("");
23
+ const key = name;
24
+ const file = `${key.slice(key.lastIndexOf("/") + 1).replace(INVALID_CHAR_REGEX, "_")}.${hash}.${ext}`;
25
+ const card = {
26
+ name,
27
+ markup,
28
+ ...resolved
29
+ };
30
+ await fetch(new URL(file, collector), {
31
+ method: "POST",
32
+ body: JSON.stringify(card)
33
+ });
34
+ const path = prependForwardSlash(joinPaths(config.assetsDir, file));
35
+ const prefix = typeof config.assetsPrefix === "string" ? config.assetsPrefix : config.assetsPrefix?.[ext] || config.assetsPrefix?.fallback;
36
+ return {
37
+ src: prefix ? `${removeTrailingForwardSlash(prefix)}${path}` : joinPaths(config.base, path),
38
+ width: resolved.width,
39
+ height: resolved.height,
40
+ type
41
+ };
42
+ }
43
+ const payload = await encodePayload({
44
+ name,
45
+ props,
46
+ ...options
47
+ });
48
+ return {
49
+ src: `${joinPaths(config.base, "_cards")}?p=${payload}`,
50
+ width: resolved.width,
51
+ height: resolved.height,
52
+ type
53
+ };
54
+ }
55
+ //#endregion
56
+ export { renderCard };
package/package.json CHANGED
@@ -1,10 +1,72 @@
1
1
  {
2
2
  "name": "astro-cards",
3
- "version": "0.0.0",
4
- "description": "OIDC trusted publishing setup package for astro-cards",
3
+ "description": "Generate Open Graph images from Astro components, at build time or on demand.",
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "author": "Adam Bouqdib <adam@abemedia.co.uk>",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/abemedia/astro-cards.git"
11
+ },
12
+ "bugs": "https://github.com/abemedia/astro-cards/issues",
13
+ "homepage": "https://github.com/abemedia/astro-cards#readme",
14
+ "types": "dist/index.d.ts",
15
+ "exports": {
16
+ ".": "./dist/index.js",
17
+ "./runtime": "./dist/runtime.js",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "engines": {
24
+ "node": ">=22.12.0"
25
+ },
5
26
  "keywords": [
6
- "oidc",
7
- "trusted-publishing",
8
- "setup"
9
- ]
10
- }
27
+ "astro-integration",
28
+ "withastro",
29
+ "astro",
30
+ "image",
31
+ "images",
32
+ "ui",
33
+ "seo",
34
+ "open-graph",
35
+ "opengraph",
36
+ "social-image",
37
+ "og-image",
38
+ "social-card",
39
+ "twitter-card",
40
+ "image-generation",
41
+ "takumi"
42
+ ],
43
+ "peerDependencies": {
44
+ "astro": "^5.1.5 || ^6.0.0 || ^7.0.0"
45
+ },
46
+ "dependencies": {
47
+ "@astrojs/internal-helpers": "^0.11.0",
48
+ "@takumi-rs/helpers": "2.13.6",
49
+ "devalue": "^5.9.2",
50
+ "takumi-js": "2.13.6"
51
+ },
52
+ "devDependencies": {
53
+ "@biomejs/biome": "^2.5.12",
54
+ "@stagelint/stagelint": "^0.1.5",
55
+ "@types/jest-image-snapshot": "^6.4.1",
56
+ "@types/node": "^22.20.1",
57
+ "astro": "^7.3.1",
58
+ "jest-image-snapshot": "^6.5.2",
59
+ "sharp": "^0.35.4",
60
+ "tsdown": "^0.23.0",
61
+ "typescript": "^6.0.3",
62
+ "vitest": "^5.0.0"
63
+ },
64
+ "scripts": {
65
+ "build": "tsdown src/index.ts src/runtime.ts src/endpoint.ts --no-fixed-extension --deps.never-bundle virtual:astro-cards",
66
+ "test": "vitest run",
67
+ "test:watch": "vitest",
68
+ "format": "biome format --write .",
69
+ "lint": "biome lint --write .",
70
+ "check": "biome check . && tsc --noEmit"
71
+ }
72
+ }