diff --git a/bin/generate-og-gallery.js b/bin/generate-og-gallery.js index 6689b1dfc9..d156e386cb 100644 --- a/bin/generate-og-gallery.js +++ b/bin/generate-og-gallery.js @@ -9,11 +9,20 @@ const fs = require('fs'); const path = require('path'); const matter = require('gray-matter'); const ogImagePlugin = require('../plugins/og-image'); +const { AI_COOKBOOK_OG_IMAGE_PATH } = require('../src/constants/aiCookbookOgImage'); const DOCS_DIR = path.join(process.cwd(), 'docs'); +const AI_COOKBOOK_DIR = path.join(process.cwd(), 'ai-cookbook'); const BUILD_DIR = path.join(process.cwd(), 'build'); const OUT_FILE = path.join(BUILD_DIR, '__og-gallery.html'); +// Kept in sync with the og-image plugin's `targets` option in +// docusaurus.config.js — every docs plugin instance it renders cards for. +const DOC_TARGETS = [ + { dir: DOCS_DIR, routeBasePath: '/' }, + { dir: AI_COOKBOOK_DIR, routeBasePath: 'ai-cookbook', footerText: 'AI COOKBOOK' }, +]; + // Section grouping/labeling is purely a gallery-review concern now — the // generated card itself dropped the section pill in the Figma redesign, so // this doesn't live in plugins/og-image/index.js (the actual production @@ -40,10 +49,10 @@ function humanize(id) { .replace(/\b\w/g, (c) => c.toUpperCase()); } -function resolveSection(docsDir, filePath) { +function resolveSection(docsDir, filePath, defaultSection) { const rel = path.relative(docsDir, filePath).replace(/\\/g, '/'); const segments = rel.split('/'); - if (segments.length === 1) return 'Docs'; + if (segments.length === 1) return defaultSection; const top = segments[0]; if (top === 'develop' && segments[1] && SDK_LABELS[segments[1]]) { return `${SDK_LABELS[segments[1]]} SDK`; @@ -69,30 +78,48 @@ async function main() { const siteUrl = await getSiteUrl(); const cards = []; - for (const filePath of ogImagePlugin.walkDir(DOCS_DIR)) { - const raw = fs.readFileSync(filePath, 'utf8'); - const { data: frontmatter, content } = matter(raw); - const urlPath = ogImagePlugin.resolveUrlPath(DOCS_DIR, filePath, frontmatter); - const htmlPath = ogImagePlugin.htmlPathForUrlPath(BUILD_DIR, urlPath); - if (!fs.existsSync(htmlPath)) continue; - - const section = resolveSection(DOCS_DIR, filePath); - const routePath = urlPath === 'index' ? '/' : `/${urlPath}`; + for (const { dir, routeBasePath, footerText } of DOC_TARGETS) { + const defaultSection = dir === AI_COOKBOOK_DIR ? 'AI Cookbook' : 'Docs'; + + for (const filePath of ogImagePlugin.walkDir(dir)) { + const raw = fs.readFileSync(filePath, 'utf8'); + const { data: frontmatter, content } = matter(raw); + const urlPath = ogImagePlugin.resolveUrlPath(dir, filePath, frontmatter, routeBasePath); + const htmlPath = ogImagePlugin.htmlPathForUrlPath(BUILD_DIR, urlPath); + if (!fs.existsSync(htmlPath)) continue; + + const section = resolveSection(dir, filePath, defaultSection); + const routePath = urlPath === 'index' ? '/' : `/${urlPath}`; + + if (ogImagePlugin.hasManualOverride(frontmatter, content)) { + const id = frontmatter.id || path.basename(filePath).replace(/\.(md|mdx)$/i, ''); + const title = ogImagePlugin.extractTitle(content, frontmatter, id); + const overrideImage = ogImagePlugin.overrideImageFor(frontmatter, content, siteUrl); + cards.push({ urlPath: routePath, section, title, isOverride: true, imgSrc: overrideImage }); + continue; + } - if (ogImagePlugin.hasManualOverride(frontmatter, content)) { const id = frontmatter.id || path.basename(filePath).replace(/\.(md|mdx)$/i, ''); const title = ogImagePlugin.extractTitle(content, frontmatter, id); - const overrideImage = ogImagePlugin.overrideImageFor(frontmatter, content, siteUrl); - cards.push({ urlPath: routePath, section, title, isOverride: true, imgSrc: overrideImage }); - continue; - } + const description = frontmatter.description; + const hash = ogImagePlugin.hashFor(title, description, footerText); - const id = frontmatter.id || path.basename(filePath).replace(/\.(md|mdx)$/i, ''); - const title = ogImagePlugin.extractTitle(content, frontmatter, id); - const description = frontmatter.description; - const hash = ogImagePlugin.hashFor(title, description); + cards.push({ urlPath: routePath, section, title, isOverride: false, imgSrc: `/img/og/${hash}.${ogImagePlugin.IMAGE_EXTENSION}` }); + } + } - cards.push({ urlPath: routePath, section, title, isOverride: false, imgSrc: `/img/og/${hash}.${ogImagePlugin.IMAGE_EXTENSION}` }); + // /ai-cookbook (src/pages/ai-cookbook.tsx) is a plain page, not an MDX doc, + // so it's invisible to the DOC_TARGETS walk above — added manually so the + // gallery still shows every card the site actually ships. + const cookbookHomeHtmlPath = path.join(BUILD_DIR, 'ai-cookbook', 'index.html'); + if (fs.existsSync(cookbookHomeHtmlPath)) { + cards.push({ + urlPath: '/ai-cookbook', + section: 'AI Cookbook', + title: 'AI Cookbook (landing page)', + isOverride: true, + imgSrc: AI_COOKBOOK_OG_IMAGE_PATH, + }); } cards.sort((a, b) => a.section.localeCompare(b.section) || a.title.localeCompare(b.title)); diff --git a/bin/validate-og-images.js b/bin/validate-og-images.js index fa6d7edbf7..b0e115a4ab 100644 --- a/bin/validate-og-images.js +++ b/bin/validate-og-images.js @@ -29,9 +29,19 @@ const fs = require('fs'); const path = require('path'); const matter = require('gray-matter'); const ogImagePlugin = require('../plugins/og-image'); +const { AI_COOKBOOK_OG_IMAGE_PATH } = require('../src/constants/aiCookbookOgImage'); const BUILD_DIR = path.join(process.cwd(), 'build'); const DOCS_DIR = path.join(process.cwd(), 'docs'); +const AI_COOKBOOK_DIR = path.join(process.cwd(), 'ai-cookbook'); + +// Every docs plugin instance the og-image plugin actually targets (see the +// `targets` option in docusaurus.config.js) — kept in sync with that list so +// this validator checks the same pages the plugin generates cards for. +const DOC_TARGETS = [ + { dir: DOCS_DIR, routeBasePath: '/' }, + { dir: AI_COOKBOOK_DIR, routeBasePath: 'ai-cookbook', footerText: 'AI COOKBOOK' }, +]; function walkHtmlFiles(dir) { if (!fs.existsSync(dir)) return []; @@ -73,58 +83,93 @@ async function main() { let overridePagesChecked = 0; let skippedPartials = 0; - for (const filePath of ogImagePlugin.walkDir(DOCS_DIR)) { - const raw = fs.readFileSync(filePath, 'utf8'); - const { data: frontmatter, content } = matter(raw); - const urlPath = ogImagePlugin.resolveUrlPath(DOCS_DIR, filePath, frontmatter); - const htmlPath = ogImagePlugin.htmlPathForUrlPath(BUILD_DIR, urlPath); - - if (!fs.existsSync(htmlPath)) { - skippedPartials++; - continue; - } - docHtmlPaths.add(htmlPath); - docPagesChecked++; - - const html = fs.readFileSync(htmlPath, 'utf8'); - const ogImage = extractMetaContent(html, 'property', 'og:image'); - const twitterImage = extractMetaContent(html, 'name', 'twitter:image'); + for (const { dir, routeBasePath, footerText } of DOC_TARGETS) { + for (const filePath of ogImagePlugin.walkDir(dir)) { + const raw = fs.readFileSync(filePath, 'utf8'); + const { data: frontmatter, content } = matter(raw); + const urlPath = ogImagePlugin.resolveUrlPath(dir, filePath, frontmatter, routeBasePath); + const htmlPath = ogImagePlugin.htmlPathForUrlPath(BUILD_DIR, urlPath); - if (ogImagePlugin.hasManualOverride(frontmatter, content)) { - overridePagesChecked++; - const expectedOverride = ogImagePlugin.overrideImageFor(frontmatter, content, siteUrl); + if (!fs.existsSync(htmlPath)) { + skippedPartials++; + continue; + } + docHtmlPaths.add(htmlPath); + docPagesChecked++; + + const html = fs.readFileSync(htmlPath, 'utf8'); + const ogImage = extractMetaContent(html, 'property', 'og:image'); + const twitterImage = extractMetaContent(html, 'name', 'twitter:image'); + + if (ogImagePlugin.hasManualOverride(frontmatter, content)) { + overridePagesChecked++; + const expectedOverride = ogImagePlugin.overrideImageFor(frontmatter, content, siteUrl); + + if (ogImage !== expectedOverride || twitterImage !== expectedOverride) { + overrideMismatches.push({ + file: path.relative(BUILD_DIR, htmlPath), + expected: expectedOverride, + ogImage, + twitterImage, + }); + } + continue; + } - if (ogImage !== expectedOverride || twitterImage !== expectedOverride) { - overrideMismatches.push({ + const id = frontmatter.id || path.basename(filePath).replace(/\.(md|mdx)$/i, ''); + const title = ogImagePlugin.extractTitle(content, frontmatter, id); + const description = frontmatter.description; + const hash = ogImagePlugin.hashFor(title, description, footerText); + const expectedImage = new URL( + path.posix.join(config.baseUrl, 'img/og', `${hash}.${ogImagePlugin.IMAGE_EXTENSION}`), + config.url, + ).toString(); + const expectedImagePath = path.join(BUILD_DIR, 'img', 'og', `${hash}.${ogImagePlugin.IMAGE_EXTENSION}`); + + if (ogImage !== expectedImage || twitterImage !== expectedImage) { + docMismatches.push({ file: path.relative(BUILD_DIR, htmlPath), - expected: expectedOverride, + expected: expectedImage, ogImage, twitterImage, }); } - continue; + if (!fs.existsSync(expectedImagePath)) { + missingImages.push({ file: path.relative(BUILD_DIR, htmlPath), expectedImagePath }); + } } + } - const id = frontmatter.id || path.basename(filePath).replace(/\.(md|mdx)$/i, ''); - const title = ogImagePlugin.extractTitle(content, frontmatter, id); - const description = frontmatter.description; - const hash = ogImagePlugin.hashFor(title, description); - const expectedImage = new URL( - path.posix.join(config.baseUrl, 'img/og', `${hash}.${ogImagePlugin.IMAGE_EXTENSION}`), + // /ai-cookbook (src/pages/ai-cookbook.tsx) is a plain page, not an MDX doc, + // so it never went through the DOC_TARGETS loop above — but it does declare + // its own og:image (see plugins/cookbook-index's postBuild), so it's + // checked here as a manual override rather than folded into "other pages + // must match the site default" below. + const cookbookHomeHtmlPath = path.join(BUILD_DIR, 'ai-cookbook', 'index.html'); + if (fs.existsSync(cookbookHomeHtmlPath)) { + docHtmlPaths.add(cookbookHomeHtmlPath); + docPagesChecked++; + overridePagesChecked++; + + const html = fs.readFileSync(cookbookHomeHtmlPath, 'utf8'); + const ogImage = extractMetaContent(html, 'property', 'og:image'); + const twitterImage = extractMetaContent(html, 'name', 'twitter:image'); + const expectedOverride = new URL( + path.posix.join(config.baseUrl, AI_COOKBOOK_OG_IMAGE_PATH.replace(/^\/+/, '')), config.url, ).toString(); - const expectedImagePath = path.join(BUILD_DIR, 'img', 'og', `${hash}.${ogImagePlugin.IMAGE_EXTENSION}`); + const expectedImagePath = path.join(BUILD_DIR, AI_COOKBOOK_OG_IMAGE_PATH); - if (ogImage !== expectedImage || twitterImage !== expectedImage) { - docMismatches.push({ - file: path.relative(BUILD_DIR, htmlPath), - expected: expectedImage, + if (ogImage !== expectedOverride || twitterImage !== expectedOverride) { + overrideMismatches.push({ + file: path.relative(BUILD_DIR, cookbookHomeHtmlPath), + expected: expectedOverride, ogImage, twitterImage, }); } if (!fs.existsSync(expectedImagePath)) { - missingImages.push({ file: path.relative(BUILD_DIR, htmlPath), expectedImagePath }); + missingImages.push({ file: path.relative(BUILD_DIR, cookbookHomeHtmlPath), expectedImagePath }); } } diff --git a/docusaurus.config.js b/docusaurus.config.js index 02ce4db173..5147cd8427 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -392,6 +392,12 @@ module.exports = async function createConfigAsync() { // use a custom item to center the content: docItemComponent: '@site/src/components/Cookbook/DocItem/CookbookDocItem', docCategoryGeneratedIndexComponent: '@site/src/components/Cookbook/DocItem/CookbookCategoryIndex', // ⬅️ isolated override + // Same remark plugin the main docs preset uses (see the `docs` preset + // option above) — injects the generated og:image path as real front + // matter during MDX compilation so it survives client hydration. + // footerText must match the og-image plugin's ai-cookbook target + // below, or the hash this injects won't match what postBuild renders. + remarkPlugins: [[require('./plugins/og-image/remarkPlugin'), { footerText: 'AI COOKBOOK' }]], }, ], [ @@ -404,13 +410,19 @@ module.exports = async function createConfigAsync() { [ require.resolve('./plugins/markdown-pages'), { - docsDir: 'docs', + targets: [ + { docsDir: 'docs', routeBasePath: '/' }, + { docsDir: 'ai-cookbook', routeBasePath: 'ai-cookbook' }, + ], }, ], [ require.resolve('./plugins/og-image'), { - docsDir: 'docs', + targets: [ + { docsDir: 'docs', routeBasePath: '/' }, + { docsDir: 'ai-cookbook', routeBasePath: 'ai-cookbook', footerText: 'AI COOKBOOK' }, + ], }, ], [ diff --git a/plugins/cookbook-index/index.js b/plugins/cookbook-index/index.js index 693a718a1b..fe905d32d9 100644 --- a/plugins/cookbook-index/index.js +++ b/plugins/cookbook-index/index.js @@ -1,6 +1,21 @@ const fs = require('fs'); const path = require('path'); const matter = require('gray-matter'); +const { renderCard } = require('../og-image/render'); +const { DEFAULT_FOOTER_TEXT } = require('../og-image/constants'); +const { AI_COOKBOOK_OG_IMAGE_PATH } = require('../../src/constants/aiCookbookOgImage'); + +// Mirrors the hero blurb in src/components/Cookbook/Home/CookbookHome.tsx — +// duplicated (not imported) because that file is a .tsx React component and +// this is a plain build-time script. Keep these in sync if the copy changes. +const HERO_BLURB = + 'Step-by-step solutions that show you how to build reliable, production-ready AI systems with Temporal. Learn practical paradigms for prompts, tools, retries, and Workflow design.'; + +const HOME_TITLE = 'AI Cookbook'; +// Deliberately the site default (not the 'AI COOKBOOK' footer the individual +// recipe cards use) — this card's title already says "AI Cookbook", so +// repeating it in the footer would be redundant. +const HOME_FOOTER_TEXT = DEFAULT_FOOTER_TEXT; module.exports = function cookbookIndexPlugin(context, options = {}) { console.log('[cookbook-index] init with docsDir:', options.docsDir); @@ -91,7 +106,42 @@ console.log('[cookbook-index] init with docsDir:', options.docsDir); await createData('cookbook.index.json', JSON.stringify(content.items, null, 2)); setGlobalData({ items: content.items }); }, - }; - + // The /ai-cookbook landing page (src/pages/ai-cookbook.tsx) is a plain + // React page, not an MDX doc, so it's invisible to plugins/markdown-pages + // (which only walks docsDir trees). It links to a markdown alternate + // () same as every recipe + // page, so something has to actually produce that file — this plugin + // already has the exact item list/sort needed, so it does it here rather + // than duplicating cookbook-item-shaping logic elsewhere. + async postBuild({ outDir }) { + const items = readItems(); + const sorted = [...items].sort((a, b) => { + const priorityA = typeof a.priority === 'number' ? a.priority : -Infinity; + const priorityB = typeof b.priority === 'number' ? b.priority : -Infinity; + if (priorityA !== priorityB) return priorityB - priorityA; + return a.title.localeCompare(b.title); + }); + + const lines = [ + '# AI Cookbook', + '', + `> ${HERO_BLURB}`, + '', + ...sorted.map((item) => `- [${item.title}](${item.permalink}): ${item.description}`), + '', + ]; + + fs.writeFileSync(path.join(outDir, 'ai-cookbook.md'), lines.join('\n')); + console.log(`[cookbook-index] Generated ai-cookbook.md index (${sorted.length} recipe(s))`); + + // Same reasoning as the .md file above: this page is invisible to + // plugins/og-image's docsDir walk, so nothing else renders it a card. + const cardBuffer = await renderCard({ title: HOME_TITLE, description: HERO_BLURB, footerText: HOME_FOOTER_TEXT }); + const cardOutPath = path.join(outDir, AI_COOKBOOK_OG_IMAGE_PATH); + fs.mkdirSync(path.dirname(cardOutPath), { recursive: true }); + fs.writeFileSync(cardOutPath, cardBuffer); + console.log(`[cookbook-index] Generated ${AI_COOKBOOK_OG_IMAGE_PATH} og:image card`); + }, + }; }; diff --git a/plugins/markdown-pages/index.js b/plugins/markdown-pages/index.js index d822e97b07..02c4871eb4 100644 --- a/plugins/markdown-pages/index.js +++ b/plugins/markdown-pages/index.js @@ -3,11 +3,22 @@ const path = require('path'); const matter = require('gray-matter'); const { walkDir, resolveUrlPath: resolveUrlPathShared } = require('../shared/docsRouting'); -module.exports = function markdownPagesPlugin(context, options = {}) { - const docsDir = path.resolve(context.siteDir, options.docsDir || 'docs'); - const routeBasePath = options.routeBasePath || '/'; +// Accepts either a single {docsDir, routeBasePath} (back-compat) or a +// `targets` array, so one plugin instance can walk multiple docs plugin +// instances that live at different routeBasePaths (e.g. the main docs/ tree +// at '/' plus ai-cookbook/ at '/ai-cookbook'). +function normalizeTargets(options) { + if (Array.isArray(options.targets) && options.targets.length) { + return options.targets; + } + return [{ docsDir: options.docsDir || 'docs', routeBasePath: options.routeBasePath }]; +} - const resolveUrlPath = (filePath, frontmatter) => resolveUrlPathShared(docsDir, filePath, frontmatter); +module.exports = function markdownPagesPlugin(context, options = {}) { + const targets = normalizeTargets(options).map(({ docsDir, routeBasePath }) => ({ + docsDir: path.resolve(context.siteDir, docsDir), + routeBasePath, + })); return { name: 'markdown-pages', @@ -21,33 +32,36 @@ module.exports = function markdownPagesPlugin(context, options = {}) { pathToFileURL(path.join(__dirname, '../../scripts/mdx-to-md.mjs')).href ); - const files = walkDir(docsDir); let generated = 0; let excluded = 0; let totalWarnings = 0; - for (const filePath of files) { - const raw = fs.readFileSync(filePath, 'utf8'); - const { data: frontmatter } = matter(raw); - - const urlPath = resolveUrlPath(filePath, frontmatter); - const outputPath = path.join(outDir, urlPath + '.md'); - - fs.mkdirSync(path.dirname(outputPath), { recursive: true }); - - if (frontmatter.llm_exclude) { - fs.writeFileSync(outputPath, frontmatter.llm_exclude + '\n'); - excluded++; - } else { - // Transform MDX → clean Markdown (flatten tabs, resolve components, - // strip imports/JSX) rather than serving the raw source. - const { markdown, warnings } = transformMdx(raw, { - sourceFile: path.relative(context.siteDir, filePath), - projectRoot: context.siteDir, - }); - fs.writeFileSync(outputPath, markdown + '\n'); - totalWarnings += warnings.length; - generated++; + for (const { docsDir, routeBasePath } of targets) { + const files = walkDir(docsDir); + + for (const filePath of files) { + const raw = fs.readFileSync(filePath, 'utf8'); + const { data: frontmatter } = matter(raw); + + const urlPath = resolveUrlPathShared(docsDir, filePath, frontmatter, routeBasePath); + const outputPath = path.join(outDir, urlPath + '.md'); + + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + + if (frontmatter.llm_exclude) { + fs.writeFileSync(outputPath, frontmatter.llm_exclude + '\n'); + excluded++; + } else { + // Transform MDX → clean Markdown (flatten tabs, resolve components, + // strip imports/JSX) rather than serving the raw source. + const { markdown, warnings } = transformMdx(raw, { + sourceFile: path.relative(context.siteDir, filePath), + projectRoot: context.siteDir, + }); + fs.writeFileSync(outputPath, markdown + '\n'); + totalWarnings += warnings.length; + generated++; + } } } diff --git a/plugins/og-image/constants.js b/plugins/og-image/constants.js index 85fbbd8711..03388fa049 100644 --- a/plugins/og-image/constants.js +++ b/plugins/og-image/constants.js @@ -10,4 +10,9 @@ const TEMPLATE_VERSION = 8; const IMAGE_EXTENSION = 'jpg'; -module.exports = { TEMPLATE_VERSION, IMAGE_EXTENSION }; +// Footer label rendered bottom-right of the card, next to the logo. Per-target +// overrides (e.g. ai-cookbook) are passed explicitly alongside docsDir/ +// routeBasePath wherever a target is configured — see docusaurus.config.js. +const DEFAULT_FOOTER_TEXT = 'DOCS.TEMPORAL.IO'; + +module.exports = { TEMPLATE_VERSION, IMAGE_EXTENSION, DEFAULT_FOOTER_TEXT }; diff --git a/plugins/og-image/index.js b/plugins/og-image/index.js index 8a460975c1..47ad47e04b 100644 --- a/plugins/og-image/index.js +++ b/plugins/og-image/index.js @@ -3,7 +3,7 @@ const path = require('path'); const matter = require('gray-matter'); const { renderCard } = require('./render'); const { walkDir, resolveUrlPath } = require('../shared/docsRouting'); -const { extractTitle, hasManualOverride, overrideImageFor, hashFor, IMAGE_EXTENSION } = require('./shared'); +const { extractTitle, hasManualOverride, overrideImageFor, hashFor, IMAGE_EXTENSION, DEFAULT_FOOTER_TEXT } = require('./shared'); const CACHE_DIR = path.join(__dirname, '../../node_modules/.cache/og-images'); @@ -13,20 +13,35 @@ function htmlPathForUrlPath(outDir, urlPath) { : path.join(outDir, urlPath, 'index.html'); } -async function getCardBuffer(title, description) { - const hash = hashFor(title, description); +async function getCardBuffer(title, description, footerText) { + const hash = hashFor(title, description, footerText); const cachePath = path.join(CACHE_DIR, `${hash}.${IMAGE_EXTENSION}`); if (fs.existsSync(cachePath)) { return { hash, buffer: fs.readFileSync(cachePath), cached: true }; } - const buffer = await renderCard({ title, description }); + const buffer = await renderCard({ title, description, footerText }); fs.mkdirSync(CACHE_DIR, { recursive: true }); fs.writeFileSync(cachePath, buffer); return { hash, buffer, cached: false }; } +// Accepts either a single {docsDir, routeBasePath} (back-compat) or a +// `targets` array, so one plugin instance can walk multiple docs plugin +// instances that live at different routeBasePaths (e.g. the main docs/ tree +// at '/' plus ai-cookbook/ at '/ai-cookbook'). +function normalizeTargets(options) { + if (Array.isArray(options.targets) && options.targets.length) { + return options.targets; + } + return [{ docsDir: options.docsDir || 'docs', routeBasePath: options.routeBasePath }]; +} + function ogImagePlugin(context, options = {}) { - const docsDir = path.resolve(context.siteDir, options.docsDir || 'docs'); + const targets = normalizeTargets(options).map(({ docsDir, routeBasePath, footerText }) => ({ + docsDir: path.resolve(context.siteDir, docsDir), + routeBasePath, + footerText: footerText || DEFAULT_FOOTER_TEXT, + })); return { name: 'og-image', @@ -39,7 +54,6 @@ function ogImagePlugin(context, options = {}) { // this postBuild hook is responsible for is making sure the *image // bytes* that path points to actually exist in the build output. async postBuild({ outDir }) { - const files = walkDir(docsDir); let generated = 0; let cached = 0; let skipped = 0; @@ -47,43 +61,47 @@ function ogImagePlugin(context, options = {}) { let renderMs = 0; let outputBytes = 0; - for (const filePath of files) { - const raw = fs.readFileSync(filePath, 'utf8'); - const { data: frontmatter, content } = matter(raw); - const urlPath = resolveUrlPath(docsDir, filePath, frontmatter); - const htmlPath = htmlPathForUrlPath(outDir, urlPath); - - if (!fs.existsSync(htmlPath)) { - // Not a routed page (e.g. an underscore-prefixed partial or an - // excluded directory) — nothing to render a card for. - skipped++; - continue; - } + for (const { docsDir, routeBasePath, footerText } of targets) { + const files = walkDir(docsDir); - if (hasManualOverride(frontmatter, content)) { - // The page's own front matter/
already won; nothing to - // render. - overridden++; - continue; - } + for (const filePath of files) { + const raw = fs.readFileSync(filePath, 'utf8'); + const { data: frontmatter, content } = matter(raw); + const urlPath = resolveUrlPath(docsDir, filePath, frontmatter, routeBasePath); + const htmlPath = htmlPathForUrlPath(outDir, urlPath); - const id = frontmatter.id || path.basename(filePath).replace(/\.(md|mdx)$/i, ''); - const title = extractTitle(content, frontmatter, id); - const description = frontmatter.description; - const renderStart = Date.now(); - const { hash, buffer, cached: wasCached } = await getCardBuffer(title, description); - if (wasCached) { - cached++; - } else { - generated++; - renderMs += Date.now() - renderStart; - } - outputBytes += buffer.length; + if (!fs.existsSync(htmlPath)) { + // Not a routed page (e.g. an underscore-prefixed partial or an + // excluded directory) — nothing to render a card for. + skipped++; + continue; + } + + if (hasManualOverride(frontmatter, content)) { + // The page's own front matter/ already won; nothing to + // render. + overridden++; + continue; + } + + const id = frontmatter.id || path.basename(filePath).replace(/\.(md|mdx)$/i, ''); + const title = extractTitle(content, frontmatter, id); + const description = frontmatter.description; + const renderStart = Date.now(); + const { hash, buffer, cached: wasCached } = await getCardBuffer(title, description, footerText); + if (wasCached) { + cached++; + } else { + generated++; + renderMs += Date.now() - renderStart; + } + outputBytes += buffer.length; - const cardOutPath = path.join(outDir, 'img', 'og', `${hash}.${IMAGE_EXTENSION}`); - if (!fs.existsSync(cardOutPath)) { - fs.mkdirSync(path.dirname(cardOutPath), { recursive: true }); - fs.copyFileSync(path.join(CACHE_DIR, `${hash}.${IMAGE_EXTENSION}`), cardOutPath); + const cardOutPath = path.join(outDir, 'img', 'og', `${hash}.${IMAGE_EXTENSION}`); + if (!fs.existsSync(cardOutPath)) { + fs.mkdirSync(path.dirname(cardOutPath), { recursive: true }); + fs.copyFileSync(path.join(CACHE_DIR, `${hash}.${IMAGE_EXTENSION}`), cardOutPath); + } } } diff --git a/plugins/og-image/remarkPlugin.js b/plugins/og-image/remarkPlugin.js index 1b453edf94..702dcad139 100644 --- a/plugins/og-image/remarkPlugin.js +++ b/plugins/og-image/remarkPlugin.js @@ -1,5 +1,5 @@ const path = require('path'); -const { extractTitle, hasManualOverride, hashFor, stripFrontmatter, IMAGE_EXTENSION } = require('./shared'); +const { extractTitle, hasManualOverride, hashFor, stripFrontmatter, IMAGE_EXTENSION, DEFAULT_FOOTER_TEXT } = require('./shared'); // This is what actually makes the generated og:image survive client-side // hydration. The previous approach patched og:image/twitter:image directly @@ -20,8 +20,13 @@ const { extractTitle, hasManualOverride, hashFor, stripFrontmatter, IMAGE_EXTENS // early enough — during MDX compilation — that it becomes genuine front // matter data. Docusaurus then renders it itself, identically, every time. // -// Registered in docusaurus.config.js's docs preset `remarkPlugins`. -function ogImageRemarkPlugin() { +// Registered in docusaurus.config.js's docs preset `remarkPlugins` (and, with +// a `footerText` override, on the ai-cookbook docs plugin instance's own +// remarkPlugins — unified's `[plugin, options]` tuple form calls this with +// `options`). +function ogImageRemarkPlugin(options = {}) { + const footerText = options.footerText || DEFAULT_FOOTER_TEXT; + return (tree, file) => { // `docusaurus build` always runs with NODE_ENV=production (see // @docusaurus/core's build command); `docusaurus start` (yarn start) @@ -41,7 +46,7 @@ function ogImageRemarkPlugin() { const id = frontMatter.id || path.basename(file.path || '').replace(/\.(md|mdx)$/i, ''); const title = extractTitle(content, frontMatter, id); const description = frontMatter.description; - const hash = hashFor(title, description); + const hash = hashFor(title, description, footerText); // Mutating in place, not reassigning file.data.frontMatter — the mdx // loader holds the same object reference and serializes it into the diff --git a/plugins/og-image/render.js b/plugins/og-image/render.js index d7657ce8c1..7690aad9c0 100644 --- a/plugins/og-image/render.js +++ b/plugins/og-image/render.js @@ -4,7 +4,7 @@ const satori = require('satori').default; const { Resvg } = require('@resvg/resvg-js'); const sharp = require('sharp'); const { TITLE_COLOR, SUBTITLE_COLOR, FOOTER_COLOR } = require('../../src/constants/ogImageColors'); -const { TEMPLATE_VERSION, IMAGE_EXTENSION } = require('./constants'); +const { TEMPLATE_VERSION, IMAGE_EXTENSION, DEFAULT_FOOTER_TEXT } = require('./constants'); const CARD_WIDTH = 1200; const CARD_HEIGHT = 630; @@ -85,7 +85,7 @@ function loadAssets() { return assetsPromise; } -function buildTree({ title, description }, { logo, background }) { +function buildTree({ title, description, footerText = DEFAULT_FOOTER_TEXT }, { logo, background }) { return { type: 'div', props: { @@ -184,7 +184,7 @@ function buildTree({ title, description }, { logo, background }) { fontWeight: 400, color: FOOTER_COLOR, }, - children: 'DOCS.TEMPORAL.IO', + children: footerText, }, }, ], @@ -198,9 +198,9 @@ function buildTree({ title, description }, { logo, background }) { }; } -async function renderCard({ title, description }) { +async function renderCard({ title, description, footerText = DEFAULT_FOOTER_TEXT }) { const { fonts, logo, background } = await loadAssets(); - const svg = await satori(buildTree({ title, description }, { logo, background }), { + const svg = await satori(buildTree({ title, description, footerText }, { logo, background }), { width: CARD_WIDTH, height: CARD_HEIGHT, fonts, diff --git a/plugins/og-image/shared.js b/plugins/og-image/shared.js index cf6f935034..c17674dcce 100644 --- a/plugins/og-image/shared.js +++ b/plugins/og-image/shared.js @@ -1,6 +1,6 @@ const crypto = require('crypto'); const matter = require('gray-matter'); -const { TEMPLATE_VERSION, IMAGE_EXTENSION } = require('./constants'); +const { TEMPLATE_VERSION, IMAGE_EXTENSION, DEFAULT_FOOTER_TEXT } = require('./constants'); // Shared by plugins/og-image/index.js (postBuild: renders the actual image // bytes) and plugins/og-image/remarkPlugin.js (build-time MDX compilation: @@ -47,10 +47,15 @@ function overrideImageFor(frontmatter, content, siteUrl) { // Deliberately excludes section: render.js doesn't render it (dropped along // with the section pill in the Figma redesign), so including it here would // just fragment the cache between pages that render pixel-identically. -function hashFor(title, description) { +// +// footerText defaults to DEFAULT_FOOTER_TEXT so existing docs pages (which +// don't pass one) hash identically to before — only a target that overrides +// it (e.g. ai-cookbook) gets a distinct cache entry, matching its distinct +// rendered appearance. +function hashFor(title, description, footerText = DEFAULT_FOOTER_TEXT) { return crypto .createHash('sha256') - .update(`v${TEMPLATE_VERSION}:${title}:${description || ''}`) + .update(`v${TEMPLATE_VERSION}:${title}:${description || ''}:${footerText}`) .digest('hex') .slice(0, 16); } @@ -73,4 +78,5 @@ module.exports = { hashFor, stripFrontmatter, IMAGE_EXTENSION, + DEFAULT_FOOTER_TEXT, }; diff --git a/plugins/shared/docsRouting.js b/plugins/shared/docsRouting.js index 318b1f0905..b0bc89fb58 100644 --- a/plugins/shared/docsRouting.js +++ b/plugins/shared/docsRouting.js @@ -16,25 +16,43 @@ function walkDir(dir) { }); } +// A docs plugin instance's routeBasePath prefixes every route it serves +// (e.g. the `ai-cookbook` instance publishes at /ai-cookbook/*, unlike the +// main docs instance whose routeBasePath is '/'). Normalized to '' when +// there's no real prefix, so callers built against the root instance don't +// need to pass this at all. +function normalizeRouteBasePath(routeBasePath) { + if (!routeBasePath || routeBasePath === '/') return ''; + return routeBasePath.replace(/^\/+/, '').replace(/\/+$/, ''); +} + // Mirrors Docusaurus's own route resolution (front-matter `slug` takes // precedence, then `id`, then the file path) so callers land on the same // route Docusaurus actually builds the page at. -function resolveUrlPath(docsDir, filePath, frontmatter) { +function resolveUrlPath(docsDir, filePath, frontmatter, routeBasePath) { + const prefix = normalizeRouteBasePath(routeBasePath); + const prefixed = (p) => (prefix ? `${prefix}/${p}` : p); + if (frontmatter.slug) { const slug = frontmatter.slug.replace(/^\/+/, '').replace(/\/+$/, ''); - return slug || 'index'; + return slug ? prefixed(slug) : prefix || 'index'; } const rel = path.relative(docsDir, filePath).replace(/\\/g, '/'); const withoutExt = rel.replace(/\.(md|mdx)$/i, ''); const id = frontmatter.id || path.basename(withoutExt); const dir = path.dirname(withoutExt); - if (dir === '.') return id === 'index' ? 'index' : id; + if (dir === '.') { + // A docsDir-root index doc (index.mdx/README.mdx) *is* the routeBasePath + // itself, not "