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 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/_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 cb250d0..e178f14 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,8 @@ 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("-r, --readme-template ", "URL to the README template file") .option("--skip-readme", "Skip README.md processing", false) docsCommand = withServerOptions(docsCommand); @@ -27,4 +29,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..9e99b27 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 { existsSync, readFileSync, 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"; +import { MODULE_DIRS } from "#scripts/build/paths.js"; +import { copyReadmeToDemo } from "#utils/copyReadmeToDemo.js"; /** * Clean up the dist folder @@ -108,9 +114,167 @@ 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.", "Doc troubles!", ); } + + + +/** + * 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; + } + + // 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; + } + + 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(); + 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", + }); + + 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/defaultDocsBuild.js b/src/scripts/build/defaultDocsBuild.js index ce60f31..fdbc6d9 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"; @@ -16,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 = { @@ -25,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) { @@ -32,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. @@ -43,18 +74,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"), }); } @@ -113,21 +148,184 @@ 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, extraVars); + } } 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. + * @param {Record} [extraVars={}] - Additional template variables for second-pass replacement. + */ +async function postProcessMarkdownFile(outputPath, extraVars = {}) { + 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, extraVars); + 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) {
+      // 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;
+    }
+  }
+
+  await fs.promises.writeFile(outputPath, outputLines.join('\n'));
+}
+
 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 a5ffcd5..d35fff5 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";
+
+import { MODULE_DIRS } from "#scripts/build/paths.js";
+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/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 e94d67d..b756275 100644
--- a/src/scripts/build/watchModeHandlers.js
+++ b/src/scripts/build/watchModeHandlers.js
@@ -2,7 +2,8 @@ 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 { registerWatcher, installShutdownHandler } from "#utils/shutdown.js";
+import { compileDemoScss, generateDocs } from "./bundleHandlers.js";
 
 // Track if any build is in progress to prevent overlapping operations
 let buildInProgress = false;
@@ -11,6 +12,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 +42,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 +91,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 +151,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 +174,7 @@ export async function handleWatcherEvents(
       isInitialBuild &&
       buildTasksResults.analyze &&
       buildTasksResults.docs &&
+      buildTasksResults.scss &&
       typeof onInitialBuildComplete === "function"
     ) {
       isInitialBuild = false;
@@ -180,7 +199,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,14 +281,12 @@ 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) {
-  process.on("SIGINT", () => {
-    const closeSpinner = ora("Wrapping up...").start();
-    watcher.close();
-    closeSpinner.succeed("All done! See you next time. ✨");
-    process.exit(0);
-  });
+export function setupWatchModeListeners(watcher, scssWatcher) {
+  registerWatcher(watcher);
+  if (scssWatcher) registerWatcher(scssWatcher);
+  installShutdownHandler();
 
   return watcher;
 }
diff --git a/src/scripts/docs/index.ts b/src/scripts/docs/index.ts
index 0d4c399..787c6b0 100644
--- a/src/scripts/docs/index.ts
+++ b/src/scripts/docs/index.ts
@@ -2,6 +2,9 @@ import ora from "ora";
 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";
 
@@ -45,8 +48,109 @@ export async function docs(options = {}) {
     docsSpinner.fail("Failed to compile MD documentation: " + errorMessage);
     throw error;
   }
+
+  copyReadmeToDemo();
+  await compileDemoScss();
+  await buildDemoBundle(options);
 }
 
 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);
+  });
+
+  // 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);
+  });
+}