diff --git a/scripts/data/sponsors.mjs b/api/sponsors.mjs similarity index 87% rename from scripts/data/sponsors.mjs rename to api/sponsors.mjs index 2c58bcbf..c95c9a46 100644 --- a/scripts/data/sponsors.mjs +++ b/api/sponsors.mjs @@ -1,12 +1,5 @@ -// Builds the data file consumed by the Sponsors layout (`#theme/sponsors`) - -import { mkdir, writeFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { fetchWithRetry } from '../utils/fetch.mjs'; - -const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); -const OUTPUT = join(ROOT, 'generated', 'sponsors.json'); +import { getCache } from '@vercel/functions'; +import { fetchWithRetry } from '../scripts/utils/fetch.mjs'; const COLLECTIVE = 'webpack'; const API = 'https://api.opencollective.com/graphql/v2'; @@ -102,7 +95,7 @@ const fetchAllOrders = async () => { return all; }; -const fromApi = async () => { +export const getOpenCollectiveSponsors = async () => { const nodes = await fetchAllOrders(); if (!nodes.length) throw new Error('No orders returned'); @@ -169,7 +162,18 @@ const fromApi = async () => { return { sponsors, backers }; }; -const data = await fromApi(); -await mkdir(dirname(OUTPUT), { recursive: true }); -await writeFile(OUTPUT, `${JSON.stringify(data, null, 2)}\n`); -console.log(`[sponsors] wrote ${OUTPUT}`); +export async function GET() { + const cache = getCache(); + + // https://vercel.com/docs/caching/runtime-cache?framework=other#using-runtime-cache + let value = await cache.get('sponsors'); + + if (!value) { + value = await getOpenCollectiveSponsors(); + await cache.set('sponsors', value, { + ttl: 3600, // 1 hour in seconds + }); + } + + return Response.json(value); +} diff --git a/components/HomePage/HomeSponsorSection/index.jsx b/components/HomePage/HomeSponsorSection/index.jsx index 80604415..9d55993d 100644 --- a/components/HomePage/HomeSponsorSection/index.jsx +++ b/components/HomePage/HomeSponsorSection/index.jsx @@ -4,10 +4,10 @@ import BaseButton from '@node-core/ui-components/Common/BaseButton'; import SectionHeader from '../../SectionHeader/index.jsx'; import SponsorTier from '../../Sponsors/Tier/index.jsx'; -import data from '#theme/sponsors' with { type: 'json' }; import styles from './index.module.css'; import BackerWall from '../../Sponsors/BackerWall/index.jsx'; +import useSponsors from '../../../hooks/useSponsors.mjs'; import { TIERS as BASE_TIERS, @@ -44,7 +44,11 @@ function SeeMore({ count, href, className }) { } export default () => { - const buckets = useMemo(() => bucketSponsors(data.sponsors, METRIC), []); + const data = useSponsors(); + const buckets = useMemo( + () => bucketSponsors(data.sponsors, METRIC), + [data.sponsors] + ); const hasAnySponsor = TIERS.some(({ tier }) => buckets[tier].length > 0); if (!hasAnySponsor) return null; diff --git a/components/MetaBar/index.jsx b/components/MetaBar/index.jsx index 14b862a3..8d1029e1 100644 --- a/components/MetaBar/index.jsx +++ b/components/MetaBar/index.jsx @@ -2,22 +2,20 @@ import MetaBar from '@node-core/ui-components/Containers/MetaBar'; import AvatarGroup from '@node-core/ui-components/Common/AvatarGroup'; import BaseButton from '@node-core/ui-components/Common/BaseButton'; import GitHubIcon from '@node-core/ui-components/Icons/Social/GitHub'; - +import useSponsors from '../../hooks/useSponsors.mjs'; import { editURL } from '#theme/config'; -import sponsors from '#theme/sponsors' with { type: 'json' }; import SponsorCard from '../Sponsors/Card/index.jsx'; import styles from './index.module.css'; const OC_URL = 'https://opencollective.com/webpack'; -// Active recurring platinum-tier sponsors, ranked by monthly amount. There are -// only ever a handful, so the MetaBar features them as full expanded cards. -const platinumSponsors = sponsors.sponsors - .filter(sponsor => sponsor.monthly.tier === 'platinum') - .sort((a, b) => b.monthly.value - a.monthly.value); - export default ({ metadata, headings = [], readingTime }) => { + const { sponsors } = useSponsors(); + const platinumSponsors = sponsors + .filter(sponsor => sponsor.monthly.tier === 'platinum') + .sort((a, b) => b.monthly.value - a.monthly.value); + const editThisPage = metadata.source ?? editURL.replace('{path}', metadata.path); const authors = metadata.authors?.split(',').map(id => ({ diff --git a/hooks/useSponsors.mjs b/hooks/useSponsors.mjs new file mode 100644 index 00000000..2ec879ca --- /dev/null +++ b/hooks/useSponsors.mjs @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react'; + +const EMPTY_DATA = { sponsors: [], backers: [] }; + +/** @returns {Awaited>} */ +export default function useSponsors() { + const [data, setData] = useState(EMPTY_DATA); + + useEffect(() => { + const controller = new AbortController(); + + const loadSponsors = async () => { + try { + const response = await fetch('/api/sponsors', { + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`Sponsor API responded ${response.status}`); + } + + const value = await response.json(); + if (!controller.signal.aborted) setData(value); + } catch (error) { + if (!controller.signal.aborted) { + console.error('Failed to load sponsors', error); + } + } + }; + + loadSponsors(); + return () => controller.abort(); + }, []); + + return data; +} diff --git a/layouts/Sponsors/index.jsx b/layouts/Sponsors/index.jsx index fe3b4738..c0be81fd 100644 --- a/layouts/Sponsors/index.jsx +++ b/layouts/Sponsors/index.jsx @@ -7,7 +7,7 @@ import SectionHeader from '../../components/SectionHeader/index.jsx'; import SponsorTier from '../../components/Sponsors/Tier/index.jsx'; import BackerWall from '../../components/Sponsors/BackerWall/index.jsx'; import SortToggle from '../../components/Sponsors/SortToggle/index.jsx'; -import data from '#theme/sponsors' with { type: 'json' }; +import useSponsors from '../../hooks/useSponsors.mjs'; import styles from './index.module.css'; @@ -65,6 +65,7 @@ export const bucketSponsors = (sponsors, metric) => { */ export default function SponsorsLayout({ metadata }) { const [metric, setMetric] = useState('monthly'); + const data = useSponsors(); const buckets = bucketSponsors(data.sponsors, metric); diff --git a/package-lock.json b/package-lock.json index 4dd6c75f..7ffab150 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "@node-core/doc-kit": "1.4.3", "@tailwindcss/node": "^4.3.0", "@vercel/analytics": "^2.0.1", + "@vercel/functions": "^3.7.6", "@vercel/speed-insights": "^2.0.0", "classnames": "^2.5.1", "gray-matter": "^4.0.3", @@ -3155,6 +3156,66 @@ } } }, + "node_modules/@vercel/cli-config": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@vercel/cli-config/-/cli-config-0.2.1.tgz", + "integrity": "sha512-RhfyXmRLHdbnry8RJqHDc+5rGxMZ0bu+fpysZjtv3bE+BubpuwxTancHOKiH5zKQREsdwFVr3mOI2kOvxlOyxA==", + "license": "Apache-2.0", + "dependencies": { + "xdg-app-paths": "5", + "zod": "4.1.11" + } + }, + "node_modules/@vercel/cli-exec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@vercel/cli-exec/-/cli-exec-1.0.0.tgz", + "integrity": "sha512-kQF8LGie/Hbdq9/psJxLE7owRTcqMQMhgybU04gCeR7cbQAr5t8OrjefDNColJv1QSSucFt4pLwRiARVmlOnug==", + "license": "Apache-2.0", + "dependencies": { + "execa": "5.1.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@vercel/functions": { + "version": "3.7.6", + "resolved": "https://registry.npmjs.org/@vercel/functions/-/functions-3.7.6.tgz", + "integrity": "sha512-QKlSfrgvo4pGEnzHw8Dha9GRTb5hhLBbImKi4rL4CmxClaVs+36hxgjW0MOqez57wWShstpKOWZY/mU8KuYoUQ==", + "license": "Apache-2.0", + "dependencies": { + "@vercel/oidc": "3.8.1" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-web-identity": "*", + "ws": ">=8" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-web-identity": { + "optional": true + }, + "ws": { + "optional": true + } + } + }, + "node_modules/@vercel/oidc": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.8.1.tgz", + "integrity": "sha512-ufdalm2MWOYksyj8KVpWjoOFPJO6zoYpuyvIggIQ2bB0CFCjTCiTkGXHqAKwG77GVRjOaN3/8S5ITlZpXWmqOw==", + "license": "Apache-2.0", + "dependencies": { + "@vercel/cli-config": "0.2.1", + "@vercel/cli-exec": "1.0.0", + "jose": "^5.9.6" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/@vercel/speed-insights": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@vercel/speed-insights/-/speed-insights-2.0.0.tgz", @@ -4032,7 +4093,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4805,6 +4865,50 @@ "node": ">=0.8.x" } }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -5151,6 +5255,18 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -5671,6 +5787,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -6154,6 +6279,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -6304,6 +6441,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8243,6 +8389,15 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -8535,6 +8690,18 @@ "which": "bin/which" } }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -8639,6 +8806,15 @@ "node": ">= 0.8.0" } }, + "node_modules/os-paths": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/os-paths/-/os-paths-4.4.0.tgz", + "integrity": "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==", + "license": "MIT", + "engines": { + "node": ">= 6.0" + } + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -8802,7 +8978,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9824,7 +9999,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -9837,7 +10011,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10250,6 +10423,15 @@ "node": ">=0.10.0" } }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -11426,7 +11608,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -11577,6 +11758,31 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/xdg-app-paths": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/xdg-app-paths/-/xdg-app-paths-5.5.1.tgz", + "integrity": "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ==", + "license": "MIT", + "dependencies": { + "os-paths": "^4.0.1", + "xdg-portable": "^7.2.0" + }, + "engines": { + "node": ">= 6.0" + } + }, + "node_modules/xdg-portable": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/xdg-portable/-/xdg-portable-7.3.0.tgz", + "integrity": "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==", + "license": "MIT", + "dependencies": { + "os-paths": "^4.0.1" + }, + "engines": { + "node": ">= 6.0" + } + }, "node_modules/xml-js": { "version": "1.6.11", "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", @@ -11618,6 +11824,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", + "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 5a393acf..58753421 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,6 @@ "build:prepare": "node scripts/prepare/index.mjs", "build:data": "npm-run-all build:data:*", "build:data:blog": "node scripts/data/blog.mjs", - "build:data:sponsors": "node scripts/data/sponsors.mjs", "build:md": "npm-run-all build:md:*", "build:md:api": "node scripts/markdown/api/index.mjs && node scripts/markdown/api/configuration.mjs", "build:md:readmes": "node scripts/markdown/readmes.mjs", @@ -26,6 +25,7 @@ "@node-core/doc-kit": "1.4.3", "@tailwindcss/node": "^4.3.0", "@vercel/analytics": "^2.0.1", + "@vercel/functions": "^3.7.6", "@vercel/speed-insights": "^2.0.0", "classnames": "^2.5.1", "gray-matter": "^4.0.3", diff --git a/scripts/html/doc-kit.config.mjs b/scripts/html/doc-kit.config.mjs index 63c0bb73..76884bb5 100644 --- a/scripts/html/doc-kit.config.mjs +++ b/scripts/html/doc-kit.config.mjs @@ -88,7 +88,6 @@ export default { '#theme/Sidebar': join(ROOT, 'components/SideBar.jsx'), '#theme/Metabar': join(ROOT, 'components/MetaBar/index.jsx'), - '#theme/sponsors': join(ROOT, 'generated/sponsors.json'), '#theme/blog': join(ROOT, 'generated/blog.json'), '#theme/Layout': join(ROOT, 'components/Layout.jsx'), '#theme/Navigation': join(ROOT, 'components/NavBar.jsx'), diff --git a/vercel.json b/vercel.json index 3a882144..3b397d6a 100644 --- a/vercel.json +++ b/vercel.json @@ -262,7 +262,7 @@ "permanent": false }, { - "source": "/api/:path*", + "source": "/api/:path((?!sponsors$).*)", "destination": "/docs/api/v5.x", "permanent": false },