From af8a6dcc0e990be1ea480745669eca7220e4da74 Mon Sep 17 00:00:00 2001 From: pmckinney-codat Date: Tue, 4 Aug 2026 16:43:44 +0100 Subject: [PATCH 1/3] EXP-2127: Add per-PR preview deployments via GitHub Pages Add a deploy-preview job to the PR workflow that publishes the built site to the gh-pages branch under pr-preview/pr-/ using rossjrw/pr-preview-action, posts a sticky comment with the preview URL, and tears the preview down when the PR closes. BASE_URL in docusaurus.config.js becomes env-driven so preview builds can target the Pages subpath; production builds are unaffected (env var unset resolves to the current baseUrl of "/"). Fork PRs are skipped: they get neither secrets nor a writable GITHUB_TOKEN, and already cannot build due to ADO npm feed auth. Co-Authored-By: Claude Fable 5 --- .env.example | 1 + .github/workflows/pr.yml | 61 ++++++++++++++++++++++++++++++++++++++++ docusaurus.config.js | 4 ++- 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 7eaf8973c..e1755d467 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,4 @@ ZENDESK_KEY= GTM_ID= BRANCH=optional (sets the edit path) +BASE_URL=optional (subpath the site is served from, no trailing slash; used by PR previews) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f302efd0b..0783150c6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -2,6 +2,9 @@ name: PR build on: pull_request: + # `closed` is included so the preview deployment is torn down when the PR + # closes; the build/link-check job skips that event + types: [opened, reopened, synchronize, closed] workflow_dispatch: # Allows manual triggering from the GitHub UI permissions: @@ -11,6 +14,7 @@ permissions: jobs: build: + if: github.event.action != 'closed' runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -116,3 +120,60 @@ jobs: if (filtered.length > 0) { core.setFailed("There are broken links in the documentation."); } + + # Deploys the built site to GitHub Pages under pr-preview/pr-/ and + # posts a sticky comment on the PR with the preview URL; the preview is + # removed when the PR closes. Builds separately from the job above because + # the preview needs a different baseUrl than the link check. Fork PRs are + # skipped: they get neither secrets nor a writable GITHUB_TOKEN. + deploy-preview: + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 30 + # Concurrent runs for the same PR would race on pushing to gh-pages + concurrency: pr-preview-${{ github.ref }} + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v7 + with: + # Images are Git LFS-tracked; skip fetching them on teardown, where + # the checkout is only needed so the action can push to gh-pages + lfs: ${{ github.event.action != 'closed' }} + + - uses: actions/setup-node@v6 + if: github.event.action != 'closed' + with: + node-version: 24 + cache: npm + + # The ADO registry requires auth even to install; the repo .npmrc is + # credential-less so creds go into ~/.npmrc here + - name: Authenticate to codat-npm feed + if: github.event.action != 'closed' + run: | + { + echo "//pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/:username=codat" + echo "//pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/:_password=${ADO_NPM_FEED_TOKEN}" + echo "//pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/:email=npm-requires-email@example.com" + } >> ~/.npmrc + env: + ADO_NPM_FEED_TOKEN: ${{ secrets.ADO_NPM_FEED_TOKEN }} + + - name: Install dependencies + if: github.event.action != 'closed' + run: npm ci + + - name: Build site for preview + if: github.event.action != 'closed' + run: npm run build + env: + GTM_ID: ${{ vars.GTM_ID }} + # GitHub Pages serves the preview from a subpath (no trailing slash) + BASE_URL: /codat-docs/pr-preview/pr-${{ github.event.number }} + + - name: Deploy preview + uses: rossjrw/pr-preview-action@v1 + with: + source-dir: ./build diff --git a/docusaurus.config.js b/docusaurus.config.js index 0ff1d2317..d36663a62 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -81,7 +81,9 @@ import redirects from "./redirects.config"; import { generateAPISitemaps } from "./src/utils/oas-sitemap.js"; -const BASE_URL = ""; +// PR preview deploys serve the site from a subpath on GitHub Pages, so the +// preview workflow overrides this. No trailing slash — baseUrl appends one. +const BASE_URL = process.env.BASE_URL ?? ""; require("dotenv").config(); From 40a7b2757c7cfebae658feac11b015b6a9203027 Mon Sep 17 00:00:00 2001 From: pmckinney-codat Date: Tue, 4 Aug 2026 17:09:41 +0100 Subject: [PATCH 2/3] EXP-2127: Make asset and spec URLs baseUrl-aware for subpath deploys Raw JSX attributes bypass Docusaurus baseUrl handling, so absolute /img, /logos, and /oas paths broke on the PR preview (served from /codat-docs/pr-preview/pr-/ rather than /): - MDXComponents/Img: apply useBaseUrl to src, fixing all raw tags in MDX content centrally (51 across 28 files) - Api: apply useBaseUrl to the OAS spec URL, fixing all 21 API reference pages centrally (Stoplight fetched /oas/... from the domain root, so specs 404'd on the preview) - Cards, Products, ClientLibraries: withBaseUrl on card images and internal links - support page, BlogSidebar: raw swapped for Docusaurus Link Production output is unchanged: with baseUrl "/", withBaseUrl is a no-op (every path already starts with "/"), and external URLs and bundled assets pass through untouched. Co-Authored-By: Claude Fable 5 --- src/components/Api/index.tsx | 6 +++++- src/components/Cards/index.js | 24 ++++++++++++++++++------ src/components/ClientLibraries/index.tsx | 9 ++++++++- src/components/Products/index.js | 24 ++++++++++++++---------- src/pages/support/index.tsx | 3 ++- src/theme/BlogSidebar/Desktop/index.tsx | 4 ++-- src/theme/MDXComponents/Img/index.js | 6 ++++++ 7 files changed, 55 insertions(+), 21 deletions(-) diff --git a/src/components/Api/index.tsx b/src/components/Api/index.tsx index e4b359d21..ca2a2f636 100644 --- a/src/components/Api/index.tsx +++ b/src/components/Api/index.tsx @@ -2,6 +2,7 @@ import React, { useState, Suspense } from "react"; import { Helmet } from "react-helmet"; import BrowserOnly from "@docusaurus/BrowserOnly"; +import useBaseUrl from "@docusaurus/useBaseUrl"; import Layout from "@theme/Layout"; import Navbar from "@theme/Navbar"; @@ -21,6 +22,9 @@ const Api = ({ socialBanner = "https://docs.codat.io/img/meta/codat-bg.png", }) => { const [menuOpen, setMenuOpen] = useState(false); + // The OAS specs live in static/, so the fetch URL must respect baseUrl for + // deploys served from a subpath (e.g. PR previews) + const specUrl = useBaseUrl(url); return ( @@ -48,7 +52,7 @@ const Api = ({ {() => ( - + )} diff --git a/src/components/Cards/index.js b/src/components/Cards/index.js index 0cf3c575b..7dc534081 100644 --- a/src/components/Cards/index.js +++ b/src/components/Cards/index.js @@ -1,10 +1,20 @@ +import { useBaseUrlUtils } from "@docusaurus/useBaseUrl"; + +// Callers pass absolute image/link paths (e.g. /img/...), which bypass +// baseUrl and break deploys served from a subpath (e.g. PR previews); +// external URLs pass through withBaseUrl untouched const Card = (props) => { const { image, icon: Icon, title, children, className } = props; + const { withBaseUrl } = useBaseUrlUtils(); return (
  • - {Icon ? : } + {Icon ? ( + + ) : ( + + )}

    {title}

    @@ -16,11 +26,12 @@ const Card = (props) => { const CardTwo = (props) => { const { image, title, link, linkText, children, className } = props; + const { withBaseUrl } = useBaseUrlUtils(); return (
  • - +

    {title}

    @@ -28,7 +39,7 @@ const CardTwo = (props) => { {children}

    - {linkText} → + {linkText} →

  • ); @@ -36,20 +47,21 @@ const CardTwo = (props) => { const MiniCard = (props) => { const { image, title, subtitle, link, children, className } = props; + const { withBaseUrl } = useBaseUrlUtils(); return (
  • diff --git a/src/components/ClientLibraries/index.tsx b/src/components/ClientLibraries/index.tsx index a756b1086..cd4731a12 100644 --- a/src/components/ClientLibraries/index.tsx +++ b/src/components/ClientLibraries/index.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useBaseUrlUtils } from "@docusaurus/useBaseUrl"; const repoBaseUrl = "https://github.com/codatio/client-sdk-"; const repoProductTMP = "/tree/main/bank-feeds"; @@ -93,6 +94,9 @@ const getShieldUrl = (productName, language) => { const ClientLibraries = ({ productName }) => { const productUrl = !productName ? "" : "/tree/main/" + productName; + // The language icons are absolute /img/ paths, which bypass baseUrl and + // break deploys served from a subpath (e.g. PR previews) + const { withBaseUrl } = useBaseUrlUtils(); return (
      {languages.map((language, i) => { @@ -104,7 +108,10 @@ const ClientLibraries = ({ productName }) => { href={repoBaseUrl + language.name + productUrl} target="_blank" > - + diff --git a/src/components/Products/index.js b/src/components/Products/index.js index 96d0647d6..7c31e2f7e 100644 --- a/src/components/Products/index.js +++ b/src/components/Products/index.js @@ -1,5 +1,6 @@ import React from "react"; import { useColorMode } from "@docusaurus/theme-common"; +import { useBaseUrlUtils } from "@docusaurus/useBaseUrl"; const other = [ { @@ -104,6 +105,9 @@ const allProducts = [ const Products = ({ mini, products, verbose }) => { const { colorMode } = useColorMode(); + // The logo/link paths above are absolute, which bypasses baseUrl and + // breaks deploys served from a subpath (e.g. PR previews) + const { withBaseUrl } = useBaseUrlUtils(); const validProducts = !products ? allProducts : products @@ -130,15 +134,15 @@ const Products = ({ mini, products, verbose }) => {
      @@ -147,7 +151,7 @@ const Products = ({ mini, products, verbose }) => {
      @@ -167,15 +171,15 @@ const Products = ({ mini, products, verbose }) => {
    • @@ -183,7 +187,7 @@ const Products = ({ mini, products, verbose }) => {

      {product.name}

      {product.description}

      - {product.linkText} + {product.linkText}

    • ); diff --git a/src/pages/support/index.tsx b/src/pages/support/index.tsx index 2961d9673..812bc3285 100644 --- a/src/pages/support/index.tsx +++ b/src/pages/support/index.tsx @@ -1,4 +1,5 @@ import React from "react"; +import Link from "@docusaurus/Link"; import GearFinanceDownIcon from "@components/GearFinanceDownIcon"; import FeatureBullet from "@components/FeatureBullet"; import Layout from "@theme/Layout"; @@ -24,7 +25,7 @@ const RaiseSupportTicket = () => {

      Raise a technical issue with our support team{" "} - here. + here.

    • diff --git a/src/theme/BlogSidebar/Desktop/index.tsx b/src/theme/BlogSidebar/Desktop/index.tsx index 115a4e0e8..ee40ef758 100644 --- a/src/theme/BlogSidebar/Desktop/index.tsx +++ b/src/theme/BlogSidebar/Desktop/index.tsx @@ -59,9 +59,9 @@ export default function BlogSidebarDesktop({ sidebar }: Props): JSX.Element { )) : "No deprecations"}
    - + See all... - +

    diff --git a/src/theme/MDXComponents/Img/index.js b/src/theme/MDXComponents/Img/index.js index 37a70166c..d29a7d32f 100644 --- a/src/theme/MDXComponents/Img/index.js +++ b/src/theme/MDXComponents/Img/index.js @@ -1,16 +1,22 @@ import React from "react"; import clsx from "clsx"; +import useBaseUrl from "@docusaurus/useBaseUrl"; import styles from "./styles.module.css"; function transformImgClassName(className) { return clsx(className, styles.img); } export default function MDXImg(props) { + // Raw in MDX bypasses baseUrl, breaking any deploy + // served from a subpath (e.g. PR previews); bundled/external srcs pass + // through useBaseUrl untouched + const src = useBaseUrl(props.src); return ( // eslint-disable-next-line jsx-a11y/alt-text ); From bdb23e59cd67b26a2775e17d5ba9604d752bb182 Mon Sep 17 00:00:00 2001 From: pmckinney-codat Date: Tue, 4 Aug 2026 17:32:16 +0100 Subject: [PATCH 3/3] EXP-2127: Cover content JSX images and frontmatter banners for subpath deploys Literal JSX written inside Markdown/MDX content bypasses the MDXComponents mapping entirely (MDX v3 semantics), so the previous MDXImg fix never saw it. Add a rehype plugin that rewrites absolute img srcs at compile time, wired into the docs and blog pipelines; it no-ops in production where baseUrl is "/". Also make PageHeader (frontmatter banner_image/banner_icon) and Clients (client logo paths) baseUrl-aware, the remaining components that received absolute asset paths as props. A clean subpath build now has zero unprefixed /img and /logos srcs across the output (was 127). Co-Authored-By: Claude Fable 5 --- docusaurus.config.js | 3 +++ src/components/Clients/index.js | 6 ++++- src/components/PageHeader/index.js | 10 ++++++-- src/utils/rehype-image-base-url.js | 40 ++++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 src/utils/rehype-image-base-url.js diff --git a/docusaurus.config.js b/docusaurus.config.js index d36663a62..0a6070e59 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -80,6 +80,7 @@ import navbar from "./nav.config"; import redirects from "./redirects.config"; import { generateAPISitemaps } from "./src/utils/oas-sitemap.js"; +import rehypeImageBaseUrl from "./src/utils/rehype-image-base-url.js"; // PR preview deploys serve the site from a subpath on GitHub Pages, so the // preview workflow overrides this. No trailing slash — baseUrl appends one. @@ -131,6 +132,7 @@ const config = { routeBasePath: "/", sidebarPath: "./sidebars.js", editUrl: `https://github.com/codatio/codat-docs/edit/${process.env?.BRANCH || "main"}/`, + rehypePlugins: [[rehypeImageBaseUrl, { baseUrl: `${BASE_URL}/` }]], exclude: ["README.md"], lastVersion: "current", versions: { @@ -142,6 +144,7 @@ const config = { }, blog: { showReadingTime: true, + rehypePlugins: [[rehypeImageBaseUrl, { baseUrl: `${BASE_URL}/` }]], blogTitle: "Codat updates", blogDescription: "Engineering and product updates from Codat.", postsPerPage: 10, diff --git a/src/components/Clients/index.js b/src/components/Clients/index.js index 24f9f48f8..3badc007d 100644 --- a/src/components/Clients/index.js +++ b/src/components/Clients/index.js @@ -1,14 +1,18 @@ import React from "react"; +import { useBaseUrlUtils } from "@docusaurus/useBaseUrl"; import styles from "./styles.module.scss"; const Client = (props) => { const { path, name, scale } = props; + // Callers pass absolute /img/ paths, which bypass baseUrl and break + // deploys served from a subpath (e.g. PR previews) + const { withBaseUrl } = useBaseUrlUtils(); return (
    {`${name} { const { colorMode } = useColorMode(); - const resolvedIcon = iconDark && colorMode === "dark" ? iconDark : icon; + // icon/img arrive as absolute paths from frontmatter (banner_image etc.), + // which bypass baseUrl and break deploys served from a subpath + const { withBaseUrl } = useBaseUrlUtils(); + const resolvedIcon = withBaseUrl( + iconDark && colorMode === "dark" ? iconDark : icon, + ); return (
    @@ -62,7 +68,7 @@ const PageHeader = ({ {videoUrl && }
    - {img && } + {img && }
    ); }; diff --git a/src/utils/rehype-image-base-url.js b/src/utils/rehype-image-base-url.js new file mode 100644 index 000000000..7060f200d --- /dev/null +++ b/src/utils/rehype-image-base-url.js @@ -0,0 +1,40 @@ +// Literal JSX written inside Markdown/MDX content bypasses the +// MDXComponents mapping (and with it useBaseUrl), so absolute src paths +// break when the site is served from a subpath (e.g. PR previews, where +// baseUrl is /codat-docs/pr-preview/pr-/). Rewrites those srcs at +// compile time instead; a no-op for production builds, where baseUrl is "/". +const rehypeImageBaseUrl = ({ baseUrl }) => { + const prefix = (src) => + typeof src === "string" && src.startsWith("/") && !src.startsWith("//") + ? baseUrl + src.slice(1) + : src; + + const visit = (node) => { + if (node.type === "element" && node.tagName === "img" && node.properties) { + node.properties.src = prefix(node.properties.src); + } + + if ( + (node.type === "mdxJsxFlowElement" || + node.type === "mdxJsxTextElement") && + node.name === "img" + ) { + for (const attr of node.attributes ?? []) { + if (attr.type === "mdxJsxAttribute" && attr.name === "src") { + attr.value = prefix(attr.value); + } + } + } + + (node.children ?? []).forEach(visit); + }; + + return (tree) => { + if (!baseUrl || baseUrl === "/") { + return; + } + visit(tree); + }; +}; + +export default rehypeImageBaseUrl;