{"version":3,"file":"build-iwtR0Oi9.mjs","names":[],"sources":["../src/asset-manager/build.ts"],"sourcesContent":["/**\n * Build pipeline for `assetMap` entries (asset-manager PR 2).\n *\n * For each entry, walks `src/<...>` matching the configured `glob`,\n * copies matches into `dest` (default `dist/<name>/`), then emits a\n * `dist/.kickjs-assets.json` manifest mapping logical\n * `<namespace>/<key>` keys to repo-relative paths inside `dist/`.\n *\n * Pure function on top of `node:fs` + `glob` — no shell, no side\n * effects beyond the configured directory writes. The build entry-\n * point in `commands/run.ts` calls `buildAssets` after the existing\n * `copyDirs` step.\n *\n * @module @forinda/kickjs-cli/asset-manager/build\n */\n\nimport { cpSync, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'\nimport { dirname, isAbsolute, join, relative, resolve } from 'node:path'\nimport { glob } from 'glob'\nimport { groupAssetKeys } from '@forinda/kickjs'\nimport type { AssetMapEntry, KickConfig } from '../config'\n\n/** Wire-format version for `dist/.kickjs-assets.json`. Bump on shape change. */\nexport const ASSET_MANIFEST_VERSION = 1 as const\n\n/** On-disk manifest format (`dist/.kickjs-assets.json`). */\nexport interface AssetManifest {\n  version: typeof ASSET_MANIFEST_VERSION\n  /**\n   * Logical key → manifest-relative path. Logical key is\n   * `<namespace>/<key>` where `<key>` is the file path under `src`\n   * with the extension stripped + path separators normalised.\n   *\n   * Path values are relative to the manifest file's directory so the\n   * runtime can resolve them with a single `path.resolve(manifestDir,\n   * entry)` regardless of where dist/ lives.\n   */\n  entries: Record<string, string>\n}\n\nexport interface BuildAssetsOptions {\n  /** Project root — resolved for every relative path in the entry. */\n  cwd: string\n  /**\n   * Output dir for the manifest + per-namespace asset copies. When\n   * omitted, falls back to `config.build?.outDir` (resolved against\n   * `cwd`), then to `dist/` under cwd. Adopters with a custom Vite\n   * `build.outDir` should set `kick.config.ts.build.outDir` to match.\n   */\n  distDir?: string\n  /** Suppress per-entry log lines. Default: false. */\n  silent?: boolean\n}\n\n/** One entry in the per-build summary returned by `buildAssets`. */\nexport interface BuildAssetsEntryResult {\n  namespace: string\n  src: string\n  dest: string\n  /**\n   * Number of files actually written this run. On an incremental\n   * rebuild where nothing changed this is 0 even though the manifest\n   * still lists every matched file.\n   */\n  filesCopied: number\n}\n\n/** Aggregated outcome of `buildAssets`. */\nexport interface BuildAssetsResult {\n  manifestPath: string\n  entries: BuildAssetsEntryResult[]\n  /** `entries` merged into a single record — useful for tests + tooling. */\n  manifest: AssetManifest\n}\n\n/**\n * Run the full asset build for a loaded config:\n *\n * 1. For each `assetMap` entry, glob → copy → manifest stub.\n * 2. Write `dist/.kickjs-assets.json`.\n *\n * Returns a summary including the manifest contents. No-op (and no\n * manifest written) when `assetMap` is empty / missing — the build\n * pipeline shouldn't litter `dist/` with empty manifests for\n * adopters who don't use the feature.\n */\nexport async function buildAssets(\n  config: KickConfig | null,\n  opts: BuildAssetsOptions,\n): Promise<BuildAssetsResult | null> {\n  const { cwd, silent = false } = opts\n  // Resolution order: explicit opts.distDir → config.build.outDir → 'dist'.\n  // The CLI build command passes nothing explicit, so adopters control\n  // the output via kick.config.ts.build.outDir alone.\n  const distDir = opts.distDir ?? config?.build?.outDir ?? 'dist'\n  const map = config?.assetMap\n  if (!map || Object.keys(map).length === 0) return null\n\n  const log = silent ? () => {} : console.log\n\n  const distAbs = resolve(cwd, distDir)\n  mkdirSync(distAbs, { recursive: true })\n\n  const summary: BuildAssetsEntryResult[] = []\n  const manifestEntries: Record<string, string> = {}\n\n  for (const [namespace, entry] of Object.entries(map)) {\n    const result = await processEntry(namespace, entry, cwd, distAbs)\n    summary.push(result.entrySummary)\n    Object.assign(manifestEntries, result.manifestSlice)\n    log(\n      `    ✓ ${namespace}: ${result.entrySummary.filesCopied} file(s) → ${result.entrySummary.dest}`,\n    )\n  }\n\n  const manifest: AssetManifest = {\n    version: ASSET_MANIFEST_VERSION,\n    entries: manifestEntries,\n  }\n  const manifestPath = join(distAbs, '.kickjs-assets.json')\n  writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\\n', 'utf-8')\n  log(\n    `    ✓ wrote manifest → ${relative(cwd, manifestPath)} (${Object.keys(manifestEntries).length} entries)`,\n  )\n\n  return { manifestPath, entries: summary, manifest }\n}\n\n/** Per-entry inner pipeline — extracted for unit-test reuse. */\nasync function processEntry(\n  namespace: string,\n  entry: AssetMapEntry,\n  cwd: string,\n  distAbs: string,\n): Promise<{\n  entrySummary: BuildAssetsEntryResult\n  manifestSlice: Record<string, string>\n}> {\n  const srcAbs = resolve(cwd, entry.src)\n  const destAbs = entry.dest ? resolve(cwd, entry.dest) : join(distAbs, namespace)\n\n  // Defensive: refuse to write outside the project root (cwd) even\n  // though validateAssetMap warned about it at config-load time. The\n  // build step shouldn't trust upstream warnings — a typo like\n  // `dest: '../../'` would otherwise sprinkle files outside the\n  // workspace despite the warning being printed.\n  if (escapesRoot(destAbs, cwd)) {\n    console.warn(\n      `  ⚠ assetMap.${namespace}.dest ('${entry.dest}') resolves outside the project root — skipping copy`,\n    )\n    return {\n      entrySummary: { namespace, src: entry.src, dest: relative(cwd, destAbs), filesCopied: 0 },\n      manifestSlice: {},\n    }\n  }\n\n  // Treat src-not-a-directory the same as src-missing — `glob` would\n  // throw if pointed at a file, surfacing as a generic build failure\n  // instead of a clean 0-files entry. Matches the validator's warning\n  // shape (already emitted at config-load time for the missing case).\n  if (!existsSync(srcAbs) || !isDirectorySync(srcAbs)) {\n    return {\n      entrySummary: { namespace, src: entry.src, dest: relative(cwd, destAbs), filesCopied: 0 },\n      manifestSlice: {},\n    }\n  }\n\n  const pattern = entry.glob ?? '**/*'\n  // `glob` returns paths relative to `cwd` when `cwd` is set —\n  // exactly the slugs we want for the manifest keys.\n  const matches = await glob(pattern, {\n    cwd: srcAbs,\n    nodir: true,\n    dot: false,\n    posix: true,\n  })\n\n  mkdirSync(destAbs, { recursive: true })\n\n  const manifestSlice: Record<string, string> = {}\n  // Sort the walk so the manifest is byte-stable across platforms.\n  // The `groupAssetKeys` helper preserves input order, so a stable\n  // sort here makes the resulting manifest stable too.\n  const sorted = [...matches].toSorted()\n  const { pairs, collisionGroupsResolved } = groupAssetKeys(namespace, sorted, {\n    strategy: entry.keys ?? 'auto',\n  })\n\n  // Copy the files and write the keyed manifest slice. Both come from\n  // the same `pairs` order so the on-disk layout matches the\n  // manifest's iteration order — easier to grep in cold-start\n  // debugging.\n  //\n  // Incremental: skip the copy when the destination is already\n  // up-to-date (exists, same size, mtime ≥ source). `cpSync` stamps the\n  // copy with the current time, so an unchanged source always satisfies\n  // `dest.mtime ≥ src.mtime` on the next run — turning a re-build into a\n  // pure stat sweep instead of re-copying every asset. The manifest\n  // slice is written unconditionally so the manifest stays complete.\n  let filesCopied = 0\n  for (const { rel: relPath, key } of pairs) {\n    const srcFile = join(srcAbs, relPath)\n    const destFile = join(destAbs, relPath)\n    manifestSlice[key] = toManifestRelative(distAbs, destFile)\n    if (isUpToDate(srcFile, destFile)) continue\n    mkdirSync(dirname(destFile), { recursive: true })\n    cpSync(srcFile, destFile)\n    filesCopied++\n  }\n\n  if (collisionGroupsResolved > 0) {\n    console.log(\n      `  ℹ assetMap.${namespace}: auto-resolved ${collisionGroupsResolved} basename collision(s) by keeping extensions ` +\n        `(set 'keys: \"strip\"' to opt back into legacy last-write-wins behaviour, or 'keys: \"with-extension\"' to keep all keys verbose).`,\n    )\n  }\n\n  return {\n    entrySummary: {\n      namespace,\n      src: entry.src,\n      dest: relative(cwd, destAbs),\n      filesCopied,\n    },\n    manifestSlice,\n  }\n}\n\n/**\n * Is `destFile` already a current copy of `srcFile`? True when the\n * destination exists with the same byte size and an mtime no older than\n * the source. Used to skip redundant copies on an incremental rebuild.\n */\nfunction isUpToDate(srcFile: string, destFile: string): boolean {\n  if (!existsSync(destFile)) return false\n  try {\n    const s = statSync(srcFile)\n    const d = statSync(destFile)\n    return d.size === s.size && d.mtimeMs >= s.mtimeMs\n  } catch {\n    return false\n  }\n}\n\n/**\n * Make `destFile` relative to the manifest's directory + force POSIX\n * separators so the manifest is byte-stable across platforms.\n */\nfunction toManifestRelative(manifestDir: string, destFile: string): string {\n  const rel = relative(manifestDir, destFile)\n  // path.relative returns OS-native separators; the manifest is JSON\n  // and the runtime uses path.resolve which handles either, but a\n  // forward-slash manifest is grep-friendly + diff-stable.\n  return rel.split(/[\\\\/]/).filter(Boolean).join('/')\n}\n\n/**\n * Pure manifest writer — handy for tests that want to assert against\n * a hand-crafted manifest without exercising the full pipeline.\n */\nexport function writeAssetManifest(distDir: string, manifest: AssetManifest): string {\n  const path = join(distDir, '.kickjs-assets.json')\n  mkdirSync(distDir, { recursive: true })\n  writeFileSync(path, JSON.stringify(manifest, null, 2) + '\\n', 'utf-8')\n  return path\n}\n\n/**\n * Read + parse a manifest from disk. Returns `null` on missing or\n * malformed file rather than throwing — the runtime resolver wants\n * to fall through to dev-mode lookup in that case.\n */\nexport function readAssetManifest(distDir: string): AssetManifest | null {\n  const path = join(distDir, '.kickjs-assets.json')\n  if (!existsSync(path)) return null\n  try {\n    // eslint-disable-next-line @typescript-eslint/no-require-imports\n    const fs = require('node:fs') as typeof import('node:fs')\n    const raw = fs.readFileSync(path, 'utf-8')\n    const parsed = JSON.parse(raw) as Partial<AssetManifest>\n    if (parsed.version !== ASSET_MANIFEST_VERSION) return null\n    if (!parsed.entries || typeof parsed.entries !== 'object') return null\n    return parsed as AssetManifest\n  } catch {\n    return null\n  }\n}\n\n/**\n * Project-root escape check that's safe across symlinks + drive letters.\n * `path.relative` returns `..` segments when the target sits above root,\n * and an absolute path when the two live on different roots (Windows).\n * `startsWith(root)` would miss both cases.\n */\nfunction escapesRoot(path: string, root: string): boolean {\n  const rel = relative(root, path)\n  if (rel === '') return false\n  return rel.startsWith('..') || isAbsolute(rel)\n}\n\n/** Pure helper — `false` for missing, non-dir, or unreadable paths. */\nfunction isDirectorySync(path: string): boolean {\n  try {\n    return statSync(path).isDirectory()\n  } catch {\n    return false\n  }\n}\n"],"mappings":"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"}