handoff-app 2.0.0-8 → 2.0.0-9

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 (34) hide show
  1. package/README.md +19 -3
  2. package/dist/catalog/normalize.js +3 -0
  3. package/dist/catalog/previews.js +5 -4
  4. package/dist/catalog/types.d.ts +8 -0
  5. package/dist/config/loader.js +2 -0
  6. package/dist/config/runtime.js +8 -10
  7. package/dist/handlebars.d.ts +10 -2
  8. package/dist/transformers/plugins/ssr-render.js +40 -15
  9. package/dist/transformers/preview/component/css.d.ts +1 -0
  10. package/dist/transformers/preview/component/css.js +85 -47
  11. package/dist/transformers/preview/pattern/html.js +3 -1
  12. package/dist/transformers/preview/types.d.ts +13 -0
  13. package/dist/transformers/utils/build.d.ts +19 -0
  14. package/dist/transformers/utils/build.js +83 -1
  15. package/dist/transformers/utils/module.js +2 -1
  16. package/dist/types/config.d.ts +6 -0
  17. package/package.json +1 -1
  18. package/src/app/components/Component/PageSliceResolver.tsx +1 -0
  19. package/src/app/components/Component/Preview.tsx +380 -189
  20. package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +4 -0
  21. package/src/catalog/README.md +3 -0
  22. package/src/catalog/normalize.ts +2 -0
  23. package/src/catalog/previews.ts +6 -3
  24. package/src/catalog/types.ts +8 -0
  25. package/src/config/loader.ts +2 -0
  26. package/src/config/runtime.ts +9 -4
  27. package/src/handlebars.ts +8 -2
  28. package/src/transformers/plugins/ssr-render.ts +53 -16
  29. package/src/transformers/preview/component/css.ts +93 -50
  30. package/src/transformers/preview/pattern/html.ts +3 -1
  31. package/src/transformers/preview/types.ts +14 -0
  32. package/src/transformers/utils/build.ts +80 -0
  33. package/src/transformers/utils/module.ts +4 -2
  34. package/src/types/config.ts +6 -0
@@ -30,6 +30,9 @@ owns `fromCSF`, because CSF is a source format of React rather than a framework.
30
30
  The renderer entry points call `createCatalogItem` with their renderer. The pattern entry point calls it without a renderer.
31
31
  `define.ts` handles the shared `implementation` forms: a helper's `{ format, file }`, a file path, or an imported component.
32
32
 
33
+ An entry point re-exports only the preview shape its renderer honors: `react.ts` exports `CatalogPreview` with `render`,
34
+ `handlebars.ts` narrows it to reject `render`, and `pattern.ts` exports neither.
35
+
33
36
  A new renderer needs:
34
37
 
35
38
  - A `RENDERERS` row in `renderers.ts`
@@ -101,6 +101,8 @@ export const normalizeCatalogItem = (moduleExports: Record<string, unknown>, opt
101
101
  entries[entryKey] = path.resolve(directory, source.file);
102
102
  // Existing builders consume template as the primary build input for every renderer.
103
103
  entries.template = entries[entryKey];
104
+ // The renderer and the hydration bundle both read a preview `render` from the declaration.
105
+ if (options.declarationPath) entries.declaration = options.declarationPath;
104
106
  // A wrong renderer builds a broken preview without failing on its own, so a mismatch is fatal.
105
107
  // `config/runtime.ts` turns the throw into a skipped item that retries on the next save.
106
108
  const claim = sourceForFile(source.file);
@@ -5,15 +5,17 @@ import type { CatalogPreview } from './types';
5
5
 
6
6
  /**
7
7
  * A declaration module exports more than previews: the item itself, the implementation it
8
- * re-exports, helper values. Accept only a plain object that owns `args` or `name`, so an exported
9
- * component or helper is never mistaken for a preview.
8
+ * re-exports, helper values. Accept only a plain object that owns one of the preview fields, so an
9
+ * exported component or helper is never mistaken for a preview.
10
10
  *
11
11
  * `getCsfStoryEntries` does not fit here: on a CSF file every named export is a story, and on a
12
12
  * declaration file it is not.
13
13
  */
14
+ const PREVIEW_KEYS = ['args', 'name', 'render'] as const;
15
+
14
16
  const isPreviewDeclaration = (value: unknown): boolean => {
15
17
  if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
16
- return Object.prototype.hasOwnProperty.call(value, 'args') || Object.prototype.hasOwnProperty.call(value, 'name');
18
+ return PREVIEW_KEYS.some((key) => Object.prototype.hasOwnProperty.call(value, key));
17
19
  };
18
20
 
19
21
  /** Matches an exported binding, an export list, or a CommonJS export assignment. */
@@ -104,6 +106,7 @@ export const createCatalogPreviews = (
104
106
  values: { ...(preview.args ?? {}) },
105
107
  url: preview.url ?? '',
106
108
  ...(preview.usage ? { usage: preview.usage } : {}),
109
+ ...(typeof preview.render === 'function' ? { render: preview.render as OptionalPreviewRender['render'] } : {}),
107
110
  };
108
111
  }
