Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 48 additions & 21 deletions bin/generate-og-gallery.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`;
Expand All @@ -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));
Expand Down
115 changes: 80 additions & 35 deletions bin/validate-og-images.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand Down Expand Up @@ -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 });
}
}

Expand Down
16 changes: 14 additions & 2 deletions docusaurus.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' }]],
},
],
[
Expand All @@ -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' },
],
},
],
[
Expand Down
54 changes: 52 additions & 2 deletions plugins/cookbook-index/index.js
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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
// (<link rel="alternate" type="text/markdown">) 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`);
},
};
};
Loading