@astryxdesign/cli 0.6.3-canary.d0d49a4 → 0.6.3-canary.dea6813

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/README.md +32 -18
  2. package/api/build/build.doc.mjs +3 -3
  3. package/api/build/build.test.mjs +14 -3
  4. package/api/build/build.type.d.mts +35 -1
  5. package/api/build/build.type.mjs +24 -3
  6. package/api/build/help/help.d.mts +8 -5
  7. package/api/build/help/help.mjs +56 -6
  8. package/api/component/component.doc.mjs +11 -2
  9. package/api/component/component.mjs +29 -0
  10. package/api/component/component.test.mjs +38 -0
  11. package/api/component/component.type.d.mts +11 -1
  12. package/api/component/component.type.mjs +8 -1
  13. package/api/discover/discover.doc.mjs +1 -1
  14. package/api/gap-report/gap-report.doc.mjs +7 -4
  15. package/api/init/init.doc.mjs +4 -0
  16. package/api/init/init.test.mjs +41 -1
  17. package/api/init/remove/remove.mjs +1 -1
  18. package/api/init/run/run.mjs +20 -10
  19. package/api/integration/add-contribution.component-names.test.mjs +120 -0
  20. package/api/integration/add-contribution.mjs +11 -0
  21. package/api/integration/add-contribution.test.mjs +80 -0
  22. package/api/integration/integrationAdd.doc.mjs +7 -4
  23. package/api/integration/integrationAddComponent.doc.mjs +1 -1
  24. package/api/integration/validate-integration.mjs +2 -0
  25. package/api/integration/validate-integration.test.mjs +91 -0
  26. package/api/integration/validateIntegration.doc.mjs +1 -1
  27. package/api/json/envelope-types.test.mjs +76 -0
  28. package/api/json/parseResponse.doc.mjs +2 -2
  29. package/api/layout/expand/expand.mjs +7 -5
  30. package/api/layout/expand/expand.path-safety.test.mjs +53 -0
  31. package/api/layout/grammar/grammar.mjs +2 -1
  32. package/api/layout/layoutExpand.doc.mjs +1 -1
  33. package/api/search/search-return-type.test.mjs +54 -0
  34. package/api/search/search.d.mts +2 -9
  35. package/api/search/search.doc.mjs +5 -1
  36. package/api/search/search.mjs +10 -3
  37. package/api/search/search.test.mjs +34 -0
  38. package/api/swizzle/copy/copy.mjs +28 -11
  39. package/api/swizzle/swizzle.doc.mjs +1 -1
  40. package/api/template/copy/copy.mjs +17 -23
  41. package/api/template/copy/copy.test.mjs +17 -0
  42. package/api/template/template.doc.mjs +2 -1
  43. package/api/theme/add/add.binary.test.mjs +91 -0
  44. package/api/theme/add/add.mjs +20 -3
  45. package/api/theme/add/add.staging.test.mjs +66 -0
  46. package/api/theme/build/build.mjs +114 -37
  47. package/api/theme/build/build.public-component-vars.test.mjs +1 -1
  48. package/api/theme/build/build.receipt-doc.test.mjs +111 -0
  49. package/api/theme/build/font-warning.mjs +3 -3
  50. package/api/theme/build/font-warning.test.mjs +5 -2
  51. package/api/theme/palette/generate/generate.mjs +7 -2
  52. package/api/theme/palette/generate/generate.test.mjs +96 -0
  53. package/api/theme/palette/generate/generator.mjs +8 -1
  54. package/api/theme/palette/generate/generator.test.mjs +10 -0
  55. package/api/theme/template/template.mjs +11 -2
  56. package/api/theme/template/template.test.mjs +15 -0
  57. package/api/theme/themeBuild.doc.mjs +7 -4
  58. package/api/theme/themeTemplate.doc.mjs +4 -1
  59. package/api/upgrade/_adapter.mjs +27 -4
  60. package/api/upgrade/list/list.mjs +2 -1
  61. package/api/upgrade/list/list.test.mjs +73 -0
  62. package/api/upgrade/status/status.mjs +2 -2
  63. package/api/upgrade/upgrade.doc.mjs +2 -1
  64. package/api/upgrade/upgrade.type.d.mts +4 -0
  65. package/api/upgrade/upgrade.type.mjs +1 -0
  66. package/assets/codemods/term-log.mjs +32 -8
  67. package/assets/codemods/term-log.test.mjs +19 -1
  68. package/assets/codemods/transforms/v0.6.0/__tests__/next-codemods.test.mjs +47 -0
  69. package/assets/codemods/transforms/v0.6.0/rename-resizable-pixel-bounds.mjs +57 -10
  70. package/assets/templates/pages/table-tree/page.tsx +1704 -0
  71. package/assets/templates/pages/table-tree/template.doc.mjs +12 -0
  72. package/authoring/config/config.doc.mjs +1 -1
  73. package/authoring/config/debug-composition.test.mjs +92 -0
  74. package/authoring/config/type.ts +15 -3
  75. package/clients/cli/command-load-failure.test.mjs +83 -0
  76. package/clients/cli/commands/blog.mjs +23 -8
  77. package/clients/cli/commands/blog.test.mjs +42 -1
  78. package/clients/cli/commands/build-theme.ascii-output.test.mjs +161 -0
  79. package/clients/cli/commands/build-theme.flag-docs.test.mjs +110 -0
  80. package/clients/cli/commands/build-theme.mjs +5 -5
  81. package/clients/cli/commands/build-theme.path-safety.test.mjs +86 -1
  82. package/clients/cli/commands/build-theme.variants.test.mjs +76 -0
  83. package/clients/cli/commands/build.doc.mjs +4 -1
  84. package/clients/cli/commands/build.exit-codes-doc.test.mjs +50 -0
  85. package/clients/cli/commands/build.mjs +32 -47
  86. package/clients/cli/commands/build.playbook.test.mjs +75 -0
  87. package/clients/cli/commands/build.text-fields.test.mjs +40 -0
  88. package/clients/cli/commands/component/index.mjs +2 -7
  89. package/clients/cli/commands/component-package.test.mjs +18 -0
  90. package/clients/cli/commands/component-resolution.test.mjs +21 -0
  91. package/clients/cli/commands/component.test.mjs +19 -0
  92. package/clients/cli/commands/detail-levels.test.mjs +2 -2
  93. package/clients/cli/commands/discover.broken-integration.test.mjs +48 -0
  94. package/clients/cli/commands/discover.components-flag.test.mjs +97 -0
  95. package/clients/cli/commands/discover.doc.mjs +4 -2
  96. package/clients/cli/commands/discover.mjs +3 -3
  97. package/clients/cli/commands/discover.text-projection.test.mjs +103 -0
  98. package/clients/cli/commands/doctor-integration.package-json.test.mjs +53 -0
  99. package/clients/cli/commands/doctor.mjs +6 -16
  100. package/clients/cli/commands/doctor.test.mjs +42 -0
  101. package/clients/cli/commands/gap-report.doc.mjs +16 -5
  102. package/clients/cli/commands/gap-report.test.mjs +72 -0
  103. package/clients/cli/commands/hook/index.mjs +7 -17
  104. package/clients/cli/commands/hook.text-projection.test.mjs +45 -0
  105. package/clients/cli/commands/init.doc.mjs +19 -8
  106. package/clients/cli/commands/init.flag-help.test.mjs +153 -0
  107. package/clients/cli/commands/integration-add.controls.test.mjs +132 -0
  108. package/clients/cli/commands/integration-add.doc.mjs +21 -5
  109. package/clients/cli/commands/interactive-guard.test.mjs +101 -24
  110. package/clients/cli/commands/json-contract.test.mjs +33 -0
  111. package/clients/cli/commands/layout-check.doc.mjs +14 -3
  112. package/clients/cli/commands/layout-expand.doc.mjs +21 -4
  113. package/clients/cli/commands/layout.doc.mjs +2 -2
  114. package/clients/cli/commands/layout.mjs +21 -9
  115. package/clients/cli/commands/layout.path-help.test.mjs +33 -0
  116. package/clients/cli/commands/layout.stdin-cap.test.mjs +47 -0
  117. package/clients/cli/commands/layout.text-fields.test.mjs +39 -0
  118. package/clients/cli/commands/no-prompt-wording.test.mjs +94 -0
  119. package/clients/cli/commands/search.doc.mjs +6 -3
  120. package/clients/cli/commands/search.mjs +17 -7
  121. package/clients/cli/commands/search.test.mjs +75 -0
  122. package/clients/cli/commands/swizzle.doc.mjs +2 -1
  123. package/clients/cli/commands/swizzle.path-safety.test.mjs +42 -0
  124. package/clients/cli/commands/template.doc.mjs +28 -6
  125. package/clients/cli/commands/template.flag-help.test.mjs +117 -0
  126. package/clients/cli/commands/template.path-help.test.mjs +40 -0
  127. package/clients/cli/commands/theme-add.doc.mjs +2 -1
  128. package/clients/cli/commands/theme-build.doc.mjs +7 -6
  129. package/clients/cli/commands/theme-palette-generate.test.mjs +19 -0
  130. package/clients/cli/commands/theme-template.behavior.test.mjs +12 -0
  131. package/clients/cli/commands/theme-template.doc.mjs +1 -1
  132. package/clients/cli/commands/upgrade.ascii-output.test.mjs +87 -0
  133. package/clients/cli/commands/upgrade.doc.mjs +17 -6
  134. package/clients/cli/commands/upgrade.flag-help.test.mjs +188 -0
  135. package/clients/cli/commands/upgrade.hook-output.test.mjs +88 -0
  136. package/clients/cli/index.mjs +13 -30
  137. package/clients/cli/latest-version-env.test.mjs +50 -0
  138. package/clients/cli/lib/cli-error.test.mjs +7 -0
  139. package/clients/cli/lib/component-format.mjs +9 -9
  140. package/clients/cli/lib/component-format.test.mjs +1 -1
  141. package/clients/cli/lib/define-command.mjs +32 -6
  142. package/clients/cli/lib/doc-text-ascii.test.mjs +82 -0
  143. package/clients/cli/lib/exit-codes.test.mjs +97 -0
  144. package/clients/cli/lib/hook-format.mjs +5 -5
  145. package/clients/cli/lib/json-shim.mjs +38 -2
  146. package/clients/cli/lib/json-shim.test.mjs +83 -0
  147. package/clients/cli/lib/manifest.d.ts +2 -0
  148. package/clients/cli/lib/manifest.mjs +22 -0
  149. package/clients/cli/lib/manifest.test.mjs +17 -0
  150. package/foundation/agent-docs/agent-docs.d.mts +4 -0
  151. package/foundation/agent-docs/agent-docs.mjs +69 -3
  152. package/foundation/agent-docs/agent-docs.path-safety.test.mjs +266 -4
  153. package/foundation/config/integration-debug.test.mjs +28 -3
  154. package/foundation/config/project.mjs +123 -9
  155. package/foundation/config/project.test.mjs +126 -0
  156. package/foundation/discovery/template-adapter.d.mts +14 -5
  157. package/foundation/discovery/template-adapter.fixture-refs.test.mjs +248 -0
  158. package/foundation/discovery/template-adapter.integration-isolation.test.mjs +94 -0
  159. package/foundation/discovery/template-adapter.mjs +292 -25
  160. package/foundation/discovery/theming-targets.test.mjs +4 -0
  161. package/foundation/fs/module-loader.d.mts +1 -0
  162. package/foundation/fs/module-loader.mjs +50 -1
  163. package/foundation/fs/module-loader.stdout.test.mjs +332 -0
  164. package/foundation/fs/path-safety.d.mts +3 -2
  165. package/foundation/fs/path-safety.mjs +49 -19
  166. package/foundation/fs/path-safety.test.mjs +50 -0
  167. package/foundation/fs/publish-file-hardlink-unavailable.test.mjs +129 -94
  168. package/foundation/integrations/autolink.mjs +6 -1
  169. package/foundation/response/base.d.ts +8 -4
  170. package/foundation/response/error-codes.doc.mjs +1 -2
  171. package/foundation/response/error-codes.mjs +1 -1
  172. package/foundation/response/error-codes.test.mjs +83 -0
  173. package/foundation/response/json-contract.test.mjs +11 -0
  174. package/foundation/response/json.d.mts +4 -2
  175. package/foundation/response/json.mjs +8 -10
  176. package/foundation/response/response-types.doc.mjs +3 -3
  177. package/foundation/xle/expand.d.mts +2 -0
  178. package/foundation/xle/expand.mjs +3 -2
  179. package/foundation/xle/expand.test.mjs +54 -0
  180. package/package.json +9 -9
  181. package/clients/cli/lib/update-check.mjs +0 -83
  182. package/clients/cli/lib/update-check.test.mjs +0 -137
  183. package/clients/cli/update-hint-commands.test.mjs +0 -54
