From 6297ee541c3740215f142e108fe08b20765b6afb Mon Sep 17 00:00:00 2001 From: Tuan Phung Date: Sun, 11 Oct 2026 18:21:45 +0200 Subject: [PATCH] Website: deploy to Cloudflare Workers instead of Netlify The site is served as Worker static assets, which read public/_headers and public/_redirects as before. website/worker.ts takes over both edge functions: the 301s to canonical URLs and the Markdown versions of pages. CI deploys main with wrangler and uploads every PR as a preview version under the alias pr-. --- .github/workflows/ci.yml | 23 +++--- .gitignore | 1 + netlify.toml | 8 -- .../netlify/edge-functions/canonical-url.ts | 38 ---------- .../edge-functions/markdown-negotiation.ts | 40 ---------- website/public/_headers | 8 +- website/public/_redirects | 27 +++---- website/worker.ts | 73 +++++++++++++++++++ wrangler.jsonc | 39 ++++++++++ 9 files changed, 143 insertions(+), 114 deletions(-) delete mode 100644 netlify.toml delete mode 100644 website/netlify/edge-functions/canonical-url.ts delete mode 100644 website/netlify/edge-functions/markdown-negotiation.ts create mode 100644 website/worker.ts create mode 100644 wrangler.jsonc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ac30c2..046285a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,14 +103,15 @@ jobs: - run: npm run website:build env: SITE_URL: https://useanimations.com - - name: Deploy to Netlify - run: npx --yes netlify-cli@27 deploy --prod --no-build --dir website/dist --site "$NETLIFY_SITE_ID" --message "$GITHUB_SHA" + # The Worker and its assets, configured in wrangler.jsonc. + - name: Deploy to Cloudflare + run: npx --yes wrangler@4 deploy --message "$GITHUB_SHA" env: - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} - NETLIFY_SITE_ID: ${{ vars.NETLIFY_SITE_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} preview: - # Every PR from this repository gets its own preview at pr---.netlify.app. + # Every PR from this repository gets its own preview at pr--useanimations..workers.dev. if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest # The link shows up in the PR as "View deployment". @@ -137,15 +138,19 @@ jobs: # Keep previews out of search engines. - run: | printf '\n/*\n X-Robots-Tag: noindex\n' >> website/dist/_headers - - name: Deploy preview to Netlify + # A new version of the Worker that production doesn't serve, under a stable alias per PR. + - name: Deploy preview to Cloudflare id: deploy run: | - url=$(npx --yes netlify-cli@27 deploy --no-build --dir website/dist --site "$NETLIFY_SITE_ID" --alias "pr-$PR" --message "PR #$PR $GITHUB_SHA" --json | jq -r '.deploy_url') + out=$(npx --yes wrangler@4 versions upload --preview-alias "pr-$PR" --message "PR #$PR $GITHUB_SHA") + echo "$out" + url=$(grep -oE 'https://pr-[0-9]+-[a-z0-9.-]+\.workers\.dev' <<< "$out" | head -n 1) + test -n "$url" echo "url=$url" >> "$GITHUB_OUTPUT" env: PR: ${{ github.event.pull_request.number }} - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} - NETLIFY_SITE_ID: ${{ vars.NETLIFY_SITE_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} # One comment per PR, updated on every push. - name: Comment preview URL run: | diff --git a/.gitignore b/.gitignore index dbc92b8..b738542 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ dist website/dist website/.astro website/.netlify +.wrangler diff --git a/netlify.toml b/netlify.toml deleted file mode 100644 index dbc0dea..0000000 --- a/netlify.toml +++ /dev/null @@ -1,8 +0,0 @@ -# useanimations.com: the Astro site in website/, built from the icon set in icons/. -[build] -command = "npm run website:build" -publish = "website/dist" -edge_functions = "website/netlify/edge-functions" - -[build.environment] -NODE_VERSION = "24" diff --git a/website/netlify/edge-functions/canonical-url.ts b/website/netlify/edge-functions/canonical-url.ts deleted file mode 100644 index 4a2a887..0000000 --- a/website/netlify/edge-functions/canonical-url.ts +++ /dev/null @@ -1,38 +0,0 @@ -// One 301 to the canonical URL for the variants static hosting would otherwise serve as copies: -// a trailing slash (/zh/, /icons/menu/), the .html file (/icons/menu.html) and old or misspelled -// paths (/docs, /licensing). A rule in _redirects can't do this: Netlify matches its rules with and -// without the trailing slash alike, so /documentation/ -> /documentation would loop. -type Context = { next: () => Promise }; - -const ALIASES: Record = { - '/docs': '/documentation', - '/license': '/licencing-and-terms', - '/licence': '/licencing-and-terms', - '/licensing': '/licencing-and-terms', - '/licensing-and-terms': '/licencing-and-terms', -}; - -/** The canonical path of a pathname, e.g. /icons/menu.html/ -> /icons/menu. */ -const canonicalPath = (pathname: string) => { - let path = pathname.replace(/\/+$/, '') || '/'; - if (path.endsWith('.html') && path !== '/404.html') path = path.slice(0, -'.html'.length); - path = path.replace(/\/index$/, '') || '/'; - return ALIASES[path] ?? path; -}; - -export default async (request: Request, context: Context) => { - const url = new URL(request.url); - const path = canonicalPath(url.pathname); - if (path === url.pathname) return context.next(); - return new Response(null, { - status: 301, - headers: { Location: `${path}${url.search}`, 'Cache-Control': 'public, max-age=3600' }, - }); -}; - -// Paths with a trailing slash, .html pages and the aliases; files (.json, .zip, .svg, .md, .png, -// .txt, .xml) never match, and /404.html is served as it is. -export const config = { - pattern: ['^/.+/$', '^/.+\\.html$', '^/(docs|license|licence|licensing|licensing-and-terms)$'], - excludedPattern: ['^/404\\.html$', '^/\\.netlify/'], -}; diff --git a/website/netlify/edge-functions/markdown-negotiation.ts b/website/netlify/edge-functions/markdown-negotiation.ts deleted file mode 100644 index 3f0857f..0000000 --- a/website/netlify/edge-functions/markdown-negotiation.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Serves the Markdown version of a page to clients that ask for it (Accept: text/markdown), -// such as AI agents. Everyone else gets the HTML page, which announces the Markdown version in a -// Link header. Both vary by Accept, so caches keep them apart. -type Context = { next: () => Promise }; - -/** The Markdown file of a page: / -> /index.md, /icons/menu -> /icons/menu.md. */ -const markdownPath = (pathname: string) => (pathname === '/' ? '/index.md' : `${pathname}.md`); - -export default async (request: Request, context: Context) => { - const markdown = markdownPath(new URL(request.url).pathname); - if (request.headers.get('accept')?.includes('text/markdown')) { - const response = await fetch(new URL(markdown, request.url)); - if (response.ok) { - return new Response(await response.text(), { - headers: { - 'Content-Type': 'text/markdown; charset=utf-8', - 'Cache-Control': 'public, max-age=3600', - Vary: 'Accept', - }, - }); - } - } - const response = await context.next(); - // Only pages that exist have a Markdown version; a 404 stays as it is. - if (!response.ok) return response; - const headers = new Headers(response.headers); - headers.append('Link', `<${markdown}>; rel="alternate"; type="text/markdown"`); - headers.append('Vary', 'Accept'); - return new Response(response.body, { status: response.status, headers }); -}; - -// Exact pages only, so files such as /icons/menu.json, .zip or .svg never run this function. -export const config = { - pattern: [ - '^/$', - '^/(documentation|licencing-and-terms)$', - '^/icons/[a-z0-9-]+$', - '^/categories/[a-z0-9-]+$', - ], -}; diff --git a/website/public/_headers b/website/public/_headers index ec82b34..7e22407 100644 --- a/website/public/_headers +++ b/website/public/_headers @@ -1,4 +1,4 @@ -# The same HSTS policy on every response (Netlify's default omits includeSubDomains and preload). +# The same HSTS policy on every response. /* Strict-Transport-Security: max-age=31536000; includeSubDomains; preload X-Frame-Options: SAMEORIGIN @@ -6,7 +6,7 @@ Referrer-Policy: strict-origin-when-cross-origin # The Markdown alternates of pages (Link and Vary: Accept) come from -# netlify/edge-functions/markdown-negotiation.ts. +# website/worker.ts. / Link: ; rel="robots" Link: ; rel="license" @@ -23,7 +23,7 @@ Access-Control-Allow-Origin: * Cache-Control: public, max-age=3600 -# The icon files are cached for an hour; the icon pages (/icons/) keep Netlify's HTML caching. +# The icon files are cached for an hour; the icon pages (/icons/) keep the default HTML caching. /icons/* Access-Control-Allow-Origin: * @@ -33,7 +33,7 @@ /icons/*.zip Cache-Control: public, max-age=3600 -/icons/*/*.svg +/icons/:id/* Cache-Control: public, max-age=3600 /r/* diff --git a/website/public/_redirects b/website/public/_redirects index cadb386..bc47f50 100644 --- a/website/public/_redirects +++ b/website/public/_redirects @@ -1,20 +1,17 @@ -# Pages of the previous site (www and http are redirected by Netlify). Trailing slashes, .html -# pages and the aliases below are redirected first by netlify/edge-functions/canonical-url.ts; -# these rules are the fallback. -/index.html / 301! -/documentation.html /documentation 301! -/licencing-and-terms.html /licencing-and-terms 301! -/zh.html /zh 301! - -# The all-icons index: icons/ is also the folder of the icon files, so serve icons.html explicitly. -/icons /icons.html 200! +# Pages of the previous site (www redirects with a Redirect Rule on the zone). Trailing slashes, +# .html pages and the aliases below are redirected first by website/worker.ts; these rules are +# the fallback. +/index.html / 301 +/documentation.html /documentation 301 +/licencing-and-terms.html /licencing-and-terms 301 +/zh.html /zh 301 # Other spellings of pages. -/docs /documentation 301! -/license /licencing-and-terms 301! -/licence /licencing-and-terms 301! -/licensing /licencing-and-terms 301! -/licensing-and-terms /licencing-and-terms 301! +/docs /documentation 301 +/license /licencing-and-terms 301 +/licence /licencing-and-terms 301 +/licensing /licencing-and-terms 301 +/licensing-and-terms /licencing-and-terms 301 # Images of the previous site. /images/OGuseAnimationsImage.jpg /og.png 301 diff --git a/website/worker.ts b/website/worker.ts new file mode 100644 index 0000000..573e925 --- /dev/null +++ b/website/worker.ts @@ -0,0 +1,73 @@ +// The Cloudflare Worker in front of useanimations.com's static files (website/dist). It runs only +// for the paths in run_worker_first (wrangler.jsonc); every other request is served straight from +// the assets, with public/_headers and public/_redirects applied. +type Env = { ASSETS: { fetch: (request: Request | URL) => Promise } }; + +// One 301 to the canonical URL for the variants static hosting would otherwise serve as copies: +// a trailing slash (/zh/, /icons/menu/), the .html file (/icons/menu.html) and old or misspelled +// paths (/docs, /licensing). +const ALIASES: Record = { + '/docs': '/documentation', + '/license': '/licencing-and-terms', + '/licence': '/licencing-and-terms', + '/licensing': '/licencing-and-terms', + '/licensing-and-terms': '/licencing-and-terms', +}; + +/** The canonical path of a pathname, e.g. /icons/menu.html/ -> /icons/menu. */ +const canonicalPath = (pathname: string) => { + let path = pathname.replace(/\/+$/, '') || '/'; + if (path.endsWith('.html') && path !== '/404.html') path = path.slice(0, -'.html'.length); + path = path.replace(/\/index$/, '') || '/'; + return ALIASES[path] ?? path; +}; + +// Pages with a Markdown version; files such as /icons/menu.json, .zip or .svg have none. +const MARKDOWN_PAGES = + /^\/(|documentation|licencing-and-terms|icons\/[a-z0-9-]+|categories\/[a-z0-9-]+)$/; + +/** The Markdown file of a page: / -> /index.md, /icons/menu -> /icons/menu.md. */ +const markdownPath = (pathname: string) => (pathname === '/' ? '/index.md' : `${pathname}.md`); + +/** + * Serves the Markdown version of a page to clients that ask for it (Accept: text/markdown), such + * as AI agents. Everyone else gets the HTML page, which announces the Markdown version in a Link + * header. Both vary by Accept, so caches keep them apart. + */ +const negotiate = async (request: Request, env: Env, url: URL) => { + const markdown = markdownPath(url.pathname); + if (request.headers.get('accept')?.includes('text/markdown')) { + const response = await env.ASSETS.fetch(new URL(markdown, url)); + if (response.ok) { + return new Response(response.body, { + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'public, max-age=3600', + Vary: 'Accept', + }, + }); + } + } + const response = await env.ASSETS.fetch(request); + // Only pages that exist have a Markdown version; a 404 stays as it is. + if (!response.ok) return response; + const headers = new Headers(response.headers); + headers.append('Link', `<${markdown}>; rel="alternate"; type="text/markdown"`); + headers.append('Vary', 'Accept'); + return new Response(response.body, { status: response.status, headers }); +}; + +export default { + async fetch(request: Request, env: Env) { + const url = new URL(request.url); + const path = canonicalPath(url.pathname); + if (path !== url.pathname && url.pathname !== '/404.html') { + return new Response(null, { + status: 301, + headers: { Location: `${path}${url.search}`, 'Cache-Control': 'public, max-age=3600' }, + }); + } + if (MARKDOWN_PAGES.test(url.pathname)) return negotiate(request, env, url); + return env.ASSETS.fetch(request); + }, +}; diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..4323e8e --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,39 @@ +// useanimations.com: the Astro site in website/, built from the icon set in icons/ with +// `npm run website:build`, then deployed by CI (.github/workflows/ci.yml). +{ + "name": "useanimations", + "main": "website/worker.ts", + "compatibility_date": "2026-10-01", + "assets": { + "directory": "website/dist", + "binding": "ASSETS", + // Pages are written as documentation.html and served at /documentation. + "html_handling": "drop-trailing-slash", + "not_found_handling": "404-page", + // The Worker handles only the pages and the URL variants it redirects; icon files, scripts + // and the rest come straight from the assets. + "run_worker_first": [ + "/", + "/documentation", + "/licencing-and-terms", + "/docs", + "/license", + "/licence", + "/licensing", + "/licensing-and-terms", + "/icons/*", + "/categories/*", + "/*/", + "/*.html", + "!/icons/*.json", + "!/icons/*.zip", + "!/icons/*.svg", + "!/icons/*.md", + ], + }, + // www and react.useanimations.com redirect here with a Redirect Rule on the zone. + "routes": [{ "pattern": "useanimations.com", "custom_domain": true }], + "workers_dev": true, + "preview_urls": true, + "observability": { "enabled": true }, +}