From 0e9d54ad3b7299ca4b91150d3b1d081fb14458ea Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 9 Sep 2025 16:29:58 -0400 Subject: [PATCH 1/2] test(@angular/cli): create validation harness for example code This commit introduces a pre-built, minimal, standalone Angular application that will serve as a validation harness for the code snippets in the `find_examples` tool. This harness, located in `tools/example-validation-harness`, will be used by a new CI script to programmatically compile and type-check all example code. Using a full Angular project environment ensures a high-fidelity validation that covers TypeScript, Angular-specific syntax, and HTML templates. --- tools/example-validation-harness/angular.json | 28 ++++++++++++++++++ tools/example-validation-harness/package.json | 21 ++++++++++++++ .../example-validation-harness/src/app/app.ts | 16 ++++++++++ .../example-validation-harness/src/index.html | 13 +++++++++ tools/example-validation-harness/src/main.ts | 12 ++++++++ .../tsconfig.app.json | 9 ++++++ .../example-validation-harness/tsconfig.json | 29 +++++++++++++++++++ 7 files changed, 128 insertions(+) create mode 100644 tools/example-validation-harness/angular.json create mode 100644 tools/example-validation-harness/package.json create mode 100644 tools/example-validation-harness/src/app/app.ts create mode 100644 tools/example-validation-harness/src/index.html create mode 100644 tools/example-validation-harness/src/main.ts create mode 100644 tools/example-validation-harness/tsconfig.app.json create mode 100644 tools/example-validation-harness/tsconfig.json diff --git a/tools/example-validation-harness/angular.json b/tools/example-validation-harness/angular.json new file mode 100644 index 000000000000..35f3d1368489 --- /dev/null +++ b/tools/example-validation-harness/angular.json @@ -0,0 +1,28 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "validation-harness": { + "projectType": "application", + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "outputPath": "dist/validation-harness", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": ["zone.js"], + "tsConfig": "tsconfig.app.json", + "assets": [], + "styles": [], + "scripts": [] + } + } + } + } + } +} diff --git a/tools/example-validation-harness/package.json b/tools/example-validation-harness/package.json new file mode 100644 index 000000000000..7718d33fb50b --- /dev/null +++ b/tools/example-validation-harness/package.json @@ -0,0 +1,21 @@ +{ + "name": "example-validation-harness", + "version": "0.0.0", + "private": true, + "dependencies": { + "@angular/common": "^21.0.0-next", + "@angular/compiler": "^21.0.0-next", + "@angular/core": "^21.0.0-next", + "@angular/forms": "^21.0.0-next", + "@angular/platform-browser": "^21.0.0-next", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.15.0" + }, + "devDependencies": { + "@angular/build": "^21.0.0-next", + "@angular/cli": "^21.0.0-next", + "@angular/compiler-cli": "^21.0.0-next", + "typescript": "~5.9.2" + } +} diff --git a/tools/example-validation-harness/src/app/app.ts b/tools/example-validation-harness/src/app/app.ts new file mode 100644 index 000000000000..3daae97bd6b1 --- /dev/null +++ b/tools/example-validation-harness/src/app/app.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-root', + imports: [], + template: '', +}) +export class App {} diff --git a/tools/example-validation-harness/src/index.html b/tools/example-validation-harness/src/index.html new file mode 100644 index 000000000000..82e66848efa8 --- /dev/null +++ b/tools/example-validation-harness/src/index.html @@ -0,0 +1,13 @@ + + + + + Codestin Search App + + + + + + + + diff --git a/tools/example-validation-harness/src/main.ts b/tools/example-validation-harness/src/main.ts new file mode 100644 index 000000000000..ed10aa54e41e --- /dev/null +++ b/tools/example-validation-harness/src/main.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { bootstrapApplication } from '@angular/platform-browser'; +import { App } from './app/app'; + +bootstrapApplication(App).catch((err) => console.error(err)); diff --git a/tools/example-validation-harness/tsconfig.app.json b/tools/example-validation-harness/tsconfig.app.json new file mode 100644 index 000000000000..8426ad9558bd --- /dev/null +++ b/tools/example-validation-harness/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/tools/example-validation-harness/tsconfig.json b/tools/example-validation-harness/tsconfig.json new file mode 100644 index 000000000000..5aa05d2d231d --- /dev/null +++ b/tools/example-validation-harness/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "typeCheckHostBindings": true, + "strictTemplates": true + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + } + ] +} From f1726d06f80440de2f05ddf64d2cb21895629c3b Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 10 Sep 2025 08:46:34 -0400 Subject: [PATCH 2/2] test(@angular/cli): add CI validation for MCP example code This commit introduces a new CI check to validate the correctness of code snippets and markdown structure within the `find_examples` markdown files. To support this, the previous the `example_db_generator.js` script has been refactored and consolidated into a single, unified, mode-driven script: `process_examples.mjs`. This improves maintainability and ensures consistent parsing and validation logic. The new script provides two new validation modes for CI: - `validate-structure`: Parses all examples to validate their front matter and ensure they adhere to the required markdown heading structure. - `validate-code`: Uses a pre-built Angular application harness to perform a full `ng build` on all example code, ensuring compilation and template correctness. This provides a strong guarantee of the quality and reliability of the code and structure of all examples served by the `find_examples` tool. --- .github/workflows/pr.yml | 2 + package.json | 2 + pnpm-lock.yaml | 12 +- tools/BUILD.bazel | 5 +- tools/example_db_generator.bzl | 2 +- tools/example_db_generator.js | 207 ------------------ tools/process_examples.mjs | 389 +++++++++++++++++++++++++++++++++ 7 files changed, 408 insertions(+), 211 deletions(-) delete mode 100644 tools/example_db_generator.js create mode 100755 tools/process_examples.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9b580c25c0b6..48e38e8332f4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -59,6 +59,8 @@ jobs: uses: angular/dev-infra/github-actions/linting/licenses@5043638fd8529765b375831a4679b9013141b326 - name: Check tooling setup run: pnpm check-tooling-setup + - name: Validate MCP Example Markdown + run: pnpm check-mcp-examples - name: Check commit message # Commit message validation is only done on pull requests as its too late to validate once # it has been merged. diff --git a/package.json b/package.json index bfb5dfecd75f..e91ff0d41443 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "public-api:update": "node goldens/public-api/manage.js accept", "ts-circular-deps": "pnpm -s ng-dev ts-circular-deps --config ./scripts/circular-deps-test.conf.mjs", "check-tooling-setup": "tsc --project .ng-dev/tsconfig.json", + "check-mcp-examples": "node tools/process_examples.mjs packages/angular/cli/lib/examples --mode validate-structure", "diff-release-package": "node --no-warnings=ExperimentalWarning --loader ts-node/esm/transpile-only scripts/diff-release-package.mts" }, "repository": { @@ -124,6 +125,7 @@ "listr2": "9.0.3", "lodash": "^4.17.21", "magic-string": "0.30.19", + "marked": "^16.2.1", "npm": "^11.0.0", "prettier": "^3.0.0", "protractor": "~7.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4f65d28cb96..8fe4bf62d8e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,6 +253,9 @@ importers: magic-string: specifier: 0.30.19 version: 0.30.19 + marked: + specifier: ^16.2.1 + version: 16.2.1 npm: specifier: ^11.0.0 version: 11.6.0 @@ -6625,6 +6628,11 @@ packages: resolution: {integrity: sha512-9GjpQcaUXO2xmre8JfALl8Oji8Jpo+SyY2HpqFFPHVczOld/I+JFRx9FkP/uedZzkJlI9uM5t/j6dGJv4BScQw==} engines: {node: ^20.17.0 || >=22.9.0} + marked@16.2.1: + resolution: {integrity: sha512-r3UrXED9lMlHF97jJByry90cwrZBBvZmjG1L68oYfuPMW+uDTnuMbyJDymCWwbTE+f+3LhpNDKfpR3a3saFyjA==} + engines: {node: '>= 20'} + hasBin: true + marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} @@ -7543,7 +7551,7 @@ packages: puppeteer@18.2.1: resolution: {integrity: sha512-7+UhmYa7wxPh2oMRwA++k8UGVDxh3YdWFB52r9C3tM81T6BU7cuusUSxImz0GEYSOYUKk/YzIhkQ6+vc0gHbxQ==} engines: {node: '>=14.1.0'} - deprecated: < 24.10.2 is no longer supported + deprecated: < 22.8.2 is no longer supported q@1.4.1: resolution: {integrity: sha512-/CdEdaw49VZVmyIDGUQKDDT53c7qBkO6g5CefWz91Ae+l4+cRtcDYwMTXh6me4O8TMldeGHG3N2Bl84V78Ywbg==} @@ -15859,6 +15867,8 @@ snapshots: transitivePeerDependencies: - supports-color + marked@16.2.1: {} + marky@1.3.0: {} math-intrinsics@1.1.0: {} diff --git a/tools/BUILD.bazel b/tools/BUILD.bazel index 59c817e3aa06..08e7f3970162 100644 --- a/tools/BUILD.bazel +++ b/tools/BUILD.bazel @@ -34,8 +34,9 @@ js_binary( js_binary( name = "ng_example_db", data = [ - "example_db_generator.js", + "process_examples.mjs", + "//:node_modules/marked", "//:node_modules/zod", ], - entry_point = "example_db_generator.js", + entry_point = "process_examples.mjs", ) diff --git a/tools/example_db_generator.bzl b/tools/example_db_generator.bzl index 7a899e3d8de4..3fbd806a6641 100644 --- a/tools/example_db_generator.bzl +++ b/tools/example_db_generator.bzl @@ -8,5 +8,5 @@ def cli_example_db(name, srcs, path, out, data = []): tool = "//tools:ng_example_db", progress_message = "Generating code example database from %s" % path, mnemonic = "NgExampleSqliteDb", - args = [path, "$(rootpath %s)" % out], + args = ["--mode generate-db", "--output $(rootpath %s)" % out, path], ) diff --git a/tools/example_db_generator.js b/tools/example_db_generator.js deleted file mode 100644 index dc1f7ba8e3be..000000000000 --- a/tools/example_db_generator.js +++ /dev/null @@ -1,207 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.dev/license - */ - -const { globSync, readdirSync, readFileSync, mkdirSync, existsSync, rmSync } = require('node:fs'); -const { resolve, dirname, join } = require('node:path'); -const { DatabaseSync } = require('node:sqlite'); -const { z } = require('zod'); - -/** - * A simple YAML front matter parser. - * - * This function extracts the YAML block enclosed by `---` at the beginning of a string - * and parses it into a JavaScript object. It is not a full YAML parser and only - * supports simple key-value pairs and string arrays. - * - * @param content The string content to parse. - * @returns A record containing the parsed front matter data. - */ -function parseFrontmatter(content) { - const match = content.match(/^---\r?\n(.*?)\r?\n---/s); - if (!match) { - return {}; - } - - const frontmatter = match[1]; - const data = {}; - const lines = frontmatter.split(/\r?\n/); - - let currentKey = ''; - let isArray = false; - const arrayValues = []; - - for (const line of lines) { - const keyValueMatch = line.match(/^([^:]+):\s*(.*)/); - if (keyValueMatch) { - if (currentKey && isArray) { - data[currentKey] = arrayValues.slice(); - arrayValues.length = 0; - } - - const [, key, value] = keyValueMatch; - currentKey = key.trim(); - isArray = value.trim() === ''; - - if (!isArray) { - const trimmedValue = value.trim(); - if (trimmedValue === 'true') { - data[currentKey] = true; - } else if (trimmedValue === 'false') { - data[currentKey] = false; - } else { - data[currentKey] = trimmedValue; - } - } - } else { - const arrayItemMatch = line.match(/^\s*-\s*(.*)/); - if (arrayItemMatch && currentKey && isArray) { - arrayValues.push(arrayItemMatch[1].trim()); - } - } - } - - if (currentKey && isArray) { - data[currentKey] = arrayValues; - } - - return data; -} - -function generate(inPath, outPath) { - const dbPath = outPath; - mkdirSync(dirname(outPath), { recursive: true }); - - if (existsSync(dbPath)) { - rmSync(dbPath); - } - const db = new DatabaseSync(dbPath); - - // Create a relational table to store the structured example data. - db.exec(` - CREATE TABLE examples ( - id INTEGER PRIMARY KEY, - title TEXT NOT NULL, - summary TEXT NOT NULL, - keywords TEXT, - required_packages TEXT, - related_concepts TEXT, - related_tools TEXT, - experimental INTEGER NOT NULL DEFAULT 0, - content TEXT NOT NULL - ); - `); - - // Create an FTS5 virtual table to provide full-text search capabilities. - db.exec(` - CREATE VIRTUAL TABLE examples_fts USING fts5( - title, - summary, - keywords, - required_packages, - related_concepts, - related_tools, - content, - content='examples', - content_rowid='id', - tokenize = 'porter ascii' - ); - `); - - // Create triggers to keep the FTS table synchronized with the examples table. - db.exec(` - CREATE TRIGGER examples_after_insert AFTER INSERT ON examples BEGIN - INSERT INTO examples_fts( - rowid, title, summary, keywords, required_packages, related_concepts, related_tools, - content - ) - VALUES ( - new.id, new.title, new.summary, new.keywords, new.required_packages, - new.related_concepts, new.related_tools, new.content - ); - END; - `); - - const insertStatement = db.prepare( - 'INSERT INTO examples(' + - 'title, summary, keywords, required_packages, related_concepts, related_tools, experimental, content' + - ') VALUES(?, ?, ?, ?, ?, ?, ?, ?);', - ); - - const frontmatterSchema = z.object({ - title: z.string(), - summary: z.string(), - keywords: z.array(z.string()).optional(), - required_packages: z.array(z.string()).optional(), - related_concepts: z.array(z.string()).optional(), - related_tools: z.array(z.string()).optional(), - experimental: z.boolean().optional(), - }); - - db.exec('BEGIN TRANSACTION'); - const entries = globSync - ? globSync('**/*.md', { cwd: resolve(inPath), withFileTypes: true }) - : readdirSync(resolve(inPath), { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith('.md')) { - continue; - } - - const content = readFileSync(join(entry.parentPath, entry.name), 'utf-8'); - const frontmatter = parseFrontmatter(content); - - const validation = frontmatterSchema.safeParse(frontmatter); - if (!validation.success) { - console.error(`Validation failed for example file: ${entry.name}`); - console.error('Issues:', validation.error.issues); - throw new Error(`Invalid front matter in ${entry.name}`); - } - - const { - title, - summary, - keywords, - required_packages, - related_concepts, - related_tools, - experimental, - } = validation.data; - insertStatement.run( - title, - summary, - JSON.stringify(keywords ?? []), - JSON.stringify(required_packages ?? []), - JSON.stringify(related_concepts ?? []), - JSON.stringify(related_tools ?? []), - experimental ? 1 : 0, - content, - ); - } - db.exec('END TRANSACTION'); - - db.close(); -} - -if (require.main === module) { - const argv = process.argv.slice(2); - if (argv.length !== 2) { - console.error('Must include 2 arguments.'); - process.exit(1); - } - - const [inPath, outPath] = argv; - - try { - generate(inPath, outPath); - } catch (error) { - console.error('An error happened:'); - console.error(error); - process.exit(127); - } -} - -exports.generate = generate; diff --git a/tools/process_examples.mjs b/tools/process_examples.mjs new file mode 100755 index 000000000000..143f8d95ab2f --- /dev/null +++ b/tools/process_examples.mjs @@ -0,0 +1,389 @@ +#!/usr/bin/env node +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { marked } from 'marked'; +import { execSync } from 'node:child_process'; +import { + cpSync, + existsSync, + globSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { parseArgs } from 'node:util'; +import { z } from 'zod'; + +const HARNESS_PATH = resolve(import.meta.dirname, 'example-validation-harness'); +const REQUIRED_HEADINGS = ['Purpose', 'When to Use', 'Key Concepts', 'Example Files']; + +/** + * A simple YAML front matter parser. + * + * This function extracts the YAML block enclosed by `---` at the beginning of a string + * and parses it into a JavaScript object. It is not a full YAML parser and only + * supports simple key-value pairs and string arrays. + * + * @param content The string content to parse. + * @returns A record containing the parsed front matter data. + */ +function parseFrontmatter(content) { + const match = content.match(/^---\r?\n(.*?)\r?\n---/s); + if (!match) { + return {}; + } + + const frontmatter = match[1]; + const data = {}; + const lines = frontmatter.split(/\r?\n/); + + let currentKey = ''; + let isArray = false; + const arrayValues = []; + + for (const line of lines) { + const keyValueMatch = line.match(/^([^:]+):\s*(.*)/); + if (keyValueMatch) { + if (currentKey && isArray) { + data[currentKey] = arrayValues.slice(); + arrayValues.length = 0; + } + + const [, key, value] = keyValueMatch; + currentKey = key.trim(); + isArray = value.trim() === ''; + + if (!isArray) { + const trimmedValue = value.trim(); + if (trimmedValue === 'true') { + data[currentKey] = true; + } else if (trimmedValue === 'false') { + data[currentKey] = false; + } else { + data[currentKey] = trimmedValue; + } + } + } else { + const arrayItemMatch = line.match(/^\s*-\s*(.*)/); + if (arrayItemMatch && currentKey && isArray) { + arrayValues.push(arrayItemMatch[1].trim()); + } + } + } + + if (currentKey && isArray) { + data[currentKey] = arrayValues; + } + + return data; +} + +/** + * Parses a markdown example file into a structured object. + * This function performs a single pass over the markdown tokens to extract + * front matter, validate the heading structure, and associate code blocks + * with their preceding filenames. + * + * @param {string} filePath The absolute path to the markdown file. + * @returns {object} A structured representation of the example. + */ +function parseExampleFile(filePath) { + const content = readFileSync(filePath, 'utf-8'); + const tokens = marked.lexer(content); + + const frontmatter = parseFrontmatter(content); + if (Object.keys(frontmatter).length === 0) { + throw new Error(`Validation failed for ${basename(filePath)}: Missing front matter.`); + } + + const sections = []; + let currentSection = null; + let currentFilename = null; + + for (const token of tokens) { + if (token.type === 'heading' && token.depth === 2) { + currentSection = { + title: token.text, + content: '', + codeBlocks: [], + }; + sections.push(currentSection); + } else if (currentSection) { + if (token.type === 'heading' && token.depth === 3) { + currentFilename = token.text.replace(/^["'`]|["'`]$/g, ''); + } else if (token.type === 'code' && currentFilename) { + currentSection.codeBlocks.push({ + filename: currentFilename, + lang: token.lang, + code: token.text, + }); + currentFilename = null; // Reset after consumption + } + currentSection.content += token.raw; + } + } + + // Validate the structure after parsing. + const headings = sections.map((s) => s.title); + let lastIndex = -1; + for (const requiredHeading of REQUIRED_HEADINGS) { + const currentIndex = headings.indexOf(requiredHeading); + if (currentIndex === -1) { + throw new Error( + `Validation failed for ${basename(filePath)}: Missing required heading '## ${requiredHeading}'.\n` + + `Please refer to docs/FIND_EXAMPLES_FORMAT.md for the correct structure.`, + ); + } + if (currentIndex < lastIndex) { + throw new Error( + `Validation failed for ${basename(filePath)}: Heading '## ${requiredHeading}' is out of order.\n` + + `Please refer to docs/FIND_EXAMPLES_FORMAT.md for the correct structure.`, + ); + } + lastIndex = currentIndex; + } + + return { + sourcePath: filePath, + frontmatter, + sections, + }; +} + +// --- Mode Implementations --- + +function runValidateStructure(examplesPath) { + console.log('Validating markdown structure of all example files...'); + const exampleFiles = globSync('**/*.md', { cwd: examplesPath }); + for (const file of exampleFiles) { + parseExampleFile(join(examplesPath, file)); + } + console.log(`Successfully validated structure of ${exampleFiles.length} files.`); +} + +function runGenerateDb(examplesPath, outputPath) { + if (!outputPath) { + throw new Error('Missing required argument: --output='); + } + console.log(`Generating example database at ${outputPath}...`); + const dbFiles = globSync('**/*.md', { cwd: examplesPath }); + const parsedExamplesForDb = dbFiles.map((f) => parseExampleFile(join(examplesPath, f))); + + const dbPath = resolve(outputPath); + mkdirSync(dirname(dbPath), { recursive: true }); + if (existsSync(dbPath)) { + rmSync(dbPath); + } + const db = new DatabaseSync(dbPath); + + // Create tables and triggers + db.exec(` + CREATE TABLE examples ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + summary TEXT NOT NULL, + keywords TEXT, + required_packages TEXT, + related_concepts TEXT, + related_tools TEXT, + experimental INTEGER NOT NULL DEFAULT 0, + content TEXT NOT NULL + ); + `); + db.exec(` + CREATE VIRTUAL TABLE examples_fts USING fts5( + title, summary, keywords, required_packages, related_concepts, related_tools, content, + content='examples', content_rowid='id', tokenize = 'porter ascii' + ); + `); + db.exec(` + CREATE TRIGGER examples_after_insert AFTER INSERT ON examples BEGIN + INSERT INTO examples_fts( + rowid, title, summary, keywords, required_packages, related_concepts, related_tools, content + ) VALUES ( + new.id, new.title, new.summary, new.keywords, new.required_packages, + new.related_concepts, new.related_tools, new.content + ); + END; + `); + + const insertStatement = db.prepare( + 'INSERT INTO examples(' + + 'title, summary, keywords, required_packages, related_concepts, related_tools, experimental, content' + + ') VALUES(?, ?, ?, ?, ?, ?, ?, ?);', + ); + + const frontmatterSchema = z.object({ + title: z.string(), + summary: z.string(), + keywords: z.array(z.string()).optional(), + required_packages: z.array(z.string()).optional(), + related_concepts: z.array(z.string()).optional(), + related_tools: z.array(z.string()).optional(), + experimental: z.boolean().optional(), + }); + + db.exec('BEGIN TRANSACTION'); + for (const example of parsedExamplesForDb) { + const validation = frontmatterSchema.safeParse(example.frontmatter); + if (!validation.success) { + throw new Error(`Invalid front matter in ${example.sourcePath}: ${validation.error.issues}`); + } + const { + title, + summary, + keywords, + required_packages, + related_concepts, + related_tools, + experimental, + } = validation.data; + insertStatement.run( + title, + summary, + JSON.stringify(keywords ?? []), + JSON.stringify(required_packages ?? []), + JSON.stringify(related_concepts ?? []), + JSON.stringify(related_tools ?? []), + experimental ? 1 : 0, + readFileSync(example.sourcePath, 'utf-8'), + ); + } + db.exec('END TRANSACTION'); + db.close(); + console.log(`Successfully generated database with ${parsedExamplesForDb.length} examples.`); +} + +function runValidateCode(examplesPath) { + console.log('Setting up validation harness...'); + const tempDir = mkdtempSync(join(tmpdir(), 'angular-cli-example-validation-')); + console.log(`Harness directory: ${tempDir}`); + + try { + cpSync(HARNESS_PATH, tempDir, { recursive: true }); + execSync('pnpm install', { cwd: tempDir, stdio: 'inherit' }); + console.log('Harness setup complete.'); + + console.log('Parsing and validating example files...'); + const codeFiles = globSync('**/*.md', { cwd: examplesPath }); + const parsedExamplesForCode = codeFiles.map((f) => parseExampleFile(join(examplesPath, f))); + console.log(`Successfully parsed ${parsedExamplesForCode.length} example files.`); + + console.log('Populating harness with example code...'); + const appDir = join(tempDir, 'src', 'app'); + const mainTsDir = join(tempDir, 'src'); + const allBootstrapFiles = []; + + for (const [index, example] of parsedExamplesForCode.entries()) { + const exampleDirName = `example-${index}-${basename(example.sourcePath, '.md')}`; + const exampleDir = join(appDir, exampleDirName); + mkdirSync(exampleDir); + + const exampleFilesSection = example.sections.find((s) => s.title === 'Example Files'); + if (!exampleFilesSection) { + continue; + } + + const componentClassNames = []; + const componentImports = []; + + for (const block of exampleFilesSection.codeBlocks) { + const match = block.code.match(/export\s+class\s+([A-Za-z0-9_]+)/); + if (match) { + const className = match[1]; + componentClassNames.push(className); + const importPath = `./app/${exampleDirName}/${basename(block.filename, '.ts')}`; + componentImports.push(`import { ${className} } from '${importPath}';`); + } + writeFileSync(join(exampleDir, block.filename), block.code); + } + + // Create a unique main.ts file for each example to create an isolated compilation unit. + if (componentClassNames.length > 0) { + const mainTsContent = ` + import { bootstrapApplication } from '@angular/platform-browser'; + ${componentImports.join('\n')} + + // Bootstrap the first component found in the example. + bootstrapApplication(${componentClassNames[0]}).catch(err => console.error(err)); + `; + const bootstrapFilename = `main.${index}.ts`; + writeFileSync(join(mainTsDir, bootstrapFilename), mainTsContent); + allBootstrapFiles.push(bootstrapFilename); + } + } + console.log('Harness population complete.'); + + // Update tsconfig to include all generated main files, effectively creating a multi-entry-point compilation. + const tsconfigPath = join(tempDir, 'tsconfig.app.json'); + const tsconfig = JSON.parse(readFileSync(tsconfigPath, 'utf-8')); + // We remove the original main.ts and replace it with our generated files. + tsconfig.files = allBootstrapFiles.map((f) => `src/${f}`); + writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2)); + console.log('Updated tsconfig.app.json with all example entry points.'); + + console.log('Running Angular build...'); + execSync('npx ng build', { cwd: tempDir, stdio: 'inherit' }); + console.log('Build successful!'); + } finally { + console.log('Cleaning up temporary directory...'); + rmSync(tempDir, { recursive: true, force: true }); + console.log('Cleanup complete.'); + } +} + +// --- Main Entry Point --- +function main() { + const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + mode: { + type: 'string', + default: 'generate-db', + }, + output: { + type: 'string', + }, + }, + }); + const examplesPath = positionals[0] ?? '.'; + const mode = values.mode; + + console.log(`Running example processor in '${mode}' mode.`); + + try { + switch (mode) { + case 'validate-structure': + runValidateStructure(examplesPath); + break; + case 'validate-code': + runValidateCode(examplesPath); + break; + case 'generate-db': + runGenerateDb(examplesPath, values.output); + break; + default: + throw new Error( + `Unknown mode: ${mode}. Available modes are: validate-structure, validate-code, generate-db.`, + ); + } + console.log(`Successfully completed '${mode}' mode.`); + } catch (error) { + console.error(`\nERROR: Example processing failed in '${mode}' mode.`); + console.error(error.message); + process.exit(1); + } +} + +main();