diff --git a/.github/workflows/cloudflare-status.yml b/.github/workflows/cloudflare-status.yml new file mode 100644 index 00000000..32d22330 --- /dev/null +++ b/.github/workflows/cloudflare-status.yml @@ -0,0 +1,103 @@ +name: Cloudflare Pages Status + +on: + pull_request: + branches: + - docs + +permissions: + pull-requests: write + contents: read + +jobs: + cloudflare-status: + name: Check Cloudflare Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Wait for Cloudflare deployment and check status + id: deployment + run: | + # Poll Cloudflare API for deployment status + # Maximum wait time: 15 minutes (90 retries * 10 seconds) + max_retries=90 + retry_count=0 + status="pending" + + echo "Waiting for Cloudflare deployment to complete..." + + while [ $retry_count -lt $max_retries ]; do + response=$(curl -s \ + -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \ + "https://api.cloudflare.com/client/v4/accounts/60b76f40370d8320885e92e3daa114b1/pages/projects/docs/deployments?per_page=3") + + # Get the most recent deployment + deployment_status=$(echo "$response" | jq -r '.result[0].latest_stage // "unknown"') + deployment_url=$(echo "$response" | jq -r '.result[0].url // ""') + deployment_created=$(echo "$response" | jq -r '.result[0].created_on // ""') + + echo "Check #$((retry_count + 1)): status=$deployment_status" + + # Check if deployment is complete + if [ "$deployment_status" = "success" ]; then + status="success" + echo "status=$status" >> $GITHUB_OUTPUT + echo "url=$deployment_url" >> $GITHUB_OUTPUT + echo "Deployment succeeded!" + break + elif [ "$deployment_status" = "failure" ] || [ "$deployment_status" = "failed" ]; then + status="failure" + echo "status=$status" >> $GITHUB_OUTPUT + echo "url=$deployment_url" >> $GITHUB_OUTPUT + echo "Deployment failed!" + break + fi + + retry_count=$((retry_count + 1)) + sleep 10 + done + + # If we exhausted retries, mark as unknown + if [ "$status" = "pending" ]; then + echo "status=timeout" >> $GITHUB_OUTPUT + echo "Timed out waiting for deployment" + fi + + - name: Post failure comment + if: steps.deployment.outputs.status == 'failure' + uses: actions/github-script@v7 + with: + script: | + const url = '${{ steps.deployment.outputs.url }}'; + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `## ⚠️ Cloudflare Build Failed + + The Cloudflare Pages deployment failed for this PR. + + **View full logs:** [Cloudflare Dashboard](${url}) + + Please check the Cloudflare dashboard for detailed error messages. Common issues: + - Missing images or assets + - Broken links in markdown files + - Build configuration errors` + }); + + - name: Post timeout comment + if: steps.deployment.outputs.status == 'timeout' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `## ⏱️ Cloudflare Build Status Unknown + + Could not determine Cloudflare deployment status within 15 minutes. + + Please check the [Cloudflare Dashboard](https://dash.cloudflare.com/60b76f40370d8320885e92e3daa114b1/pages/view/docs) manually.` + }); diff --git a/astro.config.mjs b/astro.config.mjs index 0be712a9..264ea7e6 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -3,12 +3,30 @@ import starlight from '@astrojs/starlight'; import starlightImageZoom from 'starlight-image-zoom'; import rehypeAstroRelativeMarkdownLinks from "astro-rehype-relative-markdown-links"; import starlightLinksValidator from 'starlight-links-validator'; +import rehypeInjectFigure from './src/lib/rehype-inject-figure.mjs'; import redirects from "./redirects.js"; const options = { collectionBase: false, }; +// Diagrams injected into docs pages after (or before) a specific heading. Lets +// us enrich a page with a figure without editing its markdown. Add a row per +// figure — no plugin edits needed. +// slug — page slug relative to src/content/docs (no extension) +// afterHeading — heading text to find (case-insensitive, trimmed) +// before — if true, insert immediately *before* the matched heading +// (useful when the figure introduces the section) +// replace — optional HTML tagName to swap with the figure within that +// section (e.g. 'table'). If omitted, the figure is inserted +// immediately after the heading. +// src — image path under /public +// alt — required for a11y +// width/height — optional, recommended to avoid layout shift +// caption — optional
text +// className — optional, defaults to "injected-figure" +const figureInjections = []; + export default defineConfig({ site: 'https://docs.testomat.io', image: { @@ -469,6 +487,25 @@ export default defineConfig({ markdown: { rehypePlugins: [ [rehypeAstroRelativeMarkdownLinks, options], + [rehypeInjectFigure, { injections: figureInjections }], + ], + }, + vite: { + plugins: [ + { + // rehypeInjectFigure reads diagram SVGs from /public at render + // time; Vite doesn't know about that dependency, so editing a + // diagram wouldn't refresh the page in `astro dev`. Force a full + // reload when any /public/*.svg changes. + name: 'reload-on-public-svg-change', + handleHotUpdate({ file, server }) { + const f = file.replace(/\\/g, '/'); + if (f.includes('/public/') && f.endsWith('.svg')) { + server.ws.send({ type: 'full-reload' }); + return []; + } + }, + }, ], }, redirects: redirects, diff --git a/package.json b/package.json index c2b7a770..2aa28e2e 100644 --- a/package.json +++ b/package.json @@ -39,11 +39,13 @@ "canvaskit-wasm": "^0.40.0", "dotenv": "^17.3.1", "gray-matter": "^4.0.3", + "hast-util-from-html": "^2.0.3", "meilisearch-docsearch": "^0.8.0", "schema-dts": "^1.1.5", "sharp": "^0.34.5", "starlight-image-zoom": "^0.13.2", - "starlight-links-validator": "^0.19.2" + "starlight-links-validator": "^0.19.2", + "unist-util-visit": "^5.0.0" }, "type": "module" } diff --git a/src/lib/rehype-inject-figure.mjs b/src/lib/rehype-inject-figure.mjs new file mode 100644 index 00000000..2a65555b --- /dev/null +++ b/src/lib/rehype-inject-figure.mjs @@ -0,0 +1,256 @@ +import { visit } from 'unist-util-visit'; +import { fromHtml } from 'hast-util-from-html'; +import fs from 'node:fs'; +import path from 'node:path'; + +const DOCS_ROOT = path.join(process.cwd(), 'src', 'content', 'docs'); +const PUBLIC_ROOT = path.join(process.cwd(), 'public'); + +function fileSlug(filePath) { + if (!filePath) return null; + const rel = path.relative(DOCS_ROOT, filePath); + if (rel.startsWith('..')) return null; + return rel.replace(/\.(md|mdx)$/i, '').split(path.sep).join('/'); +} + +function headingText(node) { + let out = ''; + visit(node, 'text', (n) => { out += n.value; }); + return out.trim(); +} + +// CSS inside an inline (tag name + control button from starlight-image-zoom/libs/rehype.ts). +// We build it ourselves because that plugin only auto-wraps /, +// not inline — but its runtime DOES handle inline (full-screen +// zoom). So an inlined SVG gets the same lightbox the PNG figures had. +const IMAGE_ZOOM_TAG = 'starlight-image-zoom-zoomable'; +function zoomControlButton(alt) { + return { + type: 'element', + tagName: 'button', + properties: { + 'aria-label': `Zoom image${alt ? `: ${alt}` : ''}`, + class: 'starlight-image-zoom-control', + }, + children: [ + { + type: 'element', + tagName: 'svg', + properties: { 'aria-hidden': 'true', fill: 'currentColor', viewBox: '0 0 24 24' }, + children: [ + { + type: 'element', + tagName: 'use', + properties: { href: '#starlight-image-zoom-icon-zoom' }, + children: [], + }, + ], + }, + ], + }; +} + +function buildFigureChild(cfg) { + // Inline SVG content so the browser uses the page's loaded fonts. An + // tag would render the SVG in "secure static mode" — external @font-face + // declarations don't load there, so titles fall back to serif. + if (cfg.src.toLowerCase().endsWith('.svg')) { + const svgNode = loadInlineSvg(cfg.src); + const cloned = structuredClone(svgNode); + cloned.properties = cloned.properties ?? {}; + // Drop fixed width/height so it scales to the figure's max-width. + delete cloned.properties.width; + delete cloned.properties.height; + if (cfg.alt) { + if (!cloned.properties.role) { + cloned.properties.role = 'img'; + cloned.properties['aria-label'] = cfg.alt; + } + // = accessible fallback + the caption image-zoom shows in the + // lightbox + native hover tooltip. + cloned.children = [ + { type: 'element', tagName: 'title', properties: {}, children: [{ type: 'text', value: cfg.alt }] }, + ...cloned.children, + ]; + } + return { + type: 'element', + tagName: IMAGE_ZOOM_TAG, + properties: {}, + children: [cloned, zoomControlButton(cfg.alt)], + }; + } + const imgProps = { src: cfg.src, alt: cfg.alt ?? '', loading: 'lazy' }; + if (cfg.width) imgProps.width = cfg.width; + if (cfg.height) imgProps.height = cfg.height; + return { type: 'element', tagName: 'img', properties: imgProps, children: [] }; +} + +function buildFigure(cfg) { + const children = [buildFigureChild(cfg)]; + if (cfg.captionHtml) { + const parsed = fromHtml(cfg.captionHtml, { fragment: true }); + children.push({ + type: 'element', + tagName: 'figcaption', + properties: {}, + children: parsed.children, + }); + } else if (cfg.caption) { + children.push({ + type: 'element', + tagName: 'figcaption', + properties: {}, + children: [{ type: 'text', value: cfg.caption }], + }); + } + return { + type: 'element', + tagName: 'figure', + properties: { className: cfg.className ? [cfg.className] : ['injected-figure'] }, + children, + }; +} + +export default function rehypeInjectFigure(options = {}) { + const injections = options.injections ?? []; + const bySlug = new Map(); + for (const cfg of injections) { + if (!cfg.slug || !cfg.afterHeading || !cfg.src) { + console.warn('[rehype-inject-figure] skipping invalid entry:', cfg); + continue; + } + if (!bySlug.has(cfg.slug)) bySlug.set(cfg.slug, []); + bySlug.get(cfg.slug).push(cfg); + } + + return (tree, file) => { + const slug = fileSlug(file?.path); + if (!slug) return; + const entries = bySlug.get(slug); + if (!entries?.length) return; + + const remaining = new Map(entries.map((e) => [e.afterHeading.trim().toLowerCase(), e])); + + visit(tree, 'element', (node, index, parent) => { + if (!parent || index == null) return; + if (!/^h[1-6]$/.test(node.tagName)) return; + const key = headingText(node).toLowerCase(); + const cfg = remaining.get(key); + if (!cfg) return; + remaining.delete(key); + + // If `replace` is set, swap the next matching sibling within the section + // (i.e. before the next heading of any level) with the figure. + if (cfg.replace) { + for (let i = index + 1; i < parent.children.length; i++) { + const sibling = parent.children[i]; + if (sibling.type !== 'element') continue; + if (/^h[1-6]$/.test(sibling.tagName)) break; // crossed section boundary + if (sibling.tagName === cfg.replace) { + parent.children.splice(i, 1, buildFigure(cfg)); + return [visit.SKIP, i + 1]; + } + } + console.warn( + `[rehype-inject-figure] ${slug}.md: no <${cfg.replace}> found after "${cfg.afterHeading}" — falling back to insert-after-heading` + ); + } + + // `before: true` puts the figure immediately before the matched heading + // instead of after — useful when the heading marks the start of the + // section the diagram introduces. + const insertAt = cfg.before ? index : index + 1; + parent.children.splice(insertAt, 0, buildFigure(cfg)); + return [visit.SKIP, insertAt + 1]; + }); + + for (const [, cfg] of remaining) { + console.warn( + `[rehype-inject-figure] ${slug}.md: heading "${cfg.afterHeading}" not found — figure ${cfg.src} not injected` + ); + } + }; +} diff --git a/src/styles/custom.css b/src/styles/custom.css index 31988be7..9873b75f 100644 --- a/src/styles/custom.css +++ b/src/styles/custom.css @@ -89,4 +89,46 @@ html[data-theme=dark] .docsearch-btn-placeholder { top: 40px; right: -260px; } +} + +/* Figures injected into docs pages by rehype-inject-figure.mjs. + starlight-image-zoom-zoomable is a custom element that defaults to + display:inline, which makes max-width on its SVG/img child ineffective — + force display:block + max-width:100% on the wrapper so the diagram never + overflows the docs content area. */ +.sl-markdown-content .injected-figure { + margin: 1.75em 0; + text-align: center; + max-width: 100% !important; + width: 100% !important; + overflow: hidden; +} +.sl-markdown-content .injected-figure starlight-image-zoom-zoomable { + display: block !important; + max-width: 100% !important; + width: 100% !important; + /* Diagrams shrink small on phones — signal they can be tapped to zoom. */ + cursor: zoom-in; +} +.sl-markdown-content .injected-figure img { + max-width: 100% !important; + width: 100% !important; + height: auto !important; + display: block; + margin: 0 auto; +} +/* Inlined SVG diagrams: same responsive sizing as the <img> variant. + `> svg` so the zoom-control button's icon <svg> is left alone. */ +.sl-markdown-content .injected-figure starlight-image-zoom-zoomable > svg { + max-width: 100% !important; + width: 100% !important; + height: auto !important; + display: block; + margin: 0 auto; +} +.sl-markdown-content .injected-figure figcaption { + margin-top: 0.6em; + font-size: 0.875em; + color: var(--sl-color-text-secondary, #6b7280); + font-style: italic; } \ No newline at end of file