109
112
 
@@ -53,6 +53,14 @@ export type CatalogPreview<TArgs = Record<string, unknown>> = {
53
53
  args?: TArgs;
54
54
  usage?: string;
55
55
  url?: string;
56
+ /**
57
+ * Renders this preview in place of the implementation, for props it cannot carry as data: an icon
58
+ * component, a callback, JSX children, or state the preview owns.
59
+ *
60
+ * Rendered as a component with `args`, so it can use hooks. The item still documents the
61
+ * implementation, so a wrapper never becomes the published API.
62
+ */
63
+ render?: (args: TArgs) => unknown;
56
64
  };
57
65
 
58
66
  /**
@@ -1,4 +1,5 @@
1
1
  import esbuild from 'esbuild';
2
+ import { STYLE_IMPORT_LOADERS } from '../transformers/utils/build';
2
3
  import fs from 'fs-extra';
3
4
  import mergeWith from 'lodash/mergeWith';
4
5
  import { createRequire } from 'module';
@@ -67,6 +68,7 @@ const evaluateTypeScriptConfig = (filePath: string, handoffModulePath: string):
67
68
  target: 'node16',
68
69
  logLevel: 'silent',
69
70
  external: ['handoff-app'],
71
+ loader: STYLE_IMPORT_LOADERS,
70
72
  });
71
73
 
72
74
  const code = buildResult.outputFiles?.[0]?.text;
@@ -1,4 +1,5 @@
1
1
  import esbuild from 'esbuild';
2
+ import { STYLE_IMPORT_LOADERS } from '../transformers/utils/build';
2
3
  import fs from 'fs-extra';
3
4
  import { createRequire } from 'module';
4
5
  import path from 'path';
@@ -35,7 +36,8 @@ type DeclarationResolution = {
35
36
  fileName: string;
36
37
  };
37
38
 
38
- const DECLARATION_EXTENSIONS = ['ts', 'js', 'cjs'] as const;
39
+ /** `.tsx`/`.jsx` are accepted so a preview can write its `render` as JSX. */
40
+ const DECLARATION_EXTENSIONS = ['tsx', 'ts', 'jsx', 'js', 'cjs'] as const;
39
41
 
40
42
  const getDeclarationFiles = (componentBaseName: string): string[] =>
41
43
  DECLARATION_EXTENSIONS.map((ext) => `${componentBaseName}.handoff.${ext}`);
@@ -46,7 +48,7 @@ const findPreferredDeclaration = (componentDir: string, componentBaseName: strin
46
48
  if (exactMatch) return exactMatch;
47
49
 
48
50
  const allFiles = fs.existsSync(componentDir) ? fs.readdirSync(componentDir) : [];
49
- const declarationFiles = allFiles.filter((file) => /\.handoff\.(ts|js|cjs)$/.test(file)).sort((a, b) => a.localeCompare(b));
51
+ const declarationFiles = allFiles.filter((file) => /\.handoff\.(tsx|ts|jsx|js|cjs)$/.test(file)).sort((a, b) => a.localeCompare(b));
50
52
 
51
53
  if (!declarationFiles.length) return undefined;
52
54
 
@@ -74,6 +76,7 @@ const evaluateDeclaration = (filePath: string, handoffModulePath: string): any =
74
76
  logLevel: 'silent',
75
77
  jsx: 'automatic',
76
78
  external: ['react', 'react-dom', 'handoff-app'],
79
+ loader: STYLE_IMPORT_LOADERS,
77
80
  });
78
81
 
79
82
  const code = buildResult.outputFiles?.[0]?.text;
