From a7a961a07a765b0f68999eafce998e25eefe0ba7 Mon Sep 17 00:00:00 2001 From: Duncan Mackenzie Date: Fri, 24 Jul 2026 12:54:31 -0700 Subject: [PATCH 1/3] Rename guides/saga-pattern to recover-without-restart and add a redirect. --- ...p-business-moving-architectural-comparison.png | Bin .../keep-business-moving-architecture-diagram.svg | 0 .../keep-business-moving-cover.png | Bin .../keep-business-moving-event-history.png | Bin .../keep-business-moving-fix-employer.png | Bin .../keep-business-moving-list-view.png | Bin .../keep-business-moving-success.png | Bin ...ga-pattern.mdx => recover-without-restart.mdx} | 14 +++++++------- sidebars.js | 2 +- src/components/GuidesGrid/guides-data.json | 2 +- vercel.json | 5 +++++ 11 files changed, 14 insertions(+), 9 deletions(-) rename docs/guides/assets/{saga-pattern => recover-without-restart}/keep-business-moving-architectural-comparison.png (100%) rename docs/guides/assets/{saga-pattern => recover-without-restart}/keep-business-moving-architecture-diagram.svg (100%) rename docs/guides/assets/{saga-pattern => recover-without-restart}/keep-business-moving-cover.png (100%) rename docs/guides/assets/{saga-pattern => recover-without-restart}/keep-business-moving-event-history.png (100%) rename docs/guides/assets/{saga-pattern => recover-without-restart}/keep-business-moving-fix-employer.png (100%) rename docs/guides/assets/{saga-pattern => recover-without-restart}/keep-business-moving-list-view.png (100%) rename docs/guides/assets/{saga-pattern => recover-without-restart}/keep-business-moving-success.png (100%) rename docs/guides/{saga-pattern.mdx => recover-without-restart.mdx} (98%) diff --git a/docs/guides/assets/saga-pattern/keep-business-moving-architectural-comparison.png b/docs/guides/assets/recover-without-restart/keep-business-moving-architectural-comparison.png similarity index 100% rename from docs/guides/assets/saga-pattern/keep-business-moving-architectural-comparison.png rename to docs/guides/assets/recover-without-restart/keep-business-moving-architectural-comparison.png diff --git a/docs/guides/assets/saga-pattern/keep-business-moving-architecture-diagram.svg b/docs/guides/assets/recover-without-restart/keep-business-moving-architecture-diagram.svg similarity index 100% rename from docs/guides/assets/saga-pattern/keep-business-moving-architecture-diagram.svg rename to docs/guides/assets/recover-without-restart/keep-business-moving-architecture-diagram.svg diff --git a/docs/guides/assets/saga-pattern/keep-business-moving-cover.png b/docs/guides/assets/recover-without-restart/keep-business-moving-cover.png similarity index 100% rename from docs/guides/assets/saga-pattern/keep-business-moving-cover.png rename to docs/guides/assets/recover-without-restart/keep-business-moving-cover.png diff --git a/docs/guides/assets/saga-pattern/keep-business-moving-event-history.png b/docs/guides/assets/recover-without-restart/keep-business-moving-event-history.png similarity index 100% rename from docs/guides/assets/saga-pattern/keep-business-moving-event-history.png rename to docs/guides/assets/recover-without-restart/keep-business-moving-event-history.png diff --git a/docs/guides/assets/saga-pattern/keep-business-moving-fix-employer.png b/docs/guides/assets/recover-without-restart/keep-business-moving-fix-employer.png similarity index 100% rename from docs/guides/assets/saga-pattern/keep-business-moving-fix-employer.png rename to docs/guides/assets/recover-without-restart/keep-business-moving-fix-employer.png diff --git a/docs/guides/assets/saga-pattern/keep-business-moving-list-view.png b/docs/guides/assets/recover-without-restart/keep-business-moving-list-view.png similarity index 100% rename from docs/guides/assets/saga-pattern/keep-business-moving-list-view.png rename to docs/guides/assets/recover-without-restart/keep-business-moving-list-view.png diff --git a/docs/guides/assets/saga-pattern/keep-business-moving-success.png b/docs/guides/assets/recover-without-restart/keep-business-moving-success.png similarity index 100% rename from docs/guides/assets/saga-pattern/keep-business-moving-success.png rename to docs/guides/assets/recover-without-restart/keep-business-moving-success.png diff --git a/docs/guides/saga-pattern.mdx b/docs/guides/recover-without-restart.mdx similarity index 98% rename from docs/guides/saga-pattern.mdx rename to docs/guides/recover-without-restart.mdx index d70f752330..64cffa7303 100644 --- a/docs/guides/saga-pattern.mdx +++ b/docs/guides/recover-without-restart.mdx @@ -1,5 +1,5 @@ --- -id: saga-pattern +id: recover-without-restart title: Recover business processes without restarting description: Build a multi-step business procss that pauses when it hits a permanent failure, then resumes from where it stopped without restarting. sidebar_label: Recovery without restart @@ -18,7 +18,7 @@ tags: This pattern shows how to build a multi-step business process that pauses when it hits a permanent failure — a malformed identifier, an exceeded policy limit, a failed compliance check — allows a human or automated agent to correct the underlying issue, and resumes from where it stopped without restarting from the beginning. Queryable metadata routes each blocked process to the right resolution resource, and operators get full visibility into the state of every individual execution and across the entire pipeline. -![A blocked Workflow showing the failure details and a form to send a corrective Signal](assets/saga-pattern/keep-business-moving-list-view.png) +![A blocked Workflow showing the failure details and a form to send a corrective Signal](assets/recover-without-restart/keep-business-moving-list-view.png) ## Problem statement @@ -43,7 +43,7 @@ The process picks up where it left off: completed steps do not re-execute, and t While you can use traditional architectures to implement a similar solution, they often involve significantly more complexity and demand higher implementation and operational costs. A Temporal-based solution is typically much leaner and more robust. -![Architectural comparison between a traditional implementation and the Temporal-based solution](assets/saga-pattern/keep-business-moving-architectural-comparison.png) +![Architectural comparison between a traditional implementation and the Temporal-based solution](assets/recover-without-restart/keep-business-moving-architectural-comparison.png) ## What you will achieve @@ -949,19 +949,19 @@ You can do this through the demo UI, the web service API, or the Temporal CLI. The dashboard shows all Workflows and their pipeline status. Click a blocked Workflow to inspect the failure details and send a correction. -![Dashboard showing 3 blocked Workflows and 1 completed](assets/saga-pattern/keep-business-moving-list-view.png) +![Dashboard showing 3 blocked Workflows and 1 completed](assets/recover-without-restart/keep-business-moving-list-view.png) Click a `PENDING_FIX` Workflow to open its detail panel. The UI shows the failure message and suggests a fix. Select the field to patch, enter the corrected value, and click **Patch and Retry** to send the Signal. -![Sending a corrective Signal to fix an unknown employer](assets/saga-pattern/keep-business-moving-fix-employer.png) +![Sending a corrective Signal to fix an unknown employer](assets/recover-without-restart/keep-business-moving-fix-employer.png) After the Signal is delivered, the Workflow wakes up, retries the failed Activity with the corrected data, and advances through the remaining pipeline steps. The detail panel shows the updated state including the fix history. -![Workflow completed after fixing the employer name](assets/saga-pattern/keep-business-moving-success.png) +![Workflow completed after fixing the employer name](assets/recover-without-restart/keep-business-moving-success.png) You can also verify the full Event History — including the Signal and subsequent Activity completions — in the Temporal Web UI. -![Event History in the Temporal Web UI showing the Signal and Activity completions](assets/saga-pattern/keep-business-moving-event-history.png) +![Event History in the Temporal Web UI showing the Signal and Activity completions](assets/recover-without-restart/keep-business-moving-event-history.png) #### Using the Temporal CLI diff --git a/sidebars.js b/sidebars.js index 53167269ba..433fd8f5fc 100644 --- a/sidebars.js +++ b/sidebars.js @@ -1793,7 +1793,7 @@ module.exports = { }, items: [ 'guides/entity-pattern-loyalty-points', - 'guides/saga-pattern', + 'guides/recover-without-restart', 'guides/route-specialized-workloads', 'guides/worker-execution-affinity', 'guides/temporary-rate-limit-increases', diff --git a/src/components/GuidesGrid/guides-data.json b/src/components/GuidesGrid/guides-data.json index 5cec1fea1f..826ea2579d 100644 --- a/src/components/GuidesGrid/guides-data.json +++ b/src/components/GuidesGrid/guides-data.json @@ -14,7 +14,7 @@ "Build business processes that pause on errors and recover without restarting.", "tags": ["Saga Pattern"], "sdk": "TypeScript", - "href": "/guides/saga-pattern" + "href": "/guides/recover-without-restart" }, { diff --git a/vercel.json b/vercel.json index ed3cead0b7..f13405da04 100644 --- a/vercel.json +++ b/vercel.json @@ -33,6 +33,11 @@ } ], "redirects": [ + { + "source": "/guides/saga-pattern", + "destination": "/guides/recover-without-restart", + "permanent": true + }, { "source": "/cloud/disaster-recovery", "destination": "/cloud/high-availability", From 54cd6328c282a5a50322ab9aade4713b1deeb5cb Mon Sep 17 00:00:00 2001 From: Duncan Mackenzie Date: Fri, 24 Jul 2026 15:22:10 -0700 Subject: [PATCH 2/3] Add markdown and og:image support to the /ai-cookbook section. --- bin/generate-og-gallery.js | 56 ++++++---- bin/validate-og-images.js | 101 ++++++++++-------- docusaurus.config.js | 16 ++- plugins/cookbook-index/index.js | 37 ++++++- plugins/markdown-pages/index.js | 68 +++++++----- plugins/og-image/constants.js | 7 +- plugins/og-image/index.js | 98 ++++++++++------- plugins/og-image/remarkPlugin.js | 13 ++- plugins/og-image/render.js | 10 +- plugins/og-image/shared.js | 12 ++- plugins/shared/docsRouting.js | 28 ++++- .../Cookbook/DocItem/CookbookDocItem.tsx | 5 + src/pages/ai-cookbook.tsx | 5 + 13 files changed, 300 insertions(+), 156 deletions(-) diff --git a/bin/generate-og-gallery.js b/bin/generate-og-gallery.js index 6689b1dfc9..dde74bc48d 100644 --- a/bin/generate-og-gallery.js +++ b/bin/generate-og-gallery.js @@ -11,9 +11,17 @@ const matter = require('gray-matter'); const ogImagePlugin = require('../plugins/og-image'); 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 +48,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 +77,34 @@ 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}` }); + } } 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..ce598a7572 100644 --- a/bin/validate-og-images.js +++ b/bin/validate-og-images.js @@ -32,6 +32,15 @@ const ogImagePlugin = require('../plugins/og-image'); 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 +82,60 @@ 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++; + 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); - 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 (!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; - } - - 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}`), - 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: expectedImage, - ogImage, - twitterImage, - }); - } - if (!fs.existsSync(expectedImagePath)) { - missingImages.push({ file: path.relative(BUILD_DIR, htmlPath), expectedImagePath }); + if (!fs.existsSync(expectedImagePath)) { + missingImages.push({ file: path.relative(BUILD_DIR, htmlPath), 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..eb3493174c 100644 --- a/plugins/cookbook-index/index.js +++ b/plugins/cookbook-index/index.js @@ -2,6 +2,12 @@ const fs = require('fs'); const path = require('path'); const matter = require('gray-matter'); +// 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.'; + module.exports = function cookbookIndexPlugin(context, options = {}) { console.log('[cookbook-index] init with docsDir:', options.docsDir); console.log('[cookbook-index] resolved docsDir:', path.isAbsolute(options.docsDir) @@ -91,7 +97,34 @@ 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))`); + }, + }; }; 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 "/index" — mirror that instead of nesting it. + if (id === 'index') return prefix || 'index'; + return prefixed(id); + } // Docusaurus treats a doc named the same as its parent folder (e.g. // `event-history/event-history.mdx`) as that folder's index page, the // same way it treats `index.mdx`/`README.mdx`. Mirror that convention // here so callers land on the same route as the real page. - if (id === 'index' || id === path.basename(dir)) return dir; - return `${dir}/${id}`; + if (id === 'index' || id === path.basename(dir)) return prefixed(dir); + return prefixed(`${dir}/${id}`); } module.exports = { walkDir, resolveUrlPath }; diff --git a/src/components/Cookbook/DocItem/CookbookDocItem.tsx b/src/components/Cookbook/DocItem/CookbookDocItem.tsx index 6cb1e73d41..f6c990a6cb 100644 --- a/src/components/Cookbook/DocItem/CookbookDocItem.tsx +++ b/src/components/Cookbook/DocItem/CookbookDocItem.tsx @@ -12,6 +12,8 @@ import MDXComponents from '@theme/MDXComponents'; import clsx from 'clsx'; import { usePluginData } from '@docusaurus/useGlobalData'; import DocItemStructuredData from '@site/src/theme/DocItem/StructuredData'; +import LLMActions from '@site/src/components/LLMActions/LLMActions'; +import MarkdownAlternateLink from '@site/src/components/LLMActions/MarkdownAlternateLink'; import styles from './CookbookDocItem.module.css'; @@ -239,6 +241,7 @@ function InnerCookbookDocItem({ content, tags }: CookbookDocItemProps) { <> {renderActions()} + {renderLastUpdated()} ); @@ -252,6 +255,7 @@ function InnerCookbookDocItem({ content, tags }: CookbookDocItemProps) { + {title} @@ -264,6 +268,7 @@ function InnerCookbookDocItem({ content, tags }: CookbookDocItemProps) {
{renderActions()}

