diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 87bd7c0e..e07a6440 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -25,11 +25,16 @@ jobs:
run: |
cd src
npm ci
+ npm run test:open-graph
npm run build
- name: Verify markdown export
run: |
cd src
node util/verify_markdown_export.js
+ - name: Verify Open Graph images
+ run: |
+ cd src
+ node util/verify_open_graph_images.js
- uses: actions/checkout@v4
with:
path: target
diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml
index 5caa36ac..cddc6161 100644
--- a/.github/workflows/test-build.yml
+++ b/.github/workflows/test-build.yml
@@ -31,7 +31,11 @@ jobs:
cache: npm
- name: Install dependencies
run: npm ci
+ - name: Test Open Graph images
+ run: npm run test:open-graph
- name: Build
run: npm run build
- name: Verify markdown export
- run: node util/verify_markdown_export.js
\ No newline at end of file
+ run: node util/verify_markdown_export.js
+ - name: Verify Open Graph images
+ run: node util/verify_open_graph_images.js
diff --git a/docusaurus.config.js b/docusaurus.config.js
index 3e16f281..a6bfe253 100644
--- a/docusaurus.config.js
+++ b/docusaurus.config.js
@@ -1,5 +1,4 @@
// @ts-check
-// Note: type annotations allow type checking and IDEs autocompletion
import {
themes
@@ -17,8 +16,8 @@ const config = {
onBrokenLinks: 'throw',
onBrokenAnchors: 'throw',
favicon: 'img/favicon.png',
- organizationName: 'serverpod', // Usually your GitHub org/user name.
- projectName: 'serverpod.github.io', // Usually your repo name.
+ organizationName: 'serverpod',
+ projectName: 'serverpod.github.io',
trailingSlash: false,
markdown: {
@@ -37,7 +36,6 @@ const config = {
docs: {
routeBasePath: '/',
sidebarPath: require.resolve('./sidebars.js'),
- // Please change this to your repo.
editUrl: 'https://github.com/serverpod/serverpod_docs/tree/main/',
breadcrumbs: false,
},
@@ -64,7 +62,6 @@ const config = {
disableSwitch: true,
},
navbar: {
- // title: 'My Site',
logo: {
alt: 'Serverpod Logo',
src: 'img/logo-horizontal.svg',
@@ -112,7 +109,7 @@ const config = {
],
},
algolia: {
- // Algolia DocSearch configuration. `apiKey` is safe to commit.
+ // The search-only `apiKey` is safe to commit.
appId: '6LZRX3JBXY',
indexName: 'Serverpod docs',
apiKey: '2f0125e1ff6fe9469eac332ea47423d6',
@@ -137,6 +134,7 @@ const config = {
async: true,
}, ],
plugins: [
+ './plugins/open-graph-images',
[
'@docusaurus/plugin-content-docs',
{
@@ -190,4 +188,4 @@ const config = {
],
};
-module.exports = config;
\ No newline at end of file
+module.exports = config;
diff --git a/package-lock.json b/package-lock.json
index 59f7ac2f..b9262aa4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
"@docusaurus/theme-mermaid": "^3.10.2",
"@docusaurus/theme-search-algolia": "^3.9.2",
"@mdx-js/react": "^3.1.1",
+ "@noble/hashes": "1.4.0",
"clsx": "^2.1.1",
"prism-react-renderer": "^2.4.1",
"react": "^19.2.8",
@@ -29,11 +30,14 @@
"unified": "^11.0.5"
},
"devDependencies": {
+ "cheerio": "1.2.0",
"docusaurus-plugin-snipsync": "^1.0.0",
+ "fontkit": "2.0.4",
+ "sharp": "0.35.4",
"snipsync": "^1.13.0"
},
"engines": {
- "node": ">=18.0"
+ "node": ">=20.18.1"
}
},
"node_modules/@11ty/gray-matter": {
@@ -3654,6 +3658,46 @@
"react-dom": "^18.0.0 || ^19.0.0"
}
},
+ "node_modules/@docusaurus/plugin-content-blog/node_modules/cheerio": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz",
+ "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==",
+ "license": "MIT",
+ "dependencies": {
+ "cheerio-select": "^2.1.0",
+ "dom-serializer": "^2.0.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1",
+ "htmlparser2": "^8.0.1",
+ "parse5": "^7.0.0",
+ "parse5-htmlparser2-tree-adapter": "^7.0.0"
+ },
+ "engines": {
+ "node": ">= 6"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/cheerio?sponsor=1"
+ }
+ },
+ "node_modules/@docusaurus/plugin-content-blog/node_modules/htmlparser2": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
+ "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1",
+ "entities": "^4.4.0"
+ }
+ },
"node_modules/@docusaurus/plugin-content-docs": {
"version": "3.10.2",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.10.2.tgz",
@@ -4110,47 +4154,585 @@
"integrity": "sha512-sn8unbDfUL585NtR3cwHefPicOyaHvPaX7VD0aOg/siIxUBoKyKKaGEqzJZDS64mM43TnxurkYDtmB1wsJlZsw==",
"license": "MIT",
"dependencies": {
- "@docusaurus/logger": "3.10.2",
- "@docusaurus/utils": "3.10.2",
- "@docusaurus/utils-common": "3.10.2",
- "fs-extra": "^11.2.0",
- "joi": "^17.9.2",
- "js-yaml": "^4.1.0",
- "lodash": "^4.17.21",
- "tslib": "^2.6.0"
+ "@docusaurus/logger": "3.10.2",
+ "@docusaurus/utils": "3.10.2",
+ "@docusaurus/utils-common": "3.10.2",
+ "fs-extra": "^11.2.0",
+ "joi": "^17.9.2",
+ "js-yaml": "^4.1.0",
+ "lodash": "^4.17.21",
+ "tslib": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@hapi/hoek": {
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
+ "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="
+ },
+ "node_modules/@hapi/topo": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz",
+ "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==",
+ "dependencies": {
+ "@hapi/hoek": "^9.0.0"
+ }
+ },
+ "node_modules/@iconify/types": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
+ "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
+ "license": "MIT"
+ },
+ "node_modules/@iconify/utils": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz",
+ "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==",
+ "license": "MIT",
+ "dependencies": {
+ "@antfu/install-pkg": "^1.1.0",
+ "@iconify/types": "^2.0.0",
+ "mlly": "^1.8.0"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
+ "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.3.3"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
+ "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.3.3"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
+ "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.4"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
+ "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
+ "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
+ "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
+ "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
+ "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
+ "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
+ "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
+ "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
+ "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
+ "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
+ "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.3.3"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
+ "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.3.3"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
+ "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.3.3"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
+ "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.3.3"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
+ "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.3.3"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
+ "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.3.3"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
+ "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
+ "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.3"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
+ "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
+ "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.4"
},
"engines": {
- "node": ">=20.0"
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
}
},
- "node_modules/@hapi/hoek": {
- "version": "9.3.0",
- "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
- "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="
- },
- "node_modules/@hapi/topo": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz",
- "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==",
- "dependencies": {
- "@hapi/hoek": "^9.0.0"
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
+ "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
}
},
- "node_modules/@iconify/types": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
- "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
- "license": "MIT"
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
+ "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
},
- "node_modules/@iconify/utils": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz",
- "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==",
- "license": "MIT",
- "dependencies": {
- "@antfu/install-pkg": "^1.1.0",
- "@iconify/types": "^2.0.0",
- "mlly": "^1.8.0"
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
+ "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
}
},
"node_modules/@jest/schemas": {
@@ -5162,6 +5744,16 @@
"integrity": "sha512-9EuOoaNmz7JrfGwjsrD9SxF9otU5TNMnbLu1yU4BeLK0W5cDxVXXR58Z89q9u2AnHjIctscjMTYdlqQ1gojTuw==",
"license": "Apache-2.0"
},
+ "node_modules/@swc/helpers": {
+ "version": "0.5.23",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
+ "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
"node_modules/@szmarczak/http-timer": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz",
@@ -6419,6 +7011,27 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.10.43",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
@@ -6637,6 +7250,16 @@
"node": ">=8"
}
},
+ "node_modules/brotli": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
+ "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.1.2"
+ }
+ },
"node_modules/browserslist": {
"version": "4.28.6",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
@@ -6940,21 +7563,26 @@
}
},
"node_modules/cheerio": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz",
- "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==",
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
+ "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
- "domutils": "^3.0.1",
- "htmlparser2": "^8.0.1",
- "parse5": "^7.0.0",
- "parse5-htmlparser2-tree-adapter": "^7.0.0"
+ "domutils": "^3.2.2",
+ "encoding-sniffer": "^0.2.1",
+ "htmlparser2": "^10.1.0",
+ "parse5": "^7.3.0",
+ "parse5-htmlparser2-tree-adapter": "^7.1.0",
+ "parse5-parser-stream": "^7.1.2",
+ "undici": "^7.19.0",
+ "whatwg-mimetype": "^4.0.0"
},
"engines": {
- "node": ">= 6"
+ "node": ">=20.18.1"
},
"funding": {
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
@@ -7133,6 +7761,16 @@
"node": ">=8"
}
},
+ "node_modules/clone": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
+ "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/clone-deep": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
@@ -8766,6 +9404,16 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
@@ -8800,6 +9448,13 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/dfa": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
+ "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -8890,9 +9545,10 @@
}
},
"node_modules/domutils": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
- "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
@@ -9012,6 +9668,33 @@
"node": ">= 0.8"
}
},
+ "node_modules/encoding-sniffer": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
+ "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "^0.6.3",
+ "whatwg-encoding": "^3.1.1"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
+ }
+ },
+ "node_modules/encoding-sniffer/node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/enhanced-resolve": {
"version": "5.19.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
@@ -9706,6 +10389,24 @@
}
}
},
+ "node_modules/fontkit": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
+ "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@swc/helpers": "^0.5.12",
+ "brotli": "^1.3.2",
+ "clone": "^2.1.2",
+ "dfa": "^1.2.0",
+ "fast-deep-equal": "^3.1.3",
+ "restructure": "^3.0.0",
+ "tiny-inflate": "^1.0.3",
+ "unicode-properties": "^1.4.0",
+ "unicode-trie": "^2.0.0"
+ }
+ },
"node_modules/form-data-encoder": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz",
@@ -10509,9 +11210,10 @@
}
},
"node_modules/htmlparser2": {
- "version": "8.0.2",
- "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
- "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "dev": true,
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
@@ -10523,8 +11225,21 @@
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
- "domutils": "^3.0.1",
- "entities": "^4.4.0"
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http-cache-semantics": {
@@ -14484,6 +15199,13 @@
"integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
"license": "MIT"
},
+ "node_modules/pako": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
+ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/param-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
@@ -14553,11 +15275,12 @@
"license": "ISC"
},
"node_modules/parse5": {
- "version": "7.1.2",
- "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
- "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==",
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "license": "MIT",
"dependencies": {
- "entities": "^4.4.0"
+ "entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
@@ -14576,6 +15299,31 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
+ "node_modules/parse5-parser-stream": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
+ "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parse5": "^7.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5/node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -17152,6 +17900,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/restructure": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
+ "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
@@ -17366,9 +18121,9 @@
}
},
"node_modules/semver": {
- "version": "7.7.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
- "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -17612,6 +18367,56 @@
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
"license": "MIT"
},
+ "node_modules/sharp": {
+ "version": "0.35.4",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
+ "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.1.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.8.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.35.4",
+ "@img/sharp-darwin-x64": "0.35.4",
+ "@img/sharp-freebsd-wasm32": "0.35.4",
+ "@img/sharp-libvips-darwin-arm64": "1.3.3",
+ "@img/sharp-libvips-darwin-x64": "1.3.3",
+ "@img/sharp-libvips-linux-arm": "1.3.3",
+ "@img/sharp-libvips-linux-arm64": "1.3.3",
+ "@img/sharp-libvips-linux-ppc64": "1.3.3",
+ "@img/sharp-libvips-linux-riscv64": "1.3.3",
+ "@img/sharp-libvips-linux-s390x": "1.3.3",
+ "@img/sharp-libvips-linux-x64": "1.3.3",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.3",
+ "@img/sharp-linux-arm": "0.35.4",
+ "@img/sharp-linux-arm64": "0.35.4",
+ "@img/sharp-linux-ppc64": "0.35.4",
+ "@img/sharp-linux-riscv64": "0.35.4",
+ "@img/sharp-linux-s390x": "0.35.4",
+ "@img/sharp-linux-x64": "0.35.4",
+ "@img/sharp-linuxmusl-arm64": "0.35.4",
+ "@img/sharp-linuxmusl-x64": "0.35.4",
+ "@img/sharp-webcontainers-wasm32": "0.35.4",
+ "@img/sharp-win32-arm64": "0.35.4",
+ "@img/sharp-win32-ia32": "0.35.4",
+ "@img/sharp-win32-x64": "0.35.4"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -18295,6 +19100,13 @@
"integrity": "sha512-BaiVzvNz2UXDKTdSrXzrNf4q6Ecc+/utYUh7zdEu2jzYcJVDoqYbVfUl0bCfMoOeeAqg28vD/yN63Y3E9jOrlA==",
"license": "MIT"
},
+ "node_modules/tiny-inflate": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
+ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tiny-invariant": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz",
@@ -18517,6 +19329,16 @@
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
"license": "MIT"
},
+ "node_modules/undici": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
+ "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
@@ -18559,6 +19381,17 @@
"node": ">=4"
}
},
+ "node_modules/unicode-properties": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
+ "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.0",
+ "unicode-trie": "^2.0.0"
+ }
+ },
"node_modules/unicode-property-aliases-ecmascript": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
@@ -18567,6 +19400,17 @@
"node": ">=4"
}
},
+ "node_modules/unicode-trie": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
+ "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^0.2.5",
+ "tiny-inflate": "^1.0.0"
+ }
+ },
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -19430,6 +20274,43 @@
"node": ">=0.8.0"
}
},
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-encoding/node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
diff --git a/package.json b/package.json
index 899abb6c..2326c873 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,8 @@
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
- "lint": "markdownlint './docs/**/*.md'"
+ "lint": "markdownlint './docs/**/*.md'",
+ "test:open-graph": "node --test plugins/open-graph-images/open-graph-images.test.js plugins/open-graph-images/render.test.js util/verify_open_graph_images.test.js"
},
"dependencies": {
"@docusaurus/core": "3.10.2",
@@ -24,6 +25,7 @@
"@docusaurus/theme-mermaid": "^3.10.2",
"@docusaurus/theme-search-algolia": "^3.9.2",
"@mdx-js/react": "^3.1.1",
+ "@noble/hashes": "1.4.0",
"clsx": "^2.1.1",
"prism-react-renderer": "^2.4.1",
"react": "^19.2.8",
@@ -36,7 +38,7 @@
"unified": "^11.0.5"
},
"engines": {
- "node": ">=18.0"
+ "node": ">=20.18.1"
},
"browserslist": {
"production": [
@@ -51,7 +53,10 @@
]
},
"devDependencies": {
+ "cheerio": "1.2.0",
"docusaurus-plugin-snipsync": "^1.0.0",
+ "fontkit": "2.0.4",
+ "sharp": "0.35.4",
"snipsync": "^1.13.0"
}
}
diff --git a/plugins/open-graph-images/assets/doc-icon.png b/plugins/open-graph-images/assets/doc-icon.png
new file mode 100644
index 00000000..6d08f2c5
Binary files /dev/null and b/plugins/open-graph-images/assets/doc-icon.png differ
diff --git a/plugins/open-graph-images/fonts/Inter-Black.otf b/plugins/open-graph-images/fonts/Inter-Black.otf
new file mode 100644
index 00000000..4ba27c25
Binary files /dev/null and b/plugins/open-graph-images/fonts/Inter-Black.otf differ
diff --git a/plugins/open-graph-images/fonts/Inter-Regular.otf b/plugins/open-graph-images/fonts/Inter-Regular.otf
new file mode 100644
index 00000000..13c3ec48
Binary files /dev/null and b/plugins/open-graph-images/fonts/Inter-Regular.otf differ
diff --git a/plugins/open-graph-images/fonts/LICENSE.txt b/plugins/open-graph-images/fonts/LICENSE.txt
new file mode 100644
index 00000000..9b2ca37b
--- /dev/null
+++ b/plugins/open-graph-images/fonts/LICENSE.txt
@@ -0,0 +1,92 @@
+Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION AND CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/plugins/open-graph-images/index.js b/plugins/open-graph-images/index.js
new file mode 100644
index 00000000..42548910
--- /dev/null
+++ b/plugins/open-graph-images/index.js
@@ -0,0 +1,280 @@
+const fs = require('fs');
+const path = require('path');
+const { randomUUID } = require('crypto');
+const { normalizeUrl } = require('@docusaurus/utils');
+const sharp = require('sharp');
+
+const { renderCard } = require('./render');
+const {
+ CARD_HEIGHT,
+ CARD_WIDTH,
+ PUBLIC_PATH,
+ cardId,
+ sha256Hex,
+ shouldGenerateCard,
+} = require('./shared');
+
+const PLUGIN_NAME = 'serverpod-open-graph-images';
+const ASSET_DIRECTORY = `${PLUGIN_NAME}-assets`;
+const DOCS_PLUGIN = 'docusaurus-plugin-content-docs';
+const RENDER_FINGERPRINT_DEFINE = '__SERVERPOD_OG_RENDER_FINGERPRINT__';
+const RENDER_CONCURRENCY = 6;
+const STALE_TEMP_FILE_AGE_MS = 60 * 60 * 1000;
+const FONTKIT_VERSION = JSON.parse(
+ fs.readFileSync(
+ path.join(path.dirname(require.resolve('fontkit')), '..', 'package.json'),
+ 'utf8'
+ )
+).version;
+
+async function mapWithConcurrency(items, concurrency, callback) {
+ let nextIndex = 0;
+ let firstError;
+ let didFail = false;
+ const workers = Array.from(
+ { length: Math.min(concurrency, items.length) },
+ async () => {
+ while (!didFail && nextIndex < items.length) {
+ const index = nextIndex;
+ nextIndex += 1;
+ try {
+ await callback(items[index]);
+ } catch (error) {
+ if (!didFail) {
+ didFail = true;
+ firstError = error;
+ }
+ }
+ }
+ }
+ );
+ await Promise.all(workers);
+ if (didFail) {
+ throw firstError;
+ }
+}
+
+module.exports = function openGraphImagesPlugin(context, options = {}) {
+ const generatedDir = path.join(context.generatedFilesDir, ASSET_DIRECTORY);
+ const renderSource = fs
+ .readFileSync(require.resolve('./render'), 'utf8')
+ .replaceAll('\r\n', '\n');
+ const logoPath = path.join(
+ context.siteDir,
+ 'static',
+ 'img',
+ 'logo-horizontal-dark.svg'
+ );
+ const regularFontPath =
+ options.regularFontPath ??
+ path.join(__dirname, 'fonts', 'Inter-Regular.otf');
+ const blackFontPath =
+ options.blackFontPath ?? path.join(__dirname, 'fonts', 'Inter-Black.otf');
+ const iconPath =
+ options.iconPath ?? path.join(__dirname, 'assets', 'doc-icon.png');
+ const watchedPaths = [logoPath, iconPath, regularFontPath, blackFontPath];
+ const fileCache = new Map();
+ let referencedCardIds = new Set();
+ let publishedCardIds = new Set();
+ let contentLoadQueue = Promise.resolve();
+
+ function readFileCached(filePath, encoding) {
+ const { mtimeMs, size } = fs.statSync(filePath);
+ const cached = fileCache.get(filePath);
+ if (cached?.mtimeMs === mtimeMs && cached.size === size) {
+ return cached.content;
+ }
+
+ const content = fs.readFileSync(filePath, encoding);
+ fileCache.set(filePath, { mtimeMs, size, content });
+ return content;
+ }
+
+ function loadRenderAssets() {
+ const logoSvg = readFileCached(logoPath, 'utf8');
+ const iconPng = readFileCached(iconPath);
+ const regularFontBuffer = readFileCached(regularFontPath);
+ const blackFontBuffer = readFileCached(blackFontPath);
+ const renderFingerprint = sha256Hex(
+ JSON.stringify([
+ ['renderSource', renderSource],
+ ['cardDimensions', [CARD_WIDTH, CARD_HEIGHT]],
+ ['logo-horizontal-dark.svg', logoSvg],
+ ['doc-icon.png', sha256Hex(iconPng)],
+ ['regularFont', sha256Hex(regularFontBuffer)],
+ ['blackFont', sha256Hex(blackFontBuffer)],
+ ['fontkitVersion', FONTKIT_VERSION],
+ ['sharpVersions', Object.entries(sharp.versions).sort()],
+ ['platform', process.platform],
+ ['arch', process.arch],
+ ])
+ );
+ return {
+ logoSvg,
+ iconPng,
+ regularFontBuffer,
+ blackFontBuffer,
+ renderFingerprint,
+ };
+ }
+
+ return {
+ name: PLUGIN_NAME,
+
+ getPathsToWatch() {
+ return watchedPaths;
+ },
+
+ allContentLoaded({ allContent }) {
+ const operation = contentLoadQueue.then(async () => {
+ const startedAt = Date.now();
+ const {
+ logoSvg,
+ iconPng,
+ regularFontBuffer,
+ blackFontBuffer,
+ renderFingerprint,
+ } = loadRenderAssets();
+ const instances = allContent[DOCS_PLUGIN] || {};
+ const cardsById = new Map();
+ let pageCount = 0;
+
+ for (const content of Object.values(instances)) {
+ for (const version of content.loadedVersions || []) {
+ for (const doc of version.docs || []) {
+ if (doc.frontMatter?.image != null || !shouldGenerateCard(doc)) {
+ continue;
+ }
+
+ const id = cardId({
+ title: doc.title,
+ description: doc.description,
+ renderFingerprint,
+ });
+ pageCount += 1;
+
+ if (!cardsById.has(id)) {
+ cardsById.set(id, {
+ title: doc.title,
+ description: doc.description,
+ source: doc.source,
+ logoSvg,
+ iconPng,
+ regularFontBuffer,
+ blackFontBuffer,
+ outputPath: path.join(generatedDir, `${id}.jpg`),
+ });
+ }
+ }
+ }
+ }
+
+ fs.mkdirSync(generatedDir, { recursive: true });
+ const staleBefore = Date.now() - STALE_TEMP_FILE_AGE_MS;
+ for (const fileName of fs.readdirSync(generatedDir)) {
+ if (/^[a-f0-9]{20}\.jpg\.\d+(?:\.[a-f0-9-]+)?\.tmp$/.test(fileName)) {
+ const temporaryPath = path.join(generatedDir, fileName);
+ try {
+ if (fs.statSync(temporaryPath).mtimeMs < staleBefore) {
+ fs.rmSync(temporaryPath, { force: true });
+ }
+ } catch (error) {
+ if (error.code !== 'ENOENT') {
+ throw error;
+ }
+ }
+ }
+ }
+
+ const cards = [...cardsById.values()];
+ await mapWithConcurrency(cards, RENDER_CONCURRENCY, async (card) => {
+ if (!fs.existsSync(card.outputPath)) {
+ const temporaryPath = `${card.outputPath}.${process.pid}.${randomUUID()}.tmp`;
+ try {
+ await renderCard({ ...card, outputPath: temporaryPath });
+ fs.renameSync(temporaryPath, card.outputPath);
+ } catch (error) {
+ throw new Error(
+ `Card for "${card.title}" (${card.source}): ${error.message}`,
+ { cause: error }
+ );
+ } finally {
+ fs.rmSync(temporaryPath, { force: true });
+ }
+ }
+ });
+ referencedCardIds = new Set(cardsById.keys());
+ for (const fileName of fs.readdirSync(generatedDir)) {
+ const match = /^([a-f0-9]{20})\.jpg$/.exec(fileName);
+ if (match && !referencedCardIds.has(match[1])) {
+ fs.unlinkSync(path.join(generatedDir, fileName));
+ }
+ }
+
+ const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
+ console.log(
+ `[${PLUGIN_NAME}] Prepared ${cards.length} unique social cards for ` +
+ `${pageCount} pages in ${seconds}s.`
+ );
+ });
+ contentLoadQueue = operation.catch(() => {});
+ return operation;
+ },
+
+ configureWebpack(_config, _isServer, { currentBundler }) {
+ const { DefinePlugin } = currentBundler.instance;
+ const fingerprint = DefinePlugin.runtimeValue(
+ () => JSON.stringify(loadRenderAssets().renderFingerprint),
+ {
+ fileDependencies: watchedPaths,
+ version: loadRenderAssets().renderFingerprint,
+ }
+ );
+ return {
+ plugins: [
+ new DefinePlugin({
+ [RENDER_FINGERPRINT_DEFINE]: fingerprint,
+ }),
+ ],
+ devServer: {
+ static: [
+ {
+ directory: generatedDir,
+ publicPath: normalizeUrl([context.baseUrl, PUBLIC_PATH]),
+ watch: false,
+ },
+ ],
+ },
+ };
+ },
+
+ postBuild({ outDir }) {
+ const outputDir = path.join(outDir, PUBLIC_PATH.replace(/^\//, ''));
+ for (const id of publishedCardIds) {
+ if (!referencedCardIds.has(id)) {
+ fs.rmSync(path.join(outputDir, `${id}.jpg`), { force: true });
+ }
+ }
+ if (referencedCardIds.size === 0) {
+ publishedCardIds = new Set();
+ return;
+ }
+
+ fs.mkdirSync(outputDir, { recursive: true });
+ for (const id of [...referencedCardIds].sort()) {
+ try {
+ fs.copyFileSync(
+ path.join(generatedDir, `${id}.jpg`),
+ path.join(outputDir, `${id}.jpg`),
+ fs.constants.COPYFILE_EXCL
+ );
+ } catch (error) {
+ if (error.code !== 'EEXIST' || !publishedCardIds.has(id)) {
+ throw error;
+ }
+ }
+ }
+ publishedCardIds = new Set(referencedCardIds);
+ },
+ };
+};
diff --git a/plugins/open-graph-images/open-graph-images.test.js b/plugins/open-graph-images/open-graph-images.test.js
new file mode 100644
index 00000000..3f2fca8b
--- /dev/null
+++ b/plugins/open-graph-images/open-graph-images.test.js
@@ -0,0 +1,477 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const test = require('node:test');
+
+const openGraphImagesPlugin = require('./index');
+const {
+ cardId,
+ cardPath,
+ normalizeMetadata,
+ openGraphImageForDoc,
+ shouldGenerateCard,
+} = require('./shared');
+
+const SITE_DIR = path.resolve(__dirname, '..', '..');
+const PLUGIN_NAME = 'serverpod-open-graph-images';
+const ASSET_DIRECTORY = `${PLUGIN_NAME}-assets`;
+const FINGERPRINT_DEFINE = '__SERVERPOD_OG_RENDER_FINGERPRINT__';
+const TEST_RENDER_FINGERPRINT = 'render-fingerprint';
+
+class TestDefinePlugin {
+ static runtimeValue(fn, options) {
+ return { fn, options };
+ }
+
+ constructor(definitions) {
+ this.definitions = definitions;
+ }
+}
+
+const CONFIGURE_WEBPACK_UTILS = {
+ currentBundler: { instance: { DefinePlugin: TestDefinePlugin } },
+};
+
+function temporaryPlugin(t, { baseUrl = '/', siteDir = SITE_DIR } = {}) {
+ const temporaryDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'serverpod-og-plugin-')
+ );
+ t.after(() => fs.rmSync(temporaryDir, { recursive: true, force: true }));
+ const generatedFilesDir = path.join(temporaryDir, 'generated');
+ return {
+ generatedFilesDir,
+ outDir: path.join(temporaryDir, 'build'),
+ plugin: openGraphImagesPlugin({
+ siteDir,
+ generatedFilesDir,
+ baseUrl,
+ }),
+ };
+}
+
+function pluginFingerprint(plugin) {
+ const config = plugin.configureWebpack(
+ undefined,
+ false,
+ CONFIGURE_WEBPACK_UTILS
+ );
+ const runtimeValue = config.plugins[0].definitions[FINGERPRINT_DEFINE];
+ const fingerprint = JSON.parse(runtimeValue.fn());
+ assert.equal(runtimeValue.options.version, fingerprint);
+ return fingerprint;
+}
+
+function doc({
+ id,
+ title = id,
+ description = `${id} description`,
+ frontMatter = {},
+ sidebar = 'docs',
+ permalink = `/${id}`,
+ source = `@site/docs/${id}.md`,
+}) {
+ return { id, title, description, frontMatter, sidebar, permalink, source };
+}
+
+function docsContent(loadedVersions) {
+ return {
+ 'docusaurus-plugin-content-docs': {
+ default: { loadedVersions },
+ },
+ };
+}
+
+test('card IDs normalize whitespace only and produce safe public paths', () => {
+ const id = cardId({
+ title: ' Quickstart\n',
+ description: 'Build\twith Serverpod.',
+ renderFingerprint: TEST_RENDER_FINGERPRINT,
+ });
+ assert.equal(
+ id,
+ cardId({
+ title: 'Quickstart',
+ description: 'Build with Serverpod.',
+ renderFingerprint: TEST_RENDER_FINGERPRINT,
+ })
+ );
+ assert.notEqual(
+ id,
+ cardId({
+ title: '**Quickstart**',
+ description: 'Build with Serverpod.',
+ renderFingerprint: TEST_RENDER_FINGERPRINT,
+ })
+ );
+ assert.match(id, /^[a-f0-9]{20}$/);
+ assert.equal(cardPath(id), `/img/open-graph/${id}.jpg`);
+ assert.equal(normalizeMetadata(' A\n\tB '), 'A B');
+ assert.throws(
+ () => cardId({ title: 'x', description: 'y' }),
+ /render fingerprint is required/
+ );
+});
+
+test('docs with empty titles get no generated card on either side', async (t) => {
+ assert.equal(shouldGenerateCard({ title: 'Quickstart' }), true);
+ assert.equal(shouldGenerateCard({ title: '' }), false);
+ assert.equal(shouldGenerateCard({ title: ' \n ' }), false);
+
+ assert.deepEqual(
+ openGraphImageForDoc({
+ title: '',
+ description: 'Auto-extracted CLI reference text.',
+ renderFingerprint: TEST_RENDER_FINGERPRINT,
+ }),
+ { generatedImage: undefined, image: undefined }
+ );
+ assert.deepEqual(
+ openGraphImageForDoc({
+ title: '',
+ description: 'x',
+ frontMatterImage: '/card.png',
+ renderFingerprint: TEST_RENDER_FINGERPRINT,
+ }),
+ { generatedImage: undefined, image: '/card.png' }
+ );
+
+ const { generatedFilesDir, plugin } = temporaryPlugin(t);
+ await plugin.allContentLoaded({
+ allContent: docsContent([
+ {
+ versionName: 'current',
+ sidebars: {},
+ docs: [doc({ id: 'untitled', title: '' })],
+ },
+ ]),
+ });
+ assert.deepEqual(
+ fs.readdirSync(path.join(generatedFilesDir, ASSET_DIRECTORY)),
+ []
+ );
+});
+
+test('cards generate across multiple docs plugin instances', async (t) => {
+ const { generatedFilesDir, plugin } = temporaryPlugin(t);
+ await plugin.allContentLoaded({
+ allContent: {
+ 'docusaurus-plugin-content-docs': {
+ default: {
+ loadedVersions: [
+ {
+ versionName: 'current',
+ sidebars: {},
+ docs: [doc({ id: 'framework' })],
+ },
+ ],
+ },
+ cloud: {
+ loadedVersions: [
+ {
+ versionName: 'current',
+ sidebars: {},
+ docs: [doc({ id: 'cloud' })],
+ },
+ ],
+ },
+ },
+ },
+ });
+ const files = fs.readdirSync(path.join(generatedFilesDir, ASSET_DIRECTORY));
+ assert.equal(files.length, 2);
+});
+
+test('explicit document images take precedence over generated cards', () => {
+ const card = {
+ title: 'Quickstart',
+ description: 'Build with Serverpod.',
+ renderFingerprint: TEST_RENDER_FINGERPRINT,
+ };
+ assert.deepEqual(
+ openGraphImageForDoc({
+ ...card,
+ assetImage: '/assets/processed-card.png',
+ frontMatterImage: './card.png',
+ }),
+ {
+ generatedImage: undefined,
+ image: '/assets/processed-card.png',
+ }
+ );
+ assert.deepEqual(
+ openGraphImageForDoc({ ...card, frontMatterImage: '/card.png' }),
+ { generatedImage: undefined, image: '/card.png' }
+ );
+
+ const generated = openGraphImageForDoc(card);
+ assert.equal(generated.image, generated.generatedImage);
+ assert.match(generated.image, /^\/img\/open-graph\/[a-f0-9]{20}\.jpg$/);
+});
+
+test('dev serving respects baseUrl and watches every render asset', (t) => {
+ const { plugin } = temporaryPlugin(t, { baseUrl: '/docs/' });
+ const config = plugin.configureWebpack(
+ undefined,
+ false,
+ CONFIGURE_WEBPACK_UTILS
+ );
+ assert.equal(config.devServer.static[0].publicPath, '/docs/img/open-graph');
+
+ const runtimeValue = config.plugins[0].definitions[FINGERPRINT_DEFINE];
+ assert.deepEqual(
+ runtimeValue.options.fileDependencies,
+ plugin.getPathsToWatch()
+ );
+
+ const watched = new Set(plugin.getPathsToWatch());
+ assert.ok(
+ watched.has(
+ path.join(SITE_DIR, 'static', 'img', 'logo-horizontal-dark.svg')
+ )
+ );
+ assert.ok(watched.has(path.join(__dirname, 'assets', 'doc-icon.png')));
+ assert.ok(watched.has(path.join(__dirname, 'fonts', 'Inter-Regular.otf')));
+ assert.ok(watched.has(path.join(__dirname, 'fonts', 'Inter-Black.otf')));
+ assert.match(pluginFingerprint(plugin), /^[a-f0-9]{64}$/);
+});
+
+test('changing the logo invalidates the render fingerprint', (t) => {
+ const temporaryDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'serverpod-og-assets-')
+ );
+ t.after(() => fs.rmSync(temporaryDir, { recursive: true, force: true }));
+ const staticImageDir = path.join(temporaryDir, 'static', 'img');
+ fs.mkdirSync(staticImageDir, { recursive: true });
+ fs.copyFileSync(
+ path.join(SITE_DIR, 'static', 'img', 'logo-horizontal-dark.svg'),
+ path.join(staticImageDir, 'logo-horizontal-dark.svg')
+ );
+
+ const plugin = openGraphImagesPlugin({
+ siteDir: temporaryDir,
+ generatedFilesDir: path.join(temporaryDir, 'generated'),
+ baseUrl: '/',
+ });
+ const before = pluginFingerprint(plugin);
+ fs.appendFileSync(
+ path.join(staticImageDir, 'logo-horizontal-dark.svg'),
+ '\n\n'
+ );
+ const after = pluginFingerprint(plugin);
+ assert.notEqual(after, before);
+});
+
+test('changing the icon or a font invalidates the render fingerprint', (t) => {
+ const temporaryDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'serverpod-og-assets-')
+ );
+ t.after(() => fs.rmSync(temporaryDir, { recursive: true, force: true }));
+ const staticImageDir = path.join(temporaryDir, 'static', 'img');
+ fs.mkdirSync(staticImageDir, { recursive: true });
+ fs.copyFileSync(
+ path.join(SITE_DIR, 'static', 'img', 'logo-horizontal-dark.svg'),
+ path.join(staticImageDir, 'logo-horizontal-dark.svg')
+ );
+ const iconPath = path.join(temporaryDir, 'doc-icon.png');
+ const regularFontPath = path.join(temporaryDir, 'Inter-Regular.otf');
+ fs.copyFileSync(path.join(__dirname, 'assets', 'doc-icon.png'), iconPath);
+ fs.copyFileSync(
+ path.join(__dirname, 'fonts', 'Inter-Regular.otf'),
+ regularFontPath
+ );
+
+ const plugin = openGraphImagesPlugin(
+ {
+ siteDir: temporaryDir,
+ generatedFilesDir: path.join(temporaryDir, 'generated'),
+ baseUrl: '/',
+ },
+ { iconPath, regularFontPath }
+ );
+ const initial = pluginFingerprint(plugin);
+ fs.appendFileSync(iconPath, Buffer.from([0]));
+ const afterIcon = pluginFingerprint(plugin);
+ assert.notEqual(afterIcon, initial);
+ fs.appendFileSync(regularFontPath, Buffer.from([0]));
+ assert.notEqual(pluginFingerprint(plugin), afterIcon);
+});
+
+test('plugin and theme derivation share IDs without global manifest data', async (t) => {
+ const { generatedFilesDir, outDir, plugin } = temporaryPlugin(t);
+ const generatedDir = path.join(generatedFilesDir, ASSET_DIRECTORY);
+ fs.mkdirSync(generatedDir, { recursive: true });
+ const staleId = '00000000000000000000';
+ fs.writeFileSync(path.join(generatedDir, `${staleId}.jpg`), 'stale');
+ let globalDataCalls = 0;
+
+ const quickstart = doc({
+ id: 'quickstart',
+ title: 'Quickstart',
+ description: 'Create and run a Serverpod project.',
+ });
+ await plugin.allContentLoaded({
+ allContent: docsContent([
+ {
+ versionName: 'current',
+ sidebars: {},
+ docs: [
+ quickstart,
+ doc({
+ id: 'custom-card',
+ frontMatter: { image: '/img/custom.jpg' },
+ }),
+ ],
+ },
+ ]),
+ actions: {
+ setGlobalData() {
+ globalDataCalls += 1;
+ },
+ },
+ });
+
+ const expectedId = cardId({
+ title: quickstart.title,
+ description: quickstart.description,
+ renderFingerprint: pluginFingerprint(plugin),
+ });
+ assert.equal(globalDataCalls, 0);
+ assert.ok(fs.existsSync(path.join(generatedDir, `${expectedId}.jpg`)));
+ assert.equal(fs.existsSync(path.join(generatedDir, `${staleId}.jpg`)), false);
+
+ assert.equal(plugin.postBuild({ outDir }), undefined);
+ assert.deepEqual(fs.readdirSync(path.join(outDir, 'img', 'open-graph')), [
+ `${expectedId}.jpg`,
+ ]);
+ assert.equal(plugin.postBuild({ outDir }), undefined);
+ assert.deepEqual(fs.readdirSync(path.join(outDir, 'img', 'open-graph')), [
+ `${expectedId}.jpg`,
+ ]);
+});
+
+test('serializes reloads and cleans only stale temporary files', async (t) => {
+ const { generatedFilesDir, plugin } = temporaryPlugin(t);
+ const generatedDir = path.join(generatedFilesDir, ASSET_DIRECTORY);
+ fs.mkdirSync(generatedDir, { recursive: true });
+ const staleTemporaryFileName =
+ '00000000000000000000.jpg.123.00000000-0000-0000-0000-000000000000.tmp';
+ const activeTemporaryFileName =
+ '11111111111111111111.jpg.456.11111111-1111-1111-1111-111111111111.tmp';
+ const staleTemporaryPath = path.join(generatedDir, staleTemporaryFileName);
+ fs.writeFileSync(staleTemporaryPath, 'abandoned');
+ fs.utimesSync(staleTemporaryPath, new Date(0), new Date(0));
+ fs.writeFileSync(
+ path.join(generatedDir, activeTemporaryFileName),
+ 'still active'
+ );
+
+ const renderOneCard = plugin.allContentLoaded({
+ allContent: docsContent([
+ {
+ versionName: 'current',
+ sidebars: {},
+ docs: [doc({ id: 'first' })],
+ },
+ ]),
+ });
+ const removeAllCards = plugin.allContentLoaded({
+ allContent: docsContent([]),
+ });
+
+ await Promise.all([renderOneCard, removeAllCards]);
+ assert.deepEqual(fs.readdirSync(generatedDir), [activeTemporaryFileName]);
+});
+
+test('drains render workers and recovers after a render failure', async (t) => {
+ const { generatedFilesDir, plugin } = temporaryPlugin(t);
+ const generatedDir = path.join(generatedFilesDir, ASSET_DIRECTORY);
+ const docs = Array.from({ length: 8 }, (_, index) =>
+ doc({
+ id: `failure-${index}`,
+ title: index === 0 ? 'Launch 🚀' : `Valid card ${index}`,
+ })
+ );
+
+ await assert.rejects(
+ plugin.allContentLoaded({
+ allContent: docsContent([
+ {
+ versionName: 'current',
+ sidebars: {},
+ docs,
+ },
+ ]),
+ }),
+ (error) => {
+ assert.match(
+ error.message,
+ /^Card for "Launch 🚀" \(@site\/docs\/failure-0\.md\): Open Graph title contains unsupported .*U\+1F680/
+ );
+ assert.ok(error.cause instanceof Error);
+ assert.match(
+ error.cause.message,
+ /^Open Graph title contains unsupported .*U\+1F680/
+ );
+ return true;
+ }
+ );
+
+ assert.equal(
+ fs.readdirSync(generatedDir).some((fileName) => fileName.endsWith('.tmp')),
+ false
+ );
+ await plugin.allContentLoaded({ allContent: docsContent([]) });
+ assert.deepEqual(fs.readdirSync(generatedDir), []);
+});
+
+test('archived framework versions render', async (t) => {
+ const { generatedFilesDir, plugin } = temporaryPlugin(t);
+
+ await plugin.allContentLoaded({
+ allContent: docsContent([
+ {
+ versionName: '1.0.0',
+ isLast: false,
+ sidebars: {},
+ docs: [doc({ id: 'archived', permalink: '/1.0.0/archived' })],
+ },
+ ]),
+ });
+
+ const files = fs.readdirSync(path.join(generatedFilesDir, ASSET_DIRECTORY));
+ assert.equal(files.length, 1);
+ assert.match(files[0], /^[a-f0-9]{20}\.jpg$/);
+});
+
+test('all-explicit content produces zero cards and copies nothing', async (t) => {
+ const { generatedFilesDir, outDir, plugin } = temporaryPlugin(t);
+ const generatedDir = path.join(generatedFilesDir, ASSET_DIRECTORY);
+ const outputDir = path.join(outDir, 'img', 'open-graph');
+ fs.mkdirSync(generatedDir, { recursive: true });
+ fs.mkdirSync(outputDir, { recursive: true });
+ fs.writeFileSync(
+ path.join(generatedDir, '11111111111111111111.jpg'),
+ 'cached'
+ );
+ fs.writeFileSync(path.join(outputDir, 'custom.jpg'), 'explicit image');
+
+ await plugin.allContentLoaded({
+ allContent: docsContent([
+ {
+ versionName: 'current',
+ sidebars: {},
+ docs: [
+ doc({
+ id: 'explicit',
+ frontMatter: { image: 'https://example.com/card.jpg' },
+ }),
+ ],
+ },
+ ]),
+ });
+
+ assert.equal(plugin.postBuild({ outDir }), undefined);
+ assert.deepEqual(fs.readdirSync(outputDir), ['custom.jpg']);
+ assert.deepEqual(fs.readdirSync(generatedDir), []);
+});
diff --git a/plugins/open-graph-images/render.js b/plugins/open-graph-images/render.js
new file mode 100644
index 00000000..ed0a76b4
--- /dev/null
+++ b/plugins/open-graph-images/render.js
@@ -0,0 +1,360 @@
+const fontkit = require('fontkit');
+const sharp = require('sharp');
+
+const { CARD_HEIGHT, CARD_WIDTH, normalizeMetadata } = require('./shared');
+
+// Geometry constants are measured from the serverpod.dev feature-page cards.
+const TEXT_X = 344;
+const TEXT_MAX_WIDTH = 730;
+const TITLE_BASELINE = 325;
+const TITLE_DESCRIPTION_BASELINE_GAP = 69;
+const DESCRIPTION_FONT_SIZE = 38.5;
+const DESCRIPTION_LINE_HEIGHT = 46;
+const ICON_X = 96;
+const ICON_SIZE = 216;
+const ICON_BODY_TOP_FRACTION = 21 / 256;
+const ICON_BODY_BOTTOM_FRACTION = 234 / 256;
+const LOGO_BOX = Object.freeze({ x: 134, y: 96, width: 266, height: 72 });
+const TEXT_COLOR = '#ffffff';
+const JPEG_OPTIONS = Object.freeze({ quality: 88, progressive: true });
+const parsedFontByBuffer = new WeakMap();
+
+function parseFont(fontBuffer) {
+ const cached = parsedFontByBuffer.get(fontBuffer);
+ if (cached) {
+ return cached;
+ }
+
+ const font = fontkit.create(fontBuffer);
+ parsedFontByBuffer.set(fontBuffer, font);
+ return font;
+}
+
+function measureText(font, text, fontSize) {
+ return (font.layout(text).advanceWidth * fontSize) / font.unitsPerEm;
+}
+
+function glyphPathData(glyph, x, y, scale) {
+ const fmt = (value) => Math.round(value * 100) / 100;
+ let pathData = '';
+ for (const { command, args } of glyph.path.commands) {
+ if (command === 'closePath') {
+ pathData += 'Z';
+ continue;
+ }
+ const points = [];
+ for (let i = 0; i < args.length; i += 2) {
+ points.push(fmt(x + args[i] * scale), fmt(y - args[i + 1] * scale));
+ }
+ const letter =
+ command === 'moveTo'
+ ? 'M'
+ : command === 'lineTo'
+ ? 'L'
+ : command === 'quadraticCurveTo'
+ ? 'Q'
+ : 'C';
+ pathData += letter + points.join(' ');
+ }
+ return pathData;
+}
+
+function textPathData(font, text, x, baseline, fontSize) {
+ const run = font.layout(text);
+ const scale = fontSize / font.unitsPerEm;
+ let cursor = x;
+ let pathData = '';
+ run.glyphs.forEach((glyph, index) => {
+ const position = run.positions[index];
+ pathData += glyphPathData(
+ glyph,
+ cursor + position.xOffset * scale,
+ baseline - position.yOffset * scale,
+ scale
+ );
+ cursor += position.xAdvance * scale;
+ });
+ return pathData;
+}
+
+function assertFontCoverage(value, font, fieldName) {
+ const unsupportedCharacters = [
+ ...new Set(
+ [...normalizeMetadata(value)].filter(
+ (character) =>
+ !/\s/.test(character) &&
+ !font.hasGlyphForCodePoint(character.codePointAt(0))
+ )
+ ),
+ ];
+ if (unsupportedCharacters.length === 0) {
+ return;
+ }
+
+ const details = unsupportedCharacters
+ .map(
+ (character) =>
+ `${JSON.stringify(character)} (U+${character
+ .codePointAt(0)
+ .toString(16)
+ .toUpperCase()})`
+ )
+ .join(', ');
+ throw new Error(
+ `Open Graph ${fieldName} contains unsupported characters: ${details}.`
+ );
+}
+
+function ellipsize(text, { measureText, maxWidth }) {
+ const ellipsis = '…';
+ if (measureText(ellipsis) > maxWidth) {
+ return '';
+ }
+
+ const source = text.trimEnd();
+ let prefix = '';
+ let cutMidWord = false;
+ for (const character of source) {
+ if (measureText(`${prefix}${character}${ellipsis}`) > maxWidth) {
+ cutMidWord = true;
+ break;
+ }
+ prefix += character;
+ }
+ if (cutMidWord) {
+ const lastSpace = prefix.lastIndexOf(' ');
+ if (lastSpace > 0) {
+ prefix = prefix.slice(0, lastSpace);
+ }
+ }
+ return `${prefix.replace(/[\s.,;:!?]+$/u, '')}${ellipsis}`;
+}
+
+function wrapText(text, { measureText, maxWidth, maxLines }) {
+ const words = normalizeMetadata(text).split(' ').filter(Boolean);
+ if (words.length === 0) {
+ return { lines: [], truncated: false };
+ }
+ if (maxLines < 1 || maxWidth <= 0) {
+ return { lines: [], truncated: true };
+ }
+
+ const lines = [];
+ let currentLine = '';
+ let cutToken = false;
+
+ for (const word of words) {
+ if (!currentLine) {
+ if (measureText(word) > maxWidth) {
+ lines.push(ellipsize(word, { measureText, maxWidth }));
+ cutToken = true;
+ if (lines.length === maxLines) {
+ return { lines, truncated: true };
+ }
+ continue;
+ }
+ currentLine = word;
+ continue;
+ }
+
+ const candidate = `${currentLine} ${word}`;
+ if (measureText(candidate) <= maxWidth) {
+ currentLine = candidate;
+ continue;
+ }
+
+ lines.push(currentLine);
+ currentLine = '';
+ if (lines.length === maxLines) {
+ lines[lines.length - 1] = ellipsize(lines.at(-1), {
+ measureText,
+ maxWidth,
+ });
+ return { lines, truncated: true };
+ }
+
+ if (measureText(word) > maxWidth) {
+ lines.push(ellipsize(word, { measureText, maxWidth }));
+ cutToken = true;
+ if (lines.length === maxLines) {
+ return { lines, truncated: true };
+ }
+ continue;
+ }
+ currentLine = word;
+ }
+
+ if (currentLine) {
+ lines.push(currentLine);
+ }
+ return { lines, truncated: cutToken };
+}
+
+function fitText(text, { font, fontSizes, maxWidth, maxLines }) {
+ for (const fontSize of fontSizes) {
+ const measure = (value) => measureText(font, value, fontSize);
+ const wrapped = wrapText(text, {
+ measureText: measure,
+ maxWidth,
+ maxLines,
+ });
+ if (!wrapped.truncated) {
+ return { ...wrapped, fontSize };
+ }
+ }
+
+ const fontSize = fontSizes.at(-1);
+ const measure = (value) => measureText(font, value, fontSize);
+ return {
+ ...wrapText(text, { measureText: measure, maxWidth, maxLines }),
+ fontSize,
+ };
+}
+
+function svgDataUri(svg) {
+ return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
+}
+
+function pngDataUri(png) {
+ return `data:image/png;base64,${png.toString('base64')}`;
+}
+
+function textPathElements(
+ lines,
+ { font, x, firstBaseline, lineHeight, fontSize }
+) {
+ return lines
+ .map((line, index) => {
+ const baseline = firstBaseline + index * lineHeight;
+ const pathData = textPathData(font, line, x, baseline, fontSize);
+ return ``;
+ })
+ .join('');
+}
+
+function cardSvg({
+ title,
+ description,
+ logoSvg,
+ iconPng,
+ regularFontBuffer,
+ blackFontBuffer,
+}) {
+ const regularFont = parseFont(regularFontBuffer);
+ const blackFont = parseFont(blackFontBuffer);
+ assertFontCoverage(title, blackFont, 'title');
+ assertFontCoverage(description, regularFont, 'description');
+
+ const titleOptions = {
+ font: blackFont,
+ fontSizes: [64, 56, 50],
+ maxWidth: TEXT_MAX_WIDTH,
+ };
+ let fittedTitle = fitText(title, { ...titleOptions, maxLines: 1 });
+ if (fittedTitle.truncated) {
+ fittedTitle = fitText(title, { ...titleOptions, maxLines: 2 });
+ }
+ const fittedDescription = fitText(description, {
+ font: regularFont,
+ fontSizes: [DESCRIPTION_FONT_SIZE],
+ maxWidth: TEXT_MAX_WIDTH,
+ maxLines: 2,
+ });
+
+ const titleLineHeight = Math.round(fittedTitle.fontSize * 1.11);
+ const titleLastBaseline =
+ TITLE_BASELINE +
+ Math.max(0, fittedTitle.lines.length - 1) * titleLineHeight;
+ const descriptionFirstBaseline =
+ titleLastBaseline + TITLE_DESCRIPTION_BASELINE_GAP;
+
+ const textBlockTop =
+ TITLE_BASELINE -
+ (blackFont.capHeight * fittedTitle.fontSize) / blackFont.unitsPerEm;
+ const textBlockBottom = fittedDescription.lines.length
+ ? descriptionFirstBaseline +
+ (fittedDescription.lines.length - 1) * DESCRIPTION_LINE_HEIGHT +
+ DESCRIPTION_FONT_SIZE * 0.27
+ : titleLastBaseline + fittedTitle.fontSize * 0.05;
+ const iconBodyHeight =
+ (ICON_BODY_BOTTOM_FRACTION - ICON_BODY_TOP_FRACTION) * ICON_SIZE;
+ const iconY = Math.round(
+ (textBlockTop + textBlockBottom) / 2 -
+ iconBodyHeight / 2 -
+ ICON_BODY_TOP_FRACTION * ICON_SIZE
+ );
+
+ const titleElements = textPathElements(fittedTitle.lines, {
+ font: blackFont,
+ x: TEXT_X,
+ firstBaseline: TITLE_BASELINE,
+ lineHeight: titleLineHeight,
+ fontSize: fittedTitle.fontSize,
+ });
+ const descriptionElements = textPathElements(fittedDescription.lines, {
+ font: regularFont,
+ x: TEXT_X,
+ firstBaseline: descriptionFirstBaseline,
+ lineHeight: DESCRIPTION_LINE_HEIGHT,
+ fontSize: fittedDescription.fontSize,
+ });
+
+ return `
+`.trim();
+}
+
+async function renderCard({
+ title,
+ description,
+ logoSvg,
+ iconPng,
+ regularFontBuffer,
+ blackFontBuffer,
+ outputPath,
+}) {
+ const svg = cardSvg({
+ title,
+ description,
+ logoSvg,
+ iconPng,
+ regularFontBuffer,
+ blackFontBuffer,
+ });
+ await sharp(Buffer.from(svg)).jpeg(JPEG_OPTIONS).toFile(outputPath);
+}
+
+module.exports = {
+ JPEG_OPTIONS,
+ cardSvg,
+ fitText,
+ measureText,
+ parseFont,
+ renderCard,
+ wrapText,
+};
diff --git a/plugins/open-graph-images/render.test.js b/plugins/open-graph-images/render.test.js
new file mode 100644
index 00000000..23a04ae8
--- /dev/null
+++ b/plugins/open-graph-images/render.test.js
@@ -0,0 +1,239 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const test = require('node:test');
+const sharp = require('sharp');
+
+const { CARD_HEIGHT, CARD_WIDTH, normalizeMetadata } = require('./shared');
+const {
+ cardSvg,
+ fitText,
+ measureText,
+ parseFont,
+ renderCard,
+ wrapText,
+} = require('./render');
+
+const LOGO_SVG = '';
+const ICON_PNG = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVQImWNYrGbxHxkzkC4AADdQIAHwHnrCAAAAAElFTkSuQmCC',
+ 'base64'
+);
+const REGULAR_FONT_BUFFER = fs.readFileSync(
+ path.join(__dirname, 'fonts', 'Inter-Regular.otf')
+);
+const BLACK_FONT_BUFFER = fs.readFileSync(
+ path.join(__dirname, 'fonts', 'Inter-Black.otf')
+);
+
+function cardSvgFixture(overrides = {}) {
+ return cardSvg({
+ title: 'Database and ORM',
+ description: 'Build type-safe database models.',
+ logoSvg: LOGO_SVG,
+ iconPng: ICON_PNG,
+ regularFontBuffer: REGULAR_FONT_BUFFER,
+ blackFontBuffer: BLACK_FONT_BUFFER,
+ ...overrides,
+ });
+}
+
+test('preserves identifiers and punctuation from Docusaurus metadata', () => {
+ assert.equal(
+ normalizeMetadata(
+ 'Use serverpod_auth_idp with serverpod_cloud_deploy, C++ & Dart.'
+ ),
+ 'Use serverpod_auth_idp with serverpod_cloud_deploy, C++ & Dart.'
+ );
+});
+
+test('ellipsizes an over-width token instead of allowing it to overflow', () => {
+ const token = 'ServerpodClientSharedStreamingMethods'.repeat(20);
+ const wrapped = wrapText(token, {
+ measureText: (value) => value.length * 10,
+ maxWidth: 240,
+ maxLines: 2,
+ });
+
+ assert.equal(wrapped.truncated, true);
+ assert.equal(wrapped.lines.length, 1);
+ assert.match(wrapped.lines[0], /…$/);
+ assert.ok(wrapped.lines[0].length < token.length);
+});
+
+test('an over-wide token fills remaining lines instead of ending the text', () => {
+ const measure = (value) => value.length * 10;
+ assert.deepEqual(
+ wrapText('Toolong word fits here', {
+ measureText: (v) => (v.startsWith('Toolong') ? 300 : measure(v)),
+ maxWidth: 240,
+ maxLines: 2,
+ }),
+ { lines: ['Toolon…', 'word fits here'], truncated: true }
+ );
+});
+
+test('shaping applies GPOS kerning', () => {
+ const black = parseFont(BLACK_FONT_BUFFER);
+ const kerned = measureText(black, 'To', 64);
+ const unkerned = measureText(black, 'T', 64) + measureText(black, 'o', 64);
+ assert.ok(
+ unkerned - kerned > 2,
+ `expected a kerned 'To' to be tighter: ${kerned} vs ${unkerned}`
+ );
+});
+
+test('title tiers resolve by measured width', () => {
+ const black = parseFont(BLACK_FONT_BUFFER);
+ const tier = (title) =>
+ fitText(title, {
+ font: black,
+ fontSizes: [64, 56, 50],
+ maxWidth: 730,
+ maxLines: 1,
+ });
+ assert.equal(tier('Database and ORM').fontSize, 64);
+ assert.equal(tier('Authentication providers').fontSize, 56);
+ assert.equal(tier('Creating endpoint methods').fontSize, 50);
+ assert.equal(tier('Creating endpoint methods').lines.length, 1);
+});
+
+test('truncates at word boundaries without stacking punctuation', () => {
+ const measureText = (value) => value.length * 10;
+ assert.deepEqual(
+ wrapText('alpha beta gamma', { measureText, maxWidth: 100, maxLines: 1 }),
+ { lines: ['alpha…'], truncated: true }
+ );
+ assert.deepEqual(
+ wrapText('evolves. and more', { measureText, maxWidth: 90, maxLines: 1 }),
+ { lines: ['evolves…'], truncated: true }
+ );
+});
+
+test('converts metadata to paths and embeds the logo and icon', () => {
+ const svg = cardSvgFixture({
+ title: 'serverpod_auth & "Dart"',
+ description: 'C++',
+ });
+
+ assert.doesNotMatch(svg, /serverpod_auth|<T>|/);
+ assert.match(svg, /\s+ match[1]),
+ ['svg+xml', 'png']
+ );
+ assert.equal(
+ Buffer.from(embedded[0][2], 'base64').toString('utf8'),
+ LOGO_SVG
+ );
+ assert.ok(Buffer.from(embedded[1][2], 'base64').equals(ICON_PNG));
+});
+
+test('reports metadata characters that the pinned font cannot render', () => {
+ assert.throws(
+ () => cardSvgFixture({ title: 'Launch 🚀' }),
+ /Open Graph title contains unsupported .*U\+1F680/
+ );
+});
+
+test('keeps the measured feature-card geometry', () => {
+ const svg = cardSvgFixture();
+
+ assert.match(svg, //);
+ assert.match(svg, //);
+ assert.match(svg, /stop-color="#96b2de" stop-opacity="0.55"/);
+ assert.match(
+ svg,
+ //
+ );
+ assert.match(
+ svg,
+ //
+ );
+ assert.match(svg, //);
+});
+
+test('descriptions cap at two rendered lines', () => {
+ const whiteLines = (svg) => [...svg.matchAll(/fill="#ffffff"/g)].length;
+ assert.equal(
+ whiteLines(
+ cardSvgFixture({
+ description:
+ 'Serverpod comes bundled with a simple-to-use but powerful migration system that helps you keep your database schema up to date as your project evolves over many releases.',
+ })
+ ),
+ 3
+ );
+});
+
+test('the icon centers on the text block across layouts', () => {
+ const iconY = (svg) => svg.match(/y="(-?\d+)" width="216"/)[1];
+ assert.equal(iconY(cardSvgFixture()), '234');
+ assert.equal(
+ iconY(
+ cardSvgFixture({
+ title: 'Serialize objects and share them between server and app',
+ })
+ ),
+ '267'
+ );
+ assert.equal(iconY(cardSvgFixture({ description: '' })), '196');
+ assert.equal(
+ iconY(
+ cardSvgFixture({
+ description:
+ 'Serverpod comes bundled with a simple-to-use but powerful migration system that helps you keep your database schema up to date.',
+ })
+ ),
+ '257'
+ );
+});
+
+test('prefers a smaller single-line title over a wrapped one', () => {
+ const whiteLines = (svg) => [...svg.matchAll(/fill="#ffffff"/g)].length;
+ assert.equal(
+ whiteLines(
+ cardSvgFixture({ title: 'Creating endpoint methods', description: '' })
+ ),
+ 1
+ );
+ assert.equal(
+ whiteLines(
+ cardSvgFixture({
+ title: 'Serialize objects and share them between server and app',
+ description: '',
+ })
+ ),
+ 2
+ );
+});
+
+test('renders the expected JPEG dimensions', async (t) => {
+ const temporaryDirectory = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'serverpod-og-render-')
+ );
+ t.after(() =>
+ fs.rmSync(temporaryDirectory, { recursive: true, force: true })
+ );
+ const outputPath = path.join(temporaryDirectory, 'card.jpg');
+
+ await renderCard({
+ title: 'Quickstart',
+ description: 'Create and run a Serverpod project.',
+ logoSvg: LOGO_SVG,
+ iconPng: ICON_PNG,
+ regularFontBuffer: REGULAR_FONT_BUFFER,
+ blackFontBuffer: BLACK_FONT_BUFFER,
+ outputPath,
+ });
+
+ const metadata = await sharp(outputPath).metadata();
+ assert.equal(metadata.format, 'jpeg');
+ assert.equal(metadata.width, CARD_WIDTH);
+ assert.equal(metadata.height, CARD_HEIGHT);
+});
diff --git a/plugins/open-graph-images/shared.js b/plugins/open-graph-images/shared.js
new file mode 100644
index 00000000..f0ca0829
--- /dev/null
+++ b/plugins/open-graph-images/shared.js
@@ -0,0 +1,75 @@
+// Imported by both the build plugin and the client theme bundle: keep this
+// module free of Node built-ins.
+
+const { sha256 } = require('@noble/hashes/sha256');
+const { bytesToHex, utf8ToBytes } = require('@noble/hashes/utils');
+
+const CARD_WIDTH = 1200;
+const CARD_HEIGHT = 630;
+const PUBLIC_PATH = '/img/open-graph';
+
+function normalizeMetadata(value) {
+ return String(value ?? '')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+function sha256Hex(value) {
+ const bytes = typeof value === 'string' ? utf8ToBytes(value) : value;
+ return bytesToHex(sha256(bytes));
+}
+
+function shouldGenerateCard({ title }) {
+ return normalizeMetadata(title) !== '';
+}
+
+function cardId({ title, description, renderFingerprint }) {
+ if (typeof renderFingerprint !== 'string' || !renderFingerprint) {
+ throw new Error('A render fingerprint is required to create a card ID.');
+ }
+
+ return sha256Hex(
+ JSON.stringify([
+ normalizeMetadata(title),
+ normalizeMetadata(description),
+ renderFingerprint,
+ ])
+ ).slice(0, 20);
+}
+
+function cardPath(id) {
+ if (!/^[a-f0-9]{20}$/.test(id)) {
+ throw new Error(`Invalid Open Graph card ID: ${id}`);
+ }
+ return `${PUBLIC_PATH}/${id}.jpg`;
+}
+
+function openGraphImageForDoc({
+ assetImage,
+ frontMatterImage,
+ title,
+ description,
+ renderFingerprint,
+}) {
+ const explicitImage = assetImage ?? frontMatterImage;
+ const generatedImage =
+ explicitImage == null && shouldGenerateCard({ title })
+ ? cardPath(cardId({ title, description, renderFingerprint }))
+ : undefined;
+ return {
+ generatedImage,
+ image: explicitImage ?? generatedImage,
+ };
+}
+
+module.exports = {
+ CARD_HEIGHT,
+ CARD_WIDTH,
+ PUBLIC_PATH,
+ cardId,
+ cardPath,
+ normalizeMetadata,
+ openGraphImageForDoc,
+ sha256Hex,
+ shouldGenerateCard,
+};
diff --git a/src/theme/DocItem/Metadata/index.js b/src/theme/DocItem/Metadata/index.js
new file mode 100644
index 00000000..e09080c5
--- /dev/null
+++ b/src/theme/DocItem/Metadata/index.js
@@ -0,0 +1,46 @@
+import React from 'react';
+import { PageMetadata } from '@docusaurus/theme-common';
+import { useDoc } from '@docusaurus/plugin-content-docs/client';
+import {
+ CARD_HEIGHT,
+ CARD_WIDTH,
+ openGraphImageForDoc,
+} from '@site/plugins/open-graph-images/shared';
+
+/* global __SERVERPOD_OG_RENDER_FINGERPRINT__ */
+
+export default function DocItemMetadata() {
+ const { metadata, frontMatter, assets } = useDoc();
+ const { generatedImage, image } = openGraphImageForDoc({
+ assetImage: assets.image,
+ frontMatterImage: frontMatter.image,
+ title: metadata.title,
+ description: metadata.description,
+ renderFingerprint: __SERVERPOD_OG_RENDER_FINGERPRINT__,
+ });
+
+ return (
+
+ {generatedImage && (
+ <>
+
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/util/verify_open_graph_images.js b/util/verify_open_graph_images.js
new file mode 100644
index 00000000..ba5b65f5
--- /dev/null
+++ b/util/verify_open_graph_images.js
@@ -0,0 +1,187 @@
+#!/usr/bin/env node
+// Verifies the generated Open Graph cards in a finished build: every head
+// og:image resolves to a valid emitted 1200x630 card, docs pages are not
+// silently uncovered, and no card is orphaned. Run after `npm run build`.
+
+const fs = require('fs');
+const path = require('path');
+const cheerio = require('cheerio');
+const sharp = require('sharp');
+
+const {
+ CARD_HEIGHT,
+ CARD_WIDTH,
+ PUBLIC_PATH,
+} = require('../plugins/open-graph-images/shared');
+const { SITE_URL } = require('../plugins/markdown-export/urls');
+
+const SITE_TITLE = 'Serverpod';
+const CARD_FILE_PATTERN = /^[a-f0-9]{20}\.jpg$/;
+const CARD_PATH_PATTERN = new RegExp(
+ `^${PUBLIC_PATH.replace(/\//g, '\\/')}\\/([a-f0-9]{20}\\.jpg)$`
+);
+
+function headMetadata(html) {
+ const $ = cheerio.load(html.replace(/^\uFEFF/, ''));
+ const attributes = $('head meta')
+ .toArray()
+ .map((element) => element.attribs || {});
+ const byProperty = (property) =>
+ attributes
+ .filter((a) => a.property === property)
+ .map((a) => a.content ?? '');
+ return {
+ images: byProperty('og:image'),
+ titles: byProperty('og:title'),
+ isDocPage: attributes.some((a) => a.name === 'docusaurus_version'),
+ };
+}
+
+function walkHtml(dir, onFile) {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const entryPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ walkHtml(entryPath, onFile);
+ } else if (entry.name.endsWith('.html')) {
+ onFile(entryPath);
+ }
+ }
+}
+
+async function verifyBuild(buildDir) {
+ const cardDir = path.join(buildDir, 'img', 'open-graph');
+ const siteOrigin = new URL(SITE_URL).origin;
+ const failures = [];
+ const emitted = new Set(
+ (fs.existsSync(cardDir) ? fs.readdirSync(cardDir) : []).filter((name) =>
+ CARD_FILE_PATTERN.test(name)
+ )
+ );
+ const referenced = new Set();
+ let pagesWithCards = 0;
+
+ walkHtml(buildDir, (filePath) => {
+ const page = path.relative(buildDir, filePath);
+ const { images, titles, isDocPage } = headMetadata(
+ fs.readFileSync(filePath, 'utf8')
+ );
+ let hasCard = false;
+
+ if (titles.length > 1) {
+ failures.push(`${page} has ${titles.length} conflicting og:title tags`);
+ }
+
+ for (const value of images) {
+ if (value.trim() === '') {
+ failures.push(`${page} has a blank og:image`);
+ continue;
+ }
+ let url;
+ try {
+ url = new URL(value, SITE_URL);
+ } catch {
+ failures.push(`${page} has an unparseable og:image URL: ${value}`);
+ continue;
+ }
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
+ failures.push(`${page} has a non-HTTP og:image URL: ${value}`);
+ continue;
+ }
+ const pathname = url.pathname.replace(/\/{2,}/g, '/');
+ const inCardNamespace =
+ pathname === PUBLIC_PATH || pathname.startsWith(`${PUBLIC_PATH}/`);
+ const match = pathname.match(CARD_PATH_PATTERN);
+ if (url.origin === siteOrigin && inCardNamespace && !match) {
+ failures.push(`${page} has a malformed card path: ${value}`);
+ continue;
+ }
+ if (!match) {
+ continue;
+ }
+ if (url.origin !== siteOrigin) {
+ failures.push(
+ `${page} references a card on the wrong origin: ${value}`
+ );
+ continue;
+ }
+ hasCard = true;
+ referenced.add(match[1]);
+ if (!emitted.has(match[1])) {
+ failures.push(`${page} references missing card ${match[1]}`);
+ }
+ }
+ if (hasCard) {
+ pagesWithCards += 1;
+ }
+
+ if (
+ images.length === 0 &&
+ page !== '404.html' &&
+ isDocPage &&
+ !(titles.length === 1 && titles[0] === SITE_TITLE)
+ ) {
+ failures.push(`docs page ${page} has no og:image`);
+ }
+ });
+
+ if (pagesWithCards === 0) {
+ failures.push('no page references a generated Open Graph card');
+ }
+ for (const fileName of emitted) {
+ if (!referenced.has(fileName)) {
+ failures.push(`orphaned card ${fileName} is referenced by no page`);
+ }
+ }
+
+ for (const fileName of [...referenced].sort()) {
+ if (!emitted.has(fileName)) {
+ continue;
+ }
+ const cardPath = path.join(cardDir, fileName);
+ try {
+ const metadata = await sharp(cardPath).metadata();
+ if (
+ metadata.format !== 'jpeg' ||
+ metadata.width !== CARD_WIDTH ||
+ metadata.height !== CARD_HEIGHT
+ ) {
+ failures.push(
+ `${fileName} is ${metadata.format} ${metadata.width}x${metadata.height}, ` +
+ `expected jpeg ${CARD_WIDTH}x${CARD_HEIGHT}`
+ );
+ continue;
+ }
+ await sharp(cardPath).stats();
+ } catch (error) {
+ failures.push(`${fileName} failed to decode: ${error.message}`);
+ }
+ }
+
+ return { failures, pagesWithCards, cardCount: referenced.size };
+}
+
+async function main() {
+ const buildDir = path.join(__dirname, '..', 'build');
+ const { failures, pagesWithCards, cardCount } = await verifyBuild(buildDir);
+ if (failures.length > 0) {
+ console.error(`Open Graph verification failed (${failures.length}):`);
+ for (const failure of failures) {
+ console.error(` - ${failure}`);
+ }
+ process.exitCode = 1;
+ return;
+ }
+ console.log(
+ `Open Graph verification passed: ${pagesWithCards} pages, ` +
+ `${cardCount} cards.`
+ );
+}
+
+module.exports = { verifyBuild };
+
+if (require.main === module) {
+ main().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+ });
+}
diff --git a/util/verify_open_graph_images.test.js b/util/verify_open_graph_images.test.js
new file mode 100644
index 00000000..f3f2be3d
--- /dev/null
+++ b/util/verify_open_graph_images.test.js
@@ -0,0 +1,324 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const test = require('node:test');
+const sharp = require('sharp');
+
+const { verifyBuild } = require('./verify_open_graph_images');
+
+const IDS = {
+ valid: 'a'.repeat(20),
+ reordered: 'b'.repeat(20),
+ missing: 'c'.repeat(20),
+ orphaned: 'd'.repeat(20),
+ corrupt: 'e'.repeat(20),
+ wrongSize: 'f'.repeat(20),
+ decoy: '0'.repeat(20),
+ wrongOrigin: '1'.repeat(20),
+};
+
+function page(fileName, head, body = '') {
+ return [fileName, `${head}${body}`];
+}
+
+function docHead(title, extra = '') {
+ return (
+ '' +
+ `` +
+ extra
+ );
+}
+
+function cardMeta(id, origin = 'https://docs.serverpod.dev') {
+ return ``;
+}
+
+async function jpeg(width, height) {
+ return sharp({
+ create: { width, height, channels: 3, background: { r: 9, g: 27, b: 79 } },
+ })
+ .jpeg()
+ .toBuffer();
+}
+
+async function fixtureBuild(t, { pages, cards }) {
+ const buildDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'serverpod-og-verify-')
+ );
+ t.after(() => fs.rmSync(buildDir, { recursive: true, force: true }));
+ const cardDir = path.join(buildDir, 'img', 'open-graph');
+ fs.mkdirSync(path.join(buildDir, 'nested'), { recursive: true });
+ fs.mkdirSync(cardDir, { recursive: true });
+ for (const [fileName, html] of pages) {
+ fs.writeFileSync(path.join(buildDir, fileName), html);
+ }
+ for (const [fileName, content] of Object.entries(cards)) {
+ fs.writeFileSync(path.join(cardDir, fileName), content);
+ }
+ return buildDir;
+}
+
+test('a healthy build passes: explicit images, redirects, empty titles, 404, custom files', async (t) => {
+ const card = await jpeg(1200, 630);
+ const buildDir = await fixtureBuild(t, {
+ pages: [
+ page('a.html', docHead('A | Serverpod', cardMeta(IDS.valid))),
+ page(
+ path.join('nested', 'b.html'),
+ docHead(
+ 'B | Serverpod',
+ ``
+ )
+ ),
+ page(
+ 'explicit.html',
+ docHead(
+ 'Explicit | Serverpod',
+ ''
+ )
+ ),
+ page('empty-title.html', docHead('Serverpod')),
+ page('404.html', docHead('Page Not Found | Serverpod')),
+ page('redirect.html', ''),
+ ],
+ cards: {
+ [`${IDS.valid}.jpg`]: card,
+ [`${IDS.reordered}.jpg`]: card,
+ 'custom.jpg': 'not a card, deliberately preserved',
+ },
+ });
+
+ const result = await verifyBuild(buildDir);
+ assert.deepEqual(result.failures, []);
+ assert.equal(result.pagesWithCards, 2);
+ assert.equal(result.cardCount, 2);
+});
+
+test('false-pass paths and broken cards are each reported', async (t) => {
+ const card = await jpeg(1200, 630);
+ const truncated = card.subarray(0, 500);
+ const truncatedMetadata = await sharp(truncated).metadata();
+ assert.equal(truncatedMetadata.format, 'jpeg');
+ assert.equal(truncatedMetadata.width, 1200);
+ assert.equal(truncatedMetadata.height, 630);
+
+ const buildDir = await fixtureBuild(t, {
+ pages: [
+ page('ok.html', docHead('Ok | Serverpod', cardMeta(IDS.valid))),
+ page('missing.html', docHead('M | Serverpod', cardMeta(IDS.missing))),
+ page('corrupt.html', docHead('C | Serverpod', cardMeta(IDS.corrupt))),
+ page(
+ 'wrong-size.html',
+ docHead('W | Serverpod', cardMeta(IDS.wrongSize))
+ ),
+ page(
+ 'decoy.html',
+ docHead(
+ 'D | Serverpod',
+ ``
+ )
+ ),
+ page(
+ 'wrong-origin.html',
+ docHead(
+ 'O | Serverpod',
+ cardMeta(IDS.wrongOrigin, 'https://evil.example')
+ )
+ ),
+ ],
+ cards: {
+ [`${IDS.valid}.jpg`]: card,
+ [`${IDS.orphaned}.jpg`]: card,
+ [`${IDS.decoy}.jpg`]: card,
+ [`${IDS.corrupt}.jpg`]: truncated,
+ [`${IDS.wrongSize}.jpg`]: await jpeg(800, 600),
+ },
+ });
+
+ const { failures } = await verifyBuild(buildDir);
+ assert.equal(failures.length, 6);
+ assert.ok(
+ failures.some((f) => f.includes(`missing card ${IDS.missing}.jpg`))
+ );
+ assert.ok(
+ failures.some((f) => f.includes(`orphaned card ${IDS.orphaned}.jpg`))
+ );
+ assert.ok(failures.some((f) => f.includes(`orphaned card ${IDS.decoy}.jpg`)));
+ assert.ok(
+ failures.some((f) => f.includes(`${IDS.corrupt}.jpg failed to decode`))
+ );
+ assert.ok(
+ failures.some(
+ (f) => f.includes(`${IDS.wrongSize}.jpg`) && f.includes('800x600')
+ )
+ );
+ assert.ok(
+ failures.some(
+ (f) =>
+ f.includes('wrong-origin.html') &&
+ f.includes('card on the wrong origin')
+ )
+ );
+});
+
+test('parser-level false passes are rejected', async (t) => {
+ const card = await jpeg(1200, 630);
+ const buildDir = await fixtureBuild(t, {
+ pages: [
+ page('ok.html', docHead('Ok | Serverpod', cardMeta(IDS.valid))),
+ page(
+ 'fake-property.html',
+ docHead(
+ 'F | Serverpod',
+ ``
+ )
+ ),
+ page('body-meta.html', docHead('B | Serverpod'), cardMeta(IDS.missing)),
+ page(
+ 'commented.html',
+ docHead('C | Serverpod', ``)
+ ),
+ page(
+ 'malformed.html',
+ docHead(
+ 'M | Serverpod',
+ ''
+ )
+ ),
+ page(
+ 'double-title.html',
+ '' +
+ '' +
+ '' +
+ cardMeta(IDS.valid)
+ ),
+ ],
+ cards: { [`${IDS.valid}.jpg`]: card },
+ });
+
+ const { failures } = await verifyBuild(buildDir);
+ assert.deepEqual(failures.sort(), [
+ 'docs page body-meta.html has no og:image',
+ 'docs page commented.html has no og:image',
+ 'docs page fake-property.html has no og:image',
+ 'double-title.html has 2 conflicting og:title tags',
+ 'malformed.html has a malformed card path: https://docs.serverpod.dev/img/open-graph/not-a-card.jpg',
+ ]);
+});
+
+test('blank, directory-only, and non-HTTP og:image values are rejected', async (t) => {
+ const card = await jpeg(1200, 630);
+ const buildDir = await fixtureBuild(t, {
+ pages: [
+ page('ok.html', docHead('Ok | Serverpod', cardMeta(IDS.valid))),
+ page(
+ 'blank.html',
+ docHead('B | Serverpod', '')
+ ),
+ page(
+ 'spaces.html',
+ docHead('S | Serverpod', '')
+ ),
+ page(
+ 'dir.html',
+ docHead(
+ 'D | Serverpod',
+ ''
+ )
+ ),
+ page(
+ 'dir-slash.html',
+ docHead(
+ 'E | Serverpod',
+ ''
+ )
+ ),
+ page(
+ 'scheme.html',
+ docHead(
+ 'F | Serverpod',
+ ``
+ )
+ ),
+ ],
+ cards: { [`${IDS.valid}.jpg`]: card },
+ });
+
+ const { failures } = await verifyBuild(buildDir);
+ assert.deepEqual(failures.sort(), [
+ 'blank.html has a blank og:image',
+ 'dir-slash.html has a malformed card path: https://docs.serverpod.dev/img/open-graph/',
+ 'dir.html has a malformed card path: https://docs.serverpod.dev/img/open-graph',
+ `scheme.html has a non-HTTP og:image URL: ftp://docs.serverpod.dev/img/open-graph/${IDS.valid}.jpg`,
+ 'spaces.html has a blank og:image',
+ ]);
+});
+
+test('BOM pages, contentless tags, and doubled slashes cannot hide failures', async (t) => {
+ const card = await jpeg(1200, 630);
+ const buildDir = await fixtureBuild(t, {
+ pages: [
+ page('ok.html', docHead('Ok | Serverpod', cardMeta(IDS.valid))),
+ [
+ 'bom.html',
+ '\uFEFF' +
+ docHead(
+ 'A | Serverpod',
+ '' +
+ ''
+ ) +
+ '',
+ ],
+ page(
+ 'contentless.html',
+ docHead(
+ 'C | Serverpod',
+ `${cardMeta(IDS.valid)}`
+ )
+ ),
+ page(
+ 'double-slash.html',
+ docHead(
+ 'D | Serverpod',
+ ``
+ )
+ ),
+ ],
+ cards: { [`${IDS.valid}.jpg`]: card },
+ });
+
+ const { failures } = await verifyBuild(buildDir);
+ assert.deepEqual(failures.sort(), [
+ 'bom.html has 2 conflicting og:title tags',
+ 'bom.html has a blank og:image',
+ 'contentless.html has a blank og:image',
+ `double-slash.html references missing card ${IDS.missing}.jpg`,
+ ]);
+});
+
+test('an eligible docs page without any og:image fails coverage', async (t) => {
+ const card = await jpeg(1200, 630);
+ const buildDir = await fixtureBuild(t, {
+ pages: [
+ page('ok.html', docHead('Ok | Serverpod', cardMeta(IDS.valid))),
+ page('uncovered.html', docHead('Uncovered | Serverpod')),
+ ],
+ cards: { [`${IDS.valid}.jpg`]: card },
+ });
+
+ const { failures } = await verifyBuild(buildDir);
+ assert.deepEqual(failures, ['docs page uncovered.html has no og:image']);
+});
+
+test('a build with no generated cards at all fails', async (t) => {
+ const buildDir = await fixtureBuild(t, {
+ pages: [page('plain.html', 'no og image')],
+ cards: {},
+ });
+
+ const { failures } = await verifyBuild(buildDir);
+ assert.deepEqual(failures, [
+ 'no page references a generated Open Graph card',
+ ]);
+});