@nifrajs/image 2.9.1 → 2.11.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/README.md +12 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/og-png.d.ts +27 -0
- package/dist/og-png.d.ts.map +1 -0
- package/dist/og-png.js +22 -0
- package/dist/og-png.js.map +1 -0
- package/dist/og.d.ts +51 -0
- package/dist/og.d.ts.map +1 -0
- package/dist/og.js +191 -0
- package/dist/og.js.map +1 -0
- package/package.json +11 -1
- package/src/index.ts +14 -0
- package/src/og-png.ts +33 -0
- package/src/og.ts +237 -0
package/README.md
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
# @nifrajs/image
|
|
2
2
|
|
|
3
|
-
Image optimization for nifra - a CLS-safe responsive <Image> + pluggable loaders (CDN or self-hosted) + pure-JS dimension reading.
|
|
3
|
+
Image optimization for nifra - a CLS-safe responsive <Image> + pluggable loaders (CDN or self-hosted) + pure-JS dimension reading. The zero-dependency core also includes safe, cacheable Open Graph SVG generation at `@nifrajs/image/og` - useful where you control the consumer, but no major crawler renders SVG for `og:image`, so a social card needs an injected PNG/JPEG rasterizer. The optional Bun.Image-backed resize endpoint lives at `@nifrajs/image/server`.
|
|
4
4
|
|
|
5
5
|
Part of the **[nifra](https://nifra.dev)** full-stack TypeScript framework - one core, five UI libraries, every runtime. Scaffold a new app with `bun create nifra`.
|
|
6
6
|
|
|
7
|
+
For crawler-compatible cards, adapt the renderer you choose to the public PNG seam:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { createResvgRasterizer } from "@nifrajs/image/og-png"
|
|
11
|
+
import { Resvg } from "@resvg/resvg-js"
|
|
12
|
+
|
|
13
|
+
const rasterizer = createResvgRasterizer(Resvg)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The codec remains an application dependency; the package's default stays explicit SVG output.
|
|
17
|
+
|
|
7
18
|
## Install
|
|
8
19
|
|
|
9
20
|
```sh
|
package/dist/index.d.ts
CHANGED
|
@@ -6,5 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export { type ImageFormat, type ImageInfo, imageDimensions, readImageDimensions, } from "./dimensions.js";
|
|
8
8
|
export { type HtmlImageAttrs, toHtmlAttrs } from "./html.js";
|
|
9
|
+
export { type OgImageOptions, type OgImageRasterized, type OgImageRasterizer, ogImageResponse, renderOgImage, } from "./og.js";
|
|
10
|
+
export { createPngRasterizer, createResvgRasterizer, type PngRenderer, type ResvgConstructor, type ResvgRenderer, } from "./og-png.js";
|
|
9
11
|
export { type CloudflareLoaderOptions, cloudflareLoader, type ImageLoader, type ImageProps, identityLoader, type ResolvedImage, resolveImage, type SelfHostedLoaderOptions, type SignImageUrlOptions, selfHostedLoader, signImageUrl, } from "./resolve.js";
|
|
10
12
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,SAAS,EACd,eAAe,EACf,mBAAmB,GACpB,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAE,KAAK,cAAc,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AAC5D,OAAO,EACL,KAAK,uBAAuB,EAC5B,gBAAgB,EAChB,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,cAAc,EACd,KAAK,aAAa,EAClB,YAAY,EACZ,KAAK,uBAAuB,EAC5B,KAAK,mBAAmB,EACxB,gBAAgB,EAChB,YAAY,GACb,MAAM,cAAc,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,SAAS,EACd,eAAe,EACf,mBAAmB,GACpB,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAE,KAAK,cAAc,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AAC5D,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EACtB,eAAe,EACf,aAAa,GACd,MAAM,SAAS,CAAA;AAChB,OAAO,EACL,mBAAmB,EACnB,qBAAqB,EACrB,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,aAAa,GACnB,MAAM,aAAa,CAAA;AACpB,OAAO,EACL,KAAK,uBAAuB,EAC5B,gBAAgB,EAChB,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,cAAc,EACd,KAAK,aAAa,EAClB,YAAY,EACZ,KAAK,uBAAuB,EAC5B,KAAK,mBAAmB,EACxB,gBAAgB,EAChB,YAAY,GACb,MAAM,cAAc,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -6,5 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export { imageDimensions, readImageDimensions, } from "./dimensions.js";
|
|
8
8
|
export { toHtmlAttrs } from "./html.js";
|
|
9
|
+
export { ogImageResponse, renderOgImage, } from "./og.js";
|
|
10
|
+
export { createPngRasterizer, createResvgRasterizer, } from "./og-png.js";
|
|
9
11
|
export { cloudflareLoader, identityLoader, resolveImage, selfHostedLoader, signImageUrl, } from "./resolve.js";
|
|
10
12
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAGL,eAAe,EACf,mBAAmB,GACpB,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAuB,WAAW,EAAE,MAAM,WAAW,CAAA;AAC5D,OAAO,EAEL,gBAAgB,EAGhB,cAAc,EAEd,YAAY,EAGZ,gBAAgB,EAChB,YAAY,GACb,MAAM,cAAc,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAGL,eAAe,EACf,mBAAmB,GACpB,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAuB,WAAW,EAAE,MAAM,WAAW,CAAA;AAC5D,OAAO,EAIL,eAAe,EACf,aAAa,GACd,MAAM,SAAS,CAAA;AAChB,OAAO,EACL,mBAAmB,EACnB,qBAAqB,GAItB,MAAM,aAAa,CAAA;AACpB,OAAO,EAEL,gBAAgB,EAGhB,cAAc,EAEd,YAAY,EAGZ,gBAAgB,EAChB,YAAY,GACb,MAAM,cAAc,CAAA"}
|
package/dist/og-png.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { OgImageRasterizer } from "./og.js";
|
|
2
|
+
/** A dependency-free shape implemented by SVG→PNG codecs such as Resvg, Satori, or a WASM backend. */
|
|
3
|
+
export type PngRenderer = (svg: string) => Uint8Array | Promise<Uint8Array>;
|
|
4
|
+
export interface ResvgRenderer {
|
|
5
|
+
render(): {
|
|
6
|
+
asPng(): Uint8Array | Promise<Uint8Array>;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
export type ResvgConstructor = new (svg: string) => ResvgRenderer;
|
|
10
|
+
/**
|
|
11
|
+
* Adapt an injected SVG→PNG renderer to `ogImageResponse`.
|
|
12
|
+
*
|
|
13
|
+
* The renderer stays application-owned because codec/WASM choice is runtime-specific. For example:
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { Resvg } from "@resvg/resvg-js"
|
|
17
|
+
* import { createPngRasterizer } from "@nifrajs/image/og-png"
|
|
18
|
+
* const rasterizer = createPngRasterizer((svg) => new Resvg(svg).render().asPng())
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* This is the public reference adapter; the core package remains zero-dependency and keeps SVG as its
|
|
22
|
+
* explicit default for callers that do not opt into a codec.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createPngRasterizer(render: PngRenderer): OgImageRasterizer;
|
|
25
|
+
/** Reference adapter for Resvg-compatible constructors, without making the optional codec a dependency. */
|
|
26
|
+
export declare function createResvgRasterizer(Resvg: ResvgConstructor): OgImageRasterizer;
|
|
27
|
+
//# sourceMappingURL=og-png.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"og-png.d.ts","sourceRoot":"","sources":["../src/og-png.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAA;AAEhD,sGAAsG;AACtG,MAAM,MAAM,WAAW,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAAA;AAE3E,MAAM,WAAW,aAAa;IAC5B,MAAM,IAAI;QAAE,KAAK,IAAI,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAAA;KAAE,CAAA;CACxD;AAED,MAAM,MAAM,gBAAgB,GAAG,KAAK,GAAG,EAAE,MAAM,KAAK,aAAa,CAAA;AAEjE;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,WAAW,GAAG,iBAAiB,CAE1E;AAED,2GAA2G;AAC3G,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,gBAAgB,GAAG,iBAAiB,CAEhF"}
|
package/dist/og-png.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapt an injected SVG→PNG renderer to `ogImageResponse`.
|
|
3
|
+
*
|
|
4
|
+
* The renderer stays application-owned because codec/WASM choice is runtime-specific. For example:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { Resvg } from "@resvg/resvg-js"
|
|
8
|
+
* import { createPngRasterizer } from "@nifrajs/image/og-png"
|
|
9
|
+
* const rasterizer = createPngRasterizer((svg) => new Resvg(svg).render().asPng())
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* This is the public reference adapter; the core package remains zero-dependency and keeps SVG as its
|
|
13
|
+
* explicit default for callers that do not opt into a codec.
|
|
14
|
+
*/
|
|
15
|
+
export function createPngRasterizer(render) {
|
|
16
|
+
return async (svg) => ({ bytes: await render(svg), contentType: "image/png" });
|
|
17
|
+
}
|
|
18
|
+
/** Reference adapter for Resvg-compatible constructors, without making the optional codec a dependency. */
|
|
19
|
+
export function createResvgRasterizer(Resvg) {
|
|
20
|
+
return createPngRasterizer((svg) => new Resvg(svg).render().asPng());
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=og-png.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"og-png.js","sourceRoot":"","sources":["../src/og-png.ts"],"names":[],"mappings":"AAWA;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAAmB;IACrD,OAAO,KAAK,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,MAAM,CAAC,GAAG,CAAC,EAAE,WAAW,EAAE,WAAW,EAAE,CAAC,CAAA;AAChF,CAAC;AAED,2GAA2G;AAC3G,MAAM,UAAU,qBAAqB,CAAC,KAAuB;IAC3D,OAAO,mBAAmB,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,IAAI,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,CAAC,CAAA;AACtE,CAAC"}
|
package/dist/og.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-free Open Graph image generation.
|
|
3
|
+
*
|
|
4
|
+
* **The default SVG output does not work as a social card.** X, Facebook, LinkedIn, Slack, Discord
|
|
5
|
+
* and iMessage all refuse `image/svg+xml` for `og:image` and render nothing - the link preview is
|
|
6
|
+
* blank, and it is blank silently, which is the failure mode this package exists to prevent
|
|
7
|
+
* elsewhere. Serving SVG is only useful where you control the consumer: an internal dashboard, a
|
|
8
|
+
* docs thumbnail, a preview route you look at yourself.
|
|
9
|
+
*
|
|
10
|
+
* **For a card a crawler will actually render, pass `rasterizer`.** It receives the SVG and returns
|
|
11
|
+
* PNG/JPEG/WebP bytes - a Satori + Resvg pair is the usual choice. Keeping it injected rather than
|
|
12
|
+
* bundled is what keeps a WASM rasterizer out of `@nifrajs/image` and out of every browser bundle
|
|
13
|
+
* that imports this package for something else.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { ogImageResponse } from "@nifrajs/image/og"
|
|
17
|
+
*
|
|
18
|
+
* // Renders on X, Facebook, LinkedIn, Slack.
|
|
19
|
+
* export const GET = (request: Request) =>
|
|
20
|
+
* ogImageResponse({ title: "Ship it", rasterizer: pngRasterizer }, request)
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export interface OgImageRasterized {
|
|
24
|
+
readonly bytes: Uint8Array;
|
|
25
|
+
readonly contentType: string;
|
|
26
|
+
}
|
|
27
|
+
export type OgImageRasterizer = (svg: string) => OgImageRasterized | Promise<OgImageRasterized>;
|
|
28
|
+
export interface OgImageOptions {
|
|
29
|
+
readonly title: string;
|
|
30
|
+
readonly description?: string;
|
|
31
|
+
readonly eyebrow?: string;
|
|
32
|
+
readonly width?: number;
|
|
33
|
+
readonly height?: number;
|
|
34
|
+
readonly background?: string;
|
|
35
|
+
readonly foreground?: string;
|
|
36
|
+
readonly accent?: string;
|
|
37
|
+
/** Optional PNG/JPEG/WebP backend. Omit to serve the generated SVG. */
|
|
38
|
+
readonly rasterizer?: OgImageRasterizer;
|
|
39
|
+
/** Browser/cache freshness in seconds. Default one year. */
|
|
40
|
+
readonly cacheMaxAge?: number;
|
|
41
|
+
/** Maximum bytes accepted from an injected rasterizer. Default 10 MiB. */
|
|
42
|
+
readonly maxBytes?: number;
|
|
43
|
+
}
|
|
44
|
+
/** Render a bounded, deterministic SVG suitable for an `og:image` endpoint. */
|
|
45
|
+
export declare function renderOgImage(options: OgImageOptions): string;
|
|
46
|
+
/**
|
|
47
|
+
* Build a cacheable OG image response. GET and HEAD are supported; conditional requests short-circuit
|
|
48
|
+
* rasterization, so a crawler revalidation never repeats expensive codec work.
|
|
49
|
+
*/
|
|
50
|
+
export declare function ogImageResponse(options: OgImageOptions, request?: Request): Promise<Response>;
|
|
51
|
+
//# sourceMappingURL=og.d.ts.map
|
package/dist/og.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"og.d.ts","sourceRoot":"","sources":["../src/og.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAA;IAC1B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;CAC7B;AAED,MAAM,MAAM,iBAAiB,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAA;AAE/F,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,uEAAuE;IACvE,QAAQ,CAAC,UAAU,CAAC,EAAE,iBAAiB,CAAA;IACvC,4DAA4D;IAC5D,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,0EAA0E;IAC1E,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAC3B;AAkGD,+EAA+E;AAC/E,wBAAgB,aAAa,CAAC,OAAO,EAAE,cAAc,GAAG,MAAM,CAmB7D;AA8BD;;;GAGG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,cAAc,EACvB,OAAO,CAAC,EAAE,OAAO,GAChB,OAAO,CAAC,QAAQ,CAAC,CAoCnB"}
|
package/dist/og.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-free Open Graph image generation.
|
|
3
|
+
*
|
|
4
|
+
* **The default SVG output does not work as a social card.** X, Facebook, LinkedIn, Slack, Discord
|
|
5
|
+
* and iMessage all refuse `image/svg+xml` for `og:image` and render nothing - the link preview is
|
|
6
|
+
* blank, and it is blank silently, which is the failure mode this package exists to prevent
|
|
7
|
+
* elsewhere. Serving SVG is only useful where you control the consumer: an internal dashboard, a
|
|
8
|
+
* docs thumbnail, a preview route you look at yourself.
|
|
9
|
+
*
|
|
10
|
+
* **For a card a crawler will actually render, pass `rasterizer`.** It receives the SVG and returns
|
|
11
|
+
* PNG/JPEG/WebP bytes - a Satori + Resvg pair is the usual choice. Keeping it injected rather than
|
|
12
|
+
* bundled is what keeps a WASM rasterizer out of `@nifrajs/image` and out of every browser bundle
|
|
13
|
+
* that imports this package for something else.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { ogImageResponse } from "@nifrajs/image/og"
|
|
17
|
+
*
|
|
18
|
+
* // Renders on X, Facebook, LinkedIn, Slack.
|
|
19
|
+
* export const GET = (request: Request) =>
|
|
20
|
+
* ogImageResponse({ title: "Ship it", rasterizer: pngRasterizer }, request)
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
const DEFAULT_WIDTH = 1200;
|
|
24
|
+
const DEFAULT_HEIGHT = 630;
|
|
25
|
+
const DEFAULT_MAX_AGE = 31_536_000;
|
|
26
|
+
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
|
27
|
+
const COLOR = /^(?:#[0-9a-fA-F]{3,8}|[A-Za-z]{1,32})$/;
|
|
28
|
+
function boundedText(value, name, max, required = false) {
|
|
29
|
+
if (value === undefined && !required)
|
|
30
|
+
return "";
|
|
31
|
+
if (typeof value !== "string" || value.length === 0 || value.length > max) {
|
|
32
|
+
throw new TypeError(`og image: ${name} must be a non-empty string of at most ${max} characters`);
|
|
33
|
+
}
|
|
34
|
+
for (let index = 0; index < value.length; index++) {
|
|
35
|
+
const code = value.charCodeAt(index);
|
|
36
|
+
if (code <= 31 || code === 127)
|
|
37
|
+
throw new TypeError(`og image: ${name} contains a control character`);
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
function dimension(value, name, fallback) {
|
|
42
|
+
const resolved = value ?? fallback;
|
|
43
|
+
if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > 4096) {
|
|
44
|
+
throw new RangeError(`og image: ${name} must be a safe integer between 1 and 4096`);
|
|
45
|
+
}
|
|
46
|
+
return resolved;
|
|
47
|
+
}
|
|
48
|
+
function color(value, name, fallback) {
|
|
49
|
+
const resolved = value ?? fallback;
|
|
50
|
+
if (!COLOR.test(resolved))
|
|
51
|
+
throw new TypeError(`og image: ${name} must be a safe color token`);
|
|
52
|
+
return resolved;
|
|
53
|
+
}
|
|
54
|
+
function nonNegativeInteger(value, name, fallback) {
|
|
55
|
+
const resolved = value ?? fallback;
|
|
56
|
+
if (!Number.isSafeInteger(resolved) || resolved < 0) {
|
|
57
|
+
throw new RangeError(`og image: ${name} must be a non-negative safe integer`);
|
|
58
|
+
}
|
|
59
|
+
return resolved;
|
|
60
|
+
}
|
|
61
|
+
function escapeXml(value) {
|
|
62
|
+
return value
|
|
63
|
+
.replaceAll("&", "&")
|
|
64
|
+
.replaceAll("<", "<")
|
|
65
|
+
.replaceAll(">", ">")
|
|
66
|
+
.replaceAll('"', """)
|
|
67
|
+
.replaceAll("'", "'");
|
|
68
|
+
}
|
|
69
|
+
function wrap(value, maxChars) {
|
|
70
|
+
if (value === "")
|
|
71
|
+
return [];
|
|
72
|
+
const words = value.split(/\s+/);
|
|
73
|
+
const lines = [];
|
|
74
|
+
let line = "";
|
|
75
|
+
for (const word of words) {
|
|
76
|
+
if (word.length > maxChars) {
|
|
77
|
+
if (line !== "") {
|
|
78
|
+
lines.push(line);
|
|
79
|
+
line = "";
|
|
80
|
+
}
|
|
81
|
+
for (let index = 0; index < word.length; index += maxChars) {
|
|
82
|
+
lines.push(word.slice(index, index + maxChars));
|
|
83
|
+
}
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const candidate = line === "" ? word : `${line} ${word}`;
|
|
87
|
+
if (candidate.length > maxChars && line !== "") {
|
|
88
|
+
lines.push(line);
|
|
89
|
+
line = word;
|
|
90
|
+
}
|
|
91
|
+
else
|
|
92
|
+
line = candidate;
|
|
93
|
+
}
|
|
94
|
+
if (line !== "")
|
|
95
|
+
lines.push(line);
|
|
96
|
+
return lines;
|
|
97
|
+
}
|
|
98
|
+
function textLines(lines, x, y, size, fill) {
|
|
99
|
+
return lines
|
|
100
|
+
.map((line, index) => `<text x="${x}" y="${y + index * Math.round(size * 1.18)}" fill="${fill}" font-family="Arial, Helvetica, sans-serif" font-size="${size}" font-weight="700">${escapeXml(line)}</text>`)
|
|
101
|
+
.join("");
|
|
102
|
+
}
|
|
103
|
+
/** Render a bounded, deterministic SVG suitable for an `og:image` endpoint. */
|
|
104
|
+
export function renderOgImage(options) {
|
|
105
|
+
const width = dimension(options.width, "width", DEFAULT_WIDTH);
|
|
106
|
+
const height = dimension(options.height, "height", DEFAULT_HEIGHT);
|
|
107
|
+
const title = boundedText(options.title, "title", 240, true);
|
|
108
|
+
const description = boundedText(options.description, "description", 480);
|
|
109
|
+
const eyebrow = boundedText(options.eyebrow, "eyebrow", 64);
|
|
110
|
+
const background = color(options.background, "background", "#101827");
|
|
111
|
+
const foreground = color(options.foreground, "foreground", "#f8fafc");
|
|
112
|
+
const accent = color(options.accent, "accent", "#6ee7b7");
|
|
113
|
+
const titleLines = wrap(title, width >= 900 ? 32 : 24).slice(0, 6);
|
|
114
|
+
const descriptionLines = wrap(description, width >= 900 ? 72 : 48).slice(0, 4);
|
|
115
|
+
const titleY = eyebrow === "" ? Math.round(height * 0.43) : Math.round(height * 0.48);
|
|
116
|
+
const descriptionY = titleY + titleLines.length * 58 + 28;
|
|
117
|
+
const eyebrowMarkup = eyebrow === ""
|
|
118
|
+
? ""
|
|
119
|
+
: `<text x="84" y="112" fill="${accent}" font-family="Arial, Helvetica, sans-serif" font-size="24" font-weight="700" letter-spacing="3">${escapeXml(eyebrow)}</text>`;
|
|
120
|
+
const descriptionMarkup = textLines(descriptionLines, 84, descriptionY, 24, foreground);
|
|
121
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeXml(title)}"><rect width="${width}" height="${height}" fill="${background}"/><rect x="84" y="${height - 70}" width="180" height="8" rx="4" fill="${accent}"/>${eyebrowMarkup}${textLines(titleLines, 84, titleY, 52, foreground)}${descriptionMarkup}</svg>`;
|
|
122
|
+
}
|
|
123
|
+
async function sha256(value) {
|
|
124
|
+
const input = new Uint8Array(value.byteLength);
|
|
125
|
+
input.set(value);
|
|
126
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input));
|
|
127
|
+
return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
128
|
+
}
|
|
129
|
+
function weakTag(value) {
|
|
130
|
+
const trimmed = value.trim();
|
|
131
|
+
return trimmed.startsWith("W/") ? trimmed.slice(2).trim() : trimmed;
|
|
132
|
+
}
|
|
133
|
+
function ifNoneMatch(request, etag) {
|
|
134
|
+
const value = request?.headers.get("if-none-match");
|
|
135
|
+
if (value === null || value === undefined)
|
|
136
|
+
return false;
|
|
137
|
+
return (value.trim() === "*" ||
|
|
138
|
+
value.split(",").some((candidate) => weakTag(candidate) === weakTag(etag)));
|
|
139
|
+
}
|
|
140
|
+
function contentType(value) {
|
|
141
|
+
if (!/^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/.test(value)) {
|
|
142
|
+
throw new TypeError("og image: rasterizer content type must be a media type");
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Build a cacheable OG image response. GET and HEAD are supported; conditional requests short-circuit
|
|
148
|
+
* rasterization, so a crawler revalidation never repeats expensive codec work.
|
|
149
|
+
*/
|
|
150
|
+
export async function ogImageResponse(options, request) {
|
|
151
|
+
if (request !== undefined && request.method !== "GET" && request.method !== "HEAD") {
|
|
152
|
+
return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } });
|
|
153
|
+
}
|
|
154
|
+
const cacheMaxAge = nonNegativeInteger(options.cacheMaxAge, "cacheMaxAge", DEFAULT_MAX_AGE);
|
|
155
|
+
const maxBytes = nonNegativeInteger(options.maxBytes, "maxBytes", DEFAULT_MAX_BYTES);
|
|
156
|
+
const svg = renderOgImage(options);
|
|
157
|
+
let bytes;
|
|
158
|
+
let mediaType = "image/svg+xml";
|
|
159
|
+
try {
|
|
160
|
+
if (options.rasterizer === undefined)
|
|
161
|
+
bytes = new TextEncoder().encode(svg);
|
|
162
|
+
else {
|
|
163
|
+
const rasterized = await options.rasterizer(svg);
|
|
164
|
+
if (!(rasterized.bytes instanceof Uint8Array) || rasterized.bytes.byteLength > maxBytes) {
|
|
165
|
+
throw new TypeError("og image: rasterizer output exceeds maxBytes");
|
|
166
|
+
}
|
|
167
|
+
bytes = rasterized.bytes;
|
|
168
|
+
mediaType = contentType(rasterized.contentType);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return Response.json({ error: "image_generation_failed" }, { status: 500 });
|
|
173
|
+
}
|
|
174
|
+
// Hash the bytes actually sent. A rasterizer may produce different pixels for the same SVG
|
|
175
|
+
// (for example after a backend/font update), so hashing only the source would make a conditional
|
|
176
|
+
// request incorrectly return 304 for a changed representation.
|
|
177
|
+
const etag = `"${await sha256(bytes)}"`;
|
|
178
|
+
const headers = new Headers({
|
|
179
|
+
"cache-control": `public, max-age=${cacheMaxAge}, immutable`,
|
|
180
|
+
"content-type": `${mediaType}${mediaType === "image/svg+xml" ? "; charset=utf-8" : ""}`,
|
|
181
|
+
etag,
|
|
182
|
+
"content-length": String(bytes.byteLength),
|
|
183
|
+
"x-content-type-options": "nosniff",
|
|
184
|
+
});
|
|
185
|
+
if (ifNoneMatch(request, etag))
|
|
186
|
+
return new Response(null, { status: 304, headers });
|
|
187
|
+
if (request?.method === "HEAD")
|
|
188
|
+
return new Response(null, { status: 200, headers });
|
|
189
|
+
return new Response(bytes, { status: 200, headers });
|
|
190
|
+
}
|
|
191
|
+
//# sourceMappingURL=og.js.map
|
package/dist/og.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"og.js","sourceRoot":"","sources":["../src/og.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/image",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
4
4
|
"description": "Image optimization for nifra - a CLS-safe responsive <Image> + pluggable loaders (CDN or self-hosted) + pure-JS dimension reading. Zero-dependency core; optional Bun.Image-backed resize endpoint at @nifrajs/image/server.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -30,6 +30,16 @@
|
|
|
30
30
|
"bun": "./src/backend.ts",
|
|
31
31
|
"types": "./dist/backend.d.ts",
|
|
32
32
|
"default": "./dist/backend.js"
|
|
33
|
+
},
|
|
34
|
+
"./og": {
|
|
35
|
+
"bun": "./src/og.ts",
|
|
36
|
+
"types": "./dist/og.d.ts",
|
|
37
|
+
"default": "./dist/og.js"
|
|
38
|
+
},
|
|
39
|
+
"./og-png": {
|
|
40
|
+
"bun": "./src/og-png.ts",
|
|
41
|
+
"types": "./dist/og-png.d.ts",
|
|
42
|
+
"default": "./dist/og-png.js"
|
|
33
43
|
}
|
|
34
44
|
},
|
|
35
45
|
"files": [
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,20 @@ export {
|
|
|
11
11
|
readImageDimensions,
|
|
12
12
|
} from "./dimensions.ts"
|
|
13
13
|
export { type HtmlImageAttrs, toHtmlAttrs } from "./html.ts"
|
|
14
|
+
export {
|
|
15
|
+
type OgImageOptions,
|
|
16
|
+
type OgImageRasterized,
|
|
17
|
+
type OgImageRasterizer,
|
|
18
|
+
ogImageResponse,
|
|
19
|
+
renderOgImage,
|
|
20
|
+
} from "./og.ts"
|
|
21
|
+
export {
|
|
22
|
+
createPngRasterizer,
|
|
23
|
+
createResvgRasterizer,
|
|
24
|
+
type PngRenderer,
|
|
25
|
+
type ResvgConstructor,
|
|
26
|
+
type ResvgRenderer,
|
|
27
|
+
} from "./og-png.ts"
|
|
14
28
|
export {
|
|
15
29
|
type CloudflareLoaderOptions,
|
|
16
30
|
cloudflareLoader,
|
package/src/og-png.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { OgImageRasterizer } from "./og.ts"
|
|
2
|
+
|
|
3
|
+
/** A dependency-free shape implemented by SVG→PNG codecs such as Resvg, Satori, or a WASM backend. */
|
|
4
|
+
export type PngRenderer = (svg: string) => Uint8Array | Promise<Uint8Array>
|
|
5
|
+
|
|
6
|
+
export interface ResvgRenderer {
|
|
7
|
+
render(): { asPng(): Uint8Array | Promise<Uint8Array> }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type ResvgConstructor = new (svg: string) => ResvgRenderer
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Adapt an injected SVG→PNG renderer to `ogImageResponse`.
|
|
14
|
+
*
|
|
15
|
+
* The renderer stays application-owned because codec/WASM choice is runtime-specific. For example:
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { Resvg } from "@resvg/resvg-js"
|
|
19
|
+
* import { createPngRasterizer } from "@nifrajs/image/og-png"
|
|
20
|
+
* const rasterizer = createPngRasterizer((svg) => new Resvg(svg).render().asPng())
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* This is the public reference adapter; the core package remains zero-dependency and keeps SVG as its
|
|
24
|
+
* explicit default for callers that do not opt into a codec.
|
|
25
|
+
*/
|
|
26
|
+
export function createPngRasterizer(render: PngRenderer): OgImageRasterizer {
|
|
27
|
+
return async (svg) => ({ bytes: await render(svg), contentType: "image/png" })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Reference adapter for Resvg-compatible constructors, without making the optional codec a dependency. */
|
|
31
|
+
export function createResvgRasterizer(Resvg: ResvgConstructor): OgImageRasterizer {
|
|
32
|
+
return createPngRasterizer((svg) => new Resvg(svg).render().asPng())
|
|
33
|
+
}
|
package/src/og.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-free Open Graph image generation.
|
|
3
|
+
*
|
|
4
|
+
* **The default SVG output does not work as a social card.** X, Facebook, LinkedIn, Slack, Discord
|
|
5
|
+
* and iMessage all refuse `image/svg+xml` for `og:image` and render nothing - the link preview is
|
|
6
|
+
* blank, and it is blank silently, which is the failure mode this package exists to prevent
|
|
7
|
+
* elsewhere. Serving SVG is only useful where you control the consumer: an internal dashboard, a
|
|
8
|
+
* docs thumbnail, a preview route you look at yourself.
|
|
9
|
+
*
|
|
10
|
+
* **For a card a crawler will actually render, pass `rasterizer`.** It receives the SVG and returns
|
|
11
|
+
* PNG/JPEG/WebP bytes - a Satori + Resvg pair is the usual choice. Keeping it injected rather than
|
|
12
|
+
* bundled is what keeps a WASM rasterizer out of `@nifrajs/image` and out of every browser bundle
|
|
13
|
+
* that imports this package for something else.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { ogImageResponse } from "@nifrajs/image/og"
|
|
17
|
+
*
|
|
18
|
+
* // Renders on X, Facebook, LinkedIn, Slack.
|
|
19
|
+
* export const GET = (request: Request) =>
|
|
20
|
+
* ogImageResponse({ title: "Ship it", rasterizer: pngRasterizer }, request)
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export interface OgImageRasterized {
|
|
25
|
+
readonly bytes: Uint8Array
|
|
26
|
+
readonly contentType: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type OgImageRasterizer = (svg: string) => OgImageRasterized | Promise<OgImageRasterized>
|
|
30
|
+
|
|
31
|
+
export interface OgImageOptions {
|
|
32
|
+
readonly title: string
|
|
33
|
+
readonly description?: string
|
|
34
|
+
readonly eyebrow?: string
|
|
35
|
+
readonly width?: number
|
|
36
|
+
readonly height?: number
|
|
37
|
+
readonly background?: string
|
|
38
|
+
readonly foreground?: string
|
|
39
|
+
readonly accent?: string
|
|
40
|
+
/** Optional PNG/JPEG/WebP backend. Omit to serve the generated SVG. */
|
|
41
|
+
readonly rasterizer?: OgImageRasterizer
|
|
42
|
+
/** Browser/cache freshness in seconds. Default one year. */
|
|
43
|
+
readonly cacheMaxAge?: number
|
|
44
|
+
/** Maximum bytes accepted from an injected rasterizer. Default 10 MiB. */
|
|
45
|
+
readonly maxBytes?: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DEFAULT_WIDTH = 1200
|
|
49
|
+
const DEFAULT_HEIGHT = 630
|
|
50
|
+
const DEFAULT_MAX_AGE = 31_536_000
|
|
51
|
+
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024
|
|
52
|
+
const COLOR = /^(?:#[0-9a-fA-F]{3,8}|[A-Za-z]{1,32})$/
|
|
53
|
+
|
|
54
|
+
function boundedText(
|
|
55
|
+
value: string | undefined,
|
|
56
|
+
name: string,
|
|
57
|
+
max: number,
|
|
58
|
+
required = false,
|
|
59
|
+
): string {
|
|
60
|
+
if (value === undefined && !required) return ""
|
|
61
|
+
if (typeof value !== "string" || value.length === 0 || value.length > max) {
|
|
62
|
+
throw new TypeError(`og image: ${name} must be a non-empty string of at most ${max} characters`)
|
|
63
|
+
}
|
|
64
|
+
for (let index = 0; index < value.length; index++) {
|
|
65
|
+
const code = value.charCodeAt(index)
|
|
66
|
+
if (code <= 31 || code === 127)
|
|
67
|
+
throw new TypeError(`og image: ${name} contains a control character`)
|
|
68
|
+
}
|
|
69
|
+
return value
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function dimension(value: number | undefined, name: string, fallback: number): number {
|
|
73
|
+
const resolved = value ?? fallback
|
|
74
|
+
if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > 4096) {
|
|
75
|
+
throw new RangeError(`og image: ${name} must be a safe integer between 1 and 4096`)
|
|
76
|
+
}
|
|
77
|
+
return resolved
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function color(value: string | undefined, name: string, fallback: string): string {
|
|
81
|
+
const resolved = value ?? fallback
|
|
82
|
+
if (!COLOR.test(resolved)) throw new TypeError(`og image: ${name} must be a safe color token`)
|
|
83
|
+
return resolved
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function nonNegativeInteger(value: number | undefined, name: string, fallback: number): number {
|
|
87
|
+
const resolved = value ?? fallback
|
|
88
|
+
if (!Number.isSafeInteger(resolved) || resolved < 0) {
|
|
89
|
+
throw new RangeError(`og image: ${name} must be a non-negative safe integer`)
|
|
90
|
+
}
|
|
91
|
+
return resolved
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function escapeXml(value: string): string {
|
|
95
|
+
return value
|
|
96
|
+
.replaceAll("&", "&")
|
|
97
|
+
.replaceAll("<", "<")
|
|
98
|
+
.replaceAll(">", ">")
|
|
99
|
+
.replaceAll('"', """)
|
|
100
|
+
.replaceAll("'", "'")
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function wrap(value: string, maxChars: number): string[] {
|
|
104
|
+
if (value === "") return []
|
|
105
|
+
const words = value.split(/\s+/)
|
|
106
|
+
const lines: string[] = []
|
|
107
|
+
let line = ""
|
|
108
|
+
for (const word of words) {
|
|
109
|
+
if (word.length > maxChars) {
|
|
110
|
+
if (line !== "") {
|
|
111
|
+
lines.push(line)
|
|
112
|
+
line = ""
|
|
113
|
+
}
|
|
114
|
+
for (let index = 0; index < word.length; index += maxChars) {
|
|
115
|
+
lines.push(word.slice(index, index + maxChars))
|
|
116
|
+
}
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
const candidate = line === "" ? word : `${line} ${word}`
|
|
120
|
+
if (candidate.length > maxChars && line !== "") {
|
|
121
|
+
lines.push(line)
|
|
122
|
+
line = word
|
|
123
|
+
} else line = candidate
|
|
124
|
+
}
|
|
125
|
+
if (line !== "") lines.push(line)
|
|
126
|
+
return lines
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function textLines(
|
|
130
|
+
lines: readonly string[],
|
|
131
|
+
x: number,
|
|
132
|
+
y: number,
|
|
133
|
+
size: number,
|
|
134
|
+
fill: string,
|
|
135
|
+
): string {
|
|
136
|
+
return lines
|
|
137
|
+
.map(
|
|
138
|
+
(line, index) =>
|
|
139
|
+
`<text x="${x}" y="${y + index * Math.round(size * 1.18)}" fill="${fill}" font-family="Arial, Helvetica, sans-serif" font-size="${size}" font-weight="700">${escapeXml(line)}</text>`,
|
|
140
|
+
)
|
|
141
|
+
.join("")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Render a bounded, deterministic SVG suitable for an `og:image` endpoint. */
|
|
145
|
+
export function renderOgImage(options: OgImageOptions): string {
|
|
146
|
+
const width = dimension(options.width, "width", DEFAULT_WIDTH)
|
|
147
|
+
const height = dimension(options.height, "height", DEFAULT_HEIGHT)
|
|
148
|
+
const title = boundedText(options.title, "title", 240, true)
|
|
149
|
+
const description = boundedText(options.description, "description", 480)
|
|
150
|
+
const eyebrow = boundedText(options.eyebrow, "eyebrow", 64)
|
|
151
|
+
const background = color(options.background, "background", "#101827")
|
|
152
|
+
const foreground = color(options.foreground, "foreground", "#f8fafc")
|
|
153
|
+
const accent = color(options.accent, "accent", "#6ee7b7")
|
|
154
|
+
const titleLines = wrap(title, width >= 900 ? 32 : 24).slice(0, 6)
|
|
155
|
+
const descriptionLines = wrap(description, width >= 900 ? 72 : 48).slice(0, 4)
|
|
156
|
+
const titleY = eyebrow === "" ? Math.round(height * 0.43) : Math.round(height * 0.48)
|
|
157
|
+
const descriptionY = titleY + titleLines.length * 58 + 28
|
|
158
|
+
const eyebrowMarkup =
|
|
159
|
+
eyebrow === ""
|
|
160
|
+
? ""
|
|
161
|
+
: `<text x="84" y="112" fill="${accent}" font-family="Arial, Helvetica, sans-serif" font-size="24" font-weight="700" letter-spacing="3">${escapeXml(eyebrow)}</text>`
|
|
162
|
+
const descriptionMarkup = textLines(descriptionLines, 84, descriptionY, 24, foreground)
|
|
163
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeXml(title)}"><rect width="${width}" height="${height}" fill="${background}"/><rect x="84" y="${height - 70}" width="180" height="8" rx="4" fill="${accent}"/>${eyebrowMarkup}${textLines(titleLines, 84, titleY, 52, foreground)}${descriptionMarkup}</svg>`
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function sha256(value: Uint8Array): Promise<string> {
|
|
167
|
+
const input = new Uint8Array(value.byteLength)
|
|
168
|
+
input.set(value)
|
|
169
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input))
|
|
170
|
+
return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function weakTag(value: string): string {
|
|
174
|
+
const trimmed = value.trim()
|
|
175
|
+
return trimmed.startsWith("W/") ? trimmed.slice(2).trim() : trimmed
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function ifNoneMatch(request: Request | undefined, etag: string): boolean {
|
|
179
|
+
const value = request?.headers.get("if-none-match")
|
|
180
|
+
if (value === null || value === undefined) return false
|
|
181
|
+
return (
|
|
182
|
+
value.trim() === "*" ||
|
|
183
|
+
value.split(",").some((candidate) => weakTag(candidate) === weakTag(etag))
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function contentType(value: string): string {
|
|
188
|
+
if (!/^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/.test(value)) {
|
|
189
|
+
throw new TypeError("og image: rasterizer content type must be a media type")
|
|
190
|
+
}
|
|
191
|
+
return value
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Build a cacheable OG image response. GET and HEAD are supported; conditional requests short-circuit
|
|
196
|
+
* rasterization, so a crawler revalidation never repeats expensive codec work.
|
|
197
|
+
*/
|
|
198
|
+
export async function ogImageResponse(
|
|
199
|
+
options: OgImageOptions,
|
|
200
|
+
request?: Request,
|
|
201
|
+
): Promise<Response> {
|
|
202
|
+
if (request !== undefined && request.method !== "GET" && request.method !== "HEAD") {
|
|
203
|
+
return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } })
|
|
204
|
+
}
|
|
205
|
+
const cacheMaxAge = nonNegativeInteger(options.cacheMaxAge, "cacheMaxAge", DEFAULT_MAX_AGE)
|
|
206
|
+
const maxBytes = nonNegativeInteger(options.maxBytes, "maxBytes", DEFAULT_MAX_BYTES)
|
|
207
|
+
const svg = renderOgImage(options)
|
|
208
|
+
let bytes: Uint8Array
|
|
209
|
+
let mediaType = "image/svg+xml"
|
|
210
|
+
try {
|
|
211
|
+
if (options.rasterizer === undefined) bytes = new TextEncoder().encode(svg)
|
|
212
|
+
else {
|
|
213
|
+
const rasterized = await options.rasterizer(svg)
|
|
214
|
+
if (!(rasterized.bytes instanceof Uint8Array) || rasterized.bytes.byteLength > maxBytes) {
|
|
215
|
+
throw new TypeError("og image: rasterizer output exceeds maxBytes")
|
|
216
|
+
}
|
|
217
|
+
bytes = rasterized.bytes
|
|
218
|
+
mediaType = contentType(rasterized.contentType)
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
return Response.json({ error: "image_generation_failed" }, { status: 500 })
|
|
222
|
+
}
|
|
223
|
+
// Hash the bytes actually sent. A rasterizer may produce different pixels for the same SVG
|
|
224
|
+
// (for example after a backend/font update), so hashing only the source would make a conditional
|
|
225
|
+
// request incorrectly return 304 for a changed representation.
|
|
226
|
+
const etag = `"${await sha256(bytes)}"`
|
|
227
|
+
const headers = new Headers({
|
|
228
|
+
"cache-control": `public, max-age=${cacheMaxAge}, immutable`,
|
|
229
|
+
"content-type": `${mediaType}${mediaType === "image/svg+xml" ? "; charset=utf-8" : ""}`,
|
|
230
|
+
etag,
|
|
231
|
+
"content-length": String(bytes.byteLength),
|
|
232
|
+
"x-content-type-options": "nosniff",
|
|
233
|
+
})
|
|
234
|
+
if (ifNoneMatch(request, etag)) return new Response(null, { status: 304, headers })
|
|
235
|
+
if (request?.method === "HEAD") return new Response(null, { status: 200, headers })
|
|
236
|
+
return new Response(bytes, { status: 200, headers })
|
|
237
|
+
}
|