diff --git a/.github/workflows/docusaurus_ci.yml b/.github/workflows/docusaurus_ci.yml index 93d8bebf..40603f38 100644 --- a/.github/workflows/docusaurus_ci.yml +++ b/.github/workflows/docusaurus_ci.yml @@ -45,7 +45,10 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 - ref: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.ref || github.ref }} + # A release dispatch always builds the trusted default branch. We do not + # honor an externally supplied client_payload.ref, which could otherwise + # point the privileged build (App token + npm ci) at arbitrary code. + ref: ${{ github.event_name == 'repository_dispatch' && github.event.repository.default_branch || github.ref }} token: ${{ steps.app-token.outputs.token }} - name: Resolve source branch @@ -101,15 +104,22 @@ jobs: - name: Commit docs version snapshot if: env.RELEASE_VERSION != '' + env: + SOURCE_BRANCH: ${{ steps.branch.outputs.source_branch }} run: | if git diff --quiet -- versions.json versioned_docs versioned_sidebars; then echo "No versioned docs changes to commit" exit 0 fi + if [[ ! "${SOURCE_BRANCH}" =~ ^[A-Za-z0-9._/-]+$ ]]; then + echo "Refusing to push to unexpected branch name: '${SOURCE_BRANCH}'" + exit 1 + fi + git add versions.json versioned_docs versioned_sidebars git commit -m "docs: snapshot ${RELEASE_VERSION}" - git push origin HEAD:${{ steps.branch.outputs.source_branch }} + git push origin "HEAD:refs/heads/${SOURCE_BRANCH}" - name: Build Docusaurus site run: npm run build diff --git a/src/components/SiteConsent/index.jsx b/src/components/SiteConsent/index.jsx index 557b6f4b..8b352fa1 100644 --- a/src/components/SiteConsent/index.jsx +++ b/src/components/SiteConsent/index.jsx @@ -6,6 +6,13 @@ import styles from "./styles.module.css"; const CONSENT_KEY = "maintainerr-docs-consent"; const MATOMO_SCRIPT_ID = "maintainerr-matomo-script"; +// Set by the swizzled NotFound page so the next page view is tagged as a 404. +let pendingNotFound = false; + +export function flagNotFound() { + pendingNotFound = true; +} + function getStoredConsent() { if (typeof window === "undefined") { return null; @@ -49,10 +56,32 @@ function trackPageView(location) { const fullPath = `${location.pathname}${location.search}${location.hash}`; window._paq.push(["setCustomUrl", fullPath]); - window._paq.push(["setDocumentTitle", document.title]); + + if (pendingNotFound) { + pendingNotFound = false; + // Matomo's recommended 404 tagging: title prefixed with "404/URL = ...". + window._paq.push([ + "setDocumentTitle", + `404/URL = ${encodeURIComponent(fullPath)}/From = ${encodeURIComponent( + document.referrer, + )}`, + ]); + } else { + window._paq.push(["setDocumentTitle", document.title]); + } + window._paq.push(["trackPageView"]); } +function trackSiteSearch(keyword) { + if (typeof window === "undefined" || !window._paq) { + return; + } + + // category and result count are left unset (false) — cookieless, aggregate. + window._paq.push(["trackSiteSearch", keyword, false, false]); +} + export function trackMatomoEvent(category, action, name) { if (typeof window === "undefined" || !window._paq) { return; @@ -95,6 +124,58 @@ export default function SiteConsent() { trackPageView(location); }, [consent, location, matomoConfig.enabled]); + // Track docs-search queries (cookieless). The local search updates results + // as you type, so debounce and also fire on Enter; min length avoids noise. + useEffect(() => { + if (consent !== "accepted" || !matomoConfig.enabled) { + return; + } + + const SELECTOR = ".navbar__search-input"; + let timer; + let lastTracked = ""; + + function record(value) { + const keyword = value.trim(); + if (keyword.length < 3 || keyword === lastTracked) { + return; + } + lastTracked = keyword; + trackSiteSearch(keyword); + } + + function onInput(event) { + const target = event.target; + if (!target.matches || !target.matches(SELECTOR)) { + return; + } + clearTimeout(timer); + const { value } = target; + timer = setTimeout(() => record(value), 1500); + } + + function onKeydown(event) { + if (event.key !== "Enter") { + return; + } + const target = event.target; + if (!target.matches || !target.matches(SELECTOR)) { + return; + } + clearTimeout(timer); + record(target.value); + } + + document.addEventListener("input", onInput, true); + document.addEventListener("keydown", onKeydown, true); + + return () => { + clearTimeout(timer); + document.removeEventListener("input", onInput, true); + document.removeEventListener("keydown", onKeydown, true); + }; + }, [consent, matomoConfig.enabled]); + function updateConsent(nextConsent) { window.localStorage.setItem(CONSENT_KEY, nextConsent); setConsent(nextConsent); diff --git a/src/theme/NotFound/index.js b/src/theme/NotFound/index.js new file mode 100644 index 00000000..921ba7ff --- /dev/null +++ b/src/theme/NotFound/index.js @@ -0,0 +1,17 @@ +import React, { useLayoutEffect } from "react"; +import NotFound from "@theme-original/NotFound"; +import { useLocation } from "@docusaurus/router"; +import { flagNotFound } from "@site/src/components/SiteConsent"; + +// Wraps the default 404 page so SiteConsent tags the next Matomo page view as +// a 404. useLayoutEffect runs before SiteConsent's (passive) page-view effect, +// so the flag is set in time; keyed on pathname to cover 404 -> 404 navigation. +export default function NotFoundWrapper(props) { + const location = useLocation(); + + useLayoutEffect(() => { + flagNotFound(); + }, [location.pathname]); + + return ; +}