diff --git a/.github/actions/deploy-to-github-pages/action.yml b/.github/actions/deploy-to-github-pages/action.yml index a572044af0..48bb50aacc 100644 --- a/.github/actions/deploy-to-github-pages/action.yml +++ b/.github/actions/deploy-to-github-pages/action.yml @@ -94,6 +94,14 @@ runs: npm install @playwright/test && node script/html-to-pdf.js -i public/cheat-sheet.html + - name: render per-page Open Graph cards + shell: bash + run: | + # Every manual page gets its own OG card (command name + one-line + # tagline, embedded at render time with the current version), so each + # docs URL has a distinct social preview. See script/render-og-cards.js. + node script/render-og-cards.js --out public/images/og + - name: run Pagefind ${{ env.PAGEFIND_VERSION }} to build the search index shell: bash run: npx -y pagefind@${{ env.PAGEFIND_VERSION }} --site public --write-playground diff --git a/.github/workflows/update-git-version-and-manual-pages.yml b/.github/workflows/update-git-version-and-manual-pages.yml index 1089994b85..66b5936a1b 100644 --- a/.github/workflows/update-git-version-and-manual-pages.yml +++ b/.github/workflows/update-git-version-and-manual-pages.yml @@ -65,6 +65,33 @@ jobs: - name: prepare worktree if: steps.commit.outputs.result != '' || inputs.force-rebuild == true run: git sparse-checkout disable + - uses: actions/setup-node@v5 + if: steps.commit.outputs.result != '' + - name: regenerate OG image for the new version + if: steps.commit.outputs.result != '' + run: | + # The card embeds the current version (see script/render-og-card.js), so + # regenerate it whenever a new Git version is recorded in hugo.yml. + HUGO_VERSION="$(sed -n 's/^ *hugo_version: *//p' /dev/null + npx playwright install chromium >/dev/null + node script/render-og-card.js + git add static/images/og-image.png && + if git diff-index --cached --quiet HEAD -- static/images/og-image.png + then + echo '::notice::OG image unchanged' >&2 + exit 0 + fi && + version='${{ steps.commit.outputs.result }}' && + git \ + -c user.name=${{ github.actor }} \ + -c user.email=${{ github.actor }}@noreply.github.com \ + commit -m "Update OG image for Git $version" \ + -m 'Regenerated via the `update-git-version-and-manual-pages.yml` GitHub workflow.' \ + -- static/images/og-image.png && + echo "result=modified" >>$GITHUB_OUTPUT - name: clone git.git if: steps.commit.outputs.result != '' || inputs.force-rebuild == true run: git clone --bare https://github.com/git/git '${{ runner.temp }}/git' diff --git a/layouts/_default/baseof.html b/layouts/_default/baseof.html index 533c64709f..22eb8f351a 100644 --- a/layouts/_default/baseof.html +++ b/layouts/_default/baseof.html @@ -63,6 +63,23 @@

Redirecting…

