diff --git a/.github/workflows/asset-budget.yml b/.github/workflows/asset-budget.yml new file mode 100644 index 0000000000..21fc990f01 --- /dev/null +++ b/.github/workflows/asset-budget.yml @@ -0,0 +1,56 @@ +name: Asset & Font Budget + +# Guardrails from the web-quality plan: +# X1 - no committed image > 500 KB +# X3 - no new render-blocking external font stylesheets (self-host instead) +# +# Both checks are scoped to the files CHANGED in the PR (diff against the base +# branch), so pre-existing large assets (e.g. GIFs still awaiting MP4/WebM +# conversion) never fail an unrelated PR — only newly added/modified files are +# held to the budget. + +on: + pull_request: + branches: ["main"] + +jobs: + asset-budget: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: New/changed images must be under 500 KB (X1) + run: | + # checkout ran with fetch-depth: 0, so origin/ is already present + # with full ancestry for the three-dot merge-base below. + base="origin/${{ github.base_ref }}" + max=512000 # 500 KiB + fail=0 + while IFS= read -r f; do + [ -z "$f" ] && continue + [ -f "$f" ] || continue # skip deletions + size=$(wc -c < "$f") + if [ "$size" -gt "$max" ]; then + echo "::error file=$f::$f is $((size / 1024)) KB (> 500 KB). Optimize (WebP/AVIF) or convert to video before committing." + fail=1 + fi + done < <(git diff --name-only --diff-filter=AM "$base"...HEAD | grep -iE '\.(png|jpe?g|gif|webp|avif|svg)$' || true) + if [ "$fail" -eq 0 ]; then echo "All new/changed images are within the 500 KB budget."; fi + exit "$fail" + + - name: Block new render-blocking font stylesheets (X3) + run: | + base="origin/${{ github.base_ref }}" + # Match the actual render-blocking stylesheet URL (…googleapis.com/css…), + # not prose mentions of the domain, to avoid false positives on comments. + added=$(git diff --diff-filter=AM "$base"...HEAD -- '*.js' '*.jsx' '*.ts' '*.tsx' '*.json' '*.md' '*.mdx' '*.html' \ + | grep -E '^\+' | grep -E 'fonts\.googleapis\.com/css' || true) + if [ -n "$added" ]; then + echo "::error::New external Google Fonts reference detected. Self-host the font (src/fonts/*.woff2 + @font-face in src/css/custom.css) instead of a render-blocking :" + echo "$added" + exit 1 + fi + echo "No new external font stylesheets." diff --git a/docusaurus.config.js b/docusaurus.config.js index ec3984ad19..85fc8beac0 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -3,6 +3,7 @@ import {themes as prismThemes} from "prism-react-renderer"; const path = require("path"); const fs = require("fs"); +const remarkImageSize = require("./src/remark/remarkImageSize"); import {visit} from "unist-util-visit"; const FontPreloadPlugin = require("webpack-font-preload-plugin"); @@ -29,28 +30,33 @@ const llmsFullRootContent = fs.readFileSync( /** @type {import('@docusaurus/types').DocusaurusConfig} */ module.exports = { headTags: [ - // Google Fonts - DM Sans (loaded via headTags instead of CSS @import) + // DM Sans is now self-hosted (see src/css/custom.css @font-face). The + // render-blocking Google Fonts stylesheet + its two preconnects + // (fonts.googleapis.com / fonts.gstatic.com) were removed here to cut the + // font from the critical render path and drop us to <=4 preconnects. + // Preload the latin woff2 so it loads in parallel with the CSS instead of + // being discovered only after the stylesheet parses (html -> css -> font + // chain). crossorigin is required even same-origin for font preloads. { tagName: "link", attributes: { - rel: "preconnect", - href: "https://fonts.googleapis.com", - }, - }, - { - tagName: "link", - attributes: { - rel: "preconnect", - href: "https://fonts.gstatic.com", + rel: "preload", + href: "/docs/fonts/DMSans-latin.woff2", + as: "font", + type: "font/woff2", crossorigin: "anonymous", }, }, + // DM Sans @font-face (self-hosted from static/fonts). Inlined here rather + // than in custom.css because webpack's css-loader can't resolve the stable + // /docs/fonts/ URL from within src/css. Variable font, weights 400-700, + // font-display:swap; latin + latin-ext subsets. { - tagName: "link", - attributes: { - rel: "stylesheet", - href: "https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;700&display=swap", - }, + tagName: "style", + attributes: {}, + innerHTML: ` +@font-face{font-family:"DM Sans";src:url("/docs/fonts/DMSans-latin.woff2") format("woff2");font-weight:400 700;font-style:normal;font-display:swap;unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;} +@font-face{font-family:"DM Sans";src:url("/docs/fonts/DMSans-latin-ext.woff2") format("woff2");font-weight:400 700;font-style:normal;font-display:swap;unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;}`, }, // Preconnect tag { @@ -177,20 +183,11 @@ module.exports = { }), }, // Meta Pixel Code - { - tagName: "script", - attributes: {}, - innerHTML: `!function(f,b,e,v,n,t,s) -{if(f.fbq)return;n=f.fbq=function(){n.callMethod? -n.callMethod.apply(n,arguments):n.queue.push(arguments)}; -if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0'; -n.queue=[];t=b.createElement(e);t.async=!0; -t.src=v;s=b.getElementsByTagName(e)[0]; -s.parentNode.insertBefore(t,s)}(window, document,'script', -'https://connect.facebook.net/en_US/fbevents.js'); -fbq('init', '2006330080011702'); -fbq('track', 'PageView');`, - }, + // The pixel bootstrap (fbevents.js + init + PageView) used to run + // synchronously here during head parse, blocking the main thread on every + // page. It now loads lazily on requestIdleCallback from the client module + // src/metaPixelRouteTracker.js (which also re-fires PageView on SPA route + // changes). Only the