diff --git a/.vscode/settings.json b/.vscode/settings.json index 85988bcf..4e9cfbe8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -432,6 +432,7 @@ "wscript", "xlarge", "xychart", + "XYWH", "youtu", "yoyo", "Yubi", diff --git a/astro.config.ts b/astro.config.ts index b47759b6..30b36741 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -35,6 +35,7 @@ import { testimonialsLengthWarning } from './src/integrations/TestimonialsLength import { fixContentAssetPropagation } from './src/lib/plugins/fixContentAssetPropagation' import { pwaDevAssetServer } from './src/lib/plugins/pwaDevAssetServer' import { createSerializeFunction, pagesJsonWriter } from './src/integrations/sitemapSerialize' +import { getDeepDiveArticleAliasPaths } from './src/integrations/sitemapSerialize/deepDiveAliases' // Ensure Vite's HMR websocket connects through the same exposed dev server port used by Astro. const devServerPort = Number(process.env['DEV_SERVER_PORT'] ?? 4321) @@ -44,6 +45,7 @@ const shouldEnableSentryIntegration = Boolean(sentryAuthToken) const sentryReleaseName = getPackageRelease() const astroEnvConfig = environmentalVariablesConfig as NonNullable const astroMarkdownConfig = markdownConfig as unknown as NonNullable +const deepDiveArticleAliasPaths = getDeepDiveArticleAliasPaths() const shouldSuppressViteWarning = (message: string): boolean => { return ( @@ -104,7 +106,18 @@ const standardIntegrations = [ })] : []), sitemap({ serialize: createSerializeFunction({ - exclude: ['deep-dive', 'downloads', 'print', '/articles/demo', 'testing', 'hero', 'links'], + exclude: [ + 'downloads', + 'offline', + 'print', + '/articles/demo', + 'search', + 'tags', + 'testing', + 'hero', + 'links', + ...deepDiveArticleAliasPaths, + ], }), }), /** diff --git a/package-lock.json b/package-lock.json index f8cbdb5a..e23e5ba6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,19 +80,21 @@ "@webcomponents/template-shadowroot": "0.2.1", "alex": "11.0.1", "astro": "^6.4.2", - "astro-og-canvas": "0.11.1", "astro-vtbot": "2.1.12", "baseline-browser-mapping": "2.10.33", "canvas-confetti": "1.9.4", + "canvaskit-wasm": "^0.41.1", "confusing-browser-globals": "1.0.11", "cross-env": "10.1.0", "cross-spawn": "7.0.6", "dedent": "1.7.2", + "deterministic-object-hash": "^2.0.2", "dotenv": "17.4.2", "dotenv-cli": "11.0.0", "email-validator": "2.0.4", "embla-carousel": "8.6.0", "embla-carousel-autoplay": "8.6.0", + "entities": "^8.0.0", "eslint": "10.4.1", "eslint-plugin-astro": "1.7.0", "eslint-plugin-import-lite": "0.6.0", @@ -15351,20 +15353,6 @@ "node": ">=10" } }, - "node_modules/astro-og-canvas": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/astro-og-canvas/-/astro-og-canvas-0.11.1.tgz", - "integrity": "sha512-6omy7MJeOPwxdYH5+eL3zFIY9MhuDRUMuASuCx/eXNrHGUogq9ZHdx19A428KtWhLVuXVcLt7Klw5fxqASAHmg==", - "license": "MIT", - "dependencies": { - "canvaskit-wasm": "^0.41.1", - "deterministic-object-hash": "^2.0.2", - "entities": "^8.0.0" - }, - "peerDependencies": { - "astro": "^5.0.0 || ^6.0.0-alpha" - } - }, "node_modules/astro-vtbot": { "version": "2.1.12", "resolved": "https://registry.npmjs.org/astro-vtbot/-/astro-vtbot-2.1.12.tgz", @@ -15821,20 +15809,6 @@ "node": "*" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -16503,44 +16477,6 @@ "node": ">=18" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "extraneous": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -17811,16 +17747,6 @@ "node": ">=0.10" } }, - "node_modules/data-uri-to-buffer": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-8.0.0.tgz", - "integrity": "sha512-6UHfyCux51b8PTGDgveqtz1tvphBku5DrMKKJbFAZAJOI2zsjDpDoYE1+QGj7FOMS4BdTFNJsJiR3zEB0xH0yQ==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">= 20" - } - }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -18134,24 +18060,6 @@ "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "license": "MIT" }, - "node_modules/degenerator": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-7.0.1.tgz", - "integrity": "sha512-ABErK0IefDSyHjlPH7WUEenIAX2rPPnrDcDM+TS3z3+zu9TfyKKi07BQM+8rmxpdE2y1v5fjjdoAS/x4D2U60w==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "quickjs-wasi": "^2.2.0" - } - }, "node_modules/delaunator": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", @@ -20556,21 +20464,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/get-uri": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-8.0.0.tgz", - "integrity": "sha512-CqtZlMKvfJeY0Zxv8wazDwXmSKmnMnsmNy8j8+wudi8EyG/pMUB1NqHc+Tv1QaNtpYsK9nOYjb7r7Ufu32RPSw==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "basic-ftp": "^5.2.0", - "data-uri-to-buffer": "8.0.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 20" - } - }, "node_modules/getpass": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", @@ -21868,32 +21761,6 @@ "node": ">=6.0.0" } }, - "node_modules/http-proxy-agent": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.0.0.tgz", - "integrity": "sha512-FcF8VhXYLQcxWCnt/cCpT2apKsRDUGeVEeMqGu4HSTu29U8Yw0TLOjdYIlDsYk3IkUh+taX4IDWpPcCqKDhCjA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/http-proxy-agent/node_modules/agent-base": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", - "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20" - } - }, "node_modules/http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -22325,19 +22192,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", @@ -30480,67 +30334,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pac-proxy-agent": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-9.0.1.tgz", - "integrity": "sha512-3ZOSpLboOlpW4yp8Cuv21KlTULRqyJ5Uuad3wXpSKFrxdNgcHEyoa22GRaZ2UlgCVuR6z+5BiavtYVvbajL/Yw==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4", - "get-uri": "8.0.0", - "http-proxy-agent": "9.0.0", - "https-proxy-agent": "9.0.0", - "pac-resolver": "9.0.1", - "quickjs-wasi": "^2.2.0", - "socks-proxy-agent": "10.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/pac-proxy-agent/node_modules/agent-base": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", - "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">= 20" - } - }, - "node_modules/pac-proxy-agent/node_modules/https-proxy-agent": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz", - "integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/pac-resolver": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-9.0.1.tgz", - "integrity": "sha512-lJbS008tmkj08VhoM8Hzuv/VE5tK9MS0OIQ/7+s0lIF+BYhiQWFYzkSpML7lXs9iBu2jfmzBTLzhe9n6BX+dYw==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "degenerator": "7.0.1", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "quickjs-wasi": "^2.2.0" - } - }, "node_modules/package-json": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/package-json/-/package-json-8.1.1.tgz", @@ -32209,70 +32002,6 @@ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", "license": "ISC" }, - "node_modules/proxy-agent": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-8.0.1.tgz", - "integrity": "sha512-kccqGBqHZXR8onQhY/ganJjoO8QIKKRiFBhPOzbTZK16attzSZ/0XSmp9H7jrRxPKHjhGyx1q32lMPrJ3uLFgA==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4", - "http-proxy-agent": "9.0.0", - "https-proxy-agent": "9.0.0", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "9.0.1", - "proxy-from-env": "^2.0.0", - "socks-proxy-agent": "10.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/proxy-agent/node_modules/agent-base": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", - "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">= 20" - } - }, - "node_modules/proxy-agent/node_modules/https-proxy-agent": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz", - "integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/proxy-agent/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "extraneous": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/proxy-agent/node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -32703,13 +32432,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/quickjs-wasi": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-2.2.0.tgz", - "integrity": "sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw==", - "extraneous": true, - "license": "MIT" - }, "node_modules/quotation": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/quotation/-/quotation-2.0.3.tgz", @@ -33007,32 +32729,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/recma-build-jsx": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", @@ -36515,33 +36211,6 @@ "npm": ">= 3.0.0" } }, - "node_modules/socks-proxy-agent": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.0.0.tgz", - "integrity": "sha512-pyp2YR3mNxAMu0mGLtzs4g7O3uT4/9sQOLAKcViAkaS9fJWkud7nmaf6ZREFqQEi24IPkBcjfHjXhPTUWjo3uA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/socks-proxy-agent/node_modules/agent-base": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", - "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20" - } - }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", diff --git a/package.json b/package.json index 03c790bb..9f14fc29 100644 --- a/package.json +++ b/package.json @@ -141,19 +141,21 @@ "@webcomponents/template-shadowroot": "0.2.1", "alex": "11.0.1", "astro": "^6.4.2", - "astro-og-canvas": "0.11.1", "astro-vtbot": "2.1.12", "baseline-browser-mapping": "2.10.33", "canvas-confetti": "1.9.4", + "canvaskit-wasm": "^0.41.1", "confusing-browser-globals": "1.0.11", "cross-env": "10.1.0", "cross-spawn": "7.0.6", "dedent": "1.7.2", + "deterministic-object-hash": "^2.0.2", "dotenv": "17.4.2", "dotenv-cli": "11.0.0", "email-validator": "2.0.4", "embla-carousel": "8.6.0", "embla-carousel-autoplay": "8.6.0", + "entities": "^8.0.0", "eslint": "10.4.1", "eslint-plugin-astro": "1.7.0", "eslint-plugin-import-lite": "0.6.0", diff --git a/src/integrations/sitemapSerialize/__tests__/deepDiveAliases.spec.ts b/src/integrations/sitemapSerialize/__tests__/deepDiveAliases.spec.ts new file mode 100644 index 00000000..6df92725 --- /dev/null +++ b/src/integrations/sitemapSerialize/__tests__/deepDiveAliases.spec.ts @@ -0,0 +1,37 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + getDeepDiveArticleAliasPaths, + normalizeDeepDiveEntryId, +} from '../deepDiveAliases' + +describe('deepDiveAliases', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('normalizes deep-dive pdf entries to route ids', () => { + expect(normalizeDeepDiveEntryId('platform-architecture/pdf.mdx')).toBe('platform-architecture') + expect(normalizeDeepDiveEntryId('nested/guide/pdf.mdx')).toBe('nested/guide') + expect(normalizeDeepDiveEntryId('nested\\guide\\pdf.mdx')).toBe('nested/guide') + }) + + it('returns article alias paths for deep-dive pdf files', () => { + const contentRoot = mkdtempSync(join(tmpdir(), 'deep-dive-aliases-')) + + mkdirSync(join(contentRoot, 'first-article'), { recursive: true }) + mkdirSync(join(contentRoot, 'nested', 'second-article'), { recursive: true }) + mkdirSync(join(contentRoot, 'ignored'), { recursive: true }) + + writeFileSync(join(contentRoot, 'first-article', 'pdf.mdx'), '---\ntitle: First\n---') + writeFileSync(join(contentRoot, 'nested', 'second-article', 'pdf.mdx'), '---\ntitle: Second\n---') + writeFileSync(join(contentRoot, 'ignored', 'index.mdx'), '---\ntitle: Ignored\n---') + + expect(getDeepDiveArticleAliasPaths({ contentRoot })).toEqual([ + '/articles/first-article', + '/articles/nested/second-article', + ]) + }) +}) \ No newline at end of file diff --git a/src/integrations/sitemapSerialize/deepDiveAliases.ts b/src/integrations/sitemapSerialize/deepDiveAliases.ts new file mode 100644 index 00000000..09488554 --- /dev/null +++ b/src/integrations/sitemapSerialize/deepDiveAliases.ts @@ -0,0 +1,49 @@ +import { readdirSync } from 'node:fs' +import { relative, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const deepDiveContentRoot = fileURLToPath(new URL('../../content/articles', import.meta.url)) + +export interface DeepDiveAliasOptions { + contentRoot?: string +} + +export function getDeepDiveArticleAliasPaths( + options: DeepDiveAliasOptions = {} +): string[] { + const contentRoot = options.contentRoot ?? deepDiveContentRoot + const pdfEntries = collectDeepDiveEntries(contentRoot) + + return pdfEntries + .map(entryPath => `/articles/${normalizeDeepDiveEntryId(entryPath)}`) + .sort((left, right) => left.localeCompare(right)) +} + +export function normalizeDeepDiveEntryId(entryPath: string): string { + const normalizedEntry = entryPath.replace(/\\/g, '/') + const withoutExtension = normalizedEntry.replace(/\.[^/.]+$/, '') + + return withoutExtension.endsWith('/pdf') + ? withoutExtension.slice(0, -'/pdf'.length) + : withoutExtension +} + +function collectDeepDiveEntries(contentRoot: string, currentDir = contentRoot): string[] { + const entries = readdirSync(currentDir, { withFileTypes: true }) + const deepDiveEntries: string[] = [] + + for (const entry of entries) { + const entryPath = join(currentDir, entry.name) + + if (entry.isDirectory()) { + deepDiveEntries.push(...collectDeepDiveEntries(contentRoot, entryPath)) + continue + } + + if (entry.isFile() && entry.name === 'pdf.mdx') { + deepDiveEntries.push(relative(contentRoot, entryPath)) + } + } + + return deepDiveEntries +} \ No newline at end of file diff --git a/src/pages/api/social-card/__tests__/index.spec.ts b/src/pages/api/social-card/__tests__/index.spec.ts index db357fe2..c19e9784 100644 --- a/src/pages/api/social-card/__tests__/index.spec.ts +++ b/src/pages/api/social-card/__tests__/index.spec.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { GET } from '@pages/api/social-card' -import { fileURLToPath } from 'node:url' type CollectionFixture = Array<{ id: string @@ -30,9 +29,14 @@ vi.mock('astro:content', () => ({ }), })) -vi.mock('astro-og-canvas', () => ({ - generateOpenGraphImage: generateOpenGraphImageMock, -})) +vi.mock('@pages/api/social-card/_lib', async importOriginal => { + const actual = await importOriginal() + + return { + ...actual, + generateOpenGraphImage: generateOpenGraphImageMock, + } +}) const buildRequest = (url: string) => GET({ @@ -67,9 +71,7 @@ const seedCollections = () => { } describe('Social Card API - GET /api/social-card', () => { - const expectedAvatarPath = fileURLToPath( - new URL('../../../../../public/assets/images/kevin-brown.webp', import.meta.url) - ) + const expectedAvatarUrl = 'http://localhost/assets/images/kevin-brown.webp' beforeEach(() => { generateOpenGraphImageMock.mockReset() @@ -88,9 +90,7 @@ describe('Social Card API - GET /api/social-card', () => { expect.objectContaining({ title: 'Sample Article', description: 'Article Description', - logo: expect.objectContaining({ - size: [140, 140], - }), + avatarUrl: expectedAvatarUrl, }) ) @@ -130,9 +130,7 @@ describe('Social Card API - GET /api/social-card', () => { expect(generateOpenGraphImageMock).toHaveBeenCalledWith( expect.objectContaining({ - logo: expect.objectContaining({ - path: expectedAvatarPath, - }), + avatarUrl: expectedAvatarUrl, }) ) }) diff --git a/src/pages/api/social-card/_lib/SocialCardGenerationError.ts b/src/pages/api/social-card/_lib/SocialCardGenerationError.ts new file mode 100644 index 00000000..74b0b498 --- /dev/null +++ b/src/pages/api/social-card/_lib/SocialCardGenerationError.ts @@ -0,0 +1,59 @@ +export type SocialCardGenerationStage = + | 'resolve-avatar' + | 'resolve-description-font' + | 'resolve-title-font' + | 'load-avatar' + | 'load-fonts' + | 'init-canvaskit' + | 'create-surface' + | 'render-card' + | 'encode-image' + +export class SocialCardGenerationError extends Error { + readonly stage: SocialCardGenerationStage + readonly details?: Record + + constructor( + stage: SocialCardGenerationStage, + message: string, + details?: Record, + options?: ErrorOptions + ) { + super(message, options) + this.name = 'SocialCardGenerationError' + this.stage = stage + if (details !== undefined) { + this.details = details + } + } +} + +export const getSocialCardErrorDetails = (error: unknown): Record => { + if (!(error instanceof SocialCardGenerationError)) { + return {} + } + + const details = pruneUndefined(error.details) + + return { + socialCardStage: error.stage, + socialCardMessage: error.message, + ...(details ? { socialCardDetails: details } : {}), + ...(error.cause instanceof Error ? { socialCardCause: error.cause.message } : {}), + } +} + +const pruneUndefined = ( + details: Record | undefined +): Record | undefined => { + if (!details) { + return undefined + } + + const entries = Object.entries(details).filter(([, value]) => value !== undefined) + if (entries.length === 0) { + return undefined + } + + return Object.fromEntries(entries) +} \ No newline at end of file diff --git a/src/pages/api/social-card/_lib/__tests__/SocialCardGenerationError.spec.ts b/src/pages/api/social-card/_lib/__tests__/SocialCardGenerationError.spec.ts new file mode 100644 index 00000000..6557afee --- /dev/null +++ b/src/pages/api/social-card/_lib/__tests__/SocialCardGenerationError.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { + getSocialCardErrorDetails, + SocialCardGenerationError, +} from '@pages/api/social-card/_lib/SocialCardGenerationError' + +describe('getSocialCardErrorDetails', () => { + it('returns structured metadata for social-card generation errors', () => { + const error = new SocialCardGenerationError( + 'load-fonts', + 'Failed to load a local font file for social card generation', + { + fontPath: '/tmp/title-font.ttf', + candidateCount: 2, + }, + { + cause: new Error('ENOENT'), + } + ) + + expect(getSocialCardErrorDetails(error)).toEqual({ + socialCardStage: 'load-fonts', + socialCardMessage: 'Failed to load a local font file for social card generation', + socialCardCause: 'ENOENT', + socialCardDetails: { + fontPath: '/tmp/title-font.ttf', + candidateCount: 2, + }, + }) + }) + + it('returns an empty object for non-social-card errors', () => { + expect(getSocialCardErrorDetails(new Error('boom'))).toEqual({}) + }) +}) \ No newline at end of file diff --git a/src/pages/api/social-card/_lib/assetLoaders.ts b/src/pages/api/social-card/_lib/assetLoaders.ts new file mode 100644 index 00000000..bb487130 --- /dev/null +++ b/src/pages/api/social-card/_lib/assetLoaders.ts @@ -0,0 +1,133 @@ +import type { CanvasKit, FontMgr } from 'canvaskit-wasm/full' +import fs from 'node:fs/promises' +import { createRequire } from 'node:module' +import { SocialCardGenerationError } from './SocialCardGenerationError' + +const moduleRequire = createRequire(import.meta.url) + +let canvasKitSingleton: CanvasKit | undefined + +export async function getCanvasKit(): Promise { + if (canvasKitSingleton) { + return canvasKitSingleton + } + + try { + const { default: init } = await import('canvaskit-wasm/full') + + canvasKitSingleton = await init({ + locateFile: file => moduleRequire.resolve(`canvaskit-wasm/bin/full/${file}`), + }) + + return canvasKitSingleton + } catch (cause) { + throw new SocialCardGenerationError( + 'init-canvaskit', + 'Failed to initialize CanvasKit for social card generation', + { + module: 'canvaskit-wasm/full', + }, + { cause } + ) + } +} + +class FontManager { + readonly #cache = new Map() + #manager?: FontMgr + + async #getOrCreateManager(shouldUpdate: boolean): Promise { + if (!shouldUpdate && this.#manager) { + return this.#manager + } + + const CanvasKit = await getCanvasKit() + const fontData = Array.from(this.#cache.values()) + const manager = CanvasKit.FontMgr.FromData(...fontData) + + if (!manager) { + throw new SocialCardGenerationError('load-fonts', 'Failed to create CanvasKit font manager', { + fontCount: fontData.length, + }) + } + + this.#manager = manager + return manager + } + + async get(fontPaths: string[]): Promise { + let hasNew = false + + for (const fontPath of fontPaths) { + if (this.#cache.has(fontPath)) { + continue + } + + hasNew = true + + try { + const fontFile = await fs.readFile(fontPath) + const fontData = fontFile.buffer.slice( + fontFile.byteOffset, + fontFile.byteOffset + fontFile.byteLength + ) + this.#cache.set(fontPath, fontData) + } catch (cause) { + throw new SocialCardGenerationError( + 'load-fonts', + 'Failed to load a local font file for social card generation', + { + fontPath, + }, + { cause } + ) + } + } + + return this.#getOrCreateManager(hasNew) + } +} + +export const fontManager = new FontManager() + +const imageCache = new Map() + +export const loadImage = async (imageUrl: string): Promise => { + const cachedImage = imageCache.get(imageUrl) + if (cachedImage) { + return cachedImage + } + + try { + const response = await fetch(imageUrl) + + if (!response.ok) { + throw new SocialCardGenerationError( + 'load-avatar', + 'Failed to fetch the social card avatar asset', + { + imageUrl, + status: response.status, + statusText: response.statusText, + } + ) + } + + const image = Buffer.from(await response.arrayBuffer()) + imageCache.set(imageUrl, image) + return image + } catch (cause) { + if (cause instanceof SocialCardGenerationError) { + throw cause + } + + throw new SocialCardGenerationError( + 'load-avatar', + 'Failed to fetch the social card avatar asset', + { + imageUrl, + }, + { cause } + ) + } +} diff --git a/src/pages/api/social-card/_lib/generateOpenGraphImage.ts b/src/pages/api/social-card/_lib/generateOpenGraphImage.ts new file mode 100644 index 00000000..09aac800 --- /dev/null +++ b/src/pages/api/social-card/_lib/generateOpenGraphImage.ts @@ -0,0 +1,224 @@ +import { Buffer } from 'node:buffer' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { decodeHTMLStrict } from 'entities' +import { getCanvasKit, fontManager, loadImage } from './assetLoaders' +import { SocialCardGenerationError } from './SocialCardGenerationError' +import type { RGBColor, SocialCardImageOptions } from './types' + +type CanvasKitInstance = Awaited> +type CanvasSurface = NonNullable> +type Canvas = ReturnType +type CanvasFontWeight = CanvasKitInstance['FontWeight']['Normal'] + +const width = 1200 +const height = 630 +const padding = 80 +const avatarSize = [140, 140] as const +const defaultGradient: RGBColor[] = [[0, 0, 0]] +const titleFontFamilies = ['Lora'] +const descriptionFontFamilies = ['Lora'] +const titleStyle = { + color: [255, 255, 255] as RGBColor, + size: 86, + lineHeight: 96 / 86, +} +const descriptionStyle = { + color: [226, 232, 240] as RGBColor, + size: 48, + lineHeight: 58 / 48, +} + +export async function generateOpenGraphImage({ + title, + description = '', + bgGradient = defaultGradient, + avatarUrl, +}: SocialCardImageOptions): Promise { + const { titleFontPath, descriptionFontPath } = resolveFontPaths() + const fontMgr = await fontManager.get([titleFontPath, descriptionFontPath]) + const avatarBuffer = await loadImage(avatarUrl) + const CanvasKit = await getCanvasKit() + const surface = CanvasKit.MakeSurface(width, height) + + if (!surface) { + throw new SocialCardGenerationError('create-surface', 'CanvasKit could not create a render surface', { + width, + height, + }) + } + + try { + const canvas = surface.getCanvas() + drawBackground(CanvasKit, canvas, bgGradient) + const avatarHeight = drawAvatar(CanvasKit, canvas, avatarBuffer, avatarUrl) + drawText(CanvasKit, canvas, fontMgr, title, description, avatarHeight) + + const image = surface.makeImageSnapshot() + const imageBytes = image.encodeToBytes(CanvasKit.ImageFormat.PNG, 100) + + if (!imageBytes || imageBytes.length === 0) { + throw new SocialCardGenerationError('encode-image', 'CanvasKit returned an empty social card image') + } + + return Buffer.from(imageBytes) + } catch (error) { + if (error instanceof SocialCardGenerationError) { + throw error + } + + throw new SocialCardGenerationError( + 'render-card', + 'Unexpected social card rendering failure', + { + avatarUrl, + }, + { cause: error } + ) + } finally { + surface.dispose() + } +} + +const drawBackground = ( + CanvasKit: CanvasKitInstance, + canvas: Canvas, + bgGradient: RGBColor[] +) => { + const bgRect = CanvasKit.XYWHRect(0, 0, width, height) + const bgPaint = new CanvasKit.Paint() + + bgPaint.setShader( + CanvasKit.Shader.MakeLinearGradient( + [0, 0], + [0, height], + bgGradient.map(rgb => CanvasKit.Color(...rgb)), + null, + CanvasKit.TileMode.Clamp + ) + ) + + canvas.drawRect(bgRect, bgPaint) +} + +const drawAvatar = ( + CanvasKit: CanvasKitInstance, + canvas: Canvas, + avatarBuffer: Buffer, + avatarUrl: string +): number => { + const avatarImage = CanvasKit.MakeImageFromEncoded(avatarBuffer) + + if (!avatarImage) { + throw new SocialCardGenerationError('load-avatar', 'Avatar image could not be decoded for social card generation', { + avatarUrl, + }) + } + + const [avatarWidth, avatarHeight] = avatarSize + const sourceWidth = avatarImage.width() + const sourceHeight = avatarImage.height() + const xRatio = avatarWidth / sourceWidth + const yRatio = avatarHeight / sourceHeight + const imagePaint = new CanvasKit.Paint() + + imagePaint.setImageFilter( + CanvasKit.ImageFilter.MakeMatrixTransform( + CanvasKit.Matrix.scaled(xRatio, yRatio), + { filter: CanvasKit.FilterMode.Linear }, + null + ) + ) + + canvas.drawImage(avatarImage, padding / xRatio, padding / yRatio, imagePaint) + + return avatarHeight +} + +const drawText = ( + CanvasKit: CanvasKitInstance, + canvas: Canvas, + fontMgr: Awaited>, + title: string, + description: string, + avatarHeight: number +) => { + const paragraphStyle = new CanvasKit.ParagraphStyle({ + textAlign: CanvasKit.TextAlign.Left, + textDirection: CanvasKit.TextDirection.LTR, + textStyle: buildTextStyle(CanvasKit, titleStyle, titleFontFamilies, CanvasKit.FontWeight.Bold), + }) + const paragraphBuilder = CanvasKit.ParagraphBuilder.Make(paragraphStyle, fontMgr) + + paragraphBuilder.addText(decodeHTMLStrict(title)) + paragraphBuilder.pushStyle(new CanvasKit.TextStyle({ fontSize: padding / 3, heightMultiplier: 1 })) + paragraphBuilder.addText('\n\n') + paragraphBuilder.pushStyle( + new CanvasKit.TextStyle( + buildTextStyle(CanvasKit, descriptionStyle, descriptionFontFamilies, CanvasKit.FontWeight.Normal) + ) + ) + paragraphBuilder.addText(decodeHTMLStrict(description)) + + const paragraph = paragraphBuilder.build() + const paragraphWidth = width - padding * 3 + paragraph.layout(paragraphWidth) + + const minTop = padding + avatarHeight + padding + const maxTop = minTop + padding + const naturalTop = height - padding - paragraph.getHeight() + const paragraphTop = Math.max(minTop, Math.min(maxTop, naturalTop)) + + canvas.drawParagraph(paragraph, padding, paragraphTop) +} + +const buildTextStyle = ( + CanvasKit: CanvasKitInstance, + style: { color: RGBColor; size: number; lineHeight: number }, + fontFamilies: string[], + fontWeight: CanvasFontWeight +) => ({ + color: CanvasKit.Color(...style.color), + fontFamilies, + fontSize: style.size, + fontStyle: { weight: fontWeight }, + heightMultiplier: style.lineHeight, +}) + +const resolveFontPaths = (): { descriptionFontPath: string; titleFontPath: string } => ({ + titleFontPath: resolveLocalAssetPath( + 'resolve-title-font', + [ + new URL('../../../../../public/fonts/Serif-Lora/Lora-Bold.ttf', import.meta.url), + new URL('../../../../../public/fonts/Lora-Bold.ttf', import.meta.url), + ], + 'title font' + ), + descriptionFontPath: resolveLocalAssetPath( + 'resolve-description-font', + [ + new URL('../../../../../public/fonts/Serif-Lora/Lora-Regular.ttf', import.meta.url), + new URL('../../../../../public/fonts/Lora-Regular.ttf', import.meta.url), + ], + 'description font' + ), +}) + +const resolveLocalAssetPath = ( + stage: 'resolve-description-font' | 'resolve-title-font', + candidateUrls: URL[], + label: string +): string => { + const candidatePaths = candidateUrls.map(candidateUrl => fileURLToPath(candidateUrl)) + + for (const candidatePath of candidatePaths) { + if (existsSync(candidatePath)) { + return candidatePath + } + } + + throw new SocialCardGenerationError(stage, `Social card ${label} could not be resolved`, { + candidatePaths, + label, + }) +} diff --git a/src/pages/api/social-card/_lib/index.ts b/src/pages/api/social-card/_lib/index.ts new file mode 100644 index 00000000..59c14b6f --- /dev/null +++ b/src/pages/api/social-card/_lib/index.ts @@ -0,0 +1,6 @@ +export { generateOpenGraphImage } from './generateOpenGraphImage' +export { + getSocialCardErrorDetails, + SocialCardGenerationError, +} from './SocialCardGenerationError' +export type { SocialCardImageOptions } from './types' diff --git a/src/pages/api/social-card/_lib/types.ts b/src/pages/api/social-card/_lib/types.ts new file mode 100644 index 00000000..8d8524f6 --- /dev/null +++ b/src/pages/api/social-card/_lib/types.ts @@ -0,0 +1,8 @@ +export type RGBColor = [r: number, g: number, b: number] + +export interface SocialCardImageOptions { + title: string + description?: string + bgGradient?: RGBColor[] + avatarUrl: string +} diff --git a/src/pages/api/social-card/index.ts b/src/pages/api/social-card/index.ts index 78921e79..cdf3e123 100644 --- a/src/pages/api/social-card/index.ts +++ b/src/pages/api/social-card/index.ts @@ -1,8 +1,6 @@ import type { APIRoute } from 'astro' import { getCollection } from 'astro:content' -import { generateOpenGraphImage } from 'astro-og-canvas' -import { existsSync } from 'node:fs' -import { fileURLToPath } from 'node:url' +import { generateOpenGraphImage, getSocialCardErrorDetails } from './_lib' import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_utils/errors' import { createApiFunctionContext } from '@pages/api/_utils/requestContext' @@ -10,27 +8,6 @@ export const prerender = false const ROUTE = '/api/social-card' -const resolveAvatarFilePath = (): string => { - const candidateUrls = [ - // Vercel serverless output after build. - new URL('../../static/assets/images/kevin-brown.webp', import.meta.url), - // Local source path for dev and test runs. - new URL('../../../../public/assets/images/kevin-brown.webp', import.meta.url), - // Legacy source asset path kept as a final fallback. - new URL('../../../assets/images/avatars/kevin-brown.webp', import.meta.url), - ] - - for (const candidateUrl of candidateUrls) { - const filePath = fileURLToPath(candidateUrl) - if (existsSync(filePath)) { - return filePath - } - } - - throw new Error('Social card avatar asset could not be resolved for the current runtime') -} - -const AVATAR_FILE_PATH = resolveAvatarFilePath() const DEFAULT_TITLE = 'Platform Engineering by Kevin Brown' const DEFAULT_DESCRIPTION = 'Platform engineer helping teams harden delivery, modernize cloud platforms, and improve developer experience.' @@ -136,12 +113,19 @@ export const GET: APIRoute = async ({ request, clientAddress, cookies }) => { clientAddress, cookies, }) + const socialCardContext: Record = {} try { const url = new URL(request.url) const slug = normalizeSlug(url.searchParams.get('slug')) const titleOverride = url.searchParams.get('title') const descriptionOverride = url.searchParams.get('description') + const avatarUrl = new URL('/assets/images/kevin-brown.webp', request.url).toString() + + socialCardContext['slug'] = slug + socialCardContext['hasTitleOverride'] = titleOverride !== null + socialCardContext['hasDescriptionOverride'] = descriptionOverride !== null + socialCardContext['avatarUrl'] = avatarUrl const contentIndex = await buildContentIndex() const matchedEntry = contentIndex[slug] @@ -154,37 +138,31 @@ export const GET: APIRoute = async ({ request, clientAddress, cookies }) => { ) const palette = matchedEntry?.palette ?? 'default' + socialCardContext['palette'] = palette + socialCardContext['hasMatchedEntry'] = Boolean(matchedEntry) const imageBuffer = await generateOpenGraphImage({ title, description, bgGradient: gradientPalette[palette], - padding: 80, - logo: { - path: AVATAR_FILE_PATH, - size: [140, 140], - }, - font: { - title: { - size: 86, - lineHeight: 96, - }, - description: { - size: 48, - lineHeight: 58, - color: [226, 232, 240], - }, - }, - format: 'PNG', + avatarUrl, }) + const imageBody = new Uint8Array(imageBuffer) - return new Response(imageBuffer, { + return new Response(imageBody, { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600', }, }) } catch (error) { + socialCardContext['requestUrl'] = request.url + apiContext.code = 'SOCIAL_CARD_GENERATION_FAILED' + apiContext.extra = { + ...socialCardContext, + ...getSocialCardErrorDetails(error), + } + return buildApiErrorResponse(handleApiFunctionError(error, apiContext), { fallbackMessage: 'Unable to generate social card', }) diff --git a/src/pages/robots.txt.ts b/src/pages/robots.txt.ts index bc1291ca..cd54054e 100644 --- a/src/pages/robots.txt.ts +++ b/src/pages/robots.txt.ts @@ -4,9 +4,6 @@ const getRobotsTxt = (sitemapURL: URL) => `\ User-agent: * Allow: / Disallow: /downloads/ -Disallow: /articles$ -Disallow: /case-studies$ -Disallow: /deep-dive$ Disallow: /offline Disallow: /print Disallow: /search