diff --git a/README.md b/README.md index edbad6d..1918fa2 100644 --- a/README.md +++ b/README.md @@ -58,3 +58,45 @@ Open the server to a specific directory: ``` auro dev --open src/ ``` + +`auro context` +Prints an AI-ready context document describing the Auro Design System, its components, and usage patterns. Designed to be piped into or pasted into AI coding assistants (Claude, Cursor, Copilot, etc.) to prime them on Auro. + +#### Options + +- `-o, --output ` Write the context document to a file instead of stdout (e.g. `AURO_CONTEXT.md`). + +#### Examples + +Print the context to the terminal: + +``` +auro context +``` + +Write the context to a file for your AI tool: + +``` +auro context --output AURO_CONTEXT.md +``` + +`auro cem` +Aggregates the Custom Elements Manifests (`custom-elements.json`) of every published Auro component into a single file. Each component's manifest is fetched from the latest published version on unpkg; components that do not publish a manifest yet are skipped. Useful for feeding a complete, machine-readable component API index to IDEs, docs tooling, and AI assistants. + +#### Options + +- `-o, --output ` Path to write the aggregated manifest (default: `custom-elements.aggregate.json`). + +#### Examples + +Generate the aggregated manifest: + +``` +auro cem +``` + +Write it to a specific path: + +``` +auro cem --output dist/custom-elements.aggregate.json +``` diff --git a/build-scripts/build-config.js b/build-scripts/build-config.js index db5a0f6..12c63f8 100644 --- a/build-scripts/build-config.js +++ b/build-scripts/build-config.js @@ -46,6 +46,7 @@ export const aliases = { "#configs": resolve(projectRoot, "src/configs"), "#commands": resolve(projectRoot, "src/commands"), "#scripts": resolve(projectRoot, "src/scripts"), + "#static": resolve(projectRoot, "src/static"), "#utils": resolve(projectRoot, "src/utils"), }; diff --git a/package.json b/package.json index 9e8b9ca..ba10ff1 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "#configs/*": "./src/configs/*", "#commands/*": "./src/commands/*", "#scripts/*": "./src/scripts/*", + "#static/*": "./src/static/*", "#utils/*": "./src/utils/*" }, "publishConfig": { diff --git a/src/commands/cem.ts b/src/commands/cem.ts new file mode 100644 index 0000000..5d837d6 --- /dev/null +++ b/src/commands/cem.ts @@ -0,0 +1,194 @@ +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { Logger } from "@aurodesignsystem/auro-library/scripts/utils/logger.mjs"; +import { program } from "commander"; +import ora from "ora"; +import { AURO_COMPONENT_PACKAGES } from "#static/auroComponents.js"; + +const UNPKG_BASE = "https://unpkg.com"; + +interface CemModule { + path: string; + [key: string]: unknown; +} + +interface Manifest { + schemaVersion?: string; + modules?: CemModule[]; + [key: string]: unknown; +} + +interface FetchOutcome { + pkg: string; + manifest: Manifest | null; + /** Human-readable reason the manifest was skipped. */ + reason?: string; + /** True when the skip was caused by a transient error (network/5xx), not a genuine 404. */ + transient?: boolean; +} + +interface ManifestSource { + pkg: string; + manifest: Manifest; +} + +/** + * Fetch a single package's Custom Elements Manifest from unpkg. + * Never throws — failures are returned as an outcome so the caller can + * distinguish a genuine absence (404) from a transient error. + */ +async function fetchManifest(pkg: string): Promise { + const url = `${UNPKG_BASE}/${pkg}/custom-elements.json`; + + let response: Response; + try { + response = await fetch(url); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { pkg, manifest: null, reason: `request failed (${message})`, transient: true }; + } + + if (response.status === 404) { + return { pkg, manifest: null, reason: "no custom-elements.json published" }; + } + + if (!response.ok) { + return { pkg, manifest: null, reason: `HTTP ${response.status}`, transient: true }; + } + + try { + return { pkg, manifest: (await response.json()) as Manifest }; + } catch { + return { pkg, manifest: null, reason: "custom-elements.json is not valid JSON", transient: true }; + } +} + +/** + * Recursively namespace every local module reference in a CEM node with the + * owning package. A reference is local only when it has a `module` string and + * no `package` sibling (a `package` means it points at an external package, so + * its `module` must be left untouched). This keeps the merged manifest's + * internal references (exports' `declaration.module`, `references[].module`, + * etc.) pointing at the same paths as their now-namespaced modules. + */ +function namespaceReferences(node: unknown, pkg: string): unknown { + if (Array.isArray(node)) { + return node.map((item) => namespaceReferences(item, pkg)); + } + + if (node && typeof node === "object") { + const source = node as Record; + const result: Record = {}; + for (const [key, value] of Object.entries(source)) { + if (key === "module" && typeof value === "string" && source.package == null) { + result[key] = `${pkg}/${value}`; + } else { + result[key] = namespaceReferences(value, pkg); + } + } + return result; + } + + return node; +} + +/** + * Merge per-package manifests into a single Custom Elements Manifest. + * Every module (and its internal references) is namespaced with its package so + * paths stay unique and every declaration remains traceable to its component. + */ +function mergeManifests(sources: ManifestSource[]): Manifest { + const modules: CemModule[] = []; + + for (const { pkg, manifest } of sources) { + for (const module of manifest.modules ?? []) { + const namespaced = namespaceReferences(module, pkg) as CemModule; + namespaced.path = `${pkg}/${module.path}`; + modules.push(namespaced); + } + } + + const schemaVersions = new Set( + sources.map((source) => source.manifest.schemaVersion).filter(Boolean), + ); + if (schemaVersions.size > 1) { + Logger.warn( + `Merging mixed CEM schema versions: ${[...schemaVersions].join(", ")}`, + ); + } + + return { + schemaVersion: sources[0]?.manifest.schemaVersion ?? "1.0.0", + modules, + }; +} + +export default program + .command("cem") + .description( + "Fetch every published Auro component's custom-elements.json and merge them into a single aggregated manifest", + ) + .option( + "-o, --output ", + "Path to write the aggregated manifest", + "custom-elements.aggregate.json", + ) + .action(async (options) => { + const spinner = ora( + `Fetching manifests for ${AURO_COMPONENT_PACKAGES.length} components...`, + ).start(); + + const outcomes = await Promise.all( + AURO_COMPONENT_PACKAGES.map((pkg) => fetchManifest(pkg)), + ); + + const sources: ManifestSource[] = []; + for (const outcome of outcomes) { + if (outcome.manifest) { + sources.push({ pkg: outcome.pkg, manifest: outcome.manifest }); + } + } + + if (sources.length === 0) { + spinner.fail("No component manifests could be fetched."); + process.exit(1); + } + + spinner.text = "Merging manifests..."; + const aggregate = mergeManifests(sources); + + const outputPath = path.resolve(process.cwd(), options.output); + try { + await writeFile( + outputPath, + `${JSON.stringify(aggregate, null, 2)}\n`, + "utf-8", + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + spinner.fail(`Failed to write ${options.output}: ${message}`); + process.exit(1); + } + + spinner.succeed( + `Aggregated ${sources.length}/${AURO_COMPONENT_PACKAGES.length} component manifests (${aggregate.modules?.length ?? 0} modules) to ${options.output}`, + ); + + // Report skips after the spinner so output isn't garbled. Genuine 404s are + // expected (not every component publishes a CEM yet); transient failures + // mean the aggregate is incomplete and are treated as an error. + const skipped = outcomes.filter((outcome) => !outcome.manifest); + const transientFailures = skipped.filter((outcome) => outcome.transient); + + for (const outcome of skipped) { + Logger.info(`Skipped ${outcome.pkg}: ${outcome.reason}`); + } + + if (transientFailures.length > 0) { + Logger.error( + `${transientFailures.length} component(s) failed to fetch transiently; the aggregate may be incomplete. Re-run to retry.`, + ); + process.exit(1); + } + }); diff --git a/src/commands/context.ts b/src/commands/context.ts new file mode 100644 index 0000000..70bfd09 --- /dev/null +++ b/src/commands/context.ts @@ -0,0 +1,35 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { program } from "commander"; +import ora from "ora"; +import { AURO_CONTEXT } from "#static/auroContext.js"; + +export default program + .command("context") + .description( + "Generate an AI assistant context document for the Auro Design System", + ) + .option( + "-o, --output ", + "Write context to a file instead of stdout (e.g. AURO_CONTEXT.md)", + ) + .action(async (options) => { + if (options.output) { + const spinner = ora(`Writing context to ${options.output}...`).start(); + try { + const outputPath = path.resolve(process.cwd(), options.output); + await fs.writeFile(outputPath, AURO_CONTEXT, "utf-8"); + spinner.succeed(`Auro context written to ${options.output}`); + console.log( + "\nPaste this file into your AI coding tool (Claude, Cursor, Copilot, etc.) to prime it on Auro components.", + ); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + spinner.fail(`Failed to write context: ${message}`); + process.exit(1); + } + } else { + process.stdout.write(AURO_CONTEXT); + } + }); diff --git a/src/index.ts b/src/index.ts index cfe76e6..1ad9b08 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,8 @@ import "#commands/check-commits.ts"; import "#commands/pr-release.ts"; import "#commands/test.js"; import "#commands/agent.ts"; +import "#commands/cem.ts"; +import "#commands/context.ts"; import "#commands/docs.ts"; import "#commands/ado.ts"; import "#commands/rc-workflow.ts"; diff --git a/src/static/auroComponents.ts b/src/static/auroComponents.ts new file mode 100644 index 0000000..9c9b7a3 --- /dev/null +++ b/src/static/auroComponents.ts @@ -0,0 +1,43 @@ +/** + * Candidate Auro component packages to check for a Custom Elements Manifest + * (`custom-elements.json`) at their package root. Not every component publishes + * one yet — the `auro cem` command fetches each and skips those that 404, so + * this list can safely include components ahead of their CEM adoption. + * + * Keep this list in sync with the components published under the + * `@aurodesignsystem/` npm scope. + */ +export const AURO_COMPONENT_PACKAGES = [ + "@aurodesignsystem/auro-accordion", + "@aurodesignsystem/auro-alert", + "@aurodesignsystem/auro-avatar", + "@aurodesignsystem/auro-background", + "@aurodesignsystem/auro-backtotop", + "@aurodesignsystem/auro-badge", + "@aurodesignsystem/auro-banner", + "@aurodesignsystem/auro-button", + "@aurodesignsystem/auro-card", + "@aurodesignsystem/auro-carousel", + "@aurodesignsystem/auro-datetime", + "@aurodesignsystem/auro-dialog", + "@aurodesignsystem/auro-drawer", + "@aurodesignsystem/auro-flight", + "@aurodesignsystem/auro-flightline", + "@aurodesignsystem/auro-formkit", + "@aurodesignsystem/auro-header", + "@aurodesignsystem/auro-hyperlink", + "@aurodesignsystem/auro-icon", + "@aurodesignsystem/auro-loader", + "@aurodesignsystem/auro-lockup", + "@aurodesignsystem/auro-nav", + "@aurodesignsystem/auro-pane", + "@aurodesignsystem/auro-popover", + "@aurodesignsystem/auro-sidenav", + "@aurodesignsystem/auro-skeleton", + "@aurodesignsystem/auro-slideshow", + "@aurodesignsystem/auro-table", + "@aurodesignsystem/auro-tabs", + "@aurodesignsystem/auro-tail", + "@aurodesignsystem/auro-toast", + "@aurodesignsystem/auro-tokenlist", +]; diff --git a/src/static/auroContext.ts b/src/static/auroContext.ts new file mode 100644 index 0000000..98a3008 --- /dev/null +++ b/src/static/auroContext.ts @@ -0,0 +1,171 @@ +export const AURO_CONTEXT = `# Auro Design System — AI Assistant Context +Alaska Airlines open-source design system | https://auro.alaskaair.com + +## What is Auro? +Auro is Alaska Airlines' design system built on Web Components (Custom Elements v1) using the Lit library. +- npm scope: \`@aurodesignsystem/\` (current), \`@alaskaairux/\` (legacy) +- Framework-agnostic: works with React, Angular, Vue, Svelte, or plain HTML +- Each component is a separate npm package +- All components use the \`\` custom element tag + +## Rules for Writing Auro Code + +1. **Use \`\` custom element tags — never plain HTML equivalents** + - ✗ \`