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 ` + + + + + + + + + + + + + + + + + + + + + + + + ${titleElements} + ${descriptionElements} + +`.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', + ]); +});