From 4be5b804cc2de17f4a23c4d048fe0cfd0c278861 Mon Sep 17 00:00:00 2001 From: djliden <7102904+djliden@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:09:56 -0500 Subject: [PATCH] Add template content block system for rich cookbook narratives Introduces a typed block system (markdown, recipe, code) that lets templates interleave prose and code between recipe steps instead of just concatenating recipes. Refactors all template pages and the markdown export API to use the new system, with a fallback to legacy concatenation for templates without custom blocks. Co-authored-by: Isaac --- api/content-markdown.ts | 52 +++------ src/components/templates/template-blocks.tsx | 80 +++++++++++++ src/lib/template-content.ts | 110 ++++++++++++++++++ src/pages/resources/ai-chat-app-template.tsx | 6 +- .../resources/ai-data-explorer-template.tsx | 6 +- .../analytics-dashboard-app-template.tsx | 6 +- src/pages/resources/base-app-template.tsx | 6 +- src/pages/resources/data-app-template.tsx | 6 +- tests/markdown.test.ts | 6 + 9 files changed, 220 insertions(+), 58 deletions(-) create mode 100644 src/components/templates/template-blocks.tsx create mode 100644 src/lib/template-content.ts diff --git a/api/content-markdown.ts b/api/content-markdown.ts index c88c125..49ba440 100644 --- a/api/content-markdown.ts +++ b/api/content-markdown.ts @@ -2,13 +2,13 @@ import { existsSync, readFileSync } from "fs"; import { resolve } from "path"; import { hasMarkdownSlug } from "../src/lib/content-markdown"; import { recipes, templates } from "../src/lib/recipes/recipes"; +import { + buildTemplateMarkdownDocument, + collectTemplateRecipeIds, +} from "../src/lib/template-content"; export type MarkdownSection = "docs" | "recipes" | "solutions" | "templates"; -function recipeMarkdownPath(recipeId: string): string { - return `content/recipes/${recipeId}.md`; -} - function validateSlug(slug: string): void { if (!slug || slug.trim() === "") { throw new Error("Missing slug"); @@ -92,42 +92,18 @@ function readTemplateMarkdown(rootDir: string, slug: string): string { throw new Error(`Template page not found: "${slug}"`); } - const lines: string[] = [ - "---", - `title: "${template.name.replace(/"/g, '\\"')}"`, - `url: /resources/${template.id}`, - `summary: "${template.description.replace(/"/g, '\\"')}"`, - "---", - "", - `# ${template.name}`, - "", - template.description, - "", - ]; - - for (const recipeId of template.recipeIds) { - const recipe = recipes.find((entry) => entry.id === recipeId); - if (!recipe) { - throw new Error(`Recipe not found: "${recipeId}"`); - } - if (!hasMarkdownSlug(rootDir, "recipes", recipeId)) { - throw new Error(`Recipe page not found: "${recipeId}"`); - } + const rawBySlug = Object.fromEntries( + collectTemplateRecipeIds(template).map((recipeId) => { + const recipe = recipes.find((entry) => entry.id === recipeId); + if (!recipe) { + throw new Error(`Recipe not found: "${recipeId}"`); + } - const recipePath = recipeMarkdownPath(recipeId); - const absoluteRecipePath = resolve(rootDir, recipePath); - const recipeContent = readIfExists(absoluteRecipePath); - if (!recipeContent) { - throw new Error( - `Recipe markdown missing for "${recipeId}" at ${recipePath}`, - ); - } - - lines.push(recipeContent.trim()); - lines.push(""); - } + return [recipeId, readRecipeMarkdown(rootDir, recipeId)]; + }), + ); - return lines.join("\n"); + return buildTemplateMarkdownDocument(template, rawBySlug); } export function getDetailMarkdown( diff --git a/src/components/templates/template-blocks.tsx b/src/components/templates/template-blocks.tsx new file mode 100644 index 0000000..0538597 --- /dev/null +++ b/src/components/templates/template-blocks.tsx @@ -0,0 +1,80 @@ +import CodeBlock from "@theme/CodeBlock"; +import { evaluateSync } from "@mdx-js/mdx"; +import { useMDXComponents } from "@mdx-js/react"; +import { type ComponentType, type ReactNode, useMemo } from "react"; +import * as jsxRuntime from "react/jsx-runtime"; +import type { TemplateContentBlock } from "@/lib/template-content"; + +type TemplateRecipeComponentMap = Record; + +type TemplateBlockRendererProps = { + blocks: TemplateContentBlock[]; + recipeComponents: TemplateRecipeComponentMap; +}; + +type MarkdownBlockProps = { + content: string; +}; + +function TemplateMarkdownBlock({ content }: MarkdownBlockProps): ReactNode { + const components = useMDXComponents(); + + const Content = useMemo(() => { + return evaluateSync(content, { + ...jsxRuntime, + useMDXComponents: () => components, + }).default; + }, [components, content]); + + return ; +} + +type CodeBlockProps = { + language: string; + content: string; +}; + +function TemplateCodeBlock({ language, content }: CodeBlockProps): ReactNode { + return ( + + {content.replace(/\n$/, "")} + + ); +} + +export function TemplateBlockRenderer({ + blocks, + recipeComponents, +}: TemplateBlockRendererProps): ReactNode { + return ( + <> + {blocks.map((block, index) => { + const key = `${block.type}-${index}`; + + switch (block.type) { + case "markdown": + return ; + case "code": + return ( + + ); + case "recipe": { + const RecipeComponent = recipeComponents[block.recipeId]; + if (!RecipeComponent) { + throw new Error( + `Missing recipe component for template block: ${block.recipeId}`, + ); + } + return ; + } + default: + return null; + } + })} + + ); +} diff --git a/src/lib/template-content.ts b/src/lib/template-content.ts new file mode 100644 index 0000000..ff69662 --- /dev/null +++ b/src/lib/template-content.ts @@ -0,0 +1,110 @@ +import type { Template } from "./recipes/recipes"; + +export type TemplateContentBlock = + | { type: "markdown"; content: string } + | { type: "recipe"; recipeId: string } + | { type: "code"; language: string; content: string }; + +type RawRecipeMarkdownById = Record; + +const templateContentById: Record = {}; + +export function getTemplateContentBlocks( + templateId: string, +): TemplateContentBlock[] | undefined { + return templateContentById[templateId]; +} + +export function collectTemplateRecipeIds(template: Template): string[] { + const blocks = getTemplateContentBlocks(template.id); + if (!blocks) { + return template.recipeIds; + } + + return [ + ...new Set( + blocks.flatMap((block) => + block.type === "recipe" ? [block.recipeId] : [], + ), + ), + ]; +} + +function getRecipeMarkdown( + recipeId: string, + rawBySlug: RawRecipeMarkdownById, +): string { + const markdown = rawBySlug[recipeId]; + if (!markdown) { + throw new Error(`Recipe markdown not found: ${recipeId}`); + } + return markdown.trim(); +} + +export function buildLegacyTemplateRawMarkdown( + template: Template, + rawBySlug: RawRecipeMarkdownById, +): string { + return template.recipeIds + .map((id) => rawBySlug[id]) + .filter(Boolean) + .join("\n\n---\n\n"); +} + +export function serializeTemplateContentBlocks( + blocks: TemplateContentBlock[], + rawBySlug: RawRecipeMarkdownById, +): string { + return blocks + .map((block) => { + switch (block.type) { + case "markdown": + return block.content.trim(); + case "recipe": + return getRecipeMarkdown(block.recipeId, rawBySlug); + case "code": + return `\`\`\`${block.language}\n${block.content.trimEnd()}\n\`\`\``; + default: + return ""; + } + }) + .filter(Boolean) + .join("\n\n"); +} + +export function buildTemplateRawMarkdown( + template: Template, + rawBySlug: RawRecipeMarkdownById, +): string { + const blocks = getTemplateContentBlocks(template.id); + if (!blocks) { + return buildLegacyTemplateRawMarkdown(template, rawBySlug); + } + + return serializeTemplateContentBlocks(blocks, rawBySlug); +} + +function escapeFrontmatter(value: string): string { + return value.replace(/"/g, '\\"'); +} + +export function buildTemplateMarkdownDocument( + template: Template, + rawBySlug: RawRecipeMarkdownById, +): string { + const body = buildTemplateRawMarkdown(template, rawBySlug); + + return [ + "---", + `title: "${escapeFrontmatter(template.name)}"`, + `url: /resources/${template.id}`, + `summary: "${escapeFrontmatter(template.description)}"`, + "---", + "", + `# ${template.name}`, + "", + template.description, + "", + body, + ].join("\n"); +} diff --git a/src/pages/resources/ai-chat-app-template.tsx b/src/pages/resources/ai-chat-app-template.tsx index 350a7d1..d672d1d 100644 --- a/src/pages/resources/ai-chat-app-template.tsx +++ b/src/pages/resources/ai-chat-app-template.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { TemplateDetail } from "@/components/templates/template-detail"; import { templates } from "@/lib/recipes/recipes"; +import { buildTemplateRawMarkdown } from "@/lib/template-content"; import { useAllRawRecipeMarkdown } from "@/lib/use-raw-content-markdown"; import DatabricksLocalBootstrap from "@site/content/recipes/databricks-local-bootstrap.md"; import FoundationModelsApi from "@site/content/recipes/foundation-models-api.md"; @@ -15,10 +16,7 @@ export default function AiChatAppTemplatePage(): ReactNode { if (!template) { throw new Error("Template ai-chat-app-template not found"); } - const rawMarkdown = template.recipeIds - .map((id) => rawBySlug[id]) - .filter(Boolean) - .join("\n\n---\n\n"); + const rawMarkdown = buildTemplateRawMarkdown(template, rawBySlug); return ( diff --git a/src/pages/resources/ai-data-explorer-template.tsx b/src/pages/resources/ai-data-explorer-template.tsx index c2c3a6e..c0d6c0c 100644 --- a/src/pages/resources/ai-data-explorer-template.tsx +++ b/src/pages/resources/ai-data-explorer-template.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { TemplateDetail } from "@/components/templates/template-detail"; import { templates } from "@/lib/recipes/recipes"; +import { buildTemplateRawMarkdown } from "@/lib/template-content"; import { useAllRawRecipeMarkdown } from "@/lib/use-raw-content-markdown"; import DatabricksLocalBootstrap from "@site/content/recipes/databricks-local-bootstrap.md"; import LakebaseDataPersistence from "@site/content/recipes/lakebase-data-persistence.md"; @@ -16,10 +17,7 @@ export default function AiDataExplorerTemplatePage(): ReactNode { if (!template) { throw new Error("Template ai-data-explorer-template not found"); } - const rawMarkdown = template.recipeIds - .map((id) => rawBySlug[id]) - .filter(Boolean) - .join("\n\n---\n\n"); + const rawMarkdown = buildTemplateRawMarkdown(template, rawBySlug); return ( diff --git a/src/pages/resources/analytics-dashboard-app-template.tsx b/src/pages/resources/analytics-dashboard-app-template.tsx index f9a2847..631209d 100644 --- a/src/pages/resources/analytics-dashboard-app-template.tsx +++ b/src/pages/resources/analytics-dashboard-app-template.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { TemplateDetail } from "@/components/templates/template-detail"; import { templates } from "@/lib/recipes/recipes"; +import { buildTemplateRawMarkdown } from "@/lib/template-content"; import { useAllRawRecipeMarkdown } from "@/lib/use-raw-content-markdown"; import DatabricksLocalBootstrap from "@site/content/recipes/databricks-local-bootstrap.md"; import LakebaseDataPersistence from "@site/content/recipes/lakebase-data-persistence.md"; @@ -15,10 +16,7 @@ export default function AnalyticsDashboardAppTemplatePage(): ReactNode { if (!template) { throw new Error("Template analytics-dashboard-app-template not found"); } - const rawMarkdown = template.recipeIds - .map((id) => rawBySlug[id]) - .filter(Boolean) - .join("\n\n---\n\n"); + const rawMarkdown = buildTemplateRawMarkdown(template, rawBySlug); return ( diff --git a/src/pages/resources/base-app-template.tsx b/src/pages/resources/base-app-template.tsx index eb0d14e..385e6b9 100644 --- a/src/pages/resources/base-app-template.tsx +++ b/src/pages/resources/base-app-template.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { TemplateDetail } from "@/components/templates/template-detail"; import { templates } from "@/lib/recipes/recipes"; +import { buildTemplateRawMarkdown } from "@/lib/template-content"; import { useAllRawRecipeMarkdown } from "@/lib/use-raw-content-markdown"; import DatabricksLocalBootstrap from "@site/content/recipes/databricks-local-bootstrap.md"; @@ -11,10 +12,7 @@ export default function BaseAppTemplatePage(): ReactNode { if (!template) { throw new Error("Template base-app-template not found"); } - const rawMarkdown = template.recipeIds - .map((id) => rawBySlug[id]) - .filter(Boolean) - .join("\n\n---\n\n"); + const rawMarkdown = buildTemplateRawMarkdown(template, rawBySlug); return ( diff --git a/src/pages/resources/data-app-template.tsx b/src/pages/resources/data-app-template.tsx index bbd19fe..5fd8a6c 100644 --- a/src/pages/resources/data-app-template.tsx +++ b/src/pages/resources/data-app-template.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { TemplateDetail } from "@/components/templates/template-detail"; import { templates } from "@/lib/recipes/recipes"; +import { buildTemplateRawMarkdown } from "@/lib/template-content"; import { useAllRawRecipeMarkdown } from "@/lib/use-raw-content-markdown"; import DatabricksLocalBootstrap from "@site/content/recipes/databricks-local-bootstrap.md"; import LakebaseDataPersistence from "@site/content/recipes/lakebase-data-persistence.md"; @@ -12,10 +13,7 @@ export default function DataAppTemplatePage(): ReactNode { if (!template) { throw new Error("Template data-app-template not found"); } - const rawMarkdown = template.recipeIds - .map((id) => rawBySlug[id]) - .filter(Boolean) - .join("\n\n---\n\n"); + const rawMarkdown = buildTemplateRawMarkdown(template, rawBySlug); return ( diff --git a/tests/markdown.test.ts b/tests/markdown.test.ts index a8814ce..122bd45 100644 --- a/tests/markdown.test.ts +++ b/tests/markdown.test.ts @@ -19,6 +19,12 @@ describe("detail markdown resolver", () => { expect(markdown).toContain("## Databricks Local Bootstrap"); }); + test("does not duplicate recipe headings in legacy template export", () => { + const markdown = getDetailMarkdown("templates", "ai-chat-app-template"); + const matches = markdown.match(/## Databricks Local Bootstrap/g) ?? []; + expect(matches).toHaveLength(1); + }); + test("rejects path traversal", () => { expect(() => getDetailMarkdown("docs", "../package.json")).toThrow( "path traversal",