@@ -224,10 +224,10 @@ function staleBuildOutputs(writes, cwd) {
224
224
  /** @param {Array<{dest: string, content: string}>} writes */
225
225
  function writeBuildOutputs(writes) {
226
226
  if (writes.length === 0) return;
227
- fs.mkdirSync(path.dirname(writes[0].dest), {recursive: true});
228
227
  /** @type {Array<{tmp: string, dest: string}>} */
229
228
  const staged = [];
230
229
  try {
230
+ fs.mkdirSync(path.dirname(writes[0].dest), {recursive: true});
231
231
  for (const write of writes) {
232
232
  const tmp = `${write.dest}.${process.pid}.tmp`;
233
233
  fs.writeFileSync(tmp, write.content);
@@ -1871,7 +1871,7 @@ function validatePrivateVars(themeDef) {
1871
1871
  if (prop.startsWith('--_')) {
1872
1872
  errors.push(
1873
1873
  `Component "${component}" (${[key, ...path].join(' ')}) sets private var "${prop}". ` +
1874
- `Private vars (--_*) are internal — use standard CSS properties ` +
1874
+ `Private vars (--_*) are internal; use standard CSS properties ` +
1875
1875
  `(e.g. borderRadius, padding) instead. The pipeline expands them automatically.`,
1876
1876
  );
1877
1877
  }
@@ -1931,20 +1931,7 @@ function getThemeComponentLayers(themeDef) {
1931
1931
  */
1932
1932
  function validateCustomHeadingTypes(themeDef) {
1933
1933
  const layers = getThemeComponentLayers(themeDef);
1934
- const customTypes = new Set();
1935
- for (const {components} of layers) {
1936
- const headingRules = components.heading;
1937
- if (!headingRules || typeof headingRules !== 'object') continue;
1938
-
1939
- for (const key of Object.keys(headingRules)) {
1940
- for (const pair of key.split('+')) {
1941
- const colon = pair.indexOf(':');
1942
- if (colon === -1 || pair.slice(0, colon) !== 'type') continue;
1943
- const value = pair.slice(colon + 1);
1944
- if (value && !BUILTIN_HEADING_TYPES.has(value)) customTypes.add(value);
1945
- }
1946
- }
1947
- }
1934
+ const customTypes = customHeadingTypes(themeDef);
1948
1935
 
1949
1936
  const errors = [];
1950
1937
  for (const type of customTypes) {
@@ -1971,10 +1958,65 @@ function validateCustomHeadingTypes(themeDef) {
1971
1958
  return errors;
1972
1959
  }
1973
1960
 
1961
+ /**
1962
+ * Custom Heading type names declared on the root or media-surface component
1963
+ * maps: the names the generated `HeadingTypeMap` augmentation enrolls.
1964
+ *
1965
+ * @param {{components?: Record<string, Record<string, unknown>>}} themeDef
1966
+ * @returns {Set<string>}
1967
+ */
1968
+ function customHeadingTypes(themeDef) {
1969
+ /** @type {Set<string>} */
1970
+ const customTypes = new Set();
1971
+ for (const {components} of getThemeComponentLayers(themeDef)) {
1972
+ const headingRules = components.heading;
1973
+ if (!headingRules || typeof headingRules !== 'object') continue;
1974
+
1975
+ for (const key of Object.keys(headingRules)) {
1976
+ for (const pair of key.split('+')) {
1977
+ const colon = pair.indexOf(':');
1978
+ if (colon === -1 || pair.slice(0, colon) !== 'type') continue;
1979
+ const value = pair.slice(colon + 1);
1980
+ if (value && !BUILTIN_HEADING_TYPES.has(value)) customTypes.add(value);
1981
+ }
1982
+ }
1983
+ }
1984
+ return customTypes;
1985
+ }
1986
+
1987
+ /**
1988
+ * Custom Heading types with no standalone rule in the generated CSS. A type
1989
+ * whose every declaration the compiler dropped still passes the source check,
1990
+ * and its augmentation would then name a type that has no styles.
1991
+ *
1992
+ * @param {Set<string>} types
1993
+ * @param {string} css - Generated root component and media-surface rules.
1994
+ * @returns {string[]}
1995
+ */
1996
+ function headingTypesWithoutEmittedRule(types, css) {
1997
+ /** @type {string[]} */
1998
+ const missing = [];
1999
+ for (const type of types) {
2000
+ const selector = `.astryx-heading[data-type="${type}"]`;
2001
+ let emitted = false;
2002
+ for (
2003
+ let at = css.indexOf(selector);
2004
+ at !== -1 && !emitted;
2005
+ at = css.indexOf(selector, at + 1)
2006
+ ) {
2007
+ // A following attribute is a combined selector, not the standalone rule.
2008
+ emitted = css[at + selector.length] !== '[';
2009
+ }
2010
+ if (!emitted) missing.push(type);
2011
+ }
2012
+ return missing;
2013
+ }
2014
+
1974
2015
  /**
1975
2016
  * Return true when a style object contains a declaration that the theme
1976
- * generator can emit. Empty pseudo blocks do not count: they produce no CSS
1977
- * and would leave the generated type augmentation without a usable rule.
2017
+ * generator can emit. Empty pseudo blocks and blank values do not count: they
2018
+ * produce no usable CSS and would leave the generated type augmentation
2019
+ * without a usable rule.
1978
2020
  *
1979
2021
  * @param {unknown} styles
1980
2022
  * @returns {boolean}
@@ -1988,7 +2030,12 @@ function hasUsableStyleDeclaration(styles) {
1988
2030
  if (hasUsableStyleDeclaration(value)) return true;
1989
2031
  continue;
1990
2032
  }
1991
- if (value !== undefined && value !== null && typeof value !== 'object') {
2033
+ if (
2034
+ value !== undefined &&
2035
+ value !== null &&
2036
+ typeof value !== 'object' &&
2037
+ String(value).trim() !== ''
2038
+ ) {
1992
2039
  return true;
1993
2040
  }
1994
2041
  }
@@ -2163,14 +2210,14 @@ async function themeBuildInternal(
2163
2210
  const noticeMessages = [];
2164
2211
  for (const w of warnings) {
2165
2212
  warningMessages.push(w);
2166
- logger.warn(` ⚠ ${w}`);
2213
+ logger.warn(` [warn] ${w}`);
2167
2214
  }
2168
2215
 
2169
2216
  // Validate no private vars are set directly
2170
2217
  const privateVarErrors = validatePrivateVars(themeDef);
2171
2218
  for (const e of privateVarErrors) {
2172
2219
  warningMessages.push(e);
2173
- logger.error(` ✗ ${e}`);
2220
+ logger.error(` [error] ${e}`);
2174
2221
  }
2175
2222
  if (privateVarErrors.length > 0) {
2176
2223
  logger.error(
@@ -2196,7 +2243,7 @@ async function themeBuildInternal(
2196
2243
  // capability exports are checked against what the theme actually asks for.
2197
2244
  if (!_defineTheme || !_generateThemeRulesSplit) {
2198
2245
  throw new AstryxError(
2199
- 'Could not load @astryxdesign/core/theme — `astryx theme build` requires a ' +
2246
+ 'Could not load @astryxdesign/core/theme: `astryx theme build` requires a ' +
2200
2247
  'built, resolvable @astryxdesign/core so it emits the same CSS as the ' +
2201
2248
  'runtime <Theme>. Build @astryxdesign/core first (e.g. `pnpm -F @astryxdesign/core ' +
2202
2249
  'build`)' +
@@ -2385,10 +2432,29 @@ async function themeBuildInternal(
2385
2432
  for (const message of droppedDeclarations) {
2386
2433
  const w = `Declaration ${message}. The generated CSS omits it; fix the value in the theme source.`;
2387
2434
  warningMessages.push(w);
2388
- logger.warn(` ⚠ ${w}`);
2435
+ logger.warn(` [warn] ${w}`);
2436
+ }
2437
+ const unemittedHeadingTypes = headingTypesWithoutEmittedRule(
2438
+ customHeadingTypes(themeDef),
2439
+ `${component.join('\n')}\n${onMediaCss}`,
2440
+ );
2441
+ if (unemittedHeadingTypes.length > 0) {
2442
+ throw new AstryxError(
2443
+ unemittedHeadingTypes
2444
+ .map(
2445
+ type =>
2446
+ `Custom Heading type "${type}" has no declaration the theme ` +
2447
+ `compiler could emit: every declaration in its standalone ` +
2448
+ `"type:${type}" rule was dropped. Give that rule at least one ` +
2449
+ 'valid declaration.',
2450
+ )
2451
+ .join('\n'),
2452
+ undefined,
2453
+ ERROR_CODES.ERR_THEME_INVALID,
2454
+ );
2389
2455
  }
2390
2456
  if (cssParts.length === 0) {
2391
- logger.log('No overrides found — nothing to build.');
2457
+ logger.log('No overrides found; nothing to build.');
2392
2458
  return null;
2393
2459
  }
2394
2460
  // The data-token defaults are theme-independent and go in @layer
@@ -2439,7 +2505,18 @@ async function themeBuildInternal(
2439
2505
  // Guard: relative paths must not escape cwd via `../`. Absolute paths are
2440
2506
  // trusted (the user explicitly controls where output goes, like gcc -o).
2441
2507
  if (!path.isAbsolute(options.out)) {
2442
- assertWithin(options.out, cwd, {label: 'output path'});
2508
+ try {
2509
+ assertWithin(options.out, cwd, {label: 'output path'});
2510
+ } catch (err) {
2511
+ if (err instanceof PathSafetyError) {
2512
+ throw new AstryxError(
2513
+ err.message,
2514
+ undefined,
2515
+ ERROR_CODES.ERR_PATH_TRAVERSAL,
2516
+ );
2517
+ }
2518
+ throw err;
2519
+ }
2443
2520
  }
2444
2521
  } else {
2445
2522
  outPath = path.join(path.dirname(filePath), `${baseName}.css`);
@@ -2548,10 +2625,10 @@ async function themeBuildInternal(
2548
2625
  const stale = staleBuildOutputs(writes, cwd);
2549
2626
  const upToDate = stale.length === 0;
2550
2627
  if (upToDate) {
2551
- logger.log(`\n✓ Theme outputs are up to date with ${sourceRelative}.`);
2628
+ logger.log(`\n[ok] Theme outputs are up to date with ${sourceRelative}.`);
2552
2629
  } else {
2553
2630
  logger.error(
2554
- `\n✗ ${stale.length} theme output(s) are out of date with ${sourceRelative}:`,
2631
+ `\n[fail] ${stale.length} theme output(s) are out of date with ${sourceRelative}:`,
2555
2632
  );
2556
2633
  for (const s of stale) {
2557
2634
  logger.error(
@@ -2573,17 +2650,17 @@ async function themeBuildInternal(
2573
2650
 
2574
2651
  writeBuildOutputs(writes);
2575
2652
 
2576
- logger.log(`\n✓ ${path.relative(cwd, outPath)}`);
2653
+ logger.log(`\n[ok] ${path.relative(cwd, outPath)}`);
2577
2654
  logger.log(
2578
2655
  ` ${tokenCount} token overrides, ${componentCount} component overrides`,
2579
2656
  );
2580
2657
  logger.log(` ${size} KB`);
2581
- logger.log(`✓ ${path.relative(cwd, jsPath)}`);
2582
- logger.log(`✓ ${path.relative(cwd, dtsPath)}`);
2658
+ logger.log(`[ok] ${path.relative(cwd, jsPath)}`);
2659
+ logger.log(`[ok] ${path.relative(cwd, dtsPath)}`);
2583
2660
  if (variantDtsPath && variantDecl) {
2584
2661
  const augCount = (variantDecl.match(/': true;/g) || []).length;
2585
2662
  logger.log(
2586
- `✓ ${path.relative(cwd, variantDtsPath)} (${augCount} type augmentations)`,
2663
+ `[ok] ${path.relative(cwd, variantDtsPath)} (${augCount} type augmentations)`,
2587
2664
  );
2588
2665
  }
2589
2666
 
@@ -2593,7 +2670,7 @@ async function themeBuildInternal(
2593
2670
  const cssImport = importSpecifier(relOutDir, cssBase) + '.css';
2594
2671
  const exportName = `${toIdentifier(baseName)}Theme`;
2595
2672
  logger.log(`
2596
- Install in your app (paths are relative to a file in src/ — adjust if yours lives elsewhere):
2673
+ Install in your app (paths are relative to a file in src/; adjust if yours lives elsewhere):
2597
2674
 
2598
2675
  import { ${exportName} } from '${jsImport}';
2599
2676
  import '${cssImport}';
@@ -2625,7 +2702,7 @@ Or with a <link> tag:
2625
2702
  // Adaptation rules are resolved theme writes in their own right, so a family
2626
2703
  // named only inside one needs the same notice as one named at the root.
2627
2704
  for (const family of unloadedFonts) {
2628
- const msg = `Font "${family}" is named by this theme but not loaded — add a <link> or @font-face in your app (recipe: astryx docs typography)`;
2705
+ const msg = `Font "${family}" is named by this theme but not loaded; add a <link> or @font-face in your app (recipe: astryx docs typography)`;
2629
2706
  noticeMessages.push(msg);
2630
2707
  logger.log(` note: ${msg}`);
2631
2708
  }
@@ -2919,11 +2996,11 @@ export async function themeBuildFamily(
2919
2996
  const upToDate = stale.length === 0;
2920
2997
  if (upToDate) {
2921
2998
  logger.log(
2922
- `\n✓ Theme family outputs are up to date with ${sourceRelative}.`,
2999
+ `\n[ok] Theme family outputs are up to date with ${sourceRelative}.`,
2923
3000
  );
2924
3001
  } else {
2925
3002
  logger.error(
2926
- `\n✗ ${stale.length} theme family output(s) are out of date:`,
3003
+ `\n[fail] ${stale.length} theme family output(s) are out of date:`,
2927
3004
  );
2928
3005
  for (const entry of stale) {
2929
3006
  logger.error(
@@ -2943,7 +3020,7 @@ export async function themeBuildFamily(
2943
3020
  };
2944
3021
  }
2945
3022
  writeBuildOutputs(writes);
2946
- for (const output of Object.values(outputs)) logger.log(`✓ ${output}`);
3023
+ for (const output of Object.values(outputs)) logger.log(`[ok] ${output}`);
2947
3024
  const themeBindings = members.map(member => bindings.get(member.theme.name));
2948
3025
  const relOutDir = path.relative(cwd, outDir) || '.';
2949
3026
  const jsImport = importSpecifier(relOutDir, baseName);
@@ -2961,7 +3038,7 @@ Or load the same stylesheet natively:
2961
3038
  ];
2962
3039
  const notices = unloadedFonts.map(
2963
3040
  family =>
2964
- `Font "${family}" is named by this theme family but not loaded — add a <link> or @font-face in your app (recipe: astryx docs typography)`,
3041
+ `Font "${family}" is named by this theme family but not loaded; add a <link> or @font-face in your app (recipe: astryx docs typography)`,
2965
3042
  );
2966
3043
  for (const notice of notices) logger.log(` note: ${notice}`);
2967
3044
  return {
@@ -19,7 +19,7 @@
19
19
  * next one is covered without touching this file.
20
20
  *
21
21
  * Asserted on the receipt's `warnings` rather than on a rejection: a private
22
- * var is reported (logged `✗`, collected into the receipt) and the build then
22
+ * var is reported (logged `[error]`, collected into the receipt) and the build then
23
23
  * emits its CSS and resolves anyway. Asserting a throw would pass for the
24
24
  * wrong reason — it never throws, which is why a throwaway build read as a
25
25
  * pass on the first version of #5214.
@@ -0,0 +1,111 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * Every field `themeBuild()` returns is named in its FunctionDoc and in the
5
+ * response-types doc.
6
+ *
7
+ * The receipt is a stable machine-readable response, so each field needs
8
+ * consumer documentation, not only a typedef. The fields are read off a real
9
+ * build, so a field added to the receipt without documentation fails here.
10
+ */
11
+
12
+ import {describe, it, expect, beforeAll, afterEach} from 'vitest';
13
+ import * as fs from 'node:fs';
14
+ import * as os from 'node:os';
15
+ import * as path from 'node:path';
16
+ import {themeBuild} from './build.mjs';
17
+ import {doc} from '../themeBuild.doc.mjs';
18
+ import {doc as responseTypes} from '../../../foundation/response/response-types.doc.mjs';
19
+ import {ensureCoreBuilt} from '../../../clients/cli/commands/ensure-core-built.mjs';
20
+
21
+ /** @type {string[]} */
22
+ const dirs = [];
23
+
24
+ beforeAll(() => {
25
+ ensureCoreBuilt();
26
+ }, 200_000);
27
+
28
+ afterEach(() => {
29
+ for (const dir of dirs.splice(0)) {
30
+ fs.rmSync(dir, {recursive: true, force: true});
31
+ }
32
+ });
33
+
34
+ /** @returns {string} a temp dir holding a theme that exercises every field */
35
+ function themeDir() {
36
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-receipt-doc-'));
37
+ dirs.push(dir);
38
+ fs.writeFileSync(
39
+ path.join(dir, 'ocean.mjs'),
40
+ `export default {
41
+ name: 'ocean',
42
+ tokens: {'--color-background-body': '#ffffff', '--font-family-body': '"Inter", sans-serif'},
43
+ components: {progressbar: {base: {color: 'red'}}},
44
+ };
45
+ `,
46
+ );
47
+ return dir;
48
+ }
49
+
50
+ /** @param {string} type */
51
+ function returnsDescription(type) {
52
+ const entry = doc.returns?.find(r => r.type === type);
53
+ expect(entry, `themeBuild doc has no ${type} return`).toBeDefined();
54
+ return /** @type {{description: string}} */ (entry).description;
55
+ }
56
+
57
+ /** @param {string} description @param {string[]} fields */
58
+ function undocumented(description, fields) {
59
+ return fields.filter(
60
+ field => !new RegExp(String.raw`\b${field}\b`).test(description),
61
+ );
62
+ }
63
+
64
+ describe('themeBuild() consumer docs name every receipt field', () => {
65
+ it('theme.build', async () => {
66
+ const dir = themeDir();
67
+ const result = await themeBuild('ocean.mjs', {}, {cwd: dir});
68
+
69
+ expect(result?.type).toBe('theme.build');
70
+ const data = /** @type {Record<string, any>} */ (result?.data);
71
+ // The theme is chosen so the optional arrays are non-empty.
72
+ expect(data.warnings.length).toBeGreaterThan(0);
73
+ expect(data.notices.length).toBeGreaterThan(0);
74
+
75
+ const fields = [
76
+ ...Object.keys(data),
77
+ ...Object.keys(data.outputs),
78
+ 'variantsDts',
79
+ ];
80
+ expect(undocumented(returnsDescription('theme.build'), fields)).toEqual([]);
81
+ });
82
+
83
+ it('theme.build in the response-types doc', async () => {
84
+ const dir = themeDir();
85
+ const result = await themeBuild('ocean.mjs', {}, {cwd: dir});
86
+ const data = /** @type {Record<string, any>} */ (result?.data);
87
+ const member = responseTypes.members.find(m => m.value === 'theme.build');
88
+ expect(member, 'response-types has no theme.build member').toBeDefined();
89
+
90
+ const fields = [
91
+ ...Object.keys(data),
92
+ ...Object.keys(data.outputs),
93
+ 'variantsDts',
94
+ ];
95
+ expect(
96
+ undocumented(/** @type {{description: string}} */ (member).description, fields),
97
+ ).toEqual([]);
98
+ });
99
+
100
+ it('theme.build.check', async () => {
101
+ const dir = themeDir();
102
+ const result = await themeBuild('ocean.mjs', {check: true}, {cwd: dir});
103
+
104
+ expect(result?.type).toBe('theme.build.check');
105
+ const data = /** @type {Record<string, any>} */ (result?.data);
106
+ const fields = [...Object.keys(data), ...Object.keys(data.stale[0])];
107
+ expect(
108
+ undocumented(returnsDescription('theme.build.check'), fields),
109
+ ).toEqual([]);
110
+ });
111
+ });
@@ -211,11 +211,11 @@ export function formatFontLoadingHelp(themeName, families) {
211
211
  const first = families[0];
212
212
  const slug = first.toLowerCase().replace(/ /g, '-');
213
213
  return `
214
- ⚠ Theme "${themeName}" names fonts it does not load: ${named}
215
- The built CSS only sets font-family — load these in your app, or every
214
+ [note] Theme "${themeName}" names fonts it does not load: ${named}
215
+ The built CSS only sets font-family; load these in your app, or every
216
216
  browser quietly falls back.
217
217
 
218
- Google Fonts (add :wght@… axes as your theme's weights require):
218
+ Google Fonts (add :wght@... axes as your theme's weights require):
219
219
 
220
220
  <link rel="preconnect" href="https://fonts.googleapis.com" />
221
221
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@@ -227,12 +227,15 @@ describe('formatFontLoadingHelp', () => {
227
227
  const help = formatFontLoadingHelp('ocean', ['Fraunces', 'JetBrains Mono']);
228
228
 
229
229
  it('names the theme and every family in the headline', () => {
230
- expect(help).toContain('⚠');
231
- expect(help).toContain('ocean');
230
+ expect(help).toContain('[note] Theme "ocean"');
232
231
  expect(help).toContain('"Fraunces"');
233
232
  expect(help).toContain('"JetBrains Mono"');
234
233
  });
235
234
 
235
+ it('is plain ASCII', () => {
236
+ expect(help).not.toMatch(/[\u0080-\uffff]/);
237
+ });
238
+
236
239
  it('includes the Google Fonts recipe: preconnect pair + the exact css2 URL', () => {
237
240
  expect(help).toContain(
238
241
  '<link rel="preconnect" href="https://fonts.googleapis.com"',
@@ -86,7 +86,8 @@ function serializePalette(palette, indentation = 0) {
86
86
 
87
87
  /** @param {TonalPaletteCandidate} candidate */
88
88
  export function serializePaletteCandidate(candidate) {
89
- return `{\n "schemaVersion": 1,\n "status": "candidate",\n "recipe": ${JSON.stringify(candidate.recipe)},\n "black": ${JSON.stringify(candidate.black)},\n "white": ${JSON.stringify(candidate.white)},\n "stops": ${JSON.stringify(candidate.stops)},\n "palette": ${serializePalette(candidate.palette, 2)}\n}\n`;
89
+ const stops = candidate.stops.map(stop => ` ${JSON.stringify(stop)}`);
90
+ return `{\n "schemaVersion": 1,\n "status": "candidate",\n "recipe": ${JSON.stringify(candidate.recipe)},\n "black": ${JSON.stringify(candidate.black)},\n "white": ${JSON.stringify(candidate.white)},\n "stops": [\n${stops.join(',\n')}\n ],\n "palette": ${serializePalette(candidate.palette, 2)}\n}\n`;
90
91
  }
91
92
 
92
93
  /** @param {TonalPaletteCandidate} candidate @param {string} outputPath */
@@ -168,7 +169,11 @@ function existingFileIdentity(filePath) {
168
169
  ) {
169
170
  return null;
170
171
  }
171
- throw error;
172
+ throw new AstryxError(
173
+ `Could not write palette candidate: ${error instanceof Error ? error.message : String(error)}`,
174
+ undefined,
175
+ ERROR_CODES.ERR_WRITE_FAILED,
176
+ );
172
177
  }
173
178
  }
174
179
 
@@ -1,9 +1,11 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
+ import {createHash} from 'node:crypto';
3
4
  import * as fs from 'node:fs';
4
5
  import * as os from 'node:os';
5
6
  import * as path from 'node:path';
6
7
  import {afterEach, describe, expect, it} from 'vitest';
8
+ import {isErrorCode} from '../../../../foundation/response/error-codes.mjs';
7
9
  import {themePaletteGenerate} from './generate.mjs';
8
10
 
9
11
  const temporaryDirectories = [];
@@ -101,6 +103,81 @@ describe('themePaletteGenerate', () => {
101
103
  expect(result.data.receipt).toBe('ocean.palette.receipt.json');
102
104
  });
103
105
 
106
+ it('writes candidate JSON byte-identical to the recipe fixtures', () => {
107
+ const families = [
108
+ {id: 'neutral', name: 'Neutral', seed: '#777777', kind: 'neutral'},
109
+ {id: 'blue', name: 'Blue', seed: '#0074e2'},
110
+ {id: 'orange', name: 'Orange', seed: '#d57113'},
111
+ ];
112
+ const fixtures = [
113
+ [
114
+ {families},
115
+ 3755,
116
+ '11c40191d508274d89d631bb4e1cb662f70ff0dce6c0dec101c317f9f69d3e25',
117
+ ],
118
+ [
119
+ {
120
+ modeStrategy: 'light-only',
121
+ stops: [20, 50, 80],
122
+ families: [
123
+ {
124
+ id: 'blue',
125
+ name: 'Blue',
126
+ seed: '#0074e2',
127
+ anchors: [
128
+ {mode: 'light', stop: 50, color: '#1682d5', policy: 'exact'},
129
+ ],
130
+ },
131
+ ],
132
+ },
133
+ 327,
134
+ 'c42929be3c4b5cb857cada078f62bb5a2242c1a22cfa4ae7546a18592540f7f3',
135
+ ],
136
+ [
137
+ {
138
+ modeStrategy: 'dark-only',
139
+ stops: [40],
140
+ families: [{id: 'red', name: 'Red', seed: '#d62830'}],
141
+ },
142
+ 258,
143
+ '88d3d69865c74c7fb14347b9967575285a7d44ab893087a08bc842bb66b29bbb',
144
+ ],
145
+ [
146
+ {
147
+ stops: [60, 80, 95],
148
+ families: [
149
+ {id: 'green', name: 'Green', seed: '#358a3a'},
150
+ {id: 'teal', name: 'Teal', seed: '#0c7365'},
151
+ {id: 'cyan', name: 'Cyan', seed: '#0c6f82'},
152
+ ],
153
+ },
154
+ 910,
155
+ '873821574fdbe3357304dbc06986bd2e5ec88af80d0f36305626fd826c3cf07b',
156
+ ],
157
+ ];
158
+
159
+ for (const [request, bytes, sha256] of fixtures) {
160
+ const cwd = fixture();
161
+ fs.writeFileSync(
162
+ path.join(cwd, 'palette.config.json'),
163
+ JSON.stringify(request),
164
+ );
165
+
166
+ const printed = themePaletteGenerate('palette.config.json', {}, {cwd});
167
+ expect(printed.data.generationReceipt.candidateSha256).toBe(sha256);
168
+
169
+ const result = themePaletteGenerate(
170
+ 'palette.config.json',
171
+ {out: 'candidate.json'},
172
+ {cwd},
173
+ );
174
+ const written = fs.readFileSync(path.join(cwd, 'candidate.json'));
175
+ expect(written.length).toBe(bytes);
176
+ expect(createHash('sha256').update(written).digest('hex')).toBe(sha256);
177
+ expect(result.data.generationReceipt.candidateSha256).toBe(sha256);
178
+ }
179
+ });
180
+
104
181
  it('preserves mixed decimal and integer stop order in every artifact', () => {
105
182
  const cwd = fixture();
106
183
  fs.writeFileSync(
@@ -267,4 +344,23 @@ describe('themePaletteGenerate', () => {
267
344
  themePaletteGenerate('palette.config.json', {}, {cwd}),
268
345
  ).toThrow('Could not parse palette config');
269
346
  });
347
+
348
+ it('reports an output path it cannot use with a registered error code', () => {
349
+ const cwd = fixture();
350
+ fs.writeFileSync(path.join(cwd, 'blocker'), '');
351
+
352
+ for (const options of [
353
+ {out: 'blocker/ocean.palette.json'},
354
+ {preview: 'blocker/ocean.palette.html'},
355
+ ]) {
356
+ let error;
357
+ try {
358
+ themePaletteGenerate('palette.config.json', options, {cwd});
359
+ } catch (caught) {
360
+ error = caught;
361
+ }
362
+ expect(error).toMatchObject({code: 'ERR_WRITE_FAILED'});
363
+ expect(isErrorCode(error.code)).toBe(true);
364
+ }
365
+ });
270
366
  });
@@ -37,6 +37,7 @@ export const COMPACT_11_STOPS = Object.freeze([
37
37
 
38
38
  const DARK_CHROMA_FACTOR = 0.85;
39
39
  const RESERVED_FAMILY_IDS = new Set(['black', 'white']);
40
+ const NEUTRAL_PROFILES = ['neutral-v1', 'warm-v1', 'cool-v1', 'custom'];
40
41
 
41
42
  /** @param {number} value @param {number} minimum @param {number} maximum */
42
43
  function clamp(value, minimum, maximum) {
@@ -665,11 +666,17 @@ export function normalizeGenerationRequest(input) {
665
666
  anchors,
666
667
  };
667
668
  });
669
+ const neutralProfile = input.neutralProfile ?? 'neutral-v1';
670
+ // The receipt records this request, so an unknown profile fails even when no
671
+ // neutral family reads it.
672
+ if (!NEUTRAL_PROFILES.includes(neutralProfile)) {
673
+ throw new Error(`Unknown neutral profile: ${String(neutralProfile)}`);
674
+ }
668
675
  /** @type {NormalizedRequest} */
669
676
  const request = {
670
677
  recipe: PALETTE_RECIPE,
671
678
  vibrancy: input.vibrancy ?? 50,
672
- neutralProfile: input.neutralProfile ?? 'neutral-v1',
679
+ neutralProfile,
673
680
  modeStrategy,
674
681
  stops,
675
682
  families: normalizedFamilies,
@@ -293,6 +293,16 @@ describe('astryx-oklch-v1 palette generator', () => {
293
293
  ).toThrow('Anchor mode dark is not generated by mode strategy light-only');
294
294
  });
295
295
 
296
+ it('rejects an unknown neutral profile even without a neutral family', () => {
297
+ expect(() =>
298
+ generatePaletteSet({neutralProfile: 'warm-v2', families: [families[1]]}),
299
+ ).toThrow('Unknown neutral profile: warm-v2');
300
+ expect(
301
+ generatePaletteSet({neutralProfile: 'warm-v1', families: [families[1]]})
302
+ .request.neutralProfile,
303
+ ).toBe('warm-v1');
304
+ });
305
+
296
306
  it('normalizes anchor colors in the generation receipt', () => {
297
307
  const result = generatePaletteSet({
298
308
  modeStrategy: 'light-only',
@@ -62,8 +62,17 @@ export function themeTemplate(options = {}) {
62
62
 
63
63
  // Our repo header has no business in someone else's source tree.
64
64
  const contents = stripCopyrightHeader(fs.readFileSync(THEME_TEMPLATE_SRC, 'utf-8'));
65
- fs.mkdirSync(path.dirname(resolved), {recursive: true});
66
- fs.writeFileSync(resolved, contents);
65
+ try {
66
+ fs.mkdirSync(path.dirname(resolved), {recursive: true});
67
+ fs.writeFileSync(resolved, contents);
68
+ } catch (err) {
69
+ const message = err instanceof Error ? err.message : String(err);
70
+ throw new AstryxError(
71
+ `Failed to write the theme template to ${relative}: ${message}`,
72
+ undefined,
73
+ ERROR_CODES.ERR_WRITE_FAILED,
74
+ );
75
+ }
67
76
 
68
77
  return {type: 'theme.template', data: {path: relative, written: true, reason: null}};
69
78
  }
@@ -14,6 +14,7 @@ import * as fs from 'node:fs';
14
14
  import * as path from 'node:path';
15
15
  import * as os from 'node:os';
16
16
  import {themeTemplate, THEME_TEMPLATE_DEFAULT_PATH} from './template.mjs';
17
+ import {isErrorCode} from '../../../foundation/response/error-codes.mjs';
17
18
 
18
19
  let tmpDir;
19
20
  beforeEach(() => {
@@ -79,4 +80,18 @@ describe('themeTemplate()', () => {
79
80
  );
80
81
  expect(fs.existsSync(path.join(path.dirname(tmpDir), 'escaped.ts'))).toBe(false);
81
82
  });
83
+
84
+ it('reports a failed write with a registered error code', () => {
85
+ fs.writeFileSync(path.join(tmpDir, 'blocker'), '');
86
+
87
+ let error;
88
+ try {
89
+ themeTemplate({cwd: tmpDir, targetPath: 'blocker/theme.ts'});
90
+ } catch (caught) {
91
+ error = caught;
92
+ }
93
+
94
+ expect(error).toMatchObject({code: 'ERR_WRITE_FAILED'});
95
+ expect(isErrorCode(error.code)).toBe(true);
96
+ });
82
97
  });