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 +21 -0
- package/README.md +214 -28
- package/dist/endpoint.d.ts +5 -0
- package/dist/endpoint.js +33 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +213 -0
- package/dist/payload-CizLlC8p.js +67 -0
- package/dist/render-BJFZWw2w.js +2 -0
- package/dist/render-CzpiUNuk.js +178 -0
- package/dist/runtime-BIgJTgv9.d.ts +28 -0
- package/dist/runtime.d.ts +2 -0
- package/dist/runtime.js +56 -0
- package/package.json +69 -7
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
|
-
|
|
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
|
-
|
|
6
|
+
## Why Astro Cards?
|
|
6
7
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
19
|
+
## Installation
|
|
17
20
|
|
|
18
|
-
|
|
21
|
+
```sh
|
|
22
|
+
npx astro add astro-cards
|
|
23
|
+
```
|
|
19
24
|
|
|
20
|
-
|
|
25
|
+
### Manual Install
|
|
21
26
|
|
|
22
|
-
|
|
27
|
+
Install `astro-cards` using your package manager.
|
|
23
28
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
4. Use the configured workflow to publish your actual package
|
|
29
|
+
```sh
|
|
30
|
+
npm install astro-cards
|
|
31
|
+
```
|
|
28
32
|
|
|
29
|
-
|
|
33
|
+
Add the integration to your `astro.config.mjs`:
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
39
|
+
export default defineConfig({
|
|
40
|
+
// ...
|
|
41
|
+
integrations: [cards()],
|
|
42
|
+
});
|
|
43
|
+
```
|
|
38
44
|
|
|
39
|
-
|
|
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
|
-
|
|
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`.
|
package/dist/endpoint.js
ADDED
|
@@ -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 };
|
package/dist/index.d.ts
ADDED
|
@@ -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,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 };
|
package/dist/runtime.js
ADDED
|
@@ -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
|
-
"
|
|
4
|
-
"
|
|
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
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
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
|
+
}
|