@@ -219,7 +222,7 @@ export const initRuntimeConfig = (
219
222
  if (!declaration) {
220
223
  const declarationFiles = getDeclarationFiles(itemBaseName);
221
224
  Logger.warn(
222
- `Missing catalog declaration (use .handoff.ts, .handoff.js, or .handoff.cjs; see UPGRADE.md#catalog-items): ${path.resolve(itemPath, declarationFiles.join(' or '))}`
225
+ `Missing catalog declaration (use .handoff.tsx, .handoff.ts, .handoff.jsx, .handoff.js, or .handoff.cjs; see UPGRADE.md#catalog-items): ${path.resolve(itemPath, declarationFiles.join(' or '))}`
223
226
  );
224
227
  continue;
225
228
  }
@@ -433,9 +436,9 @@ const injectPatternPreviews = (result: RuntimeConfig): void => {
433
436
 
434
437
  // Case 3: args present (with or without preview base) -> create synthetic preview
435
438
  let resolvedValues: Record<string, any> = {};
439
+ const basePreview = ref.preview ? component.previews?.[ref.preview] : undefined;
436
440
 
437
441
  if (ref.preview) {
438
- const basePreview = component.previews?.[ref.preview];
439
442
  if (basePreview) {
440
443
  resolvedValues = { ...basePreview.values };
441
444
  } else {
@@ -454,6 +457,8 @@ const injectPatternPreviews = (result: RuntimeConfig): void => {
454
457
  values: resolvedValues,
455
458
  url: '',
456
459
  sourcePreview: ref.preview,
460
+ // The ref overrides arguments, not how the preview renders, so a base `render` still applies.
461
+ ...(basePreview?.render ? { render: basePreview.render } : {}),
457
462
  };
458
463
 
459
464
  ref.resolvedPreview = syntheticKey;
package/src/handlebars.ts CHANGED
@@ -5,9 +5,15 @@
5
5
  * an argument type to check previews: `defineCatalogItem<BadgeArgs>`.
6
6
  */
7
7
  import { createCatalogItem } from './catalog/define';
8
- import type { CatalogItem, CatalogItemMeta } from './catalog/types';
8
+ import type { CatalogItem, CatalogItemMeta, CatalogPreview as RendererPreview } from './catalog/types';
9
9
 
10
- export type { CatalogItem, CatalogItemMeta, CatalogPreview, CompositionRef, Preview, SourceDescriptor } from './catalog/types';
10
+ export type { CatalogItem, CatalogItemMeta, CompositionRef, SourceDescriptor } from './catalog/types';
11
+
12
+ /** A template renderer never calls a preview `render`, so declaring one is an error, not a no-op. */
13
+ export type CatalogPreview<TArgs = Record<string, unknown>> = Omit<RendererPreview<TArgs>, 'render'> & { render?: never };
14
+
15
+ /** Preview type for a Handlebars catalog item, with its argument type applied. */
16
+ export type Preview<TItem> = TItem extends { readonly __args?: infer TArgs } ? CatalogPreview<Partial<NonNullable<TArgs>>> : never;
11
17
 
12
18
  /**
13
19
  * Declares a Handlebars catalog item. `implementation` is the path to its `.hbs` template. The
@@ -25,7 +25,7 @@ import {
25
25
  type SharedArtifactPresence,
26
26
  } from '../preview/component/shared-artifacts';
27
27
  import { TransformComponentTokensResult } from '../preview/types';
28
- import { DEFAULT_CLIENT_BUILD_CONFIG, createReactResolvePlugin } from '../utils/build';
28
+ import { DEFAULT_CLIENT_BUILD_CONFIG, createReactResolvePlugin, withStyleLoaders } from '../utils/build';
29
29
  import { formatHtml, trimPreview } from '../utils/html';
30
30
  import { buildAndEvaluateModule } from '../utils/module';
31
31
  import { loadSchemaFromComponent, loadSchemaFromFile } from '../utils/schema-loader';
@@ -138,13 +138,31 @@ async function loadComponentSchemaAndModule(
138
138
  * @param componentExport - Export holding the implementation; the default export when absent
139
139
  * @returns Client-side hydration source code
140
140
  */
141
- function generateClientHydrationSource(componentId: string, componentPath: string, componentExport?: string): string {
141
+ function generateClientHydrationSource(
142
+ componentId: string,
143
+ componentPath: string,
144
+ componentExport?: string,
145
+ declarationPath?: string
146
+ ): string {
142
147
  const importClause = componentExport && componentExport !== 'default' ? `{ ${componentExport} as Component }` : 'Component';
148
+ // A function cannot travel in the JSON props, so the client re-reads `render` from the module that
149
+ // defines it. Conditional, so an unaffected component does not ship its declaration's imports.
150
+ const declarationImport = declarationPath ? `import * as previewDeclaration from '${normalizePath(declarationPath)}';` : '';
151
+ const resolveComponent = declarationPath
152
+ ? `(mount) => {
153
+ const previewKey = mount.getAttribute('data-handoff-preview');
154
+ const render = previewKey ? previewDeclaration[previewKey]?.render : undefined;
155
+ return typeof render === 'function' ? render : Component;
156
+ }`
157
+ : '() => Component';
143
158
 
144
159
  return `
145
160
  import React from 'react';
146
161
  import { hydrateRoot } from 'react-dom/client';
147
162
  import ${importClause} from '${normalizePath(componentPath)}';
163
+ ${declarationImport}
164
+
165
+ const resolveComponent = ${resolveComponent};
148
166
 
149
167
  const parseProps = (propsId) => {
150
168
  const raw = propsId ? document.getElementById(propsId)?.textContent : '{}';
@@ -157,12 +175,14 @@ function generateClientHydrationSource(componentId: string, componentPath: strin
157
175
 
158
176
  const standaloneRoot = document.getElementById('${PLUGIN_CONSTANTS.ROOT_ELEMENT_ID}');
159
177
  if (standaloneRoot && !standaloneRoot.hasAttribute('data-handoff-component')) {
160
- hydrateRoot(standaloneRoot, <Component {...parseProps('${PLUGIN_CONSTANTS.PROPS_SCRIPT_ID}')} />);
178
+ const StandaloneComponent = resolveComponent(standaloneRoot);
179
+ hydrateRoot(standaloneRoot, <StandaloneComponent {...parseProps('${PLUGIN_CONSTANTS.PROPS_SCRIPT_ID}')} />);
161
180
  }
162
181
 
163
182
  const patternMounts = document.querySelectorAll('[data-handoff-component=' + JSON.stringify('${componentId}') + ']');
164
183
  patternMounts.forEach((mount) => {
165
- hydrateRoot(mount, <Component {...parseProps(mount.getAttribute('data-handoff-props'))} />);
184
+ const MountComponent = resolveComponent(mount);
185
+ hydrateRoot(mount, <MountComponent {...parseProps(mount.getAttribute('data-handoff-props'))} />);
166
186
  });
167
187
  `;
168
188
  }
@@ -175,12 +195,13 @@ function generateClientHydrationSource(componentId: string, componentPath: strin
175
195
  * artifact cannot be loaded.
176
196
  * @param componentId - Component identifier
177
197
  * @param previewTitle - Title for the preview
178
- * @param renderedHtml - Server-rendered HTML content
198
+ * @param renderedHtml - Server-rendered HTML, embedded verbatim so the client can hydrate it
179
199
  * @param props - Component props as JSON
180
200
  * @returns Complete HTML document
181
201
  */
182
202
  function generateHtmlDocument(
183
203
  componentId: string,
204
+ previewKey: string,
184
205
  previewTitle: string,
185
206
  renderedHtml: string,
186
207
  props: any,
@@ -220,7 +241,7 @@ function generateHtmlDocument(
220
241
  <title>${previewTitle}</title>
221
242
  </head>
222
243
  <body>
223
- <div id="${PLUGIN_CONSTANTS.ROOT_ELEMENT_ID}">${renderedHtml}</div>
244
+ <div id="${PLUGIN_CONSTANTS.ROOT_ELEMENT_ID}" data-handoff-preview="${previewKey}">${renderedHtml}</div>
224
245
  </body>
225
246
  </html>`;
226
247
  }
@@ -314,7 +335,15 @@ export function ssrRenderPlugin(
314
335
  // `component/<id>.client.js` artifact. The hydration source only imports the component and
315
336
  // reads props from the in-document `__APP_PROPS__` element, so it is identical across every
316
337
  // preview of this component.
317
- const clientHydrationSource = generateClientHydrationSource(componentId, componentPath, componentData.componentExport);
338
+ const declarationPath = Object.values(componentData.previews ?? {}).some((preview) => typeof preview.render === 'function')
339
+ ? componentData.entries?.declaration
340
+ : undefined;
341
+ const clientHydrationSource = generateClientHydrationSource(
342
+ componentId,
343
+ componentPath,
344
+ componentData.componentExport,
345
+ declarationPath
346
+ );
318
347
  const clientBuildConfig = {
319
348
  ...DEFAULT_CLIENT_BUILD_CONFIG,
320
349
  logLevel: 'silent' as const,
@@ -326,9 +355,10 @@ export function ssrRenderPlugin(
326
355
  plugins: [createReactResolvePlugin(handoff.workingPath, handoff.modulePath)],
327
356
  };
328
357
 
329
- const finalClientBuildConfig = handoff.config?.hooks?.clientBuildConfig
358
+ const hookedClientBuildConfig = handoff.config?.hooks?.clientBuildConfig
330
359
  ? handoff.config.hooks.clientBuildConfig(clientBuildConfig)
331
360
  : clientBuildConfig;
361
+ const finalClientBuildConfig = withStyleLoaders(hookedClientBuildConfig);
332
362
 
333
363
  let clientBundleJs: string;
334
364
  try {
@@ -364,16 +394,23 @@ export function ssrRenderPlugin(
364
394
 
365
395
  // Generate previews for each variation
366
396
  for (const previewKey in componentData.previews) {
367
- const previewProps = componentData.previews[previewKey].values;
368
-
369
- // Server-side render the component
370
- const serverRenderedHtml = ReactDOMServer.renderToString(React.createElement(ReactComponent, previewProps));
371
- const formattedHtml = await formatHtml(serverRenderedHtml);
397
+ const preview = componentData.previews[previewKey];
398
+ const previewProps = preview.values;
399
+ // A synthetic pattern preview is not a named export, so the client looks up its
400
+ // `sourcePreview` instead. A preview without one has no `render` either.
401
+ const hydrationPreviewKey = preview.sourcePreview ?? previewKey;
402
+ // Rendered as a component rather than called, so a preview can use hooks and own state.
403
+ const PreviewComponent = (typeof preview.render === 'function' ? preview.render : ReactComponent) as ReactComponent;
404
+
405
+ // The markup must reach the document exactly as rendered. Formatting it adds text nodes React
406
+ // does not expect, so it discards the server markup and re-renders the preview on the client.
407
+ const serverRenderedHtml = ReactDOMServer.renderToString(React.createElement(PreviewComponent, previewProps));
372
408
 
373
409
  finalHtml = generateHtmlDocument(
374
410
  componentId,
375
- componentData.previews[previewKey].title,
376
- formattedHtml,
411
+ hydrationPreviewKey,
412
+ preview.title,
413
+ serverRenderedHtml,
377
414
  previewProps,
378
415
  sharedArtifacts,
379
416
  componentArtifacts
@@ -402,7 +439,7 @@ export function ssrRenderPlugin(
402
439
  });
403
440
  }
404
441
 
405
- // Format final HTML and update component data
442
+ // The previews are already emitted, so this formats only the docs code view.
406
443
  finalHtml = await formatHtml(finalHtml);
407
444
  componentData.format = 'react';
408
445
  componentData.preview = '';
@@ -4,6 +4,7 @@ import { InlineConfig, build as viteBuild } from 'vite';
4
4
  import Handoff from '../../../index';
5
5
  import { formatDurationMs } from '../../../utils/duration';
6
6
  import { Logger } from '../../../utils/logger';
7
+ import { collectStyleImports } from '../../utils/build';
7
8
  import viteBaseConfig from '../../vite-config';
8
9
  import { getComponentOutputPath } from '../component';
9
10
  import { TransformComponentTokensResult } from '../types';
@@ -98,14 +99,56 @@ const buildCssBundle = async ({
98
99
  }
99
100
  };
100
101
 
102
+ const STYLE_SOURCE_EXTENSIONS = new Set(['.scss', '.sass', '.css']);
103
+
104
+ /**
105
+ * The stylesheets of one item, in cascade order and deduplicated by resolved path.
106
+ *
107
+ * Discovered imports come first and the declared entry last, so a declared entry can override what
108
+ * the component imports. A project that both declares and imports one file must build it once.
109
+ */
110
+ const resolveStyleSources = async (data: TransformComponentTokensResult, handoff: Handoff): Promise<string[]> => {
111
+ const template = data.entries?.template;
112
+ const discovered = template ? await collectStyleImports(path.resolve(template), handoff) : [];
113
+ const declared = data.entries?.scss ? [path.resolve(data.entries.scss)] : [];
114
+
115
+ const ordered: string[] = [];
116
+ const seen = new Set<string>();
117
+
118
+ for (const source of [...discovered, ...declared]) {
119
+ if (!seen.has(source) && STYLE_SOURCE_EXTENSIONS.has(path.extname(source)) && fs.existsSync(source)) {
120
+ seen.add(source);
121
+ ordered.push(source);
122
+ }
123
+ }
124
+
125
+ return ordered;
126
+ };
127
+
128
+ /**
129
+ * A single Sass entry that `@use`s every source in order.
130
+ *
131
+ * `@use` emits a repeated file once and inlines a plain `.css` source, which `@import` would leave
132
+ * as an unresolvable URL. Namespaces are explicit because two sources sharing a basename would
133
+ * claim the same default namespace and fail the compile.
134
+ */
135
+ const writeCombinedStyleEntry = async (sources: string[], outputPath: string, id: string): Promise<string> => {
136
+ const entryPath = path.resolve(outputPath, `.${id}.entry.scss`);
137
+ const rules = sources.map((source, index) => `@use '${source.split(path.sep).join('/')}' as ns${index};`);
138
+
139
+ await fs.outputFile(entryPath, `${rules.join('\n')}\n`);
140
+
141
+ return entryPath;
142
+ };
143
+
101
144
  const buildComponentCss = async (data: TransformComponentTokensResult, handoff: Handoff) => {
102
145
  const id = data.id;
103
146
  Logger.debug(`buildComponentCss`, id);
104
147
  const outputPath = getComponentOutputPath(handoff);
105
148
  const builtCssPath = path.resolve(outputPath, `${id}.css`);
106
- const entry = data.entries?.scss;
149
+ const sources = await resolveStyleSources(data, handoff);
107
150
 
108
- if (!entry) {
151
+ if (sources.length === 0) {
109
152
  // Keep generated output aligned with the current component declaration.
110
153
  await fs.remove(builtCssPath);
111
154
  delete data['css'];
@@ -114,64 +157,64 @@ const buildComponentCss = async (data: TransformComponentTokensResult, handoff:
114
157
  return data;
115
158
  }
116
159
 
117
- const extension = path.extname(entry);
118
-
119
- if (!extension) {
120
- return data;
121
- }
160
+ // A single source compiles directly, so its relative asset URLs and documented Sass stay as authored.
161
+ const combinedEntry = sources.length > 1 ? await writeCombinedStyleEntry(sources, outputPath, id) : undefined;
162
+ const entry = combinedEntry ?? sources[0];
122
163
 
123
164
  try {
124
- if (extension === '.scss' || extension === '.css') {
125
- // Remove the previous artifact before rebuilding so "no emitted CSS"
126
- // doesn't silently preserve stale output from an earlier build.
127
- await fs.remove(builtCssPath);
128
-
129
- // Read the original source
130
- const sourceContent = await fs.readFile(entry, 'utf8');
131
- if (extension === '.scss') {
132
- data['sass'] = sourceContent;
133
- }
165
+ // Remove the previous artifact before rebuilding so "no emitted CSS"
166
+ // doesn't silently preserve stale output from an earlier build.
167
+ await fs.remove(builtCssPath);
168
+
169
+ // Read the original source
170
+ const primarySource = data.entries?.scss ? path.resolve(data.entries.scss) : sources[0];
171
+ if (path.extname(primarySource) === '.scss') {
172
+ data['sass'] = await fs.readFile(primarySource, 'utf8');
173
+ }
134
174
 
135
- // Setup SASS load paths
136
- const loadPaths = [
137
- path.resolve(handoff.workingPath),
138
- path.resolve(handoff.workingPath, handoff.exportsDirectory, handoff.getProjectId()),
139
- path.resolve(handoff.workingPath, 'node_modules'),
140
- ];
175
+ // Setup SASS load paths
176
+ const loadPaths = [
177
+ path.resolve(handoff.workingPath),
178
+ path.resolve(handoff.workingPath, handoff.exportsDirectory, handoff.getProjectId()),
179
+ path.resolve(handoff.workingPath, 'node_modules'),
180
+ ];
141
181
 
142
- if (handoff.runtimeConfig?.entries?.scss) {
143
- loadPaths.unshift(path.dirname(handoff.runtimeConfig.entries.scss));
144
- }
182
+ if (handoff.runtimeConfig?.entries?.scss) {
183
+ loadPaths.unshift(path.dirname(handoff.runtimeConfig.entries.scss));
184
+ }
185
+
186
+ await buildCssBundle({
187
+ entry,
188
+ outputPath,
189
+ outputFilename: `${id}.css`,
190
+ loadPaths,
191
+ handoff,
192
+ });
145
193
 
146
- await buildCssBundle({
147
- entry,
148
- outputPath,
149
- outputFilename: `${id}.css`,
150
- loadPaths,
151
- handoff,
152
- });
153
-
154
- // Read the built CSS
155
- if (fs.existsSync(builtCssPath)) {
156
- const builtCss = await fs.readFile(builtCssPath, 'utf8');
157
- data['css'] = builtCss;
158
-
159
- // Handle shared styles — if the component SCSS uses the split marker,
160
- // extract shared styles from the portion above it
161
- const splitCSS = builtCss.split('/* COMPONENT STYLES*/');
162
- if (splitCSS && splitCSS.length > 1) {
163
- data['css'] = splitCSS[1];
164
- data['sharedStyles'] = splitCSS[0];
165
- await fs.writeFile(path.resolve(outputPath, SHARED_COMPONENT_CSS_FILE), data['sharedStyles']);
166
- }
167
- } else {
168
- delete data['css'];
169
- delete data['sharedStyles'];
194
+ // Read the built CSS
195
+ if (fs.existsSync(builtCssPath)) {
196
+ const builtCss = await fs.readFile(builtCssPath, 'utf8');
197
+ data['css'] = builtCss;
198
+
199
+ // Handle shared styles — if the component SCSS uses the split marker,
200
+ // extract shared styles from the portion above it
201
+ const splitCSS = builtCss.split('/* COMPONENT STYLES*/');
202
+ if (splitCSS && splitCSS.length > 1) {
203
+ data['css'] = splitCSS[1];
204
+ data['sharedStyles'] = splitCSS[0];
205
+ await fs.writeFile(path.resolve(outputPath, SHARED_COMPONENT_CSS_FILE), data['sharedStyles']);
170
206
  }
207
+ } else {
208
+ delete data['css'];
209
+ delete data['sharedStyles'];
171
210
  }
172
211
  } catch (e) {
173
212
  Logger.error(`Failed to build CSS for "${id}"`);
174
213
  throw e;
214
+ } finally {
215
+ if (combinedEntry) {
216
+ await fs.remove(combinedEntry);
217
+ }
175
218
  }
176
219
 
177
220
  return data;
@@ -56,7 +56,9 @@ export const composePatternHtml = (
56
56
  }
57
57
  }
58
58
 
59
- const doc = parse(html);
59
+ // React SSR separates adjacent text children with `<!-- -->`. The parser drops comments by
60
+ // default, which would make every composed React fragment fail to hydrate.
61
+ const doc = parse(html, { comment: true });
60
62
  const suffix = `_p${i}`;
61
63
  const namespacedRootId = `root${suffix}`;
62
64
  const namespacedPropsId = `__APP_PROPS__${suffix}`;
@@ -18,6 +18,9 @@ export interface BasePageSlice {
18
18
  type: PageSliceType;
19
19
  }
20
20
 
21
+ /** A fluid preview renders at the width the reader's stage has, so it is never scaled down. */
22
+ export type PreviewWidth = 'fluid' | number;
23
+
21
24
  export interface BestPracticesPageSlice extends BasePageSlice {
22
25
  type: 'BEST_PRACTICES';
23
26
  }
@@ -27,6 +30,8 @@ export interface ComponentDisplayPageSlice extends BasePageSlice {
27
30
  showPreview?: boolean;
28
31
  showCodeHighlight?: boolean;
29
32
  defaultHeight?: string;
33
+ /** Viewport width for the preview, in pixels. Defaults to 'fluid'. */
34
+ defaultWidth?: PreviewWidth;
30
35
  filterBy?: Filter;
31
36
  }
32
37
 
@@ -115,6 +120,8 @@ export type ComponentObject = {
115
120
  story?: string;
116
121
  /** Optional path to schema file (if available) */
117
122
  schema?: string;
123
+ /** Declaration file path; a preview `render` is read from it at build and hydration time. */
124
+ declaration?: string;
118
125
  /** Optional path to templates directory */
119
126
  templates?: string;
120
127
  };
@@ -192,6 +199,8 @@ export type TransformComponentTokensResult = {
192
199
  story?: string;
193
200
  schema?: string;
194
201
  templates?: string;
202
+ /** See `ComponentObject.entries.declaration`. */
203
+ declaration?: string;
195
204
  };
196
205
  renderer?: RendererKind;
197
206
  /** See `ComponentObject.sourceFormat`. Distinct from `format`, the preview's code language. */
@@ -212,6 +221,11 @@ export type OptionalPreviewRender = {
212
221
  values: { [key: string]: string | string[] | any };
213
222
  url: string;
214
223
  usage?: string;
224
+ /**
225
+ * Build-time only: the server renders it and the client re-reads it from the declaration module.
226
+ * `JSON.stringify` drops it from published artifacts.
227
+ */
228
+ render?: (args: Record<string, any>) => unknown;
215
229
  /** Optional source preview/story key used to derive this preview */
216
230
  sourcePreview?: string;
217
231
  };
@@ -1,6 +1,21 @@
1
1
  import esbuild from 'esbuild';
2
2
  import path from 'path';
3
3
 
4
+ const STYLE_EXTENSIONS = ['.css', '.scss', '.sass'];
5
+
6
+ /**
7
+ * Keeps co-located stylesheet imports out of the JavaScript graph; the CSS pipeline compiles them.
8
+ *
9
+ * esbuild has no `.scss` loader, and a `.css` import fails too because these builds run with
10
+ * `write: false` and no output path. `empty` rather than a plugin: `esbuild.buildSync` rejects
11
+ * plugins, and the declaration and config loaders are synchronous.
12
+ *
13
+ * CSS Modules are not covered — the empty module leaves the class map undefined.
14
+ */
15
+ export const STYLE_IMPORT_LOADERS: Record<string, esbuild.Loader> = Object.fromEntries(
16
+ STYLE_EXTENSIONS.map((extension) => [extension, 'empty' as esbuild.Loader])
17
+ );
18
+
4
19
  /**
5
20
  * Default esbuild configuration for SSR builds
6
21
  */
@@ -11,6 +26,7 @@ export const DEFAULT_SSR_BUILD_CONFIG: esbuild.BuildOptions = {
11
26
  platform: 'node',
12
27
  jsx: 'automatic',
13
28
  external: ['react', 'react-dom', '@opentelemetry/api'],
29
+ loader: STYLE_IMPORT_LOADERS,
14
30
  };
15
31
 
16
32
  /**
@@ -24,8 +40,72 @@ export const DEFAULT_CLIENT_BUILD_CONFIG: esbuild.BuildOptions = {
24
40
  jsx: 'automatic',
25
41
  sourcemap: false,
26
42
  minify: false,
43
+ loader: STYLE_IMPORT_LOADERS,
27
44
  };
28
45
 
46
+ /** Apply after a consumer hook: a hook that replaces `loader` would otherwise drop these. */
47
+ export function withStyleLoaders<T extends esbuild.BuildOptions>(config: T): T {
48
+ return { ...config, loader: { ...config.loader, ...STYLE_IMPORT_LOADERS } };
49
+ }
50
+
51
+ /**
52
+ * Stylesheets of the module graph in cascade order: every import of a module before the module
53
+ * itself, siblings in source order.
54
+ *
55
+ * This is the order a bundler emits CSS in, and the cascade depends on it. A component that
56
+ * overrides a rule of a component it imports ties that rule on specificity, so the later of the
57
+ * two wins. esbuild's `onLoad` callbacks do not give this order: they fire roughly breadth-first,
58
+ * which puts a component's own stylesheet ahead of the ones it imports and reverses every such
59
+ * override.
60
+ */
61
+ function orderStyleImports(metafile: esbuild.Metafile, entryPath: string): string[] {
62
+ const entryKey = Object.keys(metafile.inputs).find((key) => path.resolve(key) === entryPath);
63
+ if (!entryKey) return [];
64
+
65
+ const ordered: string[] = [];
66
+ const visited = new Set<string>();
67
+
68
+ const visit = (key: string) => {
69
+ if (visited.has(key)) return;
70
+ visited.add(key);
71
+
72
+ for (const imported of metafile.inputs[key]?.imports ?? []) {
73
+ if (!imported.external && metafile.inputs[imported.path]) visit(imported.path);
74
+ }
75
+
76
+ if (STYLE_EXTENSIONS.includes(path.extname(key))) ordered.push(path.resolve(key));
77
+ };
78
+
79
+ visit(entryKey);
80
+
81
+ return ordered;
82
+ }
83
+
84
+ /**
85
+ * Stylesheet paths imported anywhere in a component's module graph, in cascade order.
86
+ *
87
+ * An unbuildable graph yields an empty list rather than throwing, leaving the CSS step with the
88
+ * declared entry.
89
+ */
90
+ export async function collectStyleImports(entryPath: string, handoff: any): Promise<string[]> {
91
+ const baseConfig: esbuild.BuildOptions = {
92
+ ...DEFAULT_SSR_BUILD_CONFIG,
93
+ entryPoints: [entryPath],
94
+ logLevel: 'silent',
95
+ };
96
+ const hookedConfig = handoff?.config?.hooks?.ssrBuildConfig ? handoff.config.hooks.ssrBuildConfig(baseConfig) : baseConfig;
97
+
98
+ try {
99
+ // The `empty` loader keeps stylesheets out of the JavaScript, and the metafile still records
100
+ // them as inputs, so the graph carries the order.
101
+ const result = await esbuild.build({ ...withStyleLoaders(hookedConfig), metafile: true });
102
+
103
+ return result.metafile ? orderStyleImports(result.metafile, entryPath) : [];
104
+ } catch {
105
+ return [];
106
+ }
107
+ }
108
+
29
109
  /**
30
110
  * Resolves a module from multiple search directories
31
111
  * @param id - Module ID to resolve