// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
///
// @ts-check
/**
* Katas Taxonomy
*
* A Kata is a top-level container of educational items which are used to explain a particular topic.
*
* This file builds the content for all the Katas. The katas ordering is conveyed by JSON file where each
* string in the array represents a folder that contains all the data to build the kata.
*
* Each Kata is organized in a directory where an index.md file provides a description on how the kata must be composed.
*/
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
readdirSync,
statSync,
} from "node:fs";
import { basename, dirname, join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
import mdit from "markdown-it";
import { plugin } from "./markdown_latex_plugin.js";
const md = mdit("commonmark");
md.use(plugin);
// Set up the Markdown renderer with KaTeX support for validation
import mk from "@vscode/markdown-it-katex";
const mdValidator = mdit("commonmark");
const katexOpts = {
enableMathBlockInHtml: true,
enableMathInlineInHtml: true,
throwOnError: true,
};
// @ts-expect-error: This isn't typed correctly for some reason
mdValidator.use(mk.default, katexOpts);
const validate = true; // Consider making this a command-line option
let emitHtml = true;
const forceRegeneration =
process.argv.includes("--force") || process.argv.includes("-f");
const scriptDirPath = dirname(fileURLToPath(import.meta.url));
const katasContentPath = join(
scriptDirPath,
"..",
"..",
"..",
"katas",
"content",
);
const katasGeneratedContentPath = join(scriptDirPath, "src");
const contentFileNames = {
katasIndex: "index.json",
kataMarkdown: "index.md",
};
function tryGetTitleFromMarkdown(markdown, errorPrefix) {
const result = /^# (.*)/.exec(markdown);
if (result?.length !== 2)
throw new Error(`${errorPrefix}\nCould not get title from markdown`);
return result[1];
}
function tryGetTitleFromSegment(segment, errorPrefix) {
// The segment that represents the title can only be a markdown segment.
if (segment.type !== "markdown") {
throw new Error(
`${errorPrefix}\n` +
`segment is expected to be the title but found a segment of type '${segment.type}' instead`,
);
}
// Check that the segment has just one line.
const linesCount = segment.markdown.split(/\r?\n/).length;
if (linesCount !== 1) {
throw new Error(
`${errorPrefix}\n` +
`A title segment must be 1 line, but ${linesCount} lines are present\n` +
`Hint: is the markdown missing a @[section] macro?`,
);
}
const title = tryGetTitleFromMarkdown(segment.markdown, errorPrefix);
return title;
}
function tryParseJSON(json, errorPrefix) {
let parsed;
try {
parsed = JSON.parse(json);
} catch (e) {
throw new Error(`${errorPrefix}\n${e}`, { cause: e });
}
return parsed;
}
function tryReadFile(filePath, errorPrefix) {
let content;
try {
content = readFileSync(filePath, "utf8");
} catch (e) {
throw new Error(`${errorPrefix}\n${e}`, { cause: e });
}
return content;
}
function identifyMissingProperties(properties, required) {
return required.filter((property) => !Object.hasOwn(properties, property));
}
function getSourceId(sourcePath, basePath) {
return relative(basePath, sourcePath).replaceAll(sep, "__");
}
function aggregateSources(paths, globalCodeSources) {
const codeSources = [];
for (const path of paths) {
const id = getSourceId(path, globalCodeSources.basePath);
if (!(id in globalCodeSources.sources)) {
const code = tryReadFile(path, "Could not read code dependency");
globalCodeSources.sources[id] = code;
}
codeSources.push(id);
}
return codeSources;
}
function resolveSvgSegment(properties, baseFolderPath) {
const requiredProperties = ["path"];
const missingProperties = identifyMissingProperties(
properties,
requiredProperties,
);
if (missingProperties.length > 0) {
throw new Error(
`SVG macro is missing the following properties: ${missingProperties}`,
);
}
const svgPath = join(baseFolderPath, properties.path);
const svg = tryReadFile(
svgPath,
`Could not read the contents of the SVG file at ${svgPath}`,
);
// An SVG file is basically an HTML file. If it includes blank lines, this will
// cause issues when including in Markdown, as blank lines indicate the end of
// HTML content. Check for blank lines within the document.
if (/\n\s*\r?\n/.test(svg)) {
throw new Error(
`SVG file ${svgPath} includes blank lines, which will break the Markdown`,
);
}
properties["svg"] = svg;
}
function resolveEmbeddedContent(segments, baseFolderPath) {
for (const segment of segments) {
if (segment.type === "svg") {
resolveSvgSegment(segment.properties, baseFolderPath);
}
}
}
function appendToMarkdownSegment(markdownSegment, segmentToAppend) {
if (segmentToAppend.type === "markdown") {
markdownSegment.markdown += "\n" + segmentToAppend.markdown;
} else if (segmentToAppend.type === "svg") {
markdownSegment.markdown += "\n" + segmentToAppend.properties.svg;
} else {
throw new Error(
`Cannot append segment of type "${segmentToAppend.type}" into markdown segment`,
);
}
}
function coalesceIntoSingleMarkdownSegment(startingSegment, segmentsStack) {
const markdownSegment = { type: "markdown", markdown: "" };
appendToMarkdownSegment(markdownSegment, startingSegment);
const isCoalesceSupportedForSegment = (segment) =>
segment.type === "markdown" || segment.type === "svg";
while (
segmentsStack.length > 0 &&
isCoalesceSupportedForSegment(segmentsStack.at(-1))
) {
const currentSegment = segmentsStack.pop();
appendToMarkdownSegment(markdownSegment, currentSegment);
}
return markdownSegment;
}
function coalesceSegments(segments) {
const coalescedSegments = [];
const segmentsStack = segments.reverse();
while (segmentsStack.length > 0) {
let currentSegment = segmentsStack.pop();
let coalescedSegment;
if (currentSegment.type === "markdown" || currentSegment.type === "svg") {
coalescedSegment = coalesceIntoSingleMarkdownSegment(
currentSegment,
segmentsStack,
);
} else {
coalescedSegment = currentSegment;
}
coalescedSegments.push(coalescedSegment);
}
return coalescedSegments;
}
function preProcessSegments(segments, baseFolderPath) {
resolveEmbeddedContent(segments, baseFolderPath);
const coalescedSegments = coalesceSegments(segments);
return coalescedSegments;
}
function parseMarkdown(markdown) {
const segments = [];
const macroRegex = /@\[(?\w+)\]\((?\{.*?\})\)((\r?\n)|$)/gs;
let latestProcessedIndex = 0;
while (latestProcessedIndex < markdown.length) {
const match = macroRegex.exec(markdown);
if (match !== null) {
// If there is something between the last processed index and the start of the match that is not just whitespace,
// it represents a text segment.
const delta = match.index - latestProcessedIndex;
if (delta > 0) {
const textSegment = tryCreateMarkdownSegment(
markdown.substring(latestProcessedIndex, match.index),
);
if (textSegment !== null) {
segments.push(textSegment);
}
}
// Create a segment that corresponds to the found macro.
const macroSegment = createMacroSegment(match);
segments.push(macroSegment);
latestProcessedIndex = macroRegex.lastIndex;
} else {
// No more matches were found, create a text segment with the remaining content.
const textSegment = tryCreateMarkdownSegment(
markdown.substring(latestProcessedIndex, markdown.length),
);
if (textSegment !== null) {
segments.push(textSegment);
}
latestProcessedIndex = markdown.length;
}
}
return segments;
}
function createExample(baseFolderPath, properties) {
// Validate that the data contains the required properties.
const requiredProperties = ["id", "codePath"];
const missingProperties = identifyMissingProperties(
properties,
requiredProperties,
);
if (missingProperties.length > 0) {
throw new Error(
`Example macro is missing the following properties: ${missingProperties}`,
);
}
// Generate the object using the macro properties.
const codePath = join(baseFolderPath, properties.codePath);
const code = tryReadFile(
codePath,
`Could not read the contents of the example code file at ${codePath}`,
);
return {
type: "example",
id: properties.id,
code,
};
}
function createTextContent(markdown) {
if (validate) {
try {
mdValidator.render(markdown);
} catch (e) {
console.log("LaTeX validation error: ", e);
}
}
return {
type: "text-content",
content: emitHtml ? md.render(markdown) : markdown,
};
}
function createSolution(baseFolderPath, properties) {
// Validate that the data contains the required properties.
const requiredProperties = ["id", "codePath"];
const missingProperties = identifyMissingProperties(
properties,
requiredProperties,
);
if (missingProperties.length > 0) {
throw new Error(
`Solution macro is missing the following properties: ${missingProperties}`,
);
}
// Generate the object using the macro properties.
const codePath = join(baseFolderPath, properties.codePath);
const code = tryReadFile(
codePath,
`Could not read the contents of the solution code file at ${codePath}`,
);
return {
type: "solution",
id: properties.id,
code,
};
}
function createExplainedSolution(markdownFilePath) {
const markdown = tryReadFile(
markdownFilePath,
`Could not read solution markdown file at ${markdownFilePath}`,
);
const solutionFolderPath = dirname(markdownFilePath);
const rawSegments = parseMarkdown(markdown);
const segments = preProcessSegments(rawSegments, solutionFolderPath);
const solutionItems = [];
for (const segment of segments) {
let solutionItem = null;
if (segment.type === "example") {
solutionItem = createExample(solutionFolderPath, segment.properties);
} else if (segment.type === "solution") {
solutionItem = createSolution(solutionFolderPath, segment.properties);
} else if (segment.type === "markdown") {
solutionItem = createTextContent(segment.markdown);
}
if (solutionItem !== null) {
solutionItems.push(solutionItem);
}
}
return {
type: "explained-solution",
items: solutionItems,
};
}
function createAnswer(markdownFilePath) {
const markdown = tryReadFile(
markdownFilePath,
`Could not read answer markdown file at ${markdownFilePath}`,
);
const answerFolderPath = dirname(markdownFilePath);
const rawSegments = parseMarkdown(markdown);
const segments = preProcessSegments(rawSegments, answerFolderPath);
const items = [];
for (const segment of segments) {
let answerItem = null;
if (segment.type === "example") {
answerItem = createExample(answerFolderPath, segment.properties);
} else if (segment.type === "markdown") {
answerItem = createTextContent(segment.markdown);
}
if (answerItem !== null) {
items.push(answerItem);
}
}
return { type: "answer", items };
}
function createQuestion(kataPath, properties) {
// Validate that the data contains the required properties.
const requiredProperties = ["descriptionPath", "answerPath"];
const missingProperties = identifyMissingProperties(
properties,
requiredProperties,
);
if (missingProperties.length > 0) {
throw new Error(
`Question macro is missing the following properties\n` +
`${missingProperties}\n` +
`Macro properties:\n` +
`${JSON.stringify(properties, undefined, 2)}`,
);
}
// Generate the object using the macro properties.
const descriptionMarkdown = tryReadFile(
join(kataPath, properties.descriptionPath),
`Could not read description for question ${properties.id}`,
);
const description = createTextContent(descriptionMarkdown);
const answer = createAnswer(join(kataPath, properties.answerPath));
return {
type: "question",
description,
answer,
};
}
function createExerciseSection(kataPath, properties, globalCodeSources) {
// Validate that the data contains the required properties.
const requiredProperties = ["id", "title", "path"];
const missingProperties = identifyMissingProperties(
properties,
requiredProperties,
);
if (missingProperties.length > 0) {
throw new Error(
`Exercise macro is missing the following properties\n` +
`${missingProperties}\n` +
`Macro properties:\n` +
`${JSON.stringify(properties, undefined, 2)}`,
);
}
const exercisePath = join(kataPath, properties.path);
// Generate the object using the macro properties.
// Get the description from the index.md file in the exercise folder.
let descriptionMarkdown = tryReadFile(
join(exercisePath, "index.md"),
`Could not read index.md file for exercise ${properties.id}`,
);
// Strip inline hint blocks from exercise descriptions when requested.
// The VS Code extension provides hints through a separate AI-powered
// button instead of the embedded blocks.
const hintPattern =
/\s*[\s\S]*?Need a hint[\s\S]*?<\/summary>([\s\S]*?)<\/details>/gi;
/** @type {string[]} */
let hints = [];
if (!emitHtml) {
// Capture the inner content of each hint block before stripping.
hints = [...descriptionMarkdown.matchAll(hintPattern)].map((m) =>
m[1].trim(),
);
descriptionMarkdown = descriptionMarkdown.replace(hintPattern, "").trim();
}
const description = createTextContent(descriptionMarkdown);
// Aggregate the exercise sources. The verification source file is Verification.qs.
let resolvedVerificationFile = join(exercisePath, "Verification.qs");
// Implicit dependencies to simplify dependency handling:
// ../KatasLibrary.qs must be included
// ./Common.qs must be included if present in current kata
const implicitDependencies = ["../KatasLibrary.qs"];
if (existsSync(join(kataPath, "./Common.qs"))) {
implicitDependencies.push("./Common.qs");
}
const resolvedDependencies = implicitDependencies.map((path) =>
join(kataPath, path),
);
const resolvedSources = [resolvedVerificationFile].concat(
resolvedDependencies,
);
const sourceIds = aggregateSources(resolvedSources, globalCodeSources);
// Get the placeholder code from the Placeholder.qs file in the exercise folder.
const placeholderCode = tryReadFile(
join(exercisePath, "Placeholder.qs"),
`Could not read Placeholder.qs file for exercise '${properties.id}'`,
);
// Get the solution from the solution.md file in the exercise folder.
const explainedSolution = createExplainedSolution(
join(exercisePath, "solution.md"),
);
return {
type: "exercise",
id: properties.id,
title: properties.title,
description,
sourceIds,
placeholderCode,
explainedSolution,
...(hints.length > 0 ? { hints } : {}),
};
}
function createLessonSection(kataPath, properties, segmentsStack) {
// Validate that the data contains the required properties.
const requiredProperties = ["id", "title"];
const missingProperties = identifyMissingProperties(
properties,
requiredProperties,
);
if (missingProperties.length > 0) {
throw new Error(
`Section macro is missing the following properties\n` +
`${missingProperties}\n` +
`Macro properties:\n` +
`${JSON.stringify(properties, undefined, 2)}`,
);
}
// Continue processing segments until another section-delimiting segment appears.
const lessonItems = [];
const isSectionDelimiterSegment = (segment) =>
segment.type === "exercise" || segment.type === "section";
while (
segmentsStack.length > 0 &&
!isSectionDelimiterSegment(segmentsStack.at(-1))
) {
const currentSegment = segmentsStack.pop();
let lessonItem = null;
if (currentSegment.type === "example") {
lessonItem = createExample(kataPath, currentSegment.properties);
} else if (currentSegment.type === "markdown") {
lessonItem = createTextContent(currentSegment.markdown);
} else if (currentSegment.type === "question") {
lessonItem = createQuestion(kataPath, currentSegment.properties);
}
// Check that a valid lesson item was created.
if (lessonItem === null) {
throw new Error(
`Lesson item could not be generated for segment of type '${currentSegment.type}'\n` +
`segment:\n` +
`${JSON.stringify(currentSegment, undefined, 2)}`,
);
}
lessonItems.push(lessonItem);
}
return {
type: "lesson",
id: properties.id,
title: properties.title,
items: lessonItems,
};
}
function createMacroSegment(match) {
const type = match.groups.type;
const propertiesJson = match.groups.json;
const properties = tryParseJSON(
propertiesJson,
`Invalid JSON for macro of type ${type}.\n` + `JSON: ${propertiesJson}`,
);
return {
type,
properties,
};
}
function tryCreateMarkdownSegment(text) {
const trimmedText = text.trim();
if (trimmedText.length > 0) {
return { type: "markdown", markdown: trimmedText };
}
return null;
}
function createKata(
kataPath,
id,
title,
segments,
globalCodeSources,
published,
) {
// Validate that the kata has at least one segment.
if (segments.length === 0) {
throw new Error(`Kata '${id}' does not have any segments`);
}
// Create sections from the segments in the stack.
// Use the array of segments as a stack to keep track of the segments that have not been processed.
const segmentsStack = segments.reverse();
const sections = [];
while (segmentsStack.length > 0) {
const currentSegment = segmentsStack.pop();
let section = null;
if (currentSegment.type === "exercise") {
section = createExerciseSection(
kataPath,
currentSegment.properties,
globalCodeSources,
);
} else if (currentSegment.type === "section") {
section = createLessonSection(
kataPath,
currentSegment.properties,
segmentsStack,
);
}
// Check if a valid section was created.
if (section === null) {
throw new Error(
`Unexpected segment of type '${currentSegment.type}'\n` +
`segment:\n` +
`${JSON.stringify(currentSegment, undefined, 2)}\n` +
`Hint: is the markdown missing a @[section] macro?`,
);
}
sections.push(section);
}
return {
id,
title,
sections,
published,
};
}
function generateKataContent(path, globalCodeSources, published) {
console.log(`- Creating content for kata at: ${path}`);
const markdownPath = join(path, contentFileNames.kataMarkdown);
const markdown = tryReadFile(
markdownPath,
"Could not read the contents of the kata markdown file",
);
const kataId = basename(path);
const rawSegments = parseMarkdown(markdown);
// The first segment in the kata must be the title.
const firstSegment = rawSegments.at(0);
const title = tryGetTitleFromSegment(
firstSegment,
`Could not get title for kata '${kataId}'`,
);
// Do not use the first segment since it was already processed to get the kata's title.
const segments = preProcessSegments(rawSegments.slice(1), path);
const kata = createKata(
path,
kataId,
title,
segments,
globalCodeSources,
published,
);
console.log(
`-- '${kata.id}' kata ${kata.published ? "" : "(unpublished)"} was successfully created`,
);
return kata;
}
function validateIdsUniqueness(katas) {
console.log("Validating IDs uniqueness across all katas");
const allIds = new Set();
const assertUniqueness = (id) => {
const idAlreadyExists = allIds.has(id);
if (idAlreadyExists) {
throw new Error(`"${id}" is not unique`);
}
allIds.add(id);
};
for (const kata of katas) {
// Check kata IDs are unique.
assertUniqueness(kata.id);
for (const section of kata.sections) {
// Check section IDs are unique.
assertUniqueness(section.id);
if (section.type === "exercise") {
// Check IDs for examples and solutions within exercises are unique.
section.explainedSolution.items.forEach((item) => {
if (item.type === "example" || item.type === "solution") {
assertUniqueness(item.id);
}
});
} else if (section.type === "lesson") {
// Check IDs for examples within lessons are unique.
section.items.forEach((item) => {
if (item.type === "example") {
assertUniqueness(item.id);
}
});
}
}
}
}
function generateKatasContent(katasPath, outputPath) {
console.log("Generating katas content");
const indexPath = join(katasPath, contentFileNames.katasIndex);
const indexJson = tryReadFile(
indexPath,
"Could not read the contents of the katas index file",
);
const publishedKatasDirs = tryParseJSON(
indexJson,
`Invalid katas index at ${indexPath}`,
);
const unpublishedKatasDirs = readdirSync(katasPath, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name)
.filter((dir) => !publishedKatasDirs.includes(dir));
// Unpublished katas are listed after published in alphabetical order
const allKatasDirs = publishedKatasDirs.concat(unpublishedKatasDirs);
// Initialize an object where all the global code sources will be aggregated.
const globalCodeSourcesContainer = {
basePath: katasPath,
sources: {},
};
// Generate an object for each kata and update the global code sources with the code they reference.
var katas = [];
for (const kataDir of allKatasDirs) {
const kataPath = join(katasPath, kataDir);
const published = publishedKatasDirs.includes(kataDir);
const kata = generateKataContent(
kataPath,
globalCodeSourcesContainer,
published,
);
katas.push(kata);
}
// Create the objects that will be written to a file.
const globalCodeSources = [];
for (let id in globalCodeSourcesContainer.sources) {
globalCodeSources.push({
id: id,
code: globalCodeSourcesContainer.sources[id],
});
}
// Validate the uniqueness of IDs.
validateIdsUniqueness(katas);
// Save the JS object to a file.
const katasContent = {
katas: katas,
globalCodeSources: globalCodeSources,
};
if (!existsSync(outputPath)) {
mkdirSync(outputPath);
}
const contentJsPath = join(
outputPath,
emitHtml ? "katas-content.generated.ts" : "katas-content.generated.md.ts",
);
writeFileSync(
contentJsPath,
`export default ${JSON.stringify(katasContent, undefined, 2)}`,
"utf-8",
);
}
function needsRegeneration(katasPath, outputPath) {
if (forceRegeneration) {
return true;
}
const outputFiles = [
join(outputPath, "katas-content.generated.ts"),
join(outputPath, "katas-content.generated.md.ts"),
];
// Check if any output file is missing
for (const outputFile of outputFiles) {
if (!existsSync(outputFile)) {
console.log(`Output file ${outputFile} missing`);
return true;
}
}
// Get the oldest output file timestamp
let oldestOutputTime = Infinity;
for (const outputFile of outputFiles) {
try {
const stat = statSync(outputFile);
oldestOutputTime = Math.min(oldestOutputTime, stat.mtime.getTime());
} catch {
console.log(`Could not stat output file ${outputFile}`);
return true; // If we can't stat the file, regenerate
}
}
// Check if any input file is newer than the oldest output
function checkDirectory(dirPath) {
try {
const entries = readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dirPath, entry.name);
if (entry.isDirectory()) {
if (checkDirectory(fullPath)) return true;
} else {
try {
const stat = statSync(fullPath);
if (stat.mtime.getTime() > oldestOutputTime) {
console.log(
`Input file newer than output: ${relative(process.cwd(), fullPath)}`,
);
return true;
}
} catch {
// If we can't stat an input file, be safe and regenerate
console.log(`Could not stat input file ${fullPath}`);
return true;
}
}
}
} catch {
// If we can't read the directory, be safe and regenerate
console.log(`Could not read directory ${dirPath}`);
return true;
}
return false;
}
return checkDirectory(katasPath);
}
if (needsRegeneration(katasContentPath, katasGeneratedContentPath)) {
// Generate HTML and Markdown versions of the katas bundle
emitHtml = true;
generateKatasContent(katasContentPath, katasGeneratedContentPath);
emitHtml = false;
generateKatasContent(katasContentPath, katasGeneratedContentPath);
} else {
console.log("Content is up to date, skipping generation");
}