{{ .Page.Title | default "Git" }}{{ if (isset .Params "subtitle") }} - {{ .Params.subtitle }}{{ end }} + {{ $og_image := "https://git-scm.com/images/og-image.png" }} + {{ if (isset .Params "docname") }}{{ $og_image = printf "https://git-scm.com/images/og/%s.png" .Params.docname }}{{ end }} + + + + + + + + + + + + + + + {{ $style := resources.Get "sass/application.scss" | resources.ExecuteAsTemplate "application.scss" . | css.Sass | resources.Minify | fingerprint }} diff --git a/script/og-card-design.svg b/script/og-card-design.svg new file mode 100644 index 0000000000..addbca897f --- /dev/null +++ b/script/og-card-design.svg @@ -0,0 +1,94 @@ + + + +git-scm.com__HERO_TITLE__Free and open source distributed version control systemLatest: v__VERSION__ diff --git a/script/og-card.svg b/script/og-card.svg new file mode 100644 index 0000000000..7e8b1ac888 --- /dev/null +++ b/script/og-card.svg @@ -0,0 +1,94 @@ + + + +git-scm.comGitFree and open source distributed version control systemLatest: v2.56.0 diff --git a/script/render-og-card.js b/script/render-og-card.js new file mode 100644 index 0000000000..5b8a138e91 --- /dev/null +++ b/script/render-og-card.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +// Generate static/images/og-image.png (1200x630) — the site-wide social card +// used as the fallback for non-docs pages (homepage, book, etc.). +// +// Design source: the maintainers' own SVG (To1ne's proposal, posted by dscho +// on PR #2233) — script/og-card-design.svg. This script loads that exact file +// and substitutes only the hero title (tspan #tspan4) with "Git" and the +// version pill (tspan #tspan6) with the current version. Everything else — +// logo, colors, fonts, sizes, transforms — comes verbatim from the +// maintainers' file, so the site-wide card matches the per-page docs cards +// and the maintainers' reference pixel-for-pixel. +// +// Usage: +// node script/render-og-card.js [worktree-path] +// +// The version is read from hugo.yml (latest_version) at render time, so the +// card stays in sync whenever a new Git version is published. The image is +// regenerated as part of the Git version-bump workflow — see +// `.github/workflows/update-git-version-and-manual-pages.yml`. +// +// Rendered with the repo's existing Playwright/Chromium stack. +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +const WORKTREE = path.resolve(process.argv[2] || '.'); +const OUT = path.join(WORKTREE, 'static', 'images', 'og-image.png'); +const DESIGN = fs.readFileSync(path.join(WORKTREE, 'script', 'og-card-design.svg'), 'utf8'); + +function currentVersion() { + const hugo = fs.readFileSync(path.join(WORKTREE, 'hugo.yml'), 'utf8'); + const m = hugo.match(/^\s*latest_version:\s*([0-9][^\s]*)/m); + if (!m) throw new Error('Could not find latest_version in hugo.yml'); + return m[1]; +} + +function cardSvg(version) { + // The design SVG carries all-upper-case PLACEHOLDERS for the two variable + // values (hero title and version pill). Replace them verbatim — no regex. + return DESIGN + .replace('__HERO_TITLE__', 'Git') + .replace('__HERO_FONT_SIZE__', '32') + .replace('__VERSION__', version); +} + +(async () => { + const version = currentVersion(); + const svg = cardSvg(version); + const svgPath = path.join(WORKTREE, 'script', 'og-card.svg'); + fs.writeFileSync(svgPath, svg); + + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: 1200, height: 630 } }); + await page.goto('file://' + svgPath); + await page.waitForTimeout(200); + fs.mkdirSync(path.dirname(OUT), { recursive: true }); + await page.screenshot({ path: OUT, clip: { x: 0, y: 0, width: 1200, height: 630 } }); + await browser.close(); + console.log(`Wrote ${OUT} (Git ${version})`); +})().catch((e) => { console.error(e); process.exit(1); }); \ No newline at end of file diff --git a/script/render-og-cards.js b/script/render-og-cards.js new file mode 100644 index 0000000000..78e8e31cfa --- /dev/null +++ b/script/render-og-cards.js @@ -0,0 +1,181 @@ +#!/usr/bin/env node +// Generate a per-page Open Graph card (1200x630) for every Git manual page. +// +// Design source: the maintainers' own SVG (To1ne's proposal, posted by dscho +// on PR #2233) — script/og-card-design.svg. This script loads that exact +// file as the canonical design and substitutes only the per-page text: +// - the hero title (tspan #tspan4): " - " +// - the version pill (tspan #tspan6): "Latest: vX.Y.Z" +// Everything else — logo, colors, fonts, sizes, transforms — comes verbatim +// from the maintainers' file, so the rendered card matches their reference +// pixel-for-pixel (fonts: Arial for tagline/pill/footer, Times New Roman for +// the hero title; background #f0efea; 6px red top bar #f03c2e). +// +// Long titles: the reference is a single line. For titles wider than the card +// (e.g. git-fsck-objects, 88 chars), the hero text auto-sizes to a slightly +// smaller size on the same baseline (never wraps, never overlaps) so it stays +// readable and inside the canvas. +// +// Usage: +// node script/render-og-cards.js [worktree-path] [--out ] +// +// Reads each page under external/docs/content/docs/*.html, extracts the +// `docname` from frontmatter and the one-line NAME tagline from the body, +// and renders a card to static/images/og/.png (or /.png). +// +// Rendered with the repo's existing Playwright/Chromium stack. +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +// Parse argv: optional worktree path and optional --out override. +const args = process.argv.slice(2); +let outArg = null; +let worktreeArg = '.'; +for (let i = 0; i < args.length; i++) { + if (args[i] === '--out') { outArg = args[++i]; } + else if (!args[i].startsWith('--')) { worktreeArg = args[i]; } +} +const WORKTREE = path.resolve(worktreeArg); +const DOCS_DIR = path.join(WORKTREE, 'external', 'docs', 'content', 'docs'); +const OUT_DIR = outArg ? path.resolve(outArg) : path.join(WORKTREE, 'static', 'images', 'og'); +const DESIGN = fs.readFileSync(path.join(WORKTREE, 'script', 'og-card-design.svg'), 'utf8'); + +function currentVersion() { + const hugo = fs.readFileSync(path.join(WORKTREE, 'hugo.yml'), 'utf8'); + const m = hugo.match(/^\s*latest_version:\s*([0-9][^\s]*)/m); + if (!m) throw new Error('Could not find latest_version in hugo.yml'); + return m[1]; +} + +// Escape XML special characters for embedding in SVG text. +function esc(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// Decode common HTML entities that appear in manual page bodies. +function decodeEntities(s) { + const map = { + '’': "'", '‘': "'", '“': '"', '”': '"', + '—': '\u2014', '​': '', '&': '&', '<': '<', + '>': '>', '"': '"', ''': "'", ''': "'", + '…': '\u2026', ' ': ' ', + }; + let out = s; + for (const [k, v] of Object.entries(map)) out = out.split(k).join(v); + return out; +} + +// Extract the one-line NAME tagline from a manual page body. +//

