diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff229d761..d12fbb2a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,6 +129,9 @@ jobs: - name: Test heading variables run: npm run test:heading-vars + - name: Test social preview generation + run: npm run test:og-images + - name: Test tracking redirect middleware run: npm run test:tracking-redirect @@ -144,6 +147,9 @@ jobs: - name: Validate homepage JSON-LD run: npm run test:homepage-json-ld + - name: Validate social previews + run: npm run test:og-output + - name: Check internal links run: python3 .agents/skills/check_for_broken_links/check_links.py --internal-only - name: Test redirect invariants diff --git a/astro.config.mjs b/astro.config.mjs index c85dc0854..c7f490438 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -15,6 +15,7 @@ import { varsTransformPlugin } from './src/plugins/vars-transform.ts'; import vercel from '@astrojs/vercel'; import { sidebarTopics } from './src/sidebar.ts'; import docsMarkdownIntegration from './src/integrations/docs-markdown-integration.js'; +import ogImages from './src/integrations/og-images.js'; import { DOCS_ORIGIN } from './src/lib/site.js'; const openApiVersion = parse( @@ -253,6 +254,7 @@ export default defineConfig({ ], }), docsMarkdownIntegration(), + ogImages(), ], adapter: vercel(), vite: { diff --git a/package.json b/package.json index e6a8c2735..fb6d72a23 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "typecheck": "astro check", "lint": "trunk check", "fmt": "trunk fmt", - "og:api": "node scripts/generate-og-api.mjs", + "test:og-images": "node --test src/integrations/og-images.test.js", + "test:og-output": "node scripts/validate-og-images.mjs", "favicon:generate": "node scripts/generate-favicons.mjs", "seo:audit": "node scripts/seo-audit.mjs", "test:heading-tocs": "node scripts/validate-heading-tocs.mjs", diff --git a/scripts/generate-og-api.mjs b/scripts/generate-og-api.mjs deleted file mode 100644 index f7d42772e..000000000 --- a/scripts/generate-og-api.mjs +++ /dev/null @@ -1,52 +0,0 @@ -// Renders public/assets/og/api.png (npm run og:api) — the 1200x630 social -// card used by /api's og:image and twitter:image meta tags. Reads the -// wordmark from src/assets/warp-logo-dark.svg so the raster card can't drift -// from the canonical asset, then rasterizes to PNG since scrapers don't -// reliably accept SVG og:image. -import { mkdirSync, readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import sharp from 'sharp'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const outPath = resolve(__dirname, '../public/assets/og/api.png'); -mkdirSync(dirname(outPath), { recursive: true }); - -// The source asset has no root width/height, so a nested would default -// to 100% of its parent; add the native 598x209 dimensions explicitly. -const wordmarkSvg = readFileSync(resolve(__dirname, '../src/assets/warp-logo-dark.svg'), 'utf-8') - .trim() - .replace(' using a generic sans-serif that maps reliably across -// scraper-side renderers. Warp's accent blue underlines the wordmark. -const svg = ` - - - - - - - - - - ${wordmarkSvg} - - - - - - Agent API Reference - - - Create and manage cloud agent runs, schedules, and more. - - - DOCS.WARP.DEV - - -`; - -await sharp(Buffer.from(svg)).png().toFile(outPath); -console.log(`Wrote ${outPath}`); diff --git a/scripts/validate-og-images.mjs b/scripts/validate-og-images.mjs new file mode 100644 index 000000000..21331bed3 --- /dev/null +++ b/scripts/validate-og-images.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseHTML } from 'linkedom'; +import sharp from 'sharp'; +import { socialImageMeta, socialImageTitle } from '../src/lib/social-image.js'; + +const root = process.argv[2] + ? path.resolve(process.argv[2]) + : fileURLToPath(new URL('../.vercel/output/static/', import.meta.url)); +const images = new Set(); +let pages = 0; +let fallbackPages = 0; + +async function walk(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const filename = path.join(directory, entry.name); + if (entry.isDirectory()) { + await walk(filename); + continue; + } + if (!entry.name.endsWith('.html')) continue; + const { document } = parseHTML(await readFile(filename, 'utf8')); + const title = socialImageTitle(document); + const imageTags = document.querySelectorAll('meta[property="og:image"]'); + assert.equal(imageTags.length, 1, `${filename}: duplicate or missing image`); + const url = new URL(imageTags[0].getAttribute('content')); + assert.equal(url.origin, 'https://docs.warp.dev'); + assert.match(url.pathname, title + ? /^\/(?:assets\/og\/[a-f0-9]{24}|og-image)\.png$/ + : /^\/og-image\.png$/); + if (url.pathname === '/og-image.png') fallbackPages++; + for (const { attrs } of socialImageMeta(url.pathname, title)) { + const key = attrs.property ?? attrs.name; + const attribute = attrs.property ? 'property' : 'name'; + const tags = document.querySelectorAll(`meta[${attribute}="${key}"]`); + assert.equal(tags.length, 1, `${filename}: ${key} must be unique`); + assert.equal(tags[0].getAttribute('content'), attrs.content, `${filename}: ${key}`); + } + images.add(url.pathname); + pages++; + } +} + +await walk(root); +assert.ok(pages > 0, 'Build the docs before validating social previews'); +for (const imagePath of [...images, '/og-image.png']) { + const metadata = await sharp(path.join(root, imagePath)).metadata(); + assert.equal(metadata.format, 'png', imagePath); + assert.equal(metadata.width, 1200, imagePath); + assert.equal(metadata.height, 630, imagePath); +} +console.log(`Verified OG/Twitter metadata on ${pages} pages and ${images.size} PNG previews (1200×630); ${fallbackPages} pages use the default image.`); diff --git a/src/assets/og/fonts/Inter-OFL.txt b/src/assets/og/fonts/Inter-OFL.txt new file mode 100644 index 000000000..909a6bf9c --- /dev/null +++ b/src/assets/og/fonts/Inter-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/assets/og/fonts/Inter.ttf b/src/assets/og/fonts/Inter.ttf new file mode 100644 index 000000000..047c92f6e Binary files /dev/null and b/src/assets/og/fonts/Inter.ttf differ diff --git a/src/assets/og/fonts/NotoEmoji-OFL.txt b/src/assets/og/fonts/NotoEmoji-OFL.txt new file mode 100644 index 000000000..56263f5c9 --- /dev/null +++ b/src/assets/og/fonts/NotoEmoji-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2013 Google LLC + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/assets/og/fonts/NotoEmoji.ttf b/src/assets/og/fonts/NotoEmoji.ttf new file mode 100644 index 000000000..c2c26ab61 Binary files /dev/null and b/src/assets/og/fonts/NotoEmoji.ttf differ diff --git a/src/assets/og/fonts/NotoSansArabic-OFL.txt b/src/assets/og/fonts/NotoSansArabic-OFL.txt new file mode 100644 index 000000000..55e28e016 --- /dev/null +++ b/src/assets/og/fonts/NotoSansArabic-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/arabic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/assets/og/fonts/NotoSansArabic.ttf b/src/assets/og/fonts/NotoSansArabic.ttf new file mode 100644 index 000000000..f1d01edce Binary files /dev/null and b/src/assets/og/fonts/NotoSansArabic.ttf differ diff --git a/src/assets/og/fonts/NotoSansCJK-OFL.txt b/src/assets/og/fonts/NotoSansCJK-OFL.txt new file mode 100644 index 000000000..d952d62c0 --- /dev/null +++ b/src/assets/og/fonts/NotoSansCJK-OFL.txt @@ -0,0 +1,92 @@ +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/assets/og/fonts/NotoSansCJKsc-Regular.otf b/src/assets/og/fonts/NotoSansCJKsc-Regular.otf new file mode 100644 index 000000000..dc1556247 Binary files /dev/null and b/src/assets/og/fonts/NotoSansCJKsc-Regular.otf differ diff --git a/src/assets/og/fonts/SOURCES.txt b/src/assets/og/fonts/SOURCES.txt new file mode 100644 index 000000000..0ca015cc2 --- /dev/null +++ b/src/assets/og/fonts/SOURCES.txt @@ -0,0 +1,18 @@ +These fonts are used only by the build-time PNG renderer. They are not +downloaded by docs readers. All are distributed under the SIL Open Font +License; the accompanying *-OFL.txt files contain the license notices. + +Inter is the docs site's primary typeface. Noto provides fallback glyphs +for CJK, Arabic, and emoji without depending on Vercel's system fonts. + +Inter.ttf +https://github.com/google/fonts/blob/45e3d5269e4a17d823f6d84470bc084dc5ae46f6/ofl/inter/Inter%5Bopsz,wght%5D.ttf + +NotoSansArabic.ttf +https://github.com/google/fonts/blob/45e3d5269e4a17d823f6d84470bc084dc5ae46f6/ofl/notosansarabic/NotoSansArabic%5Bwdth,wght%5D.ttf + +NotoEmoji.ttf +https://github.com/google/fonts/blob/45e3d5269e4a17d823f6d84470bc084dc5ae46f6/ofl/notoemoji/NotoEmoji%5Bwght%5D.ttf + +NotoSansCJKsc-Regular.otf +https://github.com/notofonts/noto-cjk/blob/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf diff --git a/src/integrations/og-images.js b/src/integrations/og-images.js new file mode 100644 index 000000000..0bbbe1c61 --- /dev/null +++ b/src/integrations/og-images.js @@ -0,0 +1,77 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseHTML } from 'linkedom'; +import { renderOgImage } from '../lib/og-image.js'; +import { socialImageMeta, socialImageTitle } from '../lib/social-image.js'; + +export default function ogImages() { + return { + name: 'warp-og-images', + hooks: { + 'astro:build:generated': async ({ dir, logger }) => { + const count = await generateOgImages(fileURLToPath(dir), logger); + logger.info(`Generated social previews for ${count} HTML pages`); + }, + }, + }; +} + +export async function generateOgImages(outputRoot, logger) { + const images = new Map(); + let count = 0; + + async function imageForTitle(title) { + if (!title) return '/og-image.png'; + if (images.has(title)) return images.get(title); + let imagePath = '/og-image.png'; + try { + const png = await renderOgImage(title); + const hash = createHash('sha256').update(png).digest('hex').slice(0, 24); + imagePath = `/assets/og/${hash}.png`; + await mkdir(path.join(outputRoot, 'assets/og'), { recursive: true }); + await writeFile(path.join(outputRoot, imagePath), png); + } catch (error) { + logger.warn(`Using default social image for "${title}": ${error.message}`); + imagePath = '/og-image.png'; + } + images.set(title, imagePath); + return imagePath; + } + + async function walk(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const filename = path.join(directory, entry.name); + if (entry.isDirectory()) { + await walk(filename); + } else if (entry.isFile() && entry.name.endsWith('.html')) { + const html = await readFile(filename, 'utf8'); + const { document } = parseHTML(html); + if (!document.head) continue; + const title = socialImageTitle(document); + const imagePath = await imageForTitle(title); + await writeFile(filename, replaceSocialImageMeta(html, document, imagePath, title)); + count++; + } + } + } + + await walk(outputRoot); + return count; +} + +function replaceSocialImageMeta(html, document, imagePath, title) { + for (const meta of document.head.querySelectorAll('meta')) { + const key = meta.getAttribute('property') ?? meta.getAttribute('name') ?? ''; + if (/^(og:image(?::.*)?|twitter:image(?::.*)?|twitter:card)$/.test(key)) meta.remove(); + } + for (const { attrs } of socialImageMeta(imagePath, title)) { + const meta = document.createElement('meta'); + for (const [name, value] of Object.entries(attrs)) meta.setAttribute(name, value); + document.head.append(meta); + } + + // Leave article HTML byte-for-byte intact; only the head needs new metadata. + return html.replace(/]*>[\s\S]*?<\/head>/i, () => document.head.outerHTML); +} diff --git a/src/integrations/og-images.test.js b/src/integrations/og-images.test.js new file mode 100644 index 000000000..0106410d7 --- /dev/null +++ b/src/integrations/og-images.test.js @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { copyFile, mkdtemp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { parseHTML } from 'linkedom'; +import sharp from 'sharp'; +import { generateOgImages } from './og-images.js'; +import { renderOgImage } from '../lib/og-image.js'; + +async function validateOutput(root) { + await copyFile(new URL('../../public/og-image.png', import.meta.url), path.join(root, 'og-image.png')); + const result = spawnSync(process.execPath, [ + fileURLToPath(new URL('../../scripts/validate-og-images.mjs', import.meta.url)), + root, + ], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); +} + +const examples = [ + 'Quickstart', + 'Configuring self-hosted workers and repository access for cloud agents across multiple environments', + 'W'.repeat(180), + 'Creating and coordinating agents with permissions, environments, integrations, schedules, and repositories '.repeat(4).trim(), + 'Créer des agents — Καλημέρα · Привет · 你好 · مرحبًا 🚀', + 'CLI: & "quoted values" — what’s next?', +]; + +test('fits short, multiline, extreme, unbroken and Unicode titles within the safe area', async () => { + const cards = new Set(); + for (const title of examples) { + const png = await renderOgImage(title); + const metadata = await sharp(png).metadata(); + assert.equal(metadata.format, 'png'); + assert.equal(metadata.width, 1200); + assert.equal(metadata.height, 630); + cards.add(png.toString('base64')); + + const { data, info } = await sharp(png) + .extract({ left: 0, top: 210, width: 1200, height: 310 }) + .removeAlpha().raw().toBuffer({ resolveWithObject: true }); + let titlePixels = 0; + for (let y = 0; y < info.height; y++) { + for (let x = 0; x < info.width; x++) { + const offset = (y * info.width + x) * info.channels; + if (data[offset] < 180 || data[offset + 1] < 180 || data[offset + 2] < 180) continue; + titlePixels++; + assert.ok(x >= 80 && x < 1120 && y >= 10 && y < 300, `Title escaped padding: ${title}`); + } + } + assert.ok(titlePixels > 1000, 'Title must be visible, not blank'); + } + assert.equal(cards.size, examples.length, 'Every title must produce its own image'); + assert.deepEqual(await renderOgImage('Quickstart'), await renderOgImage('Quickstart')); +}); + +test('leaves the auxiliary header and footer regions free of text', async () => { + const png = await renderOgImage('Skills for agents'); + for (const region of [ + { left: 370, top: 70, width: 750, height: 80 }, + { left: 80, top: 540, width: 1040, height: 70 }, + ]) { + const pixels = await sharp(png).extract(region).removeAlpha().raw().toBuffer(); + assert.ok(pixels.every((channel) => channel < 50), 'Only the logo and page title belong on the card'); + } +}); + +test('generates shared hashed assets and replaces duplicate metadata without altering scripts or article HTML', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'docs-og-')); + const script = '\';'; + const body = '

Créer & <configure> 🚀

Do not put this subtitle in the image.

Article.

'; + const html = `Other title | Warp + + + + + + ${script}${body}`; + await writeFile(path.join(root, 'index.html'), html); + await mkdir(path.join(root, 'api')); + await writeFile(path.join(root, 'api/index.html'), html); + const warnings = []; + assert.equal(await generateOgImages(root, { warn: (message) => warnings.push(message) }), 2); + assert.deepEqual(warnings, []); + const result = await readFile(path.join(root, 'api/index.html'), 'utf8'); + assert.ok(result.includes(body)); + assert.ok(result.includes(script)); + const { document } = parseHTML(result); + const meta = (key) => document.querySelectorAll(`meta[property="${key}"],meta[name="${key}"]`); + const image = meta('og:image')[0].getAttribute('content'); + assert.match(image, /^https:\/\/docs\.warp\.dev\/assets\/og\/[a-f0-9]{24}\.png$/); + for (const key of ['og:image', 'twitter:image', 'og:image:secure_url']) { + assert.equal(meta(key).length, 1); + assert.equal(meta(key)[0].getAttribute('content'), image); + } + assert.equal(meta('twitter:card')[0].getAttribute('content'), 'summary_large_image'); + assert.equal(meta('og:image:alt')[0].getAttribute('content'), 'Warp Docs — Créer & 🚀'); + assert.equal(meta('twitter:image:alt')[0].getAttribute('content'), 'Warp Docs — Créer & 🚀'); + assert.equal(meta('og:title')[0].getAttribute('content'), 'Keep title'); + assert.equal(meta('description')[0].getAttribute('content'), 'Keep description'); + assert.equal((await readdir(path.join(root, 'assets/og'))).length, 1); + assert.equal((await sharp(path.join(root, new URL(image).pathname)).metadata()).width, 1200); + assert.deepEqual(await readFile(path.join(root, new URL(image).pathname)), await renderOgImage('Créer & 🚀')); + await validateOutput(root); +}); + +test('uses document titles for custom pages and the existing default for untitled or unrenderable pages', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'docs-og-fallback-')); + await writeFile(path.join(root, 'custom.html'), 'API reference | Warp'); + await writeFile(path.join(root, 'untitled.html'), '

'); + await writeFile(path.join(root, 'extreme.html'), `

${'W'.repeat(1200)}

`); + await writeFile(path.join(root, 'renderer-error.html'), '

\u0000

'); + const warnings = []; + await generateOgImages(root, { warn: (message) => warnings.push(message) }); + for (const filename of ['untitled.html', 'extreme.html', 'renderer-error.html']) { + const { document } = parseHTML(await readFile(path.join(root, filename), 'utf8')); + assert.equal(document.querySelector('meta[property="og:image"]').getAttribute('content'), 'https://docs.warp.dev/og-image.png'); + assert.equal(document.querySelector('meta[name="twitter:image"]').getAttribute('content'), 'https://docs.warp.dev/og-image.png'); + } + assert.equal(warnings.length, 2); + assert.ok(warnings.some((message) => message.includes('Title cannot fit'))); + assert.ok(warnings.some((message) => message.includes('text: invalid markup'))); + const { document } = parseHTML(await readFile(path.join(root, 'custom.html'), 'utf8')); + assert.match(document.querySelector('meta[property="og:image"]').getAttribute('content'), /\/assets\/og\//); + assert.equal(document.querySelector('meta[property="og:image:alt"]').getAttribute('content'), 'Warp Docs — API reference'); + const customImage = new URL(document.querySelector('meta[property="og:image"]').getAttribute('content')); + assert.deepEqual(await readFile(path.join(root, customImage.pathname)), await renderOgImage('API reference')); + const fallback = await sharp(new URL('../../public/og-image.png', import.meta.url).pathname).metadata(); + assert.equal(fallback.format, 'png'); + assert.equal(fallback.width, 1200); + assert.equal(fallback.height, 630); + await validateOutput(root); +}); + +test('keeps metadata on a valid fallback when writing the rendered asset fails', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'docs-og-write-failure-')); + await writeFile(path.join(root, 'index.html'), '

Quickstart

'); + await mkdir(path.join(root, 'assets')); + await writeFile(path.join(root, 'assets/og'), 'Not a directory'); + const warnings = []; + await generateOgImages(root, { warn: (message) => warnings.push(message) }); + assert.equal(warnings.length, 1); + const { document } = parseHTML(await readFile(path.join(root, 'index.html'), 'utf8')); + assert.equal(document.querySelector('meta[property="og:image"]').getAttribute('content'), 'https://docs.warp.dev/og-image.png'); + await validateOutput(root); +}); diff --git a/src/lib/og-image.js b/src/lib/og-image.js new file mode 100644 index 000000000..8c1d88337 --- /dev/null +++ b/src/lib/og-image.js @@ -0,0 +1,79 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import sharp from 'sharp'; + +const fonts = [ + ['Inter', 'Inter.ttf'], + ['Noto Sans CJK SC', 'NotoSansCJKsc-Regular.otf'], + ['Noto Sans Arabic', 'NotoSansArabic.ttf'], + ['Noto Emoji', 'NotoEmoji.ttf'], +]; +const fontFamily = fonts.map(([family]) => family).join(','); +let assets; + +function loadAssets() { + return assets ??= (async () => { + // Register fallback faces with Pango, even on hosts without system fonts. + for (const [font, filename] of fonts) { + await sharp({ + text: { + text: 'A', + font, + fontfile: fileURLToPath(new URL(`../assets/og/fonts/${filename}`, import.meta.url)), + }, + }).png().toBuffer(); + } + const logo = await sharp(await readFile(new URL('../assets/warp-logo-dark.svg', import.meta.url))) + .resize(264, 92).png().toBuffer(); + return { logo }; + })(); +} + +function escapeMarkup(text) { + return text.replace(/[&<>"']/g, (character) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[character]); +} + +function renderText(text, size, weight, color) { + return sharp({ + text: { + text: `${escapeMarkup(text)}`, + font: `${fontFamily} ${size}`, + width: 1040, + dpi: 72, + rgba: true, + wrap: 'word-char', + }, + }).png().toBuffer({ resolveWithObject: true }); +} + +export async function renderOgImage(title) { + title = title?.replace(/\s+/g, ' ').trim(); + if (!title || title.length > 2000) throw new Error('Title cannot fit a legible social image'); + const { logo } = await loadAssets(); + + let text; + for (let size = 80; size >= 28; size -= 2) { + const candidate = await renderText(title, size, 700, '#faf9f6'); + if (candidate.info.width <= 1040 && candidate.info.height <= 290) { + text = candidate; + break; + } + } + if (!text) throw new Error('Title cannot fit a legible social image'); + + const background = Buffer.from(` + + + + + + + `); + + return sharp(background).composite([ + { input: logo, left: 80, top: 48 }, + { input: text.data, left: 80, top: 220 + Math.floor((290 - text.info.height) / 2) }, + ]).png().toBuffer(); +} diff --git a/src/lib/social-image.js b/src/lib/social-image.js new file mode 100644 index 000000000..694391835 --- /dev/null +++ b/src/lib/social-image.js @@ -0,0 +1,26 @@ +import { DOCS_ORIGIN } from './site.js'; + +export function socialImageTitle(document) { + // Read the rendered heading so MDX variables and custom pages use + // the same title a reader sees, not raw frontmatter tokens. + return (document.querySelector('h1')?.textContent ?? + document.querySelector('title')?.textContent?.replace(/\s+\|\s+Warp$/, '') ?? + '').replace(/\s+/g, ' ').trim(); +} + +/** @returns {Array<{ tag: 'meta', attrs: Record }>} */ +export function socialImageMeta(imagePath = '/og-image.png', title = '') { + const url = new URL(imagePath, DOCS_ORIGIN).href; + const alt = title ? `Warp Docs — ${title}` : 'Warp Docs'; + return [ + { tag: 'meta', attrs: { property: 'og:image', content: url } }, + { tag: 'meta', attrs: { property: 'og:image:secure_url', content: url } }, + { tag: 'meta', attrs: { property: 'og:image:type', content: 'image/png' } }, + { tag: 'meta', attrs: { property: 'og:image:width', content: '1200' } }, + { tag: 'meta', attrs: { property: 'og:image:height', content: '630' } }, + { tag: 'meta', attrs: { property: 'og:image:alt', content: alt } }, + { tag: 'meta', attrs: { name: 'twitter:card', content: 'summary_large_image' } }, + { tag: 'meta', attrs: { name: 'twitter:image', content: url } }, + { tag: 'meta', attrs: { name: 'twitter:image:alt', content: alt } }, + ]; +} diff --git a/src/pages/api.astro b/src/pages/api.astro index 1c0905030..74dee6f11 100644 --- a/src/pages/api.astro +++ b/src/pages/api.astro @@ -15,6 +15,7 @@ import { parse } from 'yaml'; import { VARS } from '@data/vars'; import WarpTopbar from '../components/WarpTopbar.astro'; import DocsFeedbackLinks from '../components/DocsFeedbackLinks.astro'; +import { socialImageMeta } from '../lib/social-image.js'; const yamlContent = fs.readFileSync('developers/agent-api-openapi.yaml', 'utf-8'); const specObject = parse(yamlContent); const specJson = JSON.stringify(specObject); @@ -47,15 +48,10 @@ const specBaseUrl = (specObject.servers as Array<{ url?: string }> | undefined)? - - - - + {socialImageMeta('/og-image.png', `${VARS.WARP_PLATFORM_API} reference`).map(({ attrs }) => )} - - diff --git a/src/routeData.ts b/src/routeData.ts index f5fc833a9..c3ceb9bcf 100644 --- a/src/routeData.ts +++ b/src/routeData.ts @@ -1,6 +1,7 @@ import { defineRouteMiddleware } from '@astrojs/starlight/route-data'; import config from 'virtual:starlight/user-config'; import { getMarkdownPathFromHtmlPath } from './lib/docs-markdown.js'; +import { socialImageMeta } from './lib/social-image.js'; /** * Per-page head augmentation. @@ -31,8 +32,6 @@ export const onRequest = defineRouteMiddleware((context) => { const pageTitle = route.entry?.data?.title ?? siteTitle; const pageDescription = route.entry?.data?.description ?? config.description; const brandedTitle = `${pageTitle} ${config.titleDelimiter} ${siteTitle}`; - const site = context.site ?? new URL('https://docs.warp.dev'); - const ogImageUrl = new URL('/og-image.png', site).href; // Drop Starlight's bare og:title before adding the branded one. Inline // rather than a helper so we keep the exact `route.head` element type @@ -47,14 +46,8 @@ export const onRequest = defineRouteMiddleware((context) => { head.push( { tag: 'meta', attrs: { property: 'og:title', content: brandedTitle } }, - { tag: 'meta', attrs: { property: 'og:image', content: ogImageUrl } }, - { tag: 'meta', attrs: { property: 'og:image:secure_url', content: ogImageUrl } }, - { tag: 'meta', attrs: { property: 'og:image:type', content: 'image/png' } }, - { tag: 'meta', attrs: { property: 'og:image:width', content: '1200' } }, - { tag: 'meta', attrs: { property: 'og:image:height', content: '630' } }, - { tag: 'meta', attrs: { property: 'og:image:alt', content: `${siteTitle} — ${pageTitle}` } }, + ...socialImageMeta('/og-image.png', pageTitle), { tag: 'meta', attrs: { name: 'twitter:title', content: brandedTitle } }, - { tag: 'meta', attrs: { name: 'twitter:image', content: ogImageUrl } }, ); if (pageDescription) { head.push({