All files / packages/design-system-2/scripts buildCatalog.ts

86.2% Statements 100/116
80.55% Branches 29/36
89.47% Functions 17/19
88.34% Lines 91/103

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324                                                                                                      1x     212x 212x 212x   133x               20x 20x 29x 21x   20x           13x 13x 26x   13x           21x 7x   8x   8x                     21x       21x 21x 29x 15x 15x     21x       21x 21x                     21x 21x 7x 7x 8x     8x 7x 7x   7x                               21x 21x 7x 7x   7x 7x       7x 7x 8x 1x 1x 1x     7x             8x 8x 8x 8x   8x   21x 21x 14x   8x 8x 8x 8x 8x 8x 8x 8x   8x 21x 7x 7x 7x   21x 7x 7x 7x   21x 21x 21x 21x 21x 1x       8x       8x                                                                               8x       6x 6x 6x 6x       7x 7x                               1x 1x                                                        
#!/usr/bin/env node
/**
 * Catalog data build.
 *
 * Emits `build/catalog/index.js` — a static ES module that consumers (the sandbox
 * Component Catalog today) import to enumerate every SLDS2 component along with
 * its UIF, themeData, theme-file presence flags, and the hook set parsed from
 * its bundled CSS. It statically imports UIF (build/uif/) and themeData
 * (build/themeData/), so it lives in build/ alongside them rather than in the
 * published dist/.
 *
 * Why a generated `.js` and not a JSON file:
 *   The data is a join across several existing outputs (UIF JSON, themeData JSON,
 *   dist component CSS). Bundlers track per-file static `import` edges into the
 *   module graph, so a generated module with one static import per component
 *   gives Turbopack/Next HMR when any underlying JSON changes. A single JSON
 *   blob would force the consumer to re-fetch on every edit.
 *
 * Exports:
 *   dirs           string[]                   — sorted list of every component dir under src/slds2
 *   uif            { [dir]: ResolvedUif }     — per-dir static import of build/uif/slds/<dir>.uif.json
 *   themeData      { [dir]: ThemeData }       — per-dir static import of build/themeData/<dir>.json
 *   themes         { [dir]: { base, cosmos, lightningBlue } } — theme file presence flags
 *   distHooks      { [dir]: string[] }        — sorted `--slds-c-*` hook names parsed from
 *                                               dist/components/<dir>/<dir>.css and
 *                                               build/components/<dir>/themes/*.css
 *   componentDeps  { [dir]: string[] }        — sibling components imported from this component's
 *                                               __stories__/*.stories.js files (story compositions
 *                                               that pull in other components)
 *   stories        { [dir]: Preset[] }        — curated CSF presets parsed out of
 *                                               __stories__/*.uif.stories.js, translated into the
 *                                               sandbox catalog's override-bucket shape via
 *                                               extractUifStories.js. Omitted when a component
 *                                               has no stories with args.
 *   legacyHookData { _meta, [prefix]: string[] } — SLDS2 legacy Cosmos bundle hook audit, produced
 *                                                  by buildLegacyHookData.js. Grouped by component
 *                                                  prefix; used by compliance tooling to attribute
 *                                                  customer hooks that came in through the legacy
 *                                                  pre-theme-layer bundle.
 *
 * Run via `yarn build:catalog`, or `yarn watch:catalog` to rebuild on change.
 */
 
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { extractPresetsFromStorySource } from './extractUifStories.js';
import { HOOK_RE } from './constants.js';
 
// Match `import ... from '../../<dir>/...'` — the canonical relative form
// used inside src/slds2/<self>/__stories__/. Group 1 is the sibling dir name.
export const STORY_IMPORT_RE = /import\s+(?:[^'"]+from\s+)?['"]\.\.\/\.\.\/([^/'"]+)\//g;
 
function exists(p) {
  try {
    fs.accessSync(p);
    return true;
  } catch {
    return false;
  }
}
 
// Pure: extract `--slds-c-*` hooks named in a CSS source. Skip context hooks
// (those are parent-set assignments, not the component's own surface). Result
// is sorted for deterministic output.
export function extractHooksFromCss(css) {
  const hooks = new Set();
  for (const [name] of css.matchAll(HOOK_RE)) {
    if (name.includes('-context-')) continue;
    hooks.add(name);
  }
  return [...hooks].sort();
}
 
// Pure: extract sibling-component dir names from one stories file's source.
// `selfDir` filters out self-imports (a story importing its own component).
export function extractStoryDeps(content, selfDir) {
  const deps = new Set();
  for (const [, dep] of content.matchAll(STORY_IMPORT_RE)) {
    if (dep !== selfDir) deps.add(dep);
  }
  return [...deps].sort();
}
 
// List a directory's `.css` files as absolute paths, or [] when it's absent.
// Sorted so the read order (and thus any diagnostics) stays deterministic.
function listCssFiles(dir) {
  if (!exists(dir)) return [];
  return fs
    .readdirSync(dir)
    .filter((f) => f.endsWith('.css'))
    .sort()
    .map((f) => path.join(dir, f));
}
 