NAME

+//

git-clone - Clone a repository into a new directory

+function extractTagline(html) { + const m = html.match( + /

]*>.*?<\/h2>\s*
\s*
\s*

(.*?)<\/p>/s + ); + if (!m) return null; + return decodeEntities(m[1].replace(/<[^>]+>/g, '')).trim(); +} + +// Extract the `docname:` value from a page's YAML frontmatter. +function extractDocname(html) { + const m = html.match(/^---\s*\n([\s\S]*?)\n---/); + if (!m) return null; + const fm = m[1].match(/^docname:\s*(.+)$/m); + return fm ? decodeEntities(fm[1].trim()) : null; +} + +// Substitute the per-page hero title into the maintainers' design SVG. +// The hero tspan (#tspan4) is a single line at the reference size (32px in +// the source transform). For titles wider than the available canvas the +// renderer measures the real on-screen width and reduces the tspan font-size +// until the line fits inside the card (never wraps, never overlaps). The +// reference title "git-clone - Clone a repository into a new directory" +// renders at 32px untouched. +function cardSvg({ title, version, fontSize }) { + const TITLE = esc(title); + const PILL = `Latest: v${esc(version)}`; + // The design SVG carries all-upper-case PLACEHOLDERS for the variable + // values (hero title, hero font-size, version pill). Replace them verbatim + // — no regex. Everything else stays verbatim from the maintainers' file. + return DESIGN + .replace('__HERO_TITLE__', TITLE) + .replace('__HERO_FONT_SIZE__', String(fontSize)) + .replace('__VERSION__', version); +} + +// Measure the rendered hero text's right edge (in screen px). The hero tspan +// lives under a matrix transform; getCTM() already resolves the full chain +// (tspan -> text -> ... -> svg -> viewport), so the transformed point is +// already in screen pixels — do NOT re-scale by viewBox. +async function heroRightEdge(page, svgPath) { + await page.goto('file://' + svgPath); + await page.waitForTimeout(120); + return page.evaluate(() => { + const tsp = document.getElementById('tspan4'); + if (!tsp) return null; + const b = tsp.getBBox(); + const ctm = tsp.getCTM(); + const p = new DOMPoint(b.x + b.width, b.y).matrixTransform(ctm); + return p.x; + }); +} + +(async () => { + const version = currentVersion(); + if (!fs.existsSync(DOCS_DIR)) { + console.error(`Docs dir not found: ${DOCS_DIR}`); + process.exit(1); + } + const files = fs.readdirSync(DOCS_DIR).filter((f) => f.endsWith('.html')); + fs.mkdirSync(OUT_DIR, { recursive: true }); + + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: 1200, height: 630 } }); + + let rendered = 0; + let skipped = 0; + let shrunk = 0; + for (const file of files) { + const html = fs.readFileSync(path.join(DOCS_DIR, file), 'utf8'); + const docname = extractDocname(html); + const tagline = extractTagline(html); + if (!docname || !tagline) { + skipped++; + continue; + } + + // Start at the reference size (32px source units); the reference + // git-clone title (58 chars) renders untouched. For longer titles, reduce + // the font-size until the line's right edge clears the right margin. + const AVAIL_RIGHT = 1090; // leave a ~110px right margin (of 1200) + const MIN_FONT = 18; + let fontSize = 32; + let svg = cardSvg({ title: tagline, version, fontSize }); + const svgPath = path.join(OUT_DIR, `${docname}.svg`); + const pngPath = path.join(OUT_DIR, `${docname}.png`); + fs.writeFileSync(svgPath, svg); + let rightEdge = await heroRightEdge(page, svgPath); + if (rightEdge !== null) { + while (rightEdge > AVAIL_RIGHT && fontSize > MIN_FONT) { + fontSize -= 2; + svg = cardSvg({ title: tagline, version, fontSize }); + fs.writeFileSync(svgPath, svg); + rightEdge = await heroRightEdge(page, svgPath); + if (rightEdge === null) break; + } + if (fontSize < 32) shrunk++; + } + + await page.screenshot({ path: pngPath, clip: { x: 0, y: 0, width: 1200, height: 630 } }); + fs.unlinkSync(svgPath); // keep only the PNG + rendered++; + } + await browser.close(); + console.log(`Rendered ${rendered} per-page OG cards to ${OUT_DIR} (skipped ${skipped} without a NAME tagline; auto-shrunk ${shrunk} long titles). Git ${version}`); +})().catch((e) => { console.error(e); process.exit(1); }); \ No newline at end of file diff --git a/static/images/og-image.png b/static/images/og-image.png new file mode 100644 index 0000000000..9baaf7d586 Binary files /dev/null and b/static/images/og-image.png differ diff --git a/static/images/og/git-clone.png b/static/images/og/git-clone.png new file mode 100644 index 0000000000..37f9e1031f Binary files /dev/null and b/static/images/og/git-clone.png differ