@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 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. Zero-dependency core; optional Bun.Image-backed resize endpoint at @nifrajs/image/server.
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
@@ -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"}
@@ -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
@@ -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("&", "&amp;")
64
+ .replaceAll("<", "&lt;")
65
+ .replaceAll(">", "&gt;")
66
+ .replaceAll('"', "&quot;")
67
+ .replaceAll("'", "&apos;");
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.9.1",
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("&", "&amp;")
97
+ .replaceAll("<", "&lt;")
98
+ .replaceAll(">", "&gt;")
99
+ .replaceAll('"', "&quot;")
100
+ .replaceAll("'", "&apos;")
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
+ }