// Read a component's CSS bundle outputs and aggregate every `--slds-c-*` hook
// declared or referenced inside. Looks at the component's structure CSS
// (dist/components/<dir>/<dir>.css) plus every per-theme output under
// build/components/<dir>/themes/ — theme CSS is an internal artifact and no
// longer ships in dist. Themes are enumerated (not hardcoded) so a newly added
// theme's hooks are captured automatically rather than silently dropped from
// the compliance data.
function readDistHooks({ distComponents, buildComponents }, dir) {
  const files = [
    path.join(distComponents, dir, `${dir}.css`),
    ...listCssFiles(path.join(buildComponents, dir, 'themes')),
  ];
  const hooks = new Set();
  for (const file of files) {
    if (!exists(file)) continue;
    for (const hook of extractHooksFromCss(fs.readFileSync(file, 'utf8'))) {
      hooks.add(hook);
    }
  }
  return [...hooks].sort();
}
 
function themePresence({ srcSlds2 }, dir) {
  const themesDir = path.join(srcSlds2, dir, 'themes');
  return {
    base: exists(path.join(themesDir, 'base.css')),
    cosmos: exists(path.join(themesDir, 'cosmos.css')),
    lightningBlue: exists(path.join(themesDir, 'lightning-blue.css')),
  };
}
 
// Walk a component's __stories__/*.stories.js files for sibling-component
// imports. Stories often compose other components for demo purposes; that
// composition is the only place dependency intent is expressed in source today.
function readComponentDeps({ srcSlds2 }, dir) {
  const storiesDir = path.join(srcSlds2, dir, '__stories__');
  if (!exists(storiesDir)) return [];
  const deps = new Set();
  for (const file of fs.readdirSync(storiesDir)) {
    Iif (!file.endsWith('.stories.js')) continue;
    // UIF preset files (*.uif.stories.js) are flat data, not composition demos —
    // their imports aren't dependency intent. They're consumed by readComponentStories.
    if (file.endsWith('.uif.stories.js')) continue;
    const content = fs.readFileSync(path.join(storiesDir, file), 'utf8');
    for (const dep of extractStoryDeps(content, dir)) deps.add(dep);
  }
  return [...deps].sort();
}
 
// Parse a component's __stories__/*.uif.stories.js files for UIF-driven presets.
// Each named export's `args` becomes a preset; the UIF is consulted to route each
// arg key into the correct override bucket (variant/modifier/slot). Stories without
// args are dropped — they're not preset material.
//
// File-naming convention: UIF-driven CSF lives in `*.uif.stories.js`, separate from
// the existing Storybook `*.stories.js` files. The existing ones use the legacy
// `Template.bind()` + `.args = {}` pattern that the static parser can't extract
// anyway; keeping them apart avoids any ambiguity and prevents the catalog from
// accidentally surfacing presets authored for a different rendering pipeline.
// The UIF read here is the resolved build output (build/uif/slds/<dir>.uif.json),
// which is what extractUifStories routes against. See extractUifStories.js.
function readComponentStories({ srcSlds2, uifDir }, dir) {
  const storiesDir = path.join(srcSlds2, dir, '__stories__');
  if (!exists(storiesDir)) return [];
  const uifPath = path.join(uifDir, `${dir}.uif.json`);
  Iif (!exists(uifPath)) return [];
  let uif;
  try {
    uif = JSON.parse(fs.readFileSync(uifPath, 'utf8'));
  } catch {
    return [];
  }
  const presets = [];
  for (const file of fs.readdirSync(storiesDir)) {
    if (!file.endsWith('.uif.stories.js')) continue;
    const content = fs.readFileSync(path.join(storiesDir, file), 'utf8');
    for (const preset of extractPresetsFromStorySource(content, uif)) {
      presets.push(preset);
    }
  }
  return presets;
}
 
