microsoft/qdk

Public

mirrored from https://github.com/microsoft/qdkAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
billt/mac-intel-cryptography

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

source/npm/qsharp/generate_katas_content.js

851lines · modecode

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4/// <reference lib="es2022"/>
5// @ts-check
6
7/**
8 * Katas Taxonomy
9 *
10 * A Kata is a top-level container of educational items which are used to explain a particular topic.
11 *
12 * This file builds the content for all the Katas. The katas ordering is conveyed by JSON file where each
13 * string in the array represents a folder that contains all the data to build the kata.
14 *
15 * Each Kata is organized in a directory where an index.md file provides a description on how the kata must be composed.
16 */
17
18import {
19 existsSync,
20 mkdirSync,
21 readFileSync,
22 writeFileSync,
23 readdirSync,
24 statSync,
25} from "node:fs";
26import { basename, dirname, join, relative, sep } from "node:path";
27import { fileURLToPath } from "node:url";
28
29import mdit from "markdown-it";
30import { plugin } from "./markdown_latex_plugin.js";
31const md = mdit("commonmark");
32md.use(plugin);
33
34// Set up the Markdown renderer with KaTeX support for validation
35import mk from "@vscode/markdown-it-katex";
36const mdValidator = mdit("commonmark");
37const katexOpts = {
38 enableMathBlockInHtml: true,
39 enableMathInlineInHtml: true,
40 throwOnError: true,
41};
42// @ts-expect-error: This isn't typed correctly for some reason
43mdValidator.use(mk.default, katexOpts);
44
45const validate = true; // Consider making this a command-line option
46let emitHtml = true;
47
48const forceRegeneration =
49 process.argv.includes("--force") || process.argv.includes("-f");
50
51const scriptDirPath = dirname(fileURLToPath(import.meta.url));
52const katasContentPath = join(
53 scriptDirPath,
54 "..",
55 "..",
56 "..",
57 "katas",
58 "content",
59);
60const katasGeneratedContentPath = join(scriptDirPath, "src");
61const contentFileNames = {
62 katasIndex: "index.json",
63 kataMarkdown: "index.md",
64};
65
66function tryGetTitleFromMarkdown(markdown, errorPrefix) {
67 const result = /^# (.*)/.exec(markdown);
68 if (result?.length !== 2)
69 throw new Error(`${errorPrefix}\nCould not get title from markdown`);
70 return result[1];
71}
72
73function tryGetTitleFromSegment(segment, errorPrefix) {
74 // The segment that represents the title can only be a markdown segment.
75 if (segment.type !== "markdown") {
76 throw new Error(
77 `${errorPrefix}\n` +
78 `segment is expected to be the title but found a segment of type '${segment.type}' instead`,
79 );
80 }
81
82 // Check that the segment has just one line.
83 const linesCount = segment.markdown.split(/\r?\n/).length;
84 if (linesCount !== 1) {
85 throw new Error(
86 `${errorPrefix}\n` +
87 `A title segment must be 1 line, but ${linesCount} lines are present\n` +
88 `Hint: is the markdown missing a @[section] macro?`,
89 );
90 }
91 const title = tryGetTitleFromMarkdown(segment.markdown, errorPrefix);
92
93 return title;
94}
95
96function tryParseJSON(json, errorPrefix) {
97 let parsed;
98 try {
99 parsed = JSON.parse(json);
100 } catch (e) {
101 throw new Error(`${errorPrefix}\n${e}`, { cause: e });
102 }
103 return parsed;
104}
105
106function tryReadFile(filePath, errorPrefix) {
107 let content;
108 try {
109 content = readFileSync(filePath, "utf8");
110 } catch (e) {
111 throw new Error(`${errorPrefix}\n${e}`, { cause: e });
112 }
113 return content;
114}
115
116function identifyMissingProperties(properties, required) {
117 return required.filter((property) => !Object.hasOwn(properties, property));
118}
119
120function getSourceId(sourcePath, basePath) {
121 return relative(basePath, sourcePath).replaceAll(sep, "__");
122}
123
124function aggregateSources(paths, globalCodeSources) {
125 const codeSources = [];
126 for (const path of paths) {
127 const id = getSourceId(path, globalCodeSources.basePath);
128 if (!(id in globalCodeSources.sources)) {
129 const code = tryReadFile(path, "Could not read code dependency");
130 globalCodeSources.sources[id] = code;
131 }
132 codeSources.push(id);
133 }
134 return codeSources;
135}
136
137function resolveSvgSegment(properties, baseFolderPath) {
138 const requiredProperties = ["path"];
139 const missingProperties = identifyMissingProperties(
140 properties,
141 requiredProperties,
142 );
143 if (missingProperties.length > 0) {
144 throw new Error(
145 `SVG macro is missing the following properties: ${missingProperties}`,
146 );
147 }
148
149 const svgPath = join(baseFolderPath, properties.path);
150 const svg = tryReadFile(
151 svgPath,
152 `Could not read the contents of the SVG file at ${svgPath}`,
153 );
154
155 // An SVG file is basically an HTML file. If it includes blank lines, this will
156 // cause issues when including in Markdown, as blank lines indicate the end of
157 // HTML content. Check for blank lines within the document.
158 if (/\n\s*\r?\n/.test(svg)) {
159 throw new Error(
160 `SVG file ${svgPath} includes blank lines, which will break the Markdown`,
161 );
162 }
163
164 properties["svg"] = svg;
165}
166
167function resolveEmbeddedContent(segments, baseFolderPath) {
168 for (const segment of segments) {
169 if (segment.type === "svg") {
170 resolveSvgSegment(segment.properties, baseFolderPath);
171 }
172 }
173}
174
175function appendToMarkdownSegment(markdownSegment, segmentToAppend) {
176 if (segmentToAppend.type === "markdown") {
177 markdownSegment.markdown += "\n" + segmentToAppend.markdown;
178 } else if (segmentToAppend.type === "svg") {
179 markdownSegment.markdown += "\n" + segmentToAppend.properties.svg;
180 } else {
181 throw new Error(
182 `Cannot append segment of type "${segmentToAppend.type}" into markdown segment`,
183 );
184 }
185}
186
187function coalesceIntoSingleMarkdownSegment(startingSegment, segmentsStack) {
188 const markdownSegment = { type: "markdown", markdown: "" };
189 appendToMarkdownSegment(markdownSegment, startingSegment);
190 const isCoalesceSupportedForSegment = (segment) =>
191 segment.type === "markdown" || segment.type === "svg";
192 while (
193 segmentsStack.length > 0 &&
194 isCoalesceSupportedForSegment(segmentsStack.at(-1))
195 ) {
196 const currentSegment = segmentsStack.pop();
197 appendToMarkdownSegment(markdownSegment, currentSegment);
198 }
199
200 return markdownSegment;
201}
202
203function coalesceSegments(segments) {
204 const coalescedSegments = [];
205 const segmentsStack = segments.reverse();
206 while (segmentsStack.length > 0) {
207 let currentSegment = segmentsStack.pop();
208 let coalescedSegment;
209 if (currentSegment.type === "markdown" || currentSegment.type === "svg") {
210 coalescedSegment = coalesceIntoSingleMarkdownSegment(
211 currentSegment,
212 segmentsStack,
213 );
214 } else {
215 coalescedSegment = currentSegment;
216 }
217
218 coalescedSegments.push(coalescedSegment);
219 }
220
221 return coalescedSegments;
222}
223
224function preProcessSegments(segments, baseFolderPath) {
225 resolveEmbeddedContent(segments, baseFolderPath);
226 const coalescedSegments = coalesceSegments(segments);
227 return coalescedSegments;
228}
229
230function parseMarkdown(markdown) {
231 const segments = [];
232 const macroRegex = /@\[(?<type>\w+)\]\((?<json>\{.*?\})\)((\r?\n)|$)/gs;
233 let latestProcessedIndex = 0;
234 while (latestProcessedIndex < markdown.length) {
235 const match = macroRegex.exec(markdown);
236 if (match !== null) {
237 // If there is something between the last processed index and the start of the match that is not just whitespace,
238 // it represents a text segment.
239 const delta = match.index - latestProcessedIndex;
240 if (delta > 0) {
241 const textSegment = tryCreateMarkdownSegment(
242 markdown.substring(latestProcessedIndex, match.index),
243 );
244 if (textSegment !== null) {
245 segments.push(textSegment);
246 }
247 }
248
249 // Create a segment that corresponds to the found macro.
250 const macroSegment = createMacroSegment(match);
251 segments.push(macroSegment);
252 latestProcessedIndex = macroRegex.lastIndex;
253 } else {
254 // No more matches were found, create a text segment with the remaining content.
255 const textSegment = tryCreateMarkdownSegment(
256 markdown.substring(latestProcessedIndex, markdown.length),
257 );
258 if (textSegment !== null) {
259 segments.push(textSegment);
260 }
261 latestProcessedIndex = markdown.length;
262 }
263 }
264
265 return segments;
266}
267
268function createExample(baseFolderPath, properties) {
269 // Validate that the data contains the required properties.
270 const requiredProperties = ["id", "codePath"];
271 const missingProperties = identifyMissingProperties(
272 properties,
273 requiredProperties,
274 );
275 if (missingProperties.length > 0) {
276 throw new Error(
277 `Example macro is missing the following properties: ${missingProperties}`,
278 );
279 }
280
281 // Generate the object using the macro properties.
282 const codePath = join(baseFolderPath, properties.codePath);
283 const code = tryReadFile(
284 codePath,
285 `Could not read the contents of the example code file at ${codePath}`,
286 );
287 return {
288 type: "example",
289 id: properties.id,
290 code,
291 };
292}
293
294function createTextContent(markdown) {
295 if (validate) {
296 try {
297 mdValidator.render(markdown);
298 } catch (e) {
299 console.log("LaTeX validation error: ", e);
300 }
301 }
302
303 return {
304 type: "text-content",
305 content: emitHtml ? md.render(markdown) : markdown,
306 };
307}
308
309function createSolution(baseFolderPath, properties) {
310 // Validate that the data contains the required properties.
311 const requiredProperties = ["id", "codePath"];
312 const missingProperties = identifyMissingProperties(
313 properties,
314 requiredProperties,
315 );
316 if (missingProperties.length > 0) {
317 throw new Error(
318 `Solution macro is missing the following properties: ${missingProperties}`,
319 );
320 }
321
322 // Generate the object using the macro properties.
323 const codePath = join(baseFolderPath, properties.codePath);
324 const code = tryReadFile(
325 codePath,
326 `Could not read the contents of the solution code file at ${codePath}`,
327 );
328 return {
329 type: "solution",
330 id: properties.id,
331 code,
332 };
333}
334
335function createExplainedSolution(markdownFilePath) {
336 const markdown = tryReadFile(
337 markdownFilePath,
338 `Could not read solution markdown file at ${markdownFilePath}`,
339 );
340
341 const solutionFolderPath = dirname(markdownFilePath);
342 const rawSegments = parseMarkdown(markdown);
343 const segments = preProcessSegments(rawSegments, solutionFolderPath);
344 const solutionItems = [];
345 for (const segment of segments) {
346 let solutionItem = null;
347 if (segment.type === "example") {
348 solutionItem = createExample(solutionFolderPath, segment.properties);
349 } else if (segment.type === "solution") {
350 solutionItem = createSolution(solutionFolderPath, segment.properties);
351 } else if (segment.type === "markdown") {
352 solutionItem = createTextContent(segment.markdown);
353 }
354
355 if (solutionItem !== null) {
356 solutionItems.push(solutionItem);
357 }
358 }
359
360 return {
361 type: "explained-solution",
362 items: solutionItems,
363 };
364}
365
366function createAnswer(markdownFilePath) {
367 const markdown = tryReadFile(
368 markdownFilePath,
369 `Could not read answer markdown file at ${markdownFilePath}`,
370 );
371
372 const answerFolderPath = dirname(markdownFilePath);
373 const rawSegments = parseMarkdown(markdown);
374 const segments = preProcessSegments(rawSegments, answerFolderPath);
375 const items = [];
376 for (const segment of segments) {
377 let answerItem = null;
378 if (segment.type === "example") {
379 answerItem = createExample(answerFolderPath, segment.properties);
380 } else if (segment.type === "markdown") {
381 answerItem = createTextContent(segment.markdown);
382 }
383
384 if (answerItem !== null) {
385 items.push(answerItem);
386 }
387 }
388
389 return { type: "answer", items };
390}
391
392function createQuestion(kataPath, properties) {
393 // Validate that the data contains the required properties.
394 const requiredProperties = ["descriptionPath", "answerPath"];
395 const missingProperties = identifyMissingProperties(
396 properties,
397 requiredProperties,
398 );
399 if (missingProperties.length > 0) {
400 throw new Error(
401 `Question macro is missing the following properties\n` +
402 `${missingProperties}\n` +
403 `Macro properties:\n` +
404 `${JSON.stringify(properties, undefined, 2)}`,
405 );
406 }
407
408 // Generate the object using the macro properties.
409 const descriptionMarkdown = tryReadFile(
410 join(kataPath, properties.descriptionPath),
411 `Could not read description for question ${properties.id}`,
412 );
413 const description = createTextContent(descriptionMarkdown);
414 const answer = createAnswer(join(kataPath, properties.answerPath));
415
416 return {
417 type: "question",
418 description,
419 answer,
420 };
421}
422
423function createExerciseSection(kataPath, properties, globalCodeSources) {
424 // Validate that the data contains the required properties.
425 const requiredProperties = ["id", "title", "path"];
426 const missingProperties = identifyMissingProperties(
427 properties,
428 requiredProperties,
429 );
430 if (missingProperties.length > 0) {
431 throw new Error(
432 `Exercise macro is missing the following properties\n` +
433 `${missingProperties}\n` +
434 `Macro properties:\n` +
435 `${JSON.stringify(properties, undefined, 2)}`,
436 );
437 }
438
439 const exercisePath = join(kataPath, properties.path);
440 // Generate the object using the macro properties.
441 // Get the description from the index.md file in the exercise folder.
442 let descriptionMarkdown = tryReadFile(
443 join(exercisePath, "index.md"),
444 `Could not read index.md file for exercise ${properties.id}`,
445 );
446
447 // Strip inline hint blocks from exercise descriptions when requested.
448 // The VS Code extension provides hints through a separate AI-powered
449 // button instead of the embedded <details> blocks.
450 const hintPattern =
451 /<details>\s*<summary>[\s\S]*?Need a hint[\s\S]*?<\/summary>([\s\S]*?)<\/details>/gi;
452 /** @type {string[]} */
453 let hints = [];
454 if (!emitHtml) {
455 // Capture the inner content of each hint block before stripping.
456 hints = [...descriptionMarkdown.matchAll(hintPattern)].map((m) =>
457 m[1].trim(),
458 );
459 descriptionMarkdown = descriptionMarkdown.replace(hintPattern, "").trim();
460 }
461
462 const description = createTextContent(descriptionMarkdown);
463
464 // Aggregate the exercise sources. The verification source file is Verification.qs.
465 let resolvedVerificationFile = join(exercisePath, "Verification.qs");
466
467 // Implicit dependencies to simplify dependency handling:
468 // ../KatasLibrary.qs must be included
469 // ./Common.qs must be included if present in current kata
470 const implicitDependencies = ["../KatasLibrary.qs"];
471 if (existsSync(join(kataPath, "./Common.qs"))) {
472 implicitDependencies.push("./Common.qs");
473 }
474
475 const resolvedDependencies = implicitDependencies.map((path) =>
476 join(kataPath, path),
477 );
478 const resolvedSources = [resolvedVerificationFile].concat(
479 resolvedDependencies,
480 );
481 const sourceIds = aggregateSources(resolvedSources, globalCodeSources);
482
483 // Get the placeholder code from the Placeholder.qs file in the exercise folder.
484 const placeholderCode = tryReadFile(
485 join(exercisePath, "Placeholder.qs"),
486 `Could not read Placeholder.qs file for exercise '${properties.id}'`,
487 );
488
489 // Get the solution from the solution.md file in the exercise folder.
490 const explainedSolution = createExplainedSolution(
491 join(exercisePath, "solution.md"),
492 );
493
494 return {
495 type: "exercise",
496 id: properties.id,
497 title: properties.title,
498 description,
499 sourceIds,
500 placeholderCode,
501 explainedSolution,
502 ...(hints.length > 0 ? { hints } : {}),
503 };
504}
505
506function createLessonSection(kataPath, properties, segmentsStack) {
507 // Validate that the data contains the required properties.
508 const requiredProperties = ["id", "title"];
509 const missingProperties = identifyMissingProperties(
510 properties,
511 requiredProperties,
512 );
513 if (missingProperties.length > 0) {
514 throw new Error(
515 `Section macro is missing the following properties\n` +
516 `${missingProperties}\n` +
517 `Macro properties:\n` +
518 `${JSON.stringify(properties, undefined, 2)}`,
519 );
520 }
521
522 // Continue processing segments until another section-delimiting segment appears.
523 const lessonItems = [];
524 const isSectionDelimiterSegment = (segment) =>
525 segment.type === "exercise" || segment.type === "section";
526 while (
527 segmentsStack.length > 0 &&
528 !isSectionDelimiterSegment(segmentsStack.at(-1))
529 ) {
530 const currentSegment = segmentsStack.pop();
531 let lessonItem = null;
532 if (currentSegment.type === "example") {
533 lessonItem = createExample(kataPath, currentSegment.properties);
534 } else if (currentSegment.type === "markdown") {
535 lessonItem = createTextContent(currentSegment.markdown);
536 } else if (currentSegment.type === "question") {
537 lessonItem = createQuestion(kataPath, currentSegment.properties);
538 }
539
540 // Check that a valid lesson item was created.
541 if (lessonItem === null) {
542 throw new Error(
543 `Lesson item could not be generated for segment of type '${currentSegment.type}'\n` +
544 `segment:\n` +
545 `${JSON.stringify(currentSegment, undefined, 2)}`,
546 );
547 }
548
549 lessonItems.push(lessonItem);
550 }
551
552 return {
553 type: "lesson",
554 id: properties.id,
555 title: properties.title,
556 items: lessonItems,
557 };
558}
559
560function createMacroSegment(match) {
561 const type = match.groups.type;
562 const propertiesJson = match.groups.json;
563 const properties = tryParseJSON(
564 propertiesJson,
565 `Invalid JSON for macro of type ${type}.\n` + `JSON: ${propertiesJson}`,
566 );
567 return {
568 type,
569 properties,
570 };
571}
572
573function tryCreateMarkdownSegment(text) {
574 const trimmedText = text.trim();
575 if (trimmedText.length > 0) {
576 return { type: "markdown", markdown: trimmedText };
577 }
578
579 return null;
580}
581
582function createKata(
583 kataPath,
584 id,
585 title,
586 segments,
587 globalCodeSources,
588 published,
589) {
590 // Validate that the kata has at least one segment.
591 if (segments.length === 0) {
592 throw new Error(`Kata '${id}' does not have any segments`);
593 }
594
595 // Create sections from the segments in the stack.
596 // Use the array of segments as a stack to keep track of the segments that have not been processed.
597 const segmentsStack = segments.reverse();
598 const sections = [];
599 while (segmentsStack.length > 0) {
600 const currentSegment = segmentsStack.pop();
601 let section = null;
602 if (currentSegment.type === "exercise") {
603 section = createExerciseSection(
604 kataPath,
605 currentSegment.properties,
606 globalCodeSources,
607 );
608 } else if (currentSegment.type === "section") {
609 section = createLessonSection(
610 kataPath,
611 currentSegment.properties,
612 segmentsStack,
613 );
614 }
615
616 // Check if a valid section was created.
617 if (section === null) {
618 throw new Error(
619 `Unexpected segment of type '${currentSegment.type}'\n` +
620 `segment:\n` +
621 `${JSON.stringify(currentSegment, undefined, 2)}\n` +
622 `Hint: is the markdown missing a @[section] macro?`,
623 );
624 }
625
626 sections.push(section);
627 }
628
629 return {
630 id,
631 title,
632 sections,
633 published,
634 };
635}
636
637function generateKataContent(path, globalCodeSources, published) {
638 console.log(`- Creating content for kata at: ${path}`);
639 const markdownPath = join(path, contentFileNames.kataMarkdown);
640 const markdown = tryReadFile(
641 markdownPath,
642 "Could not read the contents of the kata markdown file",
643 );
644
645 const kataId = basename(path);
646 const rawSegments = parseMarkdown(markdown);
647
648 // The first segment in the kata must be the title.
649 const firstSegment = rawSegments.at(0);
650 const title = tryGetTitleFromSegment(
651 firstSegment,
652 `Could not get title for kata '${kataId}'`,
653 );
654
655 // Do not use the first segment since it was already processed to get the kata's title.
656 const segments = preProcessSegments(rawSegments.slice(1), path);
657 const kata = createKata(
658 path,
659 kataId,
660 title,
661 segments,
662 globalCodeSources,
663 published,
664 );
665 console.log(
666 `-- '${kata.id}' kata ${kata.published ? "" : "(unpublished)"} was successfully created`,
667 );
668 return kata;
669}
670
671function validateIdsUniqueness(katas) {
672 console.log("Validating IDs uniqueness across all katas");
673 const allIds = new Set();
674 const assertUniqueness = (id) => {
675 const idAlreadyExists = allIds.has(id);
676 if (idAlreadyExists) {
677 throw new Error(`"${id}" is not unique`);
678 }
679 allIds.add(id);
680 };
681
682 for (const kata of katas) {
683 // Check kata IDs are unique.
684 assertUniqueness(kata.id);
685 for (const section of kata.sections) {
686 // Check section IDs are unique.
687 assertUniqueness(section.id);
688 if (section.type === "exercise") {
689 // Check IDs for examples and solutions within exercises are unique.
690 section.explainedSolution.items.forEach((item) => {
691 if (item.type === "example" || item.type === "solution") {
692 assertUniqueness(item.id);
693 }
694 });
695 } else if (section.type === "lesson") {
696 // Check IDs for examples within lessons are unique.
697 section.items.forEach((item) => {
698 if (item.type === "example") {
699 assertUniqueness(item.id);
700 }
701 });
702 }
703 }
704 }
705}
706
707function generateKatasContent(katasPath, outputPath) {
708 console.log("Generating katas content");
709 const indexPath = join(katasPath, contentFileNames.katasIndex);
710 const indexJson = tryReadFile(
711 indexPath,
712 "Could not read the contents of the katas index file",
713 );
714 const publishedKatasDirs = tryParseJSON(
715 indexJson,
716 `Invalid katas index at ${indexPath}`,
717 );
718 const unpublishedKatasDirs = readdirSync(katasPath, { withFileTypes: true })
719 .filter((dirent) => dirent.isDirectory())
720 .map((dirent) => dirent.name)
721 .filter((dir) => !publishedKatasDirs.includes(dir));
722
723 // Unpublished katas are listed after published in alphabetical order
724 const allKatasDirs = publishedKatasDirs.concat(unpublishedKatasDirs);
725
726 // Initialize an object where all the global code sources will be aggregated.
727 const globalCodeSourcesContainer = {
728 basePath: katasPath,
729 sources: {},
730 };
731
732 // Generate an object for each kata and update the global code sources with the code they reference.
733 var katas = [];
734 for (const kataDir of allKatasDirs) {
735 const kataPath = join(katasPath, kataDir);
736 const published = publishedKatasDirs.includes(kataDir);
737 const kata = generateKataContent(
738 kataPath,
739 globalCodeSourcesContainer,
740 published,
741 );
742 katas.push(kata);
743 }
744
745 // Create the objects that will be written to a file.
746 const globalCodeSources = [];
747 for (let id in globalCodeSourcesContainer.sources) {
748 globalCodeSources.push({
749 id: id,
750 code: globalCodeSourcesContainer.sources[id],
751 });
752 }
753
754 // Validate the uniqueness of IDs.
755 validateIdsUniqueness(katas);
756
757 // Save the JS object to a file.
758 const katasContent = {
759 katas: katas,
760 globalCodeSources: globalCodeSources,
761 };
762
763 if (!existsSync(outputPath)) {
764 mkdirSync(outputPath);
765 }
766
767 const contentJsPath = join(
768 outputPath,
769 emitHtml ? "katas-content.generated.ts" : "katas-content.generated.md.ts",
770 );
771 writeFileSync(
772 contentJsPath,
773 `export default ${JSON.stringify(katasContent, undefined, 2)}`,
774 "utf-8",
775 );
776}
777
778function needsRegeneration(katasPath, outputPath) {
779 if (forceRegeneration) {
780 return true;
781 }
782
783 const outputFiles = [
784 join(outputPath, "katas-content.generated.ts"),
785 join(outputPath, "katas-content.generated.md.ts"),
786 ];
787
788 // Check if any output file is missing
789 for (const outputFile of outputFiles) {
790 if (!existsSync(outputFile)) {
791 console.log(`Output file ${outputFile} missing`);
792 return true;
793 }
794 }
795
796 // Get the oldest output file timestamp
797 let oldestOutputTime = Infinity;
798 for (const outputFile of outputFiles) {
799 try {
800 const stat = statSync(outputFile);
801 oldestOutputTime = Math.min(oldestOutputTime, stat.mtime.getTime());
802 } catch {
803 console.log(`Could not stat output file ${outputFile}`);
804 return true; // If we can't stat the file, regenerate
805 }
806 }
807
808 // Check if any input file is newer than the oldest output
809 function checkDirectory(dirPath) {
810 try {
811 const entries = readdirSync(dirPath, { withFileTypes: true });
812 for (const entry of entries) {
813 const fullPath = join(dirPath, entry.name);
814 if (entry.isDirectory()) {
815 if (checkDirectory(fullPath)) return true;
816 } else {
817 try {
818 const stat = statSync(fullPath);
819 if (stat.mtime.getTime() > oldestOutputTime) {
820 console.log(
821 `Input file newer than output: ${relative(process.cwd(), fullPath)}`,
822 );
823 return true;
824 }
825 } catch {
826 // If we can't stat an input file, be safe and regenerate
827 console.log(`Could not stat input file ${fullPath}`);
828 return true;
829 }
830 }
831 }
832 } catch {
833 // If we can't read the directory, be safe and regenerate
834 console.log(`Could not read directory ${dirPath}`);
835 return true;
836 }
837 return false;
838 }
839
840 return checkDirectory(katasPath);
841}
842
843if (needsRegeneration(katasContentPath, katasGeneratedContentPath)) {
844 // Generate HTML and Markdown versions of the katas bundle
845 emitHtml = true;
846 generateKatasContent(katasContentPath, katasGeneratedContentPath);
847 emitHtml = false;
848 generateKatasContent(katasContentPath, katasGeneratedContentPath);
849} else {
850 console.log("Content is up to date, skipping generation");
851}