/** * Build-time boundary manifest derivation. * * Scans a project for boundary definition modules (`boundaries.ts` / * `*.boundaries.ts`) and `@quantize` CSS blocks, then derives the * `BoundaryManifest` that `virtual:czap/boundaries` exports and the * `@czap/astro` integration writes to `czap-boundary-manifest.json`: each * boundary's `Boundary.make` content address plus precompiled * `CompiledOutputs` for every (motion x design) tier. * * This is the build half of the edge caching design (ADR-0003): identity * and compilation are derived here, so edge workers consume the manifest * instead of hand-typing boundary ids or bundling the CSS compiler. * * @module */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { pathToFileURL } from 'node:url'; import { Diagnostics } from '@czap/core'; import type { Boundary } from '@czap/core'; import { CSSCompiler, dispatch, type CSSAtRuleGroup } from '@czap/compiler'; import type { WGSLUniformValue, WGSLUniformVector } from '@czap/compiler'; import { DESIGN_TIERS, MOTION_TIERS, dedupeOutputsByTier, tierKey } from '@czap/edge'; import type { BoundaryManifest, BoundaryManifestEntry, CompiledOutputs, TierKey } from '@czap/edge'; import { CAST_TARGETS, parseQuantizeBlocks, viewportContainmentRule, viewportQueryAxis, type CastTarget, type QuantizeAtRuleGroup, type QuantizeStateBody, } from './css-quantize.js'; import { findConventionFiles } from './resolve-fs.js'; const DIAGNOSTIC_SOURCE = 'czap/vite.boundary-manifest'; /** Directory names never descended into while scanning a project. */ const SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage', '.git', '.astro', '.wrangler', '.cache', '.output']); /** Options for {@link collectBoundaryManifest}. */ export interface CollectBoundaryManifestOptions { /** * Extra directory holding boundary definitions -- mirror of the plugin's * `dirs.boundary` override; scanned in addition to the project walk. */ readonly boundaryDir?: string; /** * Selector the auto-emitted viewport `@container` containment is declared * on (default `:root`) -- mirror of the plugin's `quantize.container`, so * the manifest-served CSS matches the transform layer's containment target. */ readonly container?: string; } /** The set of scannable files (boundary modules + stylesheets) from one project walk. */ export interface ProjectScan { readonly boundaryFiles: readonly string[]; readonly cssFiles: readonly string[]; } /** Project-wide boundary definitions keyed by export name, including their source module path. */ export type BoundaryDefinitionMap = ReadonlyMap< string, { readonly primitive: Boundary.Shape; readonly source: string } >; function isBoundaryModuleFile(fileName: string): boolean { return fileName === 'boundaries.ts' || fileName.endsWith('.boundaries.ts'); } /** * Walk the project once, collecting boundary-definition modules and stylesheets. * Package-internal (not re-exported from the entry): the Vite plugin shares one scan * across the manifest + definitions derivations instead of walking the tree twice. */ export function scanProject(projectRoot: string): ProjectScan { const boundaryFiles: string[] = []; const cssFiles: string[] = []; const stack: string[] = [projectRoot]; // Physical (realpath) identity of every directory already walked -- // symlinked directories are followed below, so without this a circular // link (`dir/loop -> dir`) would recurse forever. const visited = new Set(); while (stack.length > 0) { const dir = stack.pop()!; let realDir: string; try { realDir = fs.realpathSync(dir); } catch { // Broken link or vanished dir; readdir below reports the details. realDir = path.resolve(dir); } if (visited.has(realDir)) continue; visited.add(realDir); let entries: fs.Dirent[] = []; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (error) { Diagnostics.warnOnce({ source: DIAGNOSTIC_SOURCE, code: 'scan-readdir-failed', message: `Could not read "${dir}" while scanning for boundary definitions; entries under it are skipped.`, cause: error, }); continue; } for (const entry of entries) { const entryPath = path.join(dir, entry.name); let isDirectory = entry.isDirectory(); let isFile = entry.isFile(); if (entry.isSymbolicLink()) { // Follow links to their targets (linked source dirs scan like // real ones); the visited set above contains circular links. try { const stat = fs.statSync(entryPath); isDirectory = stat.isDirectory(); isFile = stat.isFile(); } catch { continue; // Dangling symlink -- nothing to scan. } } if (isDirectory) { if (!SKIP_DIRS.has(entry.name)) stack.push(entryPath); continue; } if (!isFile) continue; if (isBoundaryModuleFile(entry.name)) { boundaryFiles.push(entryPath); } else if (entry.name.endsWith('.css') || entry.name.endsWith('.astro')) { // .astro components carry @quantize inside