// Build the catalog ES module body from a paths object. Pure aside from the
// fs reads inside readDistHooks/themePresence/readComponentDeps — those each
// take their own paths arg so tests can point them at a fixture tree.
export function renderCatalog(paths) {
  const { srcSlds2, uifDir, themeData, legacyHooksFile, outDir } = paths;
  const relUif = path.relative(outDir, uifDir);
  const relThemeData = path.relative(outDir, themeData);
  const relLegacyHooks = path.relative(outDir, legacyHooksFile);
 
  const dirs = fs
    .readdirSync(srcSlds2, { withFileTypes: true })
    .filter((e) => e.isDirectory())
    .map((e) => e.name)
    .sort((a, b) => a.localeCompare(b));
 
  const uifImports = [];
  const themeDataImports = [];
  const uifMap = [];
  const themeDataMap = [];
  const themesMap = [];
  const distHooksMap = [];
  const componentDepsMap = [];
  const storiesMap = [];
 
  dirs.forEach((dir, idx) => {
    if (exists(path.join(uifDir, `${dir}.uif.json`))) {
      const ident = `uif_${idx}`;
      uifImports.push(`import ${ident} from '${relUif}/${dir}.uif.json' with { type: 'json' };`);
      uifMap.push(`  ${JSON.stringify(dir)}: ${ident},`);
    }
    if (exists(path.join(themeData, `${dir}.json`))) {
      const ident = `td_${idx}`;
      themeDataImports.push(`import ${ident} from '${relThemeData}/${dir}.json' with { type: 'json' };`);
      themeDataMap.push(`  ${JSON.stringify(dir)}: ${ident},`);
    }
    themesMap.push(`  ${JSON.stringify(dir)}: ${JSON.stringify(themePresence(paths, dir))},`);
    distHooksMap.push(`  ${JSON.stringify(dir)}: ${JSON.stringify(readDistHooks(paths, dir))},`);
    componentDepsMap.push(`  ${JSON.stringify(dir)}: ${JSON.stringify(readComponentDeps(paths, dir))},`);
    const presets = readComponentStories(paths, dir);
    if (presets.length > 0) {
      storiesMap.push(`  ${JSON.stringify(dir)}: ${JSON.stringify(presets)},`);
    }
  });
 
  const legacyHookImport = exists(legacyHooksFile)
    ? `import legacyHookData from '${relLegacyHooks}' with { type: 'json' };`
    : `const legacyHookData = {};`;
 
  const body = `// AUTOGENERATED by scripts/buildCatalog.js — do not edit by hand.
// Run \`yarn build:catalog\` (or \`yarn watch:catalog\`) to refresh.
 
${uifImports.join('\n')}
 
${themeDataImports.join('\n')}
 
${legacyHookImport}
 
export { legacyHookData };
 
export const dirs = ${JSON.stringify(dirs, null, 2)};
 
export const uif = {
${uifMap.join('\n')}
};
 
export const themeData = {
${themeDataMap.join('\n')}
};
 
export const themes = {
${themesMap.join('\n')}
};
 
export const distHooks = {
${distHooksMap.join('\n')}
};
 
export const componentDeps = {
${componentDepsMap.join('\n')}
};
 
// Curated CSF presets, keyed by component dir. Parsed from __stories__/*.uif.stories.js
// via extractUifStories.js. Dirs with no args-bearing presets are omitted; the
// sandbox's \`manifest.stories\` consumer guards with \`?? {}\`.
export const stories = {
${storiesMap.join('\n')}
};
`;
  return { body, dirs, uifCount: uifMap.length, themeDataCount: themeDataMap.length };
}
 
export function build(paths) {
  const { body, dirs, uifCount, themeDataCount } = renderCatalog(paths);
  fs.mkdirSync(paths.outDir, { recursive: true });
  fs.writeFileSync(paths.outFile, body);
  return { dirs: dirs.length, uifCount, themeDataCount };
}
 
export function defaultPaths(pkgRoot) {
  const outDir = path.join(pkgRoot, 'build/catalog');
  return {
    pkgRoot,
    srcSlds2: path.join(pkgRoot, 'src/slds2'),
    uifDir: path.join(pkgRoot, 'build/uif/slds'),
    distComponents: path.join(pkgRoot, 'dist/components'),
    buildComponents: path.join(pkgRoot, 'build/components'),
    themeData: path.join(pkgRoot, 'build/themeData'),
    legacyHooksFile: path.join(pkgRoot, 'build/legacy-hooks/slds2-legacy-hook-data.json'),
    outDir,
    outFile: path.join(outDir, 'index.js'),
  };
}
 
// Only run the build side-effect when invoked as the entry module — tests
// import this file for unit-testing the pure helpers and don't want a real
// file written.
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
Iif (isMain) {
  const paths = defaultPaths(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'));
  const { dirs, uifCount, themeDataCount } = build(paths);
  console.log(
    `[catalog] wrote ${path.relative(paths.pkgRoot, paths.outFile)} (${dirs} dirs, ${uifCount} uif, ${themeDataCount} themeData)`,
  );
 
  if (process.argv.includes('--watch')) {
    const { default: chokidar } = await import('chokidar');
    const watcher = chokidar.watch(
      [
        path.join(paths.themeData, '*.json'),
        path.join(paths.uifDir, '*.uif.json'),
        path.join(paths.distComponents, '**/*.css'),
        path.join(paths.buildComponents, '**/themes/*.css'),
        path.join(paths.srcSlds2, '*/themes'),
        path.join(paths.srcSlds2, '*/__stories__/*.stories.js'),
      ],
      { ignoreInitial: true },
    );
    let pending = null;
    watcher.on('all', () => {
      if (pending) clearTimeout(pending);
      pending = setTimeout(() => build(paths), 100);
    });
    console.log('[catalog] watching for changes...');
  }
}