Skip to content
Closed
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
52 changes: 14 additions & 38 deletions api/content-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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(
Expand Down
80 changes: 80 additions & 0 deletions src/components/templates/template-blocks.tsx
Original file line number Diff line number Diff line change
@@ -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<string, ComponentType>;

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 <Content />;
}

type CodeBlockProps = {
language: string;
content: string;
};

function TemplateCodeBlock({ language, content }: CodeBlockProps): ReactNode {
return (
<CodeBlock language={language} title={language || undefined}>
{content.replace(/\n$/, "")}
</CodeBlock>
);
}

export function TemplateBlockRenderer({
blocks,
recipeComponents,
}: TemplateBlockRendererProps): ReactNode {
return (
<>
{blocks.map((block, index) => {
const key = `${block.type}-${index}`;

switch (block.type) {
case "markdown":
return <TemplateMarkdownBlock key={key} content={block.content} />;
case "code":
return (
<TemplateCodeBlock
key={key}
language={block.language}
content={block.content}
/>
);
case "recipe": {
const RecipeComponent = recipeComponents[block.recipeId];
if (!RecipeComponent) {
throw new Error(
`Missing recipe component for template block: ${block.recipeId}`,
);
}
return <RecipeComponent key={key} />;
}
default:
return null;
}
})}
</>
);
}
110 changes: 110 additions & 0 deletions src/lib/template-content.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>;

const templateContentById: Record<string, TemplateContentBlock[]> = {};

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");
}
6 changes: 2 additions & 4 deletions src/pages/resources/ai-chat-app-template.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<TemplateDetail template={template} rawMarkdown={rawMarkdown}>
<DatabricksLocalBootstrap />
Expand Down
6 changes: 2 additions & 4 deletions src/pages/resources/ai-data-explorer-template.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<TemplateDetail template={template} rawMarkdown={rawMarkdown}>
<DatabricksLocalBootstrap />
Expand Down
6 changes: 2 additions & 4 deletions src/pages/resources/analytics-dashboard-app-template.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<TemplateDetail template={template} rawMarkdown={rawMarkdown}>
<DatabricksLocalBootstrap />
Expand Down
6 changes: 2 additions & 4 deletions src/pages/resources/base-app-template.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 (
<TemplateDetail template={template} rawMarkdown={rawMarkdown}>
<DatabricksLocalBootstrap />
Expand Down
6 changes: 2 additions & 4 deletions src/pages/resources/data-app-template.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<TemplateDetail template={template} rawMarkdown={rawMarkdown}>
<DatabricksLocalBootstrap />
Expand Down
6 changes: 6 additions & 0 deletions tests/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down