From 3f7a5f6be82fb60ee33cbeb1fe1e4f4757edd8c1 Mon Sep 17 00:00:00 2001 From: rmenner Date: Fri, 1 May 2026 17:26:37 -0400 Subject: [PATCH 1/7] feat: add demo SCSS compilation, README copy, and watch mode to docs command - Add compileDemoScss to build/dev paths for consistent demo SCSS handling - Copy processed README.md to demo directory as readme.md after docs build - Add --watch flag to docs command with chokidar file watcher - Add dedicated demo SCSS watcher in dev watch mode (separate from rollup) - Fix feedback loops by pausing watcher during rebuilds and ignoring output files - Add .min.css to output ignore patterns across all watchers Co-authored-by: Copilot --- package-lock.json | 80 +++++++++++++++ package.json | 1 + src/commands/docs.ts | 7 +- src/scripts/build/bundleHandlers.js | 132 ++++++++++++++++++++++++- src/scripts/build/configUtils.js | 3 + src/scripts/build/devServerUtils.js | 98 +++++++++++++++++- src/scripts/build/index.js | 45 ++++++++- src/scripts/build/watchModeHandlers.js | 32 +++++- src/scripts/docs/index.ts | 126 +++++++++++++++++++++++ 9 files changed, 512 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 420f51c..89201ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@octokit/rest": "^22.0.0", "@open-wc/dev-server-hmr": "^0.2.0", "@open-wc/testing": "^4.0.0", + "@rollup/plugin-commonjs": "^28.0.3", "@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.4", @@ -4498,6 +4499,61 @@ "node": ">=10" } }, + "node_modules/@rollup/plugin-commonjs": { + "version": "28.0.9", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz", + "integrity": "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@rollup/plugin-node-resolve": { "version": "16.0.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", @@ -8586,6 +8642,12 @@ "node": ">= 12.0.0" } }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "license": "MIT" + }, "node_modules/compare-func": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", @@ -12535,6 +12597,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -13700,6 +13771,15 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/make-asynchronous": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.0.1.tgz", diff --git a/package.json b/package.json index 22c2329..2e6d1ec 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@octokit/rest": "^22.0.0", "@open-wc/dev-server-hmr": "^0.2.0", "@open-wc/testing": "^4.0.0", + "@rollup/plugin-commonjs": "^28.0.3", "@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.4", diff --git a/src/commands/docs.ts b/src/commands/docs.ts index cb250d0..3bf96ac 100644 --- a/src/commands/docs.ts +++ b/src/commands/docs.ts @@ -1,5 +1,5 @@ import { program } from "commander"; -import { api, cem, docs, serve } from "#scripts/docs/index.ts"; +import { api, cem, docs, serve, watchDocs } from "#scripts/docs/index.ts"; import { withServerOptions } from "#commands/_sharedOptions.js"; let docsCommand = program @@ -7,6 +7,7 @@ let docsCommand = program .description("Generate API documentation") .option("-c, --cem", "Generate Custom Elements Manifest (CEM) file", false) .option("-a, --api", "Creates api md file from CEM", false) + .option("-w, --watch", "Watch for changes and rebuild docs", false) .option("--skip-readme", "Skip README.md processing", false) docsCommand = withServerOptions(docsCommand); @@ -27,4 +28,8 @@ let docsCommand = program await serve(options); } + if (options.watch) { + await watchDocs(options); + } + }); diff --git a/src/scripts/build/bundleHandlers.js b/src/scripts/build/bundleHandlers.js index 144510f..265e6a8 100644 --- a/src/scripts/build/bundleHandlers.js +++ b/src/scripts/build/bundleHandlers.js @@ -1,9 +1,15 @@ -import { rmSync } from "node:fs"; -import { join } from "node:path"; +import { copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { glob } from "glob"; import ora from "ora"; import { rollup } from "rollup"; +import * as sass from "sass"; import { analyzeComponents } from "#scripts/analyze.js"; import { runDefaultDocsBuild } from "#scripts/build/defaultDocsBuild.js"; +import { getDemoConfig } from "#scripts/build/configUtils.js"; + +const MODULE_DIRS = ["node_modules", "../node_modules", "../../node_modules", "../../../node_modules"]; /** * Clean up the dist folder @@ -109,8 +115,130 @@ export async function generateDocs(options) { async () => { await analyzeComponents(sourceFiles, outFile); await runDefaultDocsBuild(); + copyReadmeToDemo(); }, "Docs ready! Looking good.", "Doc troubles!", ); } + +/** + * Copies the processed README.md from the project root into the demo directory. + */ +function copyReadmeToDemo() { + const cwd = process.cwd(); + const readmeSrc = resolve(cwd, "README.md"); + const demoDir = resolve(cwd, "demo"); + const readmeDest = join(demoDir, "readme.md"); + + if (!existsSync(readmeSrc)) { + return; + } + + if (!existsSync(demoDir)) { + mkdirSync(demoDir, { recursive: true }); + } + + copyFileSync(readmeSrc, readmeDest); +} + +/** + * Sass FileImporter that resolves bare package imports from node_modules + * and redirects hoisted dependencies. Returns file: URLs so that + * within-package relative imports resolve natively on disk. When a + * relative import fails (e.g. ./../../node_modules/@pkg/foo pointing at + * a hoisted location that doesn't exist), Sass falls back to findFileUrl, + * where we redirect to the actual hoisted location. + */ +function createNodeModulesImporter() { + const cwd = process.cwd(); + + function tryResolve(filePath) { + const candidates = [ + filePath, + `${filePath}.scss`, + `${filePath}.css`, + join(dirname(filePath), `_${basename(filePath)}.scss`), + join(filePath, "_index.scss"), + join(filePath, "index.scss"), + ]; + return candidates.find((c) => existsSync(c)); + } + + function findInModuleDirs(pkgPath) { + for (const dir of MODULE_DIRS) { + const found = tryResolve(resolve(cwd, dir, pkgPath)); + if (found) return found; + } + return null; + } + + return { + findFileUrl(url) { + // Failed relative import containing a node_modules path that + // doesn't exist due to hoisting — redirect to hoisted location + if (url.includes("/node_modules/")) { + const lastIdx = url.lastIndexOf("/node_modules/"); + const pkgPath = url.slice(lastIdx + "/node_modules/".length); + const found = findInModuleDirs(pkgPath); + if (found) return pathToFileURL(found); + } + + // Bare package import (e.g. @aurodesignsystem/webcorestylesheets/...) + if (!url.startsWith(".") && !url.startsWith("/") && !url.startsWith("file:")) { + const found = findInModuleDirs(url); + if (found) return pathToFileURL(found); + } + + return null; + }, + }; +} + +/** + * Compiles all SCSS files in the demo directory to CSS. + * @param {string} [demoDir="./demo"] - Path to the demo directory + */ +export async function compileDemoScss(demoDir = "./demo") { + return runBuildStep( + "Compiling demo SCSS...", + async () => { + const scssFiles = glob.sync(join(demoDir, "**/*.scss")); + const importer = createNodeModulesImporter(); + + for (const scssFile of scssFiles) { + const result = sass.compile(scssFile, { + importers: [importer], + silenceDeprecations: ["import"], + style: "compressed", + }); + + const cssFile = scssFile.replace(/\.scss$/, ".min.css"); + writeFileSync(cssFile, result.css); + } + + return scssFiles.length; + }, + "Demo SCSS compiled.", + "SCSS compilation failed.", + ); +} + +/** + * Bundles demo JS files to minified ESM output. + * @param {object} [options={}] - Options passed to getDemoConfig + */ +export async function buildDemoBundle(options = {}) { + const demoConfig = getDemoConfig(options); + + return runBuildStep( + "Bundling demo JS...", + async () => { + const bundle = await rollup(demoConfig.config); + await bundle.write(demoConfig.config.output); + await bundle.close(); + }, + "Demo JS bundled.", + "Demo JS bundling failed.", + ); +} diff --git a/src/scripts/build/configUtils.js b/src/scripts/build/configUtils.js index e3ac4ca..85ed522 100644 --- a/src/scripts/build/configUtils.js +++ b/src/scripts/build/configUtils.js @@ -1,4 +1,5 @@ import { basename, join } from "node:path"; +import commonjs from "@rollup/plugin-commonjs"; import { nodeResolve } from "@rollup/plugin-node-resolve"; import { glob } from "glob"; import { litScss } from "rollup-plugin-scss-lit"; @@ -33,6 +34,7 @@ export function getPluginsConfig(modulePaths = [], options = {}) { preferBuiltins: false, moduleDirectories: DEFAULTS.moduleDirectories, }), + commonjs(), litScss({ // Disable CSS minification in dev for readability and faster rebuilds minify: dev ? false : { fast: true }, @@ -146,6 +148,7 @@ export function getWatcherConfig(watchOptions) { "**/custom-elements.json", "**/demo/*.md", "**/demo/**/*.min.js", + "**/demo/**/*.min.css", "**/docs/api.md", "**/node_modules/**", "**/.git/**", diff --git a/src/scripts/build/devServerUtils.js b/src/scripts/build/devServerUtils.js index a5ffcd5..e02546f 100644 --- a/src/scripts/build/devServerUtils.js +++ b/src/scripts/build/devServerUtils.js @@ -1,6 +1,98 @@ +import { existsSync, readFileSync } from "node:fs"; +import { builtinModules } from "node:module"; +import { resolve } from "node:path"; import { startDevServer } from "@web/dev-server"; import { hmrPlugin } from "@web/dev-server-hmr"; +import * as esbuild from "esbuild"; import ora from "ora"; + +const MODULE_DIRS = ["node_modules", "../node_modules", "../../node_modules", "../../../node_modules"]; +const WDS_OUTSIDE_ROOT_RE = /^\/__wds-outside-root__\/(\d+)\/(.+)$/; + +/** + * Dev-server plugin that serves CSS files from node_modules when the URL + * looks like a bare package specifier (e.g. /@scope/pkg/dist/file.css). + * Handles CSS @import rules that reference node_modules packages. + */ +function nodeModulesCssPlugin() { + return { + name: "node-modules-css", + + serve(context) { + if (!context.path.endsWith(".css")) return; + if (!context.path.startsWith("/@") && !/^\/[a-z]/i.test(context.path)) return; + + const urlPath = context.path.slice(1); + const cwd = process.cwd(); + + for (const dir of MODULE_DIRS) { + const candidate = resolve(cwd, dir, urlPath); + if (existsSync(candidate)) { + return { body: readFileSync(candidate, "utf-8"), type: "css" }; + } + } + }, + }; +} + +function resolveWdsPath(urlPath, rootDir) { + const match = urlPath.match(WDS_OUTSIDE_ROOT_RE); + if (match) { + return resolve(rootDir, "../".repeat(Number.parseInt(match[1], 10)), match[2]); + } + return resolve(rootDir, `.${urlPath}`); +} + +/** + * Dev-server plugin that converts CommonJS node_modules to ESM on-the-fly + * using esbuild. Needed because nodeResolve serves node_modules files + * directly to the browser, which can't handle require()/module.exports. + * @returns {object} - A @web/dev-server plugin + */ +function cjsToEsmPlugin() { + const CJS_PATTERN = /\b(require\s*\(|module\.exports\b|exports\.\w)/; + const cache = new Map(); + let resolvedRootDir; + + return { + name: "cjs-to-esm", + + async serverStart({ config }) { + resolvedRootDir = resolve(config.rootDir); + }, + + async transform(context) { + if (!context.path.includes("node_modules")) return; + if (!context.path.endsWith(".js") && !context.path.endsWith(".cjs")) return; + if (typeof context.body !== "string") return; + if (!CJS_PATTERN.test(context.body)) return; + + const filePath = resolveWdsPath(context.path, resolvedRootDir); + + if (cache.has(filePath)) return cache.get(filePath); + if (!existsSync(filePath)) return; + + try { + const result = await esbuild.build({ + entryPoints: [filePath], + bundle: true, + format: "esm", + platform: "browser", + write: false, + logLevel: "silent", + external: builtinModules, + }); + + const output = { body: result.outputFiles[0].text }; + cache.set(filePath, output); + return output; + } catch (error) { + console.error(`CJS-to-ESM bundling failed for ${context.path}:`, error.message); + } + }, + }; +} + /** * Default server configuration */ @@ -28,7 +120,6 @@ export async function startDevelopmentServer(options = {}) { const serverSpinner = ora("Firing up dev server...\n").start(); try { - // Merge options with defaults const serverConfig = { port: Number(options.port) || undefined, open: options.open ? "/" : undefined, @@ -37,7 +128,6 @@ export async function startDevelopmentServer(options = {}) { basePath: options.basePath ?? DEFAULT_CONFIG.basePath, rootDir: options.rootDir ?? DEFAULT_CONFIG.rootDir, - // HTML file extension middleware middleware: [ function rewriteIndex(context, next) { if (!context.url.endsWith("/") && !context.url.includes(".")) { @@ -47,15 +137,15 @@ export async function startDevelopmentServer(options = {}) { }, ], - // Hot Module Replacement plugin plugins: [ + nodeModulesCssPlugin(), + cjsToEsmPlugin(), hmrPlugin({ include: options.hmrInclude ?? DEFAULT_CONFIG.hmrInclude, }), ], }; - // Start the server with our configuration const server = await startDevServer({ config: serverConfig, readCliArgs: false, diff --git a/src/scripts/build/index.js b/src/scripts/build/index.js index 472dbef..ed75d49 100644 --- a/src/scripts/build/index.js +++ b/src/scripts/build/index.js @@ -3,6 +3,7 @@ import { watch } from "rollup"; import { buildCombinedBundle, cleanupDist, + compileDemoScss, generateDocs, } from "./bundleHandlers.js"; import { @@ -32,6 +33,9 @@ async function runProductionBuild(options) { // Generate docs if enabled await generateDocs(options); + // Compile demo SCSS to CSS + await compileDemoScss(); + // Build main and demo bundles await buildCombinedBundle(mainBundleConfig.config, demoConfig.config); } @@ -56,8 +60,47 @@ async function setupWatchMode(options) { isDevMode ? async () => startDevelopmentServer(options) : undefined, ); + // Watch demo SCSS files separately since they are not part of the rollup bundle + const chokidar = await import("chokidar"); + let scssCompiling = false; + let scssPending = false; + let scssTimer = null; + + const scssWatcher = chokidar.watch("./demo/**/*.scss", { + ignoreInitial: true, + ignored: ["**/demo/**/*.min.css"], + awaitWriteFinish: { + stabilityThreshold: 500, + pollInterval: 100, + }, + }); + + scssWatcher.on("all", () => { + if (scssTimer) clearTimeout(scssTimer); + + scssTimer = setTimeout(async () => { + if (scssCompiling) { + scssPending = true; + return; + } + + scssCompiling = true; + try { + await compileDemoScss(); + } catch (error) { + console.error("Demo SCSS watch compilation error:", error); + } finally { + scssCompiling = false; + if (scssPending) { + scssPending = false; + scssWatcher.emit("all"); + } + } + }, 500); + }); + // Set up clean shutdown - setupWatchModeListeners(watcher); + setupWatchModeListeners(watcher, scssWatcher); return watcher; } diff --git a/src/scripts/build/watchModeHandlers.js b/src/scripts/build/watchModeHandlers.js index e94d67d..2c282e6 100644 --- a/src/scripts/build/watchModeHandlers.js +++ b/src/scripts/build/watchModeHandlers.js @@ -2,7 +2,7 @@ import path from "node:path"; import ora from "ora"; import { rollup } from "rollup"; import { analyzeComponents } from "#scripts/analyze.js"; -import { generateDocs } from "./bundleHandlers.js"; +import { compileDemoScss, generateDocs } from "./bundleHandlers.js"; // Track if any build is in progress to prevent overlapping operations let buildInProgress = false; @@ -11,6 +11,7 @@ let buildInProgress = false; const builds = { analyze: { active: false, lastTime: 0 }, docs: { active: false, lastTime: 0 }, + scss: { active: false, lastTime: 0 }, }; // Minimum time between builds of the same type (in ms) @@ -40,6 +41,7 @@ function isOutputFile(filePath) { OUTPUT_PATHS.some((outputPath) => normalizedPath.endsWith(outputPath)) || normalizedPath.includes("/dist/") || normalizedPath.endsWith(".min.js") || + normalizedPath.endsWith(".min.css") || normalizedPath.endsWith(".d.ts") ); } catch (error) { @@ -88,7 +90,7 @@ export async function handleWatcherEvents( // Track if this is the first build let isInitialBuild = true; // biome-ignore lint/style/useConst: This is an object that is mutated. - let buildTasksResults = {analyze: false, docs: false }; + let buildTasksResults = {analyze: false, docs: false, scss: false }; let scheduledTasksTimer = null; let bundleSpinner; @@ -148,6 +150,21 @@ export async function handleWatcherEvents( console.error("Documentation rebuild error:", error); } }, + + // Function to compile demo SCSS + scss: async () => { + if (buildInProgress) { + return false; + } + + try { + await compileDemoScss(); + return true; + } catch (error) { + console.error("Demo SCSS compilation error:", error); + return false; + } + }, }; // Check if all initial build tasks completed successfully @@ -156,6 +173,7 @@ export async function handleWatcherEvents( isInitialBuild && buildTasksResults.analyze && buildTasksResults.docs && + buildTasksResults.scss && typeof onInitialBuildComplete === "function" ) { isInitialBuild = false; @@ -180,7 +198,11 @@ export async function handleWatcherEvents( setTimeout(async () => { buildTasksResults.docs = await runBuildTask("docs", buildTasks.docs); - checkInitialBuildComplete(); + + setTimeout(async () => { + buildTasksResults.scss = await runBuildTask("scss", buildTasks.scss); + checkInitialBuildComplete(); + }, 500); }, 1000); }, 1000); }, delay); @@ -258,11 +280,13 @@ export async function handleWatcherEvents( /** * Setup watch mode for rollup * @param {object} watcher - Rollup watcher instance + * @param {object} [scssWatcher] - Optional chokidar watcher for demo SCSS */ -export function setupWatchModeListeners(watcher) { +export function setupWatchModeListeners(watcher, scssWatcher) { process.on("SIGINT", () => { const closeSpinner = ora("Wrapping up...").start(); watcher.close(); + if (scssWatcher) scssWatcher.close(); closeSpinner.succeed("All done! See you next time. ✨"); process.exit(0); }); diff --git a/src/scripts/docs/index.ts b/src/scripts/docs/index.ts index 0d4c399..826a7e3 100644 --- a/src/scripts/docs/index.ts +++ b/src/scripts/docs/index.ts @@ -1,7 +1,10 @@ import ora from "ora"; +import fs from "node:fs"; +import path from "node:path"; import { shell } from "#utils/shell.js"; import Docs from "./docs-generator.ts"; import { configPath } from "#utils/pathUtils.js"; +import { buildDemoBundle, compileDemoScss } from "../build/bundleHandlers.js"; import { runDefaultDocsBuild } from "../build/defaultDocsBuild.js"; import { startDevelopmentServer } from "../build/devServerUtils.js"; @@ -45,8 +48,131 @@ export async function docs(options = {}) { docsSpinner.fail("Failed to compile MD documentation: " + errorMessage); throw error; } + + copyReadmeToDemo(); + await compileDemoScss(); + await buildDemoBundle(options); +} + +/** + * Copies the processed README.md from the project root into the demo directory. + */ +function copyReadmeToDemo() { + const readmeSrc = path.resolve(process.cwd(), "README.md"); + const demoDir = path.resolve(process.cwd(), "demo"); + const readmeDest = path.join(demoDir, "readme.md"); + + if (!fs.existsSync(readmeSrc)) { + return; + } + + if (!fs.existsSync(demoDir)) { + fs.mkdirSync(demoDir, { recursive: true }); + } + + fs.copyFileSync(readmeSrc, readmeDest); } export async function serve(options = {}) { await startDevelopmentServer(options); } + +/** + * Watches doc and src files and rebuilds on changes. + */ +export async function watchDocs(options = {}) { + const chokidar = await import("chokidar"); + + const watchPaths = [ + "./src/**/*", + "./docs/**/*", + "./docTemplates/**/*", + "./apiExamples/**/*", + "./demo/**/*.scss", + ]; + + const ignored = [ + // Output files that should never trigger a rebuild + "**/demo/*.min.js", + "**/demo/*.min.css", + "**/demo/*.md", + "**/demo/readme.md", + "**/docs/api.md", + "**/custom-elements.json", + "**/node_modules/**", + "**/dist/**", + "**/.git/**", + ]; + + const watcher = chokidar.watch(watchPaths, { + ignoreInitial: true, + ignored, + awaitWriteFinish: { + stabilityThreshold: 1000, + pollInterval: 100, + }, + }); + + const watchSpinner = ora("Waiting for changes..."); + watchSpinner.spinner = "bouncingBar"; + watchSpinner.color = "green"; + watchSpinner.start(); + + let rebuildTimer: ReturnType | null = null; + let rebuilding = false; + let pendingRebuild = false; + + async function rebuild(triggeredBy: string) { + if (rebuilding) { + pendingRebuild = true; + return; + } + + rebuilding = true; + + // Pause watcher during rebuild to avoid feedback loops + watcher.unwatch(watchPaths); + + const spinner = ora(`Change detected: ${triggeredBy}`).start(); + try { + await runDefaultDocsBuild(options); + copyReadmeToDemo(); + await compileDemoScss(); + await buildDemoBundle(options); + spinner.succeed("Docs rebuilt!"); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + spinner.fail("Rebuild failed: " + errorMessage); + } finally { + // Re-enable watcher after a short delay for file writes to settle + setTimeout(() => { + watcher.add(watchPaths); + rebuilding = false; + + if (pendingRebuild) { + pendingRebuild = false; + rebuild("queued changes"); + } + }, 1000); + } + } + + watcher.on("all", (_event: string, filePath: string) => { + if (rebuilding) return; + + if (rebuildTimer) { + clearTimeout(rebuildTimer); + } + + rebuildTimer = setTimeout(() => { + rebuild(filePath); + }, 1000); + }); + + // Keep process alive and handle clean shutdown + process.on("SIGINT", () => { + watcher.close(); + ora().succeed("Doc watch stopped."); + process.exit(0); + }); +} From e71cd8f8ef7846cfde31285bd4c49f72b6047fbb Mon Sep 17 00:00:00 2001 From: rmenner Date: Fri, 1 May 2026 18:08:53 -0400 Subject: [PATCH 2/7] feat: add --readme-template flag and centralize shared utilities - Add -r, --readme-template flag to docs/dev/build commands for custom README template - Support both URLs and local file paths for README template input - Extract copyReadmeToDemo to shared util - Extract MODULE_DIRS to shared paths.js - Unify SIGINT shutdown handlers via shared shutdown utility Co-authored-by: Copilot --- src/commands/_sharedOptions.js | 1 + src/commands/docs.ts | 1 + src/scripts/build/bundleHandlers.js | 26 +++---------------- src/scripts/build/defaultDocsBuild.js | 36 +++++++++++++++++--------- src/scripts/build/devServerUtils.js | 2 +- src/scripts/build/paths.js | 10 +++++++ src/scripts/build/watchModeHandlers.js | 11 +++----- src/scripts/docs/index.ts | 32 ++++------------------- src/utils/copyReadmeToDemo.js | 22 ++++++++++++++++ src/utils/shutdown.js | 28 ++++++++++++++++++++ 10 files changed, 100 insertions(+), 69 deletions(-) create mode 100644 src/scripts/build/paths.js create mode 100644 src/utils/copyReadmeToDemo.js create mode 100644 src/utils/shutdown.js diff --git a/src/commands/_sharedOptions.js b/src/commands/_sharedOptions.js index 0137a2f..e5d0e4d 100644 --- a/src/commands/_sharedOptions.js +++ b/src/commands/_sharedOptions.js @@ -7,6 +7,7 @@ export function withBuildOptions(command) { .option("-m, --module-paths [paths...]", "Path(s) to node_modules folder") .option("-w, --watch", "Watches for changes") .option("--skip-docs", "Skip documentation generation", false) + .option("-r, --readme-template ", "URL to the README template file") .option( "--wca-input [files...]", "Source file(s) to analyze for API documentation", diff --git a/src/commands/docs.ts b/src/commands/docs.ts index 3bf96ac..e178f14 100644 --- a/src/commands/docs.ts +++ b/src/commands/docs.ts @@ -8,6 +8,7 @@ let docsCommand = program .option("-c, --cem", "Generate Custom Elements Manifest (CEM) file", false) .option("-a, --api", "Creates api md file from CEM", false) .option("-w, --watch", "Watch for changes and rebuild docs", false) + .option("-r, --readme-template ", "URL to the README template file") .option("--skip-readme", "Skip README.md processing", false) docsCommand = withServerOptions(docsCommand); diff --git a/src/scripts/build/bundleHandlers.js b/src/scripts/build/bundleHandlers.js index 265e6a8..2db8907 100644 --- a/src/scripts/build/bundleHandlers.js +++ b/src/scripts/build/bundleHandlers.js @@ -1,4 +1,4 @@ -import { copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, rmSync, writeFileSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { glob } from "glob"; @@ -8,8 +8,8 @@ import * as sass from "sass"; import { analyzeComponents } from "#scripts/analyze.js"; import { runDefaultDocsBuild } from "#scripts/build/defaultDocsBuild.js"; import { getDemoConfig } from "#scripts/build/configUtils.js"; - -const MODULE_DIRS = ["node_modules", "../node_modules", "../../node_modules", "../../../node_modules"]; +import { MODULE_DIRS } from "#scripts/build/paths.js"; +import { copyReadmeToDemo } from "#utils/copyReadmeToDemo.js"; /** * Clean up the dist folder @@ -114,7 +114,7 @@ export async function generateDocs(options) { "Analyzing components and making docs...", async () => { await analyzeComponents(sourceFiles, outFile); - await runDefaultDocsBuild(); + await runDefaultDocsBuild(options); copyReadmeToDemo(); }, "Docs ready! Looking good.", @@ -122,25 +122,7 @@ export async function generateDocs(options) { ); } -/** - * Copies the processed README.md from the project root into the demo directory. - */ -function copyReadmeToDemo() { - const cwd = process.cwd(); - const readmeSrc = resolve(cwd, "README.md"); - const demoDir = resolve(cwd, "demo"); - const readmeDest = join(demoDir, "readme.md"); - - if (!existsSync(readmeSrc)) { - return; - } - if (!existsSync(demoDir)) { - mkdirSync(demoDir, { recursive: true }); - } - - copyFileSync(readmeSrc, readmeDest); -} /** * Sass FileImporter that resolves bare package imports from node_modules diff --git a/src/scripts/build/defaultDocsBuild.js b/src/scripts/build/defaultDocsBuild.js index ce60f31..6189960 100644 --- a/src/scripts/build/defaultDocsBuild.js +++ b/src/scripts/build/defaultDocsBuild.js @@ -43,18 +43,22 @@ export async function fileConfigs(config, skipReadme = false) { // ---------- README.md ---------- // Don't need to check for existence of README.md since it's always created if (!skipReadme) { + const inputConfig = config.localReadmePath + ? config.localReadmePath + : { + remoteUrl: + config.remoteReadmeUrl || + generateReadmeUrl( + config.remoteReadmeVersion, + config.remoteReadmeVariant, + ), + fileName: pathFromCwd("/docTemplates/README.md"), + overwrite: config.overwriteLocalCopies, + }; + configs.push({ identifier: "README.md", - input: { - remoteUrl: - config.remoteReadmeUrl || - generateReadmeUrl( - config.remoteReadmeVersion, - config.remoteReadmeVariant, - ), - fileName: pathFromCwd("/docTemplates/README.md"), - overwrite: config.overwriteLocalCopies, - }, + input: inputConfig, output: pathFromCwd("/README.md"), }); } @@ -124,10 +128,18 @@ export async function processDocFiles(config = defaultDocsProcessorConfig, skipR } export async function runDefaultDocsBuild(options = {}) { + const readmeTemplate = options.readmeTemplate; + const isLocalPath = readmeTemplate && !readmeTemplate.startsWith("http"); + await processDocFiles({ ...defaultDocsProcessorConfig, - remoteReadmeUrl: - "https://raw.githubusercontent.com/AlaskaAirlines/auro-templates/main/templates/default/README.md", + ...(isLocalPath + ? { localReadmePath: path.resolve(process.cwd(), readmeTemplate) } + : { + remoteReadmeUrl: + readmeTemplate || + "https://raw.githubusercontent.com/AlaskaAirlines/auro-templates/main/templates/default/README.md", + }), }, options.skipReadme); } diff --git a/src/scripts/build/devServerUtils.js b/src/scripts/build/devServerUtils.js index e02546f..d35fff5 100644 --- a/src/scripts/build/devServerUtils.js +++ b/src/scripts/build/devServerUtils.js @@ -6,7 +6,7 @@ import { hmrPlugin } from "@web/dev-server-hmr"; import * as esbuild from "esbuild"; import ora from "ora"; -const MODULE_DIRS = ["node_modules", "../node_modules", "../../node_modules", "../../../node_modules"]; +import { MODULE_DIRS } from "#scripts/build/paths.js"; const WDS_OUTSIDE_ROOT_RE = /^\/__wds-outside-root__\/(\d+)\/(.+)$/; /** diff --git a/src/scripts/build/paths.js b/src/scripts/build/paths.js new file mode 100644 index 0000000..16d0203 --- /dev/null +++ b/src/scripts/build/paths.js @@ -0,0 +1,10 @@ +/** + * Common node_modules directory search paths for resolving packages + * in monorepo and hoisted dependency structures. + */ +export const MODULE_DIRS = [ + "node_modules", + "../node_modules", + "../../node_modules", + "../../../node_modules", +]; diff --git a/src/scripts/build/watchModeHandlers.js b/src/scripts/build/watchModeHandlers.js index 2c282e6..b756275 100644 --- a/src/scripts/build/watchModeHandlers.js +++ b/src/scripts/build/watchModeHandlers.js @@ -2,6 +2,7 @@ import path from "node:path"; import ora from "ora"; import { rollup } from "rollup"; import { analyzeComponents } from "#scripts/analyze.js"; +import { registerWatcher, installShutdownHandler } from "#utils/shutdown.js"; import { compileDemoScss, generateDocs } from "./bundleHandlers.js"; // Track if any build is in progress to prevent overlapping operations @@ -283,13 +284,9 @@ export async function handleWatcherEvents( * @param {object} [scssWatcher] - Optional chokidar watcher for demo SCSS */ export function setupWatchModeListeners(watcher, scssWatcher) { - process.on("SIGINT", () => { - const closeSpinner = ora("Wrapping up...").start(); - watcher.close(); - if (scssWatcher) scssWatcher.close(); - closeSpinner.succeed("All done! See you next time. ✨"); - process.exit(0); - }); + registerWatcher(watcher); + if (scssWatcher) registerWatcher(scssWatcher); + installShutdownHandler(); return watcher; } diff --git a/src/scripts/docs/index.ts b/src/scripts/docs/index.ts index 826a7e3..787c6b0 100644 --- a/src/scripts/docs/index.ts +++ b/src/scripts/docs/index.ts @@ -1,9 +1,9 @@ import ora from "ora"; -import fs from "node:fs"; -import path from "node:path"; import { shell } from "#utils/shell.js"; import Docs from "./docs-generator.ts"; import { configPath } from "#utils/pathUtils.js"; +import { copyReadmeToDemo } from "#utils/copyReadmeToDemo.js"; +import { registerWatcher, installShutdownHandler } from "#utils/shutdown.js"; import { buildDemoBundle, compileDemoScss } from "../build/bundleHandlers.js"; import { runDefaultDocsBuild } from "../build/defaultDocsBuild.js"; import { startDevelopmentServer } from "../build/devServerUtils.js"; @@ -54,25 +54,6 @@ export async function docs(options = {}) { await buildDemoBundle(options); } -/** - * Copies the processed README.md from the project root into the demo directory. - */ -function copyReadmeToDemo() { - const readmeSrc = path.resolve(process.cwd(), "README.md"); - const demoDir = path.resolve(process.cwd(), "demo"); - const readmeDest = path.join(demoDir, "readme.md"); - - if (!fs.existsSync(readmeSrc)) { - return; - } - - if (!fs.existsSync(demoDir)) { - fs.mkdirSync(demoDir, { recursive: true }); - } - - fs.copyFileSync(readmeSrc, readmeDest); -} - export async function serve(options = {}) { await startDevelopmentServer(options); } @@ -169,10 +150,7 @@ export async function watchDocs(options = {}) { }, 1000); }); - // Keep process alive and handle clean shutdown - process.on("SIGINT", () => { - watcher.close(); - ora().succeed("Doc watch stopped."); - process.exit(0); - }); + // Register watcher for clean shutdown + registerWatcher(watcher); + installShutdownHandler(); } diff --git a/src/utils/copyReadmeToDemo.js b/src/utils/copyReadmeToDemo.js new file mode 100644 index 0000000..52d6f66 --- /dev/null +++ b/src/utils/copyReadmeToDemo.js @@ -0,0 +1,22 @@ +import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { join, resolve } from "node:path"; + +/** + * Copies the processed README.md from the project root into the demo directory as readme.md. + */ +export function copyReadmeToDemo() { + const cwd = process.cwd(); + const readmeSrc = resolve(cwd, "README.md"); + const readmeDest = join(resolve(cwd, "demo"), "readme.md"); + + if (!existsSync(readmeSrc)) { + return; + } + + const demoDir = resolve(cwd, "demo"); + if (!existsSync(demoDir)) { + mkdirSync(demoDir, { recursive: true }); + } + + copyFileSync(readmeSrc, readmeDest); +} diff --git a/src/utils/shutdown.js b/src/utils/shutdown.js new file mode 100644 index 0000000..04ceeb4 --- /dev/null +++ b/src/utils/shutdown.js @@ -0,0 +1,28 @@ +import ora from "ora"; + +const watchers = []; + +/** + * Register a watcher (rollup or chokidar) for clean shutdown on SIGINT. + * @param {object} watcher - A watcher with a close() method + */ +export function registerWatcher(watcher) { + watchers.push(watcher); +} + +// Single SIGINT handler for all registered watchers +let handlerInstalled = false; + +export function installShutdownHandler() { + if (handlerInstalled) return; + handlerInstalled = true; + + process.on("SIGINT", () => { + const closeSpinner = ora("Wrapping up...").start(); + for (const watcher of watchers) { + watcher.close(); + } + closeSpinner.succeed("All done! See you next time. ✨"); + process.exit(0); + }); +} From 97517203737c5d382c32632b84b57892fa023519 Mon Sep 17 00:00:00 2001 From: rmenner Date: Fri, 1 May 2026 18:37:04 -0400 Subject: [PATCH 3/7] fix: resolve SCSS imports using package.json exports map - Add exports field resolution to Sass node_modules importer - Add loadPaths for monorepo node_modules directory traversal - Properly resolve bare specifiers like @aurodesignsystem/config/demo-styles via the package's exports map instead of hardcoded src/ lookup --- src/scripts/build/bundleHandlers.js | 56 ++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/src/scripts/build/bundleHandlers.js b/src/scripts/build/bundleHandlers.js index 2db8907..9e99b27 100644 --- a/src/scripts/build/bundleHandlers.js +++ b/src/scripts/build/bundleHandlers.js @@ -1,4 +1,4 @@ -import { existsSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { glob } from "glob"; @@ -152,6 +152,57 @@ function createNodeModulesImporter() { const found = tryResolve(resolve(cwd, dir, pkgPath)); if (found) return found; } + + // Try resolving via package.json "exports" map + const exportResolved = resolveViaExports(pkgPath); + if (exportResolved) return exportResolved; + + return null; + } + + /** + * Resolves a bare specifier using the package.json "exports" field. + * e.g. "@scope/pkg/demo-styles" looks up "./demo-styles" in the exports map + * of @scope/pkg/package.json and resolves the mapped path. + */ + function resolveViaExports(pkgPath) { + let pkgName; + let subpath; + + if (pkgPath.startsWith("@")) { + const parts = pkgPath.split("/"); + if (parts.length < 3) return null; + pkgName = `${parts[0]}/${parts[1]}`; + subpath = `./${parts.slice(2).join("/")}`; + } else { + const slashIdx = pkgPath.indexOf("/"); + if (slashIdx === -1) return null; + pkgName = pkgPath.slice(0, slashIdx); + subpath = `./${pkgPath.slice(slashIdx + 1)}`; + } + + for (const dir of MODULE_DIRS) { + const pkgJsonPath = resolve(cwd, dir, pkgName, "package.json"); + if (!existsSync(pkgJsonPath)) continue; + + try { + const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8")); + const exports = pkgJson.exports; + if (!exports || typeof exports !== "object") continue; + + const mapped = exports[subpath]; + if (!mapped) continue; + + const target = typeof mapped === "string" ? mapped : mapped.default || mapped.import; + if (!target) continue; + + const resolved = tryResolve(resolve(cwd, dir, pkgName, target)); + if (resolved) return resolved; + } catch { + continue; + } + } + return null; } @@ -187,10 +238,13 @@ export async function compileDemoScss(demoDir = "./demo") { async () => { const scssFiles = glob.sync(join(demoDir, "**/*.scss")); const importer = createNodeModulesImporter(); + const cwd = process.cwd(); + const loadPaths = MODULE_DIRS.map((dir) => resolve(cwd, dir)); for (const scssFile of scssFiles) { const result = sass.compile(scssFile, { importers: [importer], + loadPaths, silenceDeprecations: ["import"], style: "compressed", }); From a5d63bc5241c28d1c95d8bb78dd7aef8b2eafec7 Mon Sep 17 00:00:00 2001 From: rmenner Date: Fri, 1 May 2026 19:08:52 -0400 Subject: [PATCH 4/7] feat: add post-processing for markdown doc files Port formkit docProcessor enhancements to auro-cli: - Resolve second-pass AURO-GENERATED-CONTENT tags from inlined partials - Replace template variables in nested partial content - Convert markdown code fences to HTML pre/code blocks - Normalize whitespace for marked.js compatibility --- src/scripts/build/defaultDocsBuild.js | 135 ++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/src/scripts/build/defaultDocsBuild.js b/src/scripts/build/defaultDocsBuild.js index 6189960..ff500bc 100644 --- a/src/scripts/build/defaultDocsBuild.js +++ b/src/scripts/build/defaultDocsBuild.js @@ -5,6 +5,7 @@ import { templateFiller, } from "@aurodesignsystem/auro-library/scripts/utils/sharedFileProcessorUtils.mjs"; import fs from "node:fs"; +import { readFileSync, existsSync } from "node:fs"; import path from "node:path"; const PAGE_TEMPLATE_PATH = "/docs/pages"; @@ -121,12 +122,146 @@ export async function processDocFiles(config = defaultDocsProcessorConfig, skipR try { // eslint-disable-next-line no-await-in-loop await processContentForFile(fileConfig); + + // Post-processing for markdown output files + if (fileConfig.output.endsWith('.md')) { + await postProcessMarkdownFile(fileConfig.output); + } } catch (err) { Logger.error(`Error processing ${fileConfig.identifier}: ${err.message}`); } } } +/** + * Post-process a markdown file to resolve second-pass AURO-GENERATED-CONTENT tags, + * convert markdown code fences to HTML, and normalize whitespace for marked.js. + * @param {string} outputPath - The absolute path to the output markdown file. + */ +async function postProcessMarkdownFile(outputPath) { + const outputDir = path.dirname(outputPath); + + // --- Second-pass: resolve empty AURO-GENERATED-CONTENT tags --- + // These tags have empty content (START immediately followed by END) because + // markdown-magic only runs one pass and doesn't process tags introduced + // during that same pass. + let outputContents = await fs.promises.readFile(outputPath, 'utf8'); + const emptyTagPattern = /^[ \t]*\n[ \t]*/gm; + let match; + let modified = false; + + // Fallback directory: paths in shared partials are typically written + // relative to the demo/ output directory. When the same partial is + // inlined into a README (output at the project root), the path + // won't resolve from that shallower directory. Using the demo dir + // as a fallback ensures nested imports resolve consistently. + const demoDir = pathFromCwd('demo'); + + while ((match = emptyTagPattern.exec(outputContents)) !== null) { + const [fullMatch, type, srcPath] = match; + const resolvedPath = path.resolve(outputDir, srcPath); + const fallbackPath = path.resolve(demoDir, srcPath); + const actualPath = existsSync(resolvedPath) ? resolvedPath : (existsSync(fallbackPath) ? fallbackPath : null); + + if (actualPath) { + const fileContent = readFileSync(actualPath, 'utf8'); + let replacement; + + if (type === 'FILE') { + replacement = `\n\n${fileContent.trimEnd()}\n`; + } else { + // CODE: wrap in a pre/code HTML block with language classes + const ext = path.extname(srcPath).slice(1) || 'html'; + const escaped = fileContent.trimEnd() + .replace(/&/g, '&') + .replace(//g, '>'); + replacement = `\n\n
${escaped}\n
\n`; + } + + outputContents = outputContents.replace(fullMatch, replacement); + // Reset lastIndex so the regex rescans from the start of + // the replacement — otherwise consecutive tags are skipped + // because the string length changed. + emptyTagPattern.lastIndex = 0; + modified = true; + } + } + + if (modified) { + // Replace template variables (e.g. {{ componentName }}) in content + // introduced by second-pass inlining — the first pass only + // replaces variables in the original file, not in nested partials. + outputContents = templateFiller.replaceTemplateValues(outputContents); + await fs.promises.writeFile(outputPath, outputContents); + } + + // --- Convert markdown code fences to
 HTML blocks ---
+  // marked.js won't parse fences inside HTML block context, so all
+  // fenced code blocks need to be converted to raw HTML for consistent rendering.
+  outputContents = await fs.promises.readFile(outputPath, 'utf8');
+  const fencePattern = /^[ \t]*```(\w*)\n([\s\S]*?)^[ \t]*```[ \t]*$/gm;
+  const convertedContents = outputContents.replace(fencePattern, (_match, lang, code) => {
+    const language = lang || 'html';
+    const escaped = code.trimEnd()
+      .replace(/&/g, '&')
+      .replace(//g, '>');
+    return `
${escaped}\n
`; + }); + + if (convertedContents !== outputContents) { + await fs.promises.writeFile(outputPath, convertedContents); + } + + // --- Whitespace normalization for marked.js compatibility --- + outputContents = await fs.promises.readFile(outputPath, 'utf8'); + + // Dedent and fix blank lines inside
...
blocks + outputContents = outputContents.replace( + /(]*>]*>)([\s\S]*?)(<\/code><\/pre>)/g, + (_match, open, content, close) => { + const lines = content.split('\n'); + // Find minimum indentation across non-empty lines + const nonEmpty = lines.filter(l => l.trim().length > 0); + if (nonEmpty.length === 0) return _match; + const minIndent = Math.min(...nonEmpty.map(l => { + const m = l.match(/^[ \t]*/); + return m ? m[0].length : 0; + })); + // Dedent and replace blank lines with zero-width space + const processed = lines.map(l => { + if (l === '') return '\u200B'; + if (l.trim().length === 0) return '\u200B'; + return minIndent > 0 ? l.substring(minIndent) : l; + }); + // Strip trailing empty/zwsp lines + while (processed.length > 0 && (processed[processed.length - 1] === '\u200B' || processed[processed.length - 1] === '')) { + processed.pop(); + } + return open + processed.join('\n') + close; + } + ); + + // Strip leading whitespace outside
 blocks
+  const outputLines = outputContents.split('\n');
+  let insidePre = false;
+
+  for (let i = 0; i < outputLines.length; i++) {
+    if (/]/i.test(outputLines[i])) {
+      insidePre = true;
+    }
+    if (!insidePre) {
+      outputLines[i] = outputLines[i].replace(/^[ \t]+/, '');
+    }
+    if (/<\/pre>/i.test(outputLines[i])) {
+      insidePre = false;
+    }
+  }
+
+  await fs.promises.writeFile(outputPath, outputLines.join('\n'));
+}
+
 export async function runDefaultDocsBuild(options = {}) {
   const readmeTemplate = options.readmeTemplate;
   const isLocalPath = readmeTemplate && !readmeTemplate.startsWith("http");

From 36242f3ad2b78df299204932c04ec8d648cdaa7a Mon Sep 17 00:00:00 2001
From: rmenner 
Date: Mon, 4 May 2026 12:49:46 -0400
Subject: [PATCH 5/7] fix: refine whitespace stripping in post-processing to
 preserve markdown structure

---
 src/scripts/build/defaultDocsBuild.js | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/src/scripts/build/defaultDocsBuild.js b/src/scripts/build/defaultDocsBuild.js
index ff500bc..ec1fa5e 100644
--- a/src/scripts/build/defaultDocsBuild.js
+++ b/src/scripts/build/defaultDocsBuild.js
@@ -252,7 +252,9 @@ async function postProcessMarkdownFile(outputPath) {
       insidePre = true;
     }
     if (!insidePre) {
-      outputLines[i] = outputLines[i].replace(/^[ \t]+/, '');
+      // Only strip leading whitespace before HTML tags — not before markdown
+      // content like indented list items, which rely on indentation for structure.
+      outputLines[i] = outputLines[i].replace(/^[ \t]+(?=<)/, '');
     }
     if (/<\/pre>/i.test(outputLines[i])) {
       insidePre = false;

From e252574f8adaa7b92dd2a418ecc04b66cd16db20 Mon Sep 17 00:00:00 2001
From: rmenner 
Date: Mon, 4 May 2026 16:57:55 -0400
Subject: [PATCH 6/7] feat: enhance defaultDocsProcessorConfig with
 monorepoName and extraVars support

Co-authored-by: Copilot 
---
 src/scripts/build/defaultDocsBuild.js | 57 +++++++++++++++++++++++++--
 1 file changed, 53 insertions(+), 4 deletions(-)

diff --git a/src/scripts/build/defaultDocsBuild.js b/src/scripts/build/defaultDocsBuild.js
index ec1fa5e..fdbc6d9 100644
--- a/src/scripts/build/defaultDocsBuild.js
+++ b/src/scripts/build/defaultDocsBuild.js
@@ -17,6 +17,8 @@ const PAGE_TEMPLATE_PATH = "/docs/pages";
  * @property {string} [remoteReadmeVersion="master"] - The release version tag to use instead of master.
  * @property {string} [remoteReadmeUrl] - The release version tag to use instead of master.
  * @property {string} [remoteReadmeVariant=""] - The variant string to use for the README source (like "_esm" to make README_esm.md).
+ * @property {string} [monorepoName] - The name of the monorepo, used as a template variable.
+ * @property {Record} [extraVars] - Additional template variables to pass to the template filler.
  * @param {ProcessorConfig} config - The configuration for this processor.
  */
 export const defaultDocsProcessorConfig = {
@@ -26,6 +28,8 @@ export const defaultDocsProcessorConfig = {
   // TODO: remove this variant when all components are updated to use latest auro-library
   // AND the default README.md is updated to use the new paths
   remoteReadmeVariant: "_updated_paths",
+  monorepoName: undefined,
+  extraVars: {},
 };
 
 function pathFromCwd(pathLike) {
@@ -33,6 +37,32 @@ function pathFromCwd(pathLike) {
   return `${cwd}/${pathLike}`;
 }
 
+/**
+ * Walk up the directory tree from the given start directory to find the monorepo
+ * root — identified as the nearest ancestor (or self) whose package.json has a
+ * "workspaces" field. Falls back to the start directory if none is found.
+ * @param {string} [startDir=process.cwd()] - Directory to start searching from.
+ * @returns {string} Absolute path to the monorepo root directory.
+ */
+function findMonorepoRoot(startDir = process.cwd()) {
+  let dir = startDir;
+  while (true) {
+    const pkgPath = path.join(dir, "package.json");
+    if (existsSync(pkgPath)) {
+      try {
+        const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
+        if (pkg.workspaces) return dir;
+      } catch {
+        // malformed package.json — keep walking up
+      }
+    }
+    const parent = path.dirname(dir);
+    if (parent === dir) break; // reached filesystem root
+    dir = parent;
+  }
+  return startDir;
+}
+
 /**
  * @param {ProcessorConfig} config - The configuration for this processor.
  * @param {boolean} [skipReadme=false] - Whether to skip README.md processing.
@@ -118,14 +148,32 @@ export async function processDocFiles(config = defaultDocsProcessorConfig, skipR
 
   const fileConfigsList = await fileConfigs(config, skipReadme);
 
+  let monorepoName = config.monorepoName;
+  if (!monorepoName) {
+    try {
+      const rootPkgPath = path.join(findMonorepoRoot(), "package.json");
+      const pkgJson = JSON.parse(readFileSync(rootPkgPath, "utf8"));
+      // Strip the npm scope prefix ("@scope/") to get the bare package name used in
+      // template variables such as {{ monorepoName }} (e.g. "auro-formkit").
+      monorepoName = pkgJson.name?.replace(/^@[^/]+\//, '');
+    } catch {
+      // no root package.json or name field — leave undefined
+    }
+  }
+
+  const extraVars = {
+    ...(monorepoName ? { monorepoName } : {}),
+    ...(config.extraVars || {}),
+  };
+
   for (const fileConfig of fileConfigsList) {
     try {
       // eslint-disable-next-line no-await-in-loop
-      await processContentForFile(fileConfig);
+      await processContentForFile({ ...fileConfig, extraVars });
 
       // Post-processing for markdown output files
       if (fileConfig.output.endsWith('.md')) {
-        await postProcessMarkdownFile(fileConfig.output);
+        await postProcessMarkdownFile(fileConfig.output, extraVars);
       }
     } catch (err) {
       Logger.error(`Error processing ${fileConfig.identifier}: ${err.message}`);
@@ -137,8 +185,9 @@ export async function processDocFiles(config = defaultDocsProcessorConfig, skipR
  * Post-process a markdown file to resolve second-pass AURO-GENERATED-CONTENT tags,
  * convert markdown code fences to HTML, and normalize whitespace for marked.js.
  * @param {string} outputPath - The absolute path to the output markdown file.
+ * @param {Record} [extraVars={}] - Additional template variables for second-pass replacement.
  */
-async function postProcessMarkdownFile(outputPath) {
+async function postProcessMarkdownFile(outputPath, extraVars = {}) {
   const outputDir = path.dirname(outputPath);
 
   // --- Second-pass: resolve empty AURO-GENERATED-CONTENT tags ---
@@ -192,7 +241,7 @@ async function postProcessMarkdownFile(outputPath) {
     // Replace template variables (e.g. {{ componentName }}) in content
     // introduced by second-pass inlining — the first pass only
     // replaces variables in the original file, not in nested partials.
-    outputContents = templateFiller.replaceTemplateValues(outputContents);
+    outputContents = templateFiller.replaceTemplateValues(outputContents, extraVars);
     await fs.promises.writeFile(outputPath, outputContents);
   }
 

From 53fd747faf8de08b3943e258d6ee9a3ba9c0e390 Mon Sep 17 00:00:00 2001
From: rmenner 
Date: Wed, 6 May 2026 17:51:20 -0400
Subject: [PATCH 7/7] ci: update Node.js version to 22.22.1 in workflows

Co-authored-by: Copilot 
---
 .github/workflows/check-commits.yml    | 2 +-
 .github/workflows/test-and-release.yml | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/.github/workflows/check-commits.yml b/.github/workflows/check-commits.yml
index 01d88fb..e17ea34 100644
--- a/.github/workflows/check-commits.yml
+++ b/.github/workflows/check-commits.yml
@@ -18,7 +18,7 @@ jobs:
       - name: Setup Node.js
         uses: actions/setup-node@v4
         with:
-          node-version: 22
+          node-version: 22.22.1
       - name: Install dependencies
         run: npm install
       - name: Ensure Git is installed
diff --git a/.github/workflows/test-and-release.yml b/.github/workflows/test-and-release.yml
index 2e13c53..57e5a36 100644
--- a/.github/workflows/test-and-release.yml
+++ b/.github/workflows/test-and-release.yml
@@ -17,7 +17,7 @@ jobs:
     strategy:
       matrix:
         os: [ubuntu-latest, windows-latest]
-        node-version: [20, 22]
+        node-version: [20, 22.22.1]
 
     steps:
       - uses: actions/checkout@v4
@@ -51,7 +51,7 @@ jobs:
       - name: Setup Node.js
         uses: actions/setup-node@v4
         with:
-          node-version: 22
+          node-version: 22.22.1
       - name: Install dependencies
         run: npm clean-install