@soonit/rspress-plugin-og 0.0.5 → 0.1.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/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as Options } from "./types-R_GC9oNb.mjs";
1
+ import { t as Options } from "./types-7FdncbbI.mjs";
2
2
  import { RspressPlugin } from "@rspress/core";
3
3
 
4
4
  //#region src/index.d.ts
package/dist/index.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  import { dirname, join, relative } from "node:path";
2
2
  import { performance } from "node:perf_hooks";
3
3
  import node_process, { cwd } from "node:process";
4
+ import { Buffer } from "node:buffer";
4
5
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
5
- import { writeFile } from "node:fs/promises";
6
6
  import { Resvg } from "@resvg/resvg-js";
7
+ import sharp from "sharp";
7
8
  import node_os from "node:os";
8
9
  import node_tty from "node:tty";
9
10
  import { joinURL } from "ufo";
@@ -49,84 +50,45 @@ function createOgImageTypeHead() {
49
50
  //#endregion
50
51
  //#region ../core/src/og.ts
51
52
  const templates = /* @__PURE__ */ new Map();
53
+ const baseImages = /* @__PURE__ */ new Map();
52
54
  function escapeHtml(unsafe) {
53
55
  return unsafe.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
54
56
  }
55
- /**
56
- * Wrap a title string into at most three lines with a best-effort “do not break words” strategy.
57
- *
58
- * The input is first split by explicit newlines into paragraphs (empty lines are ignored). Then each
59
- * paragraph is wrapped into lines whose length does not exceed {@link maxSizePerLine} when possible:
60
- *
61
- * - If the paragraph contains whitespace, it is tokenized by whitespace and tokens are joined with a
62
- * single space.
63
- * - If the paragraph contains no whitespace (e.g. CJK text), it is tokenized by individual characters.
64
- * - If a single token is longer than the line limit, it is hard-split into fixed-size chunks.
65
- *
66
- * Wrapping stops once three lines have been produced in total.
67
- *
68
- * @param input - The title text to wrap. `null`/`undefined`/empty (after trimming) results in `[]`.
69
- * @param maxSizePerLine - Maximum allowed character count per line. Values `<= 0` are treated as `1`.
70
- * @returns An array of wrapped lines, with a maximum length of 3.
71
- */
72
- function wrapTitleToLines(input, maxSizePerLine) {
73
- const text = (input ?? "").trim();
74
- if (!text) return [];
75
- const max = Math.max(1, maxSizePerLine || 1);
76
- const paragraphs = text.split(/\r?\n+/).map((s) => s.trim()).filter(Boolean);
77
- const out = [];
78
- for (const para of paragraphs) {
79
- if (out.length >= 3) break;
80
- const tokens = /\s/.test(para) ? para.split(/\s+/).filter(Boolean) : [...para];
81
- let line = "";
82
- for (const token of tokens) {
83
- if (out.length >= 3) break;
84
- const next = line ? /\s/.test(para) ? `${line} ${token}` : `${line}${token}` : token;
85
- if (next.length <= max) {
86
- line = next;
87
- continue;
88
- }
89
- if (line) {
90
- out.push(line);
91
- line = "";
92
- if (out.length >= 3) break;
93
- }
94
- if (token.length > max) {
95
- const chunks = token.match(new RegExp(`.{1,${max}}`, "g")) ?? [];
96
- for (const chunk of chunks) {
97
- if (out.length >= 3) break;
98
- if (chunk.length === max) out.push(chunk);
99
- else line = chunk;
100
- }
101
- } else line = token;
102
- }
103
- if (out.length >= 3) break;
104
- if (line) out.push(line);
105
- }
106
- return out.slice(0, 3);
107
- }
108
57
  async function generateOgImage({ title }, output, options) {
109
58
  if (existsSync(output)) return;
110
59
  if (!templates.has(options.ogTemplate)) templates.set(options.ogTemplate, readFileSync(options.ogTemplate, "utf-8"));
111
60
  const ogTemplate = templates.get(options.ogTemplate);
112
- mkdirSync(dirname(output), { recursive: true });
113
- const lines = wrapTitleToLines(title, options.maxTitleSizePerLine);
114
- if (lines.length === 0) {
115
- console.warn("[vitepress-plugin-og] Missing page title, skip og image generation.");
116
- return;
61
+ if (!baseImages.has(options.ogTemplate)) {
62
+ const baseSvg = ogTemplate.replace(/\{\{[^}]+\}\}/g, "").replace(/<text[^>]*>[\s\S]*?<\/text>/g, "");
63
+ const baseImageBuffer = await sharp(Buffer.from(baseSvg)).resize(1200, 630).png().toBuffer();
64
+ baseImages.set(options.ogTemplate, baseImageBuffer);
117
65
  }
118
- const data = {
66
+ mkdirSync(dirname(output), { recursive: true });
67
+ const lines = title.trim().split(new RegExp(`(.{0,${options.maxTitleSizePerLine}})(?:\\s|$)`, "g")).filter(Boolean);
68
+ const textLayerBuffer = new Resvg(createTextLayerSvg(ogTemplate, {
119
69
  line1: lines[0] ? escapeHtml(lines[0]) : "",
120
70
  line2: lines[1] ? escapeHtml(lines[1]) : "",
121
71
  line3: lines[2] ? escapeHtml(lines[2]) : ""
122
- };
123
- await writeFile(output, new Resvg(ogTemplate.replace(/\{\{([^}]+)\}\}/g, (_, name) => data[name] || ""), {
72
+ }), {
124
73
  fitTo: {
125
74
  mode: "width",
126
75
  value: 1200
127
76
  },
77
+ font: { loadSystemFonts: false },
78
+ shapeRendering: 0,
79
+ textRendering: 2,
80
+ imageRendering: 1,
81
+ dpi: 96,
128
82
  ...options.resvgOptions
129
- }).render().asPng());
83
+ }).render().asPng();
84
+ await sharp(baseImages.get(options.ogTemplate)).composite([{
85
+ input: textLayerBuffer,
86
+ blend: "over"
87
+ }]).png().toFile(output);
88
+ }
89
+ function createTextLayerSvg(template, data) {
90
+ const processedText = (template.match(/<text[^>]*>[\s\S]*?<\/text>/g) || []).map((element) => element.replace(/\{\{([^}]+)\}\}/g, (_, name) => data[name] || ""));
91
+ return `${template.match(/<svg[^>]*>/)?.[0] || "<svg>"}${processedText.join("")}</svg>`;
130
92
  }
131
93
 
132
94
  //#endregion
@@ -389,7 +351,6 @@ async function resolveOptions(userOptions) {
389
351
  outDir: "og",
390
352
  ogTemplate: "og-template.svg",
391
353
  maxTitleSizePerLine: 30,
392
- resvgOptions: void 0,
393
354
  ...userOptions
394
355
  };
395
356
  if (typeof options.resvgOptions === "function") options.resvgOptions = await options.resvgOptions();
@@ -0,0 +1,94 @@
1
+ import { Buffer } from "node:buffer";
2
+
3
+ //#region ../core/src/types.d.ts
4
+
5
+ /**
6
+ * resvg render options
7
+ * @see https://github.com/yisibl/resvg-js
8
+ */
9
+ interface ResvgRenderOptions {
10
+ /**
11
+ * Custom font data
12
+ */
13
+ font?: {
14
+ fontBuffers?: Buffer[];
15
+ loadSystemFonts?: boolean;
16
+ defaultFontFamily?: string;
17
+ defaultFontSize?: number;
18
+ serifFamily?: string;
19
+ sansSerifFamily?: string;
20
+ cursiveFamily?: string;
21
+ fantasyFamily?: string;
22
+ monospaceFamily?: string;
23
+ };
24
+ /**
25
+ * DPI for rendering
26
+ * @default 96
27
+ */
28
+ dpi?: number;
29
+ /**
30
+ * Shape rendering mode
31
+ */
32
+ shapeRendering?: 0 | 1 | 2;
33
+ /**
34
+ * Text rendering mode
35
+ */
36
+ textRendering?: 0 | 1 | 2;
37
+ /**
38
+ * Image rendering mode
39
+ */
40
+ imageRendering?: 0 | 1;
41
+ /**
42
+ * Fit to dimensions
43
+ */
44
+ fitTo?: {
45
+ mode: 'original' | 'width' | 'height' | 'zoom';
46
+ value?: number;
47
+ };
48
+ /**
49
+ * Background color
50
+ */
51
+ background?: string;
52
+ /**
53
+ * Log level for resvg
54
+ */
55
+ logLevel?: 'off' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
56
+ }
57
+ interface Options {
58
+ /**
59
+ * The domain to use for the generated OG image URLs.
60
+ */
61
+ domain: string;
62
+ /**
63
+ * Output directory for the generated OG images.
64
+ *
65
+ * @default 'og'
66
+ */
67
+ outDir?: string;
68
+ /**
69
+ * Maximum number of characters per line in the title.
70
+ *
71
+ * @default 30
72
+ */
73
+ maxTitleSizePerLine?: number;
74
+ /**
75
+ * The path to the OG image template file.
76
+ *
77
+ * @default '.vitepress/og-template.svg' for VitePress
78
+ * @default 'og-template.svg' for Rspress
79
+ */
80
+ ogTemplate?: string;
81
+ /**
82
+ * Options for resvg rendering
83
+ */
84
+ resvgOptions?: ResvgRenderOptions | (() => Promise<ResvgRenderOptions> | ResvgRenderOptions);
85
+ }
86
+ interface ResolvedOptions extends Required<Omit<Options, 'resvgOptions'>> {
87
+ resvgOptions?: ResvgRenderOptions;
88
+ }
89
+ //#endregion
90
+ //#region src/types.d.ts
91
+ interface Options$1 extends Options {}
92
+ interface ResolvedOptions$1 extends ResolvedOptions {}
93
+ //#endregion
94
+ export { ResolvedOptions$1 as n, Options$1 as t };
package/dist/types.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { n as ResolvedOptions, t as Options } from "./types-R_GC9oNb.mjs";
1
+ import { n as ResolvedOptions, t as Options } from "./types-7FdncbbI.mjs";
2
2
  export { Options, ResolvedOptions };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@soonit/rspress-plugin-og",
3
3
  "type": "module",
4
- "version": "0.0.5",
4
+ "version": "0.1.0",
5
5
  "description": "Automatically generate Open Graph images for your Rspress pages.",
6
6
  "author": "Estéban Soubiran <esteban@soubiran.dev>",
7
7
  "license": "MIT",
@@ -36,6 +36,7 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@resvg/resvg-js": "^2.6.2",
39
+ "sharp": "^0.34.5",
39
40
  "ufo": "^1.6.1"
40
41
  },
41
42
  "devDependencies": {
@@ -1,41 +0,0 @@
1
- import { ResvgRenderOptions } from "@resvg/resvg-js";
2
-
3
- //#region ../core/src/types.d.ts
4
- interface Options {
5
- /**
6
- * The domain to use for the generated OG image URLs.
7
- */
8
- domain: string;
9
- /**
10
- * Output directory for the generated OG images.
11
- *
12
- * @default 'og'
13
- */
14
- outDir?: string;
15
- /**
16
- * Maximum number of characters per line in the title.
17
- *
18
- * @default 30
19
- */
20
- maxTitleSizePerLine?: number;
21
- /**
22
- * The path to the OG image template file.
23
- *
24
- * @default '.vitepress/og-template.svg' for VitePress
25
- * @default 'og-template.svg' for Rspress
26
- */
27
- ogTemplate?: string;
28
- /**
29
- * Options for resvg rendering
30
- */
31
- resvgOptions?: ResvgRenderOptions | (() => Promise<ResvgRenderOptions>);
32
- }
33
- interface ResolvedOptions extends Required<Omit<Options, 'resvgOptions'>> {
34
- resvgOptions?: ResvgRenderOptions;
35
- }
36
- //#endregion
37
- //#region src/types.d.ts
38
- interface Options$1 extends Options {}
39
- interface ResolvedOptions$1 extends ResolvedOptions {}
40
- //#endregion
41
- export { ResolvedOptions$1 as n, Options$1 as t };