{syntheticTitle}

+ {renderLastUpdated()}
)} diff --git a/src/pages/ai-cookbook.tsx b/src/pages/ai-cookbook.tsx index 1d4e2a31c9..e8a425e001 100644 --- a/src/pages/ai-cookbook.tsx +++ b/src/pages/ai-cookbook.tsx @@ -1,10 +1,15 @@ import React from 'react'; +import Head from '@docusaurus/Head'; import Layout from '@theme/Layout'; import { CookbookHome } from '@site/src/components'; export default function CookbookLanding() { return ( + {/* Generated by plugins/cookbook-index's postBuild hook — see MARKDOWN_PIPELINE.md. */} + + + ); From 0ed3cc7b7dbacbb43d57cea3bb32be69e97f326d Mon Sep 17 00:00:00 2001 From: Duncan Mackenzie Date: Mon, 27 Jul 2026 07:55:56 -0700 Subject: [PATCH 3/3] Align AI Cookbook better with the rest of docs by adding manual Open Graph image handling and ensuring proper metadata for the landing page. This includes checks for the og:image in the validation script and generating the image card during the build process. --- bin/generate-og-gallery.js | 15 +++++++++++++ bin/validate-og-images.js | 34 ++++++++++++++++++++++++++++++ plugins/cookbook-index/index.js | 17 +++++++++++++++ src/constants/aiCookbookOgImage.js | 12 +++++++++++ src/pages/ai-cookbook.tsx | 12 ++++++++++- 5 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 src/constants/aiCookbookOgImage.js diff --git a/bin/generate-og-gallery.js b/bin/generate-og-gallery.js index dde74bc48d..d156e386cb 100644 --- a/bin/generate-og-gallery.js +++ b/bin/generate-og-gallery.js @@ -9,6 +9,7 @@ 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'); @@ -107,6 +108,20 @@ async function main() { } } + // /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)); const html = ` diff --git a/bin/validate-og-images.js b/bin/validate-og-images.js index ce598a7572..b0e115a4ab 100644 --- a/bin/validate-og-images.js +++ b/bin/validate-og-images.js @@ -29,6 +29,7 @@ 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'); @@ -139,6 +140,39 @@ async function main() { } } + // /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, AI_COOKBOOK_OG_IMAGE_PATH); + + 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, cookbookHomeHtmlPath), expectedImagePath }); + } + } + const otherMismatches = []; const allHtmlFiles = walkHtmlFiles(BUILD_DIR); const otherFiles = allHtmlFiles.filter((f) => !docHtmlPaths.has(f)); diff --git a/plugins/cookbook-index/index.js b/plugins/cookbook-index/index.js index eb3493174c..fe905d32d9 100644 --- a/plugins/cookbook-index/index.js +++ b/plugins/cookbook-index/index.js @@ -1,6 +1,9 @@ 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 @@ -8,6 +11,12 @@ const matter = require('gray-matter'); 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); console.log('[cookbook-index] resolved docsDir:', path.isAbsolute(options.docsDir) @@ -125,6 +134,14 @@ console.log('[cookbook-index] init with docsDir:', options.docsDir); 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/src/constants/aiCookbookOgImage.js b/src/constants/aiCookbookOgImage.js new file mode 100644 index 0000000000..634d25d9c7 --- /dev/null +++ b/src/constants/aiCookbookOgImage.js @@ -0,0 +1,12 @@ +// Fixed (not content-hashed) og:image path for the /ai-cookbook landing page +// (src/pages/ai-cookbook.tsx) — a plain React page, not an MDX doc, so it's +// outside the per-doc content-hash cache plugins/og-image/index.js uses for +// everything else. Its title/description are hardcoded literals that rarely +// change, so a fixed filename (same convention as themeConfig's sitewide +// default image) is simpler than keeping a hash in sync across a Node build +// script and a React component. Shared by plugins/cookbook-index/index.js +// (renders the file here), src/pages/ai-cookbook.tsx (references it in +// og:image/twitter:image meta), and bin/validate-og-images.js (asserts it). +module.exports = { + AI_COOKBOOK_OG_IMAGE_PATH: '/img/og/ai-cookbook.jpg', +}; diff --git a/src/pages/ai-cookbook.tsx b/src/pages/ai-cookbook.tsx index e8a425e001..c319193985 100644 --- a/src/pages/ai-cookbook.tsx +++ b/src/pages/ai-cookbook.tsx @@ -1,14 +1,24 @@ import React from 'react'; import Head from '@docusaurus/Head'; import Layout from '@theme/Layout'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import { CookbookHome } from '@site/src/components'; +import { AI_COOKBOOK_OG_IMAGE_PATH } from '@site/src/constants/aiCookbookOgImage'; export default function CookbookLanding() { + const { siteConfig } = useDocusaurusContext(); + const ogImageUrl = `${siteConfig.url}${AI_COOKBOOK_OG_IMAGE_PATH}`; + return ( - {/* Generated by plugins/cookbook-index's postBuild hook — see MARKDOWN_PIPELINE.md. */} + {/* Markdown + og:image both generated by plugins/cookbook-index's + postBuild hook — see MARKDOWN_PIPELINE.md. This page is a plain + React page, not an MDX doc, so it's outside the automatic per-doc + pipeline and needs these tags set explicitly. */} + +