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
package/README.md CHANGED
@@ -74,17 +74,19 @@ The declaration supplies stable identity, documentation metadata, source entries
74
74
 
75
75
  ### React
76
76
 
77
- Handoff finds the implementation file from its import, so you do not need to repeat the path.
77
+ Handoff finds the implementation file from its import, so you do not need to repeat the path, and
78
+ builds the item's CSS from the stylesheets that import chain pulls in, descendants included.
78
79
  Previews are named exports. `Preview<typeof item>` gives them the implementation's argument type.
79
80
 
80
81
  ```tsx
81
82
  // components/example/Component.tsx
82
83
  export type ComponentProps = {
83
84
  label: string;
85
+ onSelect?: () => void;
84
86
  };
85
87
 
86
- export default function Component({ label }: ComponentProps) {
87
- return <div>{label}</div>;
88
+ export default function Component({ label, onSelect }: ComponentProps) {
89
+ return <div onClick={onSelect}>{label}</div>;
88
90
  }
89
91
  ```
90
92
 
@@ -112,6 +114,20 @@ export const Default = {
112
114
 
113
115
  The export name is the preview name. Use `name` to set a different display title.
114
116
 
117
+ Arguments are serialized, so a preview that needs an icon, a callback, JSX children, or its own
118
+ state declares `render` instead. It renders as a component, so a capitalized name lets it use hooks, and the item keeps
119
+ documenting the implementation rather than a wrapper. Name a declaration that holds JSX
120
+ `example.handoff.tsx`.
121
+
122
+ ```tsx
123
+ export const Interactive = {
124
+ render: function Interactive() {
125
+ const [label, setLabel] = useState('Example');
126
+ return <Component label={label} onSelect={() => setLabel('Selected')} />;
127
+ },
128
+ } satisfies ComponentPreview;
129
+ ```
130
+
115
131
  ### Handlebars
116
132
 
117
133
  The implementation is the path to the template. Arguments are `Record<string, unknown>` by default;
@@ -88,6 +88,9 @@ const normalizeCatalogItem = (moduleExports, options) => {
88
88
  entries[entryKey] = path_1.default.resolve(directory, source.file);
89
89
  // Existing builders consume template as the primary build input for every renderer.
90
90
  entries.template = entries[entryKey];
91
+ // The renderer and the hydration bundle both read a preview `render` from the declaration.
92
+ if (options.declarationPath)
93
+ entries.declaration = options.declarationPath;
91
94
  // A wrong renderer builds a broken preview without failing on its own, so a mismatch is fatal.
92
95
  // `config/runtime.ts` turns the throw into a skipped item that retries on the next save.
93
96
  const claim = (0, renderers_1.sourceForFile)(source.file);
@@ -8,16 +8,17 @@ const fs_extra_1 = __importDefault(require("fs-extra"));
8
8
  const lodash_1 = require("lodash");
9
9
  /**
10
10
  * A declaration module exports more than previews: the item itself, the implementation it
11
- * re-exports, helper values. Accept only a plain object that owns `args` or `name`, so an exported
12
- * component or helper is never mistaken for a preview.
11
+ * re-exports, helper values. Accept only a plain object that owns one of the preview fields, so an
12
+ * exported component or helper is never mistaken for a preview.
13
13
  *
14
14
  * `getCsfStoryEntries` does not fit here: on a CSF file every named export is a story, and on a
15
15
  * declaration file it is not.
16
16
  */
17
+ const PREVIEW_KEYS = ['args', 'name', 'render'];
17
18
  const isPreviewDeclaration = (value) => {
18
19
  if (!value || typeof value !== 'object' || Array.isArray(value))
19
20
  return false;
20
- return Object.prototype.hasOwnProperty.call(value, 'args') || Object.prototype.hasOwnProperty.call(value, 'name');
21
+ return PREVIEW_KEYS.some((key) => Object.prototype.hasOwnProperty.call(value, key));
21
22
  };
22
23
  /** Matches an exported binding, an export list, or a CommonJS export assignment. */
23
24
  const EXPORT_PATTERNS = [
@@ -99,7 +100,7 @@ const createCatalogPreviews = (moduleExports, declarationPath) => {
99
100
  throw new Error(`Preview "${exportName}" uses removed fields. Use args and name. See UPGRADE.md#catalog-items.`);
100
101
  }
101
102
  const preview = value;
102
- previews[exportName] = Object.assign({ title: preview.name || (0, lodash_1.startCase)(exportName), values: Object.assign({}, ((_a = preview.args) !== null && _a !== void 0 ? _a : {})), url: (_b = preview.url) !== null && _b !== void 0 ? _b : '' }, (preview.usage ? { usage: preview.usage } : {}));
103
+ previews[exportName] = Object.assign(Object.assign({ title: preview.name || (0, lodash_1.startCase)(exportName), values: Object.assign({}, ((_a = preview.args) !== null && _a !== void 0 ? _a : {})), url: (_b = preview.url) !== null && _b !== void 0 ? _b : '' }, (preview.usage ? { usage: preview.usage } : {})), (typeof preview.render === 'function' ? { render: preview.render } : {}));
103
104
  }
104
105
  return declarationPath ? (0, exports.orderPreviews)(previews, (0, exports.readExportOrder)(declarationPath)) : previews;
105
106
  };
@@ -43,6 +43,14 @@ export type CatalogPreview<TArgs = Record<string, unknown>> = {
43
43
  args?: TArgs;
44
44
  usage?: string;
45
45
  url?: string;
46
+ /**
47
+ * Renders this preview in place of the implementation, for props it cannot carry as data: an icon
48
+ * component, a callback, JSX children, or state the preview owns.
49
+ *
50
+ * Rendered as a component with `args`, so it can use hooks. The item still documents the
51
+ * implementation, so a wrapper never becomes the published API.
52
+ */
53
+ render?: (args: TArgs) => unknown;
46
54
  };
47
55
  /**
48
56
  * `__args` exists only in the type system so `Preview<typeof item>` can infer the preview argument type.
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.initConfig = exports.initConfigWithMetadata = exports.resolveProfileSelection = exports.HandoffConfigError = exports.CONFIG_FILE_PREFERENCE = void 0;
7
7
  const esbuild_1 = __importDefault(require("esbuild"));
8
+ const build_1 = require("../transformers/utils/build");
8
9
  const fs_extra_1 = __importDefault(require("fs-extra"));
9
10
  const mergeWith_1 = __importDefault(require("lodash/mergeWith"));
10
11
  const module_1 = require("module");
@@ -40,6 +41,7 @@ const evaluateTypeScriptConfig = (filePath, handoffModulePath) => {
40
41
  target: 'node16',
41
42
  logLevel: 'silent',
42
43
  external: ['handoff-app'],
44
+ loader: build_1.STYLE_IMPORT_LOADERS,
43
45
  });
44
46
  const code = (_b = (_a = buildResult.outputFiles) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.text;
45
47
  if (!code) {
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.toLowerCaseKeysAndValues = exports.getComponentsForPath = exports.isComponentDirectory = exports.initRuntimeConfig = exports.getCatalogItemDirectories = exports.resolveComponentDeclaration = void 0;
7
7
  const esbuild_1 = __importDefault(require("esbuild"));
8
+ const build_1 = require("../transformers/utils/build");
8
9
  const fs_extra_1 = __importDefault(require("fs-extra"));
9
10
  const module_1 = require("module");
10
11
  const path_1 = __importDefault(require("path"));
@@ -17,7 +18,8 @@ const markdown_1 = require("../utils/markdown");
17
18
  const pages_1 = require("../utils/pages");
18
19
  const path_2 = require("../utils/path");
19
20
  const page_1 = require("./normalizers/page");
20
- const DECLARATION_EXTENSIONS = ['ts', 'js', 'cjs'];
21
+ /** `.tsx`/`.jsx` are accepted so a preview can write its `render` as JSX. */
22
+ const DECLARATION_EXTENSIONS = ['tsx', 'ts', 'jsx', 'js', 'cjs'];
21
23
  const getDeclarationFiles = (componentBaseName) => DECLARATION_EXTENSIONS.map((ext) => `${componentBaseName}.handoff.${ext}`);
22
24
  const findPreferredDeclaration = (componentDir, componentBaseName) => {
23
25
  const exactCandidates = getDeclarationFiles(componentBaseName);
@@ -25,7 +27,7 @@ const findPreferredDeclaration = (componentDir, componentBaseName) => {
25
27
  if (exactMatch)
26
28
  return exactMatch;
27
29
  const allFiles = fs_extra_1.default.existsSync(componentDir) ? fs_extra_1.default.readdirSync(componentDir) : [];
28
- const declarationFiles = allFiles.filter((file) => /\.handoff\.(ts|js|cjs)$/.test(file)).sort((a, b) => a.localeCompare(b));
30
+ const declarationFiles = allFiles.filter((file) => /\.handoff\.(tsx|ts|jsx|js|cjs)$/.test(file)).sort((a, b) => a.localeCompare(b));
29
31
  if (!declarationFiles.length)
30
32
  return undefined;
31
33
  for (const ext of DECLARATION_EXTENSIONS) {
@@ -52,6 +54,7 @@ const evaluateDeclaration = (filePath, handoffModulePath) => {
52
54
  logLevel: 'silent',
53
55
  jsx: 'automatic',
54
56
  external: ['react', 'react-dom', 'handoff-app'],
57
+ loader: build_1.STYLE_IMPORT_LOADERS,
55
58
  });
56
59
  const code = (_b = (_a = buildResult.outputFiles) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.text;
57
60
  if (!code) {
@@ -178,7 +181,7 @@ const initRuntimeConfig = (handoff) => {
178
181
  const declaration = (0, exports.resolveComponentDeclaration)(itemPath, itemBaseName);
179
182
  if (!declaration) {
180
183
  const declarationFiles = getDeclarationFiles(itemBaseName);
181
- logger_1.Logger.warn(`Missing catalog declaration (use .handoff.ts, .handoff.js, or .handoff.cjs; see UPGRADE.md#catalog-items): ${path_1.default.resolve(itemPath, declarationFiles.join(' or '))}`);
184
+ logger_1.Logger.warn(`Missing catalog declaration (use .handoff.tsx, .handoff.ts, .handoff.jsx, .handoff.js, or .handoff.cjs; see UPGRADE.md#catalog-items): ${path_1.default.resolve(itemPath, declarationFiles.join(' or '))}`);
182
185
  continue;
183
186
  }
184
187
  const declarationPath = path_1.default.resolve(itemPath, declaration.fileName);
@@ -361,8 +364,8 @@ const injectPatternPreviews = (result) => {
361
364
  }
362
365
  // Case 3: args present (with or without preview base) -> create synthetic preview
363
366
  let resolvedValues = {};
367
+ const basePreview = ref.preview ? (_b = component.previews) === null || _b === void 0 ? void 0 : _b[ref.preview] : undefined;
364
368
  if (ref.preview) {
365
- const basePreview = (_b = component.previews) === null || _b === void 0 ? void 0 : _b[ref.preview];
366
369
  if (basePreview) {
367
370
  resolvedValues = Object.assign({}, basePreview.values);
368
371
  }
@@ -374,12 +377,7 @@ const injectPatternPreviews = (result) => {
374
377
  resolvedValues = Object.assign(Object.assign({}, resolvedValues), ref.args);
375
378
  const syntheticKey = `__pattern_${patternId}_${i}`;
376
379
  component.internalPatternPreviews = component.internalPatternPreviews || {};
377
- component.internalPatternPreviews[syntheticKey] = {
378
- title: syntheticKey,
379
- values: resolvedValues,
380
- url: '',
381
- sourcePreview: ref.preview,
382
- };
380
+ component.internalPatternPreviews[syntheticKey] = Object.assign({ title: syntheticKey, values: resolvedValues, url: '', sourcePreview: ref.preview }, ((basePreview === null || basePreview === void 0 ? void 0 : basePreview.render) ? { render: basePreview.render } : {}));
383
381
  ref.resolvedPreview = syntheticKey;
384
382
  if (ref.resolved === undefined) {
385
383
  ref.resolved = true;
@@ -1,5 +1,13 @@
1
- import type { CatalogItem, CatalogItemMeta } from './catalog/types';
2
- export type { CatalogItem, CatalogItemMeta, CatalogPreview, CompositionRef, Preview, SourceDescriptor } from './catalog/types';
1
+ import type { CatalogItem, CatalogItemMeta, CatalogPreview as RendererPreview } from './catalog/types';
2
+ export type { CatalogItem, CatalogItemMeta, CompositionRef, SourceDescriptor } from './catalog/types';
3
+ /** A template renderer never calls a preview `render`, so declaring one is an error, not a no-op. */
4
+ export type CatalogPreview<TArgs = Record<string, unknown>> = Omit<RendererPreview<TArgs>, 'render'> & {
5
+ render?: never;
6
+ };
7
+ /** Preview type for a Handlebars catalog item, with its argument type applied. */
8
+ export type Preview<TItem> = TItem extends {
9
+ readonly __args?: infer TArgs;
10
+ } ? CatalogPreview<Partial<NonNullable<TArgs>>> : never;
3
11
  /**
4
12
  * Declares a Handlebars catalog item. `implementation` is the path to its `.hbs` template. The
5
13
  * renderer comes from this module, so the build fails an item whose file does not match it.
@@ -121,12 +121,25 @@ function loadComponentSchemaAndModule(componentData, componentPath, handoff) {
121
121
  * @param componentExport - Export holding the implementation; the default export when absent
122
122
  * @returns Client-side hydration source code
123
123
  */
124
- function generateClientHydrationSource(componentId, componentPath, componentExport) {
124
+ function generateClientHydrationSource(componentId, componentPath, componentExport, declarationPath) {
125
125
  const importClause = componentExport && componentExport !== 'default' ? `{ ${componentExport} as Component }` : 'Component';
126
+ // A function cannot travel in the JSON props, so the client re-reads `render` from the module that
127
+ // defines it. Conditional, so an unaffected component does not ship its declaration's imports.
128
+ const declarationImport = declarationPath ? `import * as previewDeclaration from '${(0, vite_1.normalizePath)(declarationPath)}';` : '';
129
+ const resolveComponent = declarationPath
130
+ ? `(mount) => {
131
+ const previewKey = mount.getAttribute('data-handoff-preview');
132
+ const render = previewKey ? previewDeclaration[previewKey]?.render : undefined;
133
+ return typeof render === 'function' ? render : Component;
134
+ }`
135
+ : '() => Component';
126
136
  return `
127
137
  import React from 'react';
128
138
  import { hydrateRoot } from 'react-dom/client';
129
139
  import ${importClause} from '${(0, vite_1.normalizePath)(componentPath)}';
140
+ ${declarationImport}
141
+
142
+ const resolveComponent = ${resolveComponent};
130
143
 
131
144
  const parseProps = (propsId) => {
132
145
  const raw = propsId ? document.getElementById(propsId)?.textContent : '{}';
@@ -139,12 +152,14 @@ function generateClientHydrationSource(componentId, componentPath, componentExpo
139
152
 
140
153
  const standaloneRoot = document.getElementById('${PLUGIN_CONSTANTS.ROOT_ELEMENT_ID}');
141
154
  if (standaloneRoot && !standaloneRoot.hasAttribute('data-handoff-component')) {
142
- hydrateRoot(standaloneRoot, <Component {...parseProps('${PLUGIN_CONSTANTS.PROPS_SCRIPT_ID}')} />);
155
+ const StandaloneComponent = resolveComponent(standaloneRoot);
156
+ hydrateRoot(standaloneRoot, <StandaloneComponent {...parseProps('${PLUGIN_CONSTANTS.PROPS_SCRIPT_ID}')} />);
143
157
  }
144
158
 
145
159
  const patternMounts = document.querySelectorAll('[data-handoff-component=' + JSON.stringify('${componentId}') + ']');
146
160
  patternMounts.forEach((mount) => {
147
- hydrateRoot(mount, <Component {...parseProps(mount.getAttribute('data-handoff-props'))} />);
161
+ const MountComponent = resolveComponent(mount);
162
+ hydrateRoot(mount, <MountComponent {...parseProps(mount.getAttribute('data-handoff-props'))} />);
148
163
  });
149
164
  `;
150
165
  }
@@ -156,11 +171,11 @@ function generateClientHydrationSource(componentId, componentPath, componentExpo
156
171
  * artifact cannot be loaded.
157
172
  * @param componentId - Component identifier
158
173
  * @param previewTitle - Title for the preview
159
- * @param renderedHtml - Server-rendered HTML content
174
+ * @param renderedHtml - Server-rendered HTML, embedded verbatim so the client can hydrate it
160
175
  * @param props - Component props as JSON
161
176
  * @returns Complete HTML document
162
177
  */
163
- function generateHtmlDocument(componentId, previewTitle, renderedHtml, props, sharedArtifacts, componentArtifacts) {
178
+ function generateHtmlDocument(componentId, previewKey, previewTitle, renderedHtml, props, sharedArtifacts, componentArtifacts) {
164
179
  var _a;
165
180
  const basePath = (_a = process.env.HANDOFF_APP_BASE_PATH) !== null && _a !== void 0 ? _a : '';
166
181
  const clientArtifactUrl = (0, url_1.buildArtifactUrl)(clientArtifactPath(componentId), basePath);
@@ -193,7 +208,7 @@ function generateHtmlDocument(componentId, previewTitle, renderedHtml, props, sh
193
208
  <title>${previewTitle}</title>
194
209
  </head>
195
210
  <body>
196
- <div id="${PLUGIN_CONSTANTS.ROOT_ELEMENT_ID}">${renderedHtml}</div>
211
+ <div id="${PLUGIN_CONSTANTS.ROOT_ELEMENT_ID}" data-handoff-preview="${previewKey}">${renderedHtml}</div>
197
212
  </body>
198
213
  </html>`;
199
214
  }
@@ -224,7 +239,7 @@ function ssrRenderPlugin(componentData, documentationComponents, handoff) {
224
239
  },
225
240
  generateBundle(_, bundle) {
226
241
  return __awaiter(this, void 0, void 0, function* () {
227
- var _a, _b;
242
+ var _a, _b, _c, _d, _e;
228
243
  // Remove all JS chunks to prevent conflicts
229
244
  for (const [fileName, chunkInfo] of Object.entries(bundle)) {
230
245
  if (chunkInfo.type === 'chunk' && fileName.includes(PLUGIN_CONSTANTS.SCRIPT_ID)) {
@@ -275,15 +290,19 @@ function ssrRenderPlugin(componentData, documentationComponents, handoff) {
275
290
  // `component/<id>.client.js` artifact. The hydration source only imports the component and
276
291
  // reads props from the in-document `__APP_PROPS__` element, so it is identical across every
277
292
  // preview of this component.
278
- const clientHydrationSource = generateClientHydrationSource(componentId, componentPath, componentData.componentExport);
293
+ const declarationPath = Object.values((_a = componentData.previews) !== null && _a !== void 0 ? _a : {}).some((preview) => typeof preview.render === 'function')
294
+ ? (_b = componentData.entries) === null || _b === void 0 ? void 0 : _b.declaration
295
+ : undefined;
296
+ const clientHydrationSource = generateClientHydrationSource(componentId, componentPath, componentData.componentExport, declarationPath);
279
297
  const clientBuildConfig = Object.assign(Object.assign({}, build_1.DEFAULT_CLIENT_BUILD_CONFIG), { logLevel: 'silent', stdin: {
280
298
  contents: clientHydrationSource,
281
299
  resolveDir: process.cwd(),
282
300
  loader: 'tsx',
283
301
  }, plugins: [(0, build_1.createReactResolvePlugin)(handoff.workingPath, handoff.modulePath)] });
284
- const finalClientBuildConfig = ((_b = (_a = handoff.config) === null || _a === void 0 ? void 0 : _a.hooks) === null || _b === void 0 ? void 0 : _b.clientBuildConfig)
302
+ const hookedClientBuildConfig = ((_d = (_c = handoff.config) === null || _c === void 0 ? void 0 : _c.hooks) === null || _d === void 0 ? void 0 : _d.clientBuildConfig)
285
303
  ? handoff.config.hooks.clientBuildConfig(clientBuildConfig)
286
304
  : clientBuildConfig;
305
+ const finalClientBuildConfig = (0, build_1.withStyleLoaders)(hookedClientBuildConfig);
287
306
  let clientBundleJs;
288
307
  try {
289
308
  const bundledClient = yield esbuild_1.default.build(finalClientBuildConfig);
@@ -315,11 +334,17 @@ function ssrRenderPlugin(componentData, documentationComponents, handoff) {
315
334
  const componentArtifacts = (0, shared_artifacts_1.resolveComponentArtifactPresence)(handoff, componentId);
316
335
  // Generate previews for each variation
317
336
  for (const previewKey in componentData.previews) {
318
- const previewProps = componentData.previews[previewKey].values;
319
- // Server-side render the component
320
- const serverRenderedHtml = server_1.default.renderToString(react_1.default.createElement(ReactComponent, previewProps));
321
- const formattedHtml = yield (0, html_1.formatHtml)(serverRenderedHtml);
322
- finalHtml = generateHtmlDocument(componentId, componentData.previews[previewKey].title, formattedHtml, previewProps, sharedArtifacts, componentArtifacts);
337
+ const preview = componentData.previews[previewKey];
338
+ const previewProps = preview.values;
339
+ // A synthetic pattern preview is not a named export, so the client looks up its
340
+ // `sourcePreview` instead. A preview without one has no `render` either.
341
+ const hydrationPreviewKey = (_e = preview.sourcePreview) !== null && _e !== void 0 ? _e : previewKey;
342
+ // Rendered as a component rather than called, so a preview can use hooks and own state.
343
+ const PreviewComponent = (typeof preview.render === 'function' ? preview.render : ReactComponent);
344
+ // The markup must reach the document exactly as rendered. Formatting it adds text nodes React
345
+ // does not expect, so it discards the server markup and re-renders the preview on the client.
346
+ const serverRenderedHtml = server_1.default.renderToString(react_1.default.createElement(PreviewComponent, previewProps));
347
+ finalHtml = generateHtmlDocument(componentId, hydrationPreviewKey, preview.title, serverRenderedHtml, previewProps, sharedArtifacts, componentArtifacts);
323
348
  // Emit preview files
324
349
  this.emitFile({
325
350
  type: 'asset',
@@ -340,7 +365,7 @@ function ssrRenderPlugin(componentData, documentationComponents, handoff) {
340
365
  templateFileName: path_1.default.basename(componentPath),
341
366
  });
342
367
  }
343
- // Format final HTML and update component data
368
+ // The previews are already emitted, so this formats only the docs code view.
344
369
  finalHtml = yield (0, html_1.formatHtml)(finalHtml);
345
370
  componentData.format = 'react';
346
371
  componentData.preview = '';
@@ -42,6 +42,7 @@ declare const buildComponentCss: (data: TransformComponentTokensResult, handoff:
42
42
  story?: string;
43
43
  schema?: string;
44
44
  templates?: string;
45
+ declaration?: string;
45
46
  };
46
47
  renderer?: import("../../../index").RendererKind;
47
48
  sourceFormat?: import("../../../index").SourceFormat;
@@ -18,6 +18,7 @@ const path_1 = __importDefault(require("path"));
18
18
  const vite_1 = require("vite");
19
19
  const duration_1 = require("../../../utils/duration");
20
20
  const logger_1 = require("../../../utils/logger");
21
+ const build_1 = require("../../utils/build");
21
22
  const vite_config_1 = __importDefault(require("../../vite-config"));
22
23
  const component_1 = require("../component");
23
24
  const { pathToFileURL } = require('url');
@@ -88,14 +89,49 @@ const buildCssBundle = (_a) => __awaiter(void 0, [_a], void 0, function* ({ entr
88
89
  }
89
90
  }
90
91
  });
92
+ const STYLE_SOURCE_EXTENSIONS = new Set(['.scss', '.sass', '.css']);
93
+ /**
94
+ * The stylesheets of one item, in cascade order and deduplicated by resolved path.
95
+ *
96
+ * Discovered imports come first and the declared entry last, so a declared entry can override what
97
+ * the component imports. A project that both declares and imports one file must build it once.
98
+ */
99
+ const resolveStyleSources = (data, handoff) => __awaiter(void 0, void 0, void 0, function* () {
100
+ var _a, _b;
101
+ const template = (_a = data.entries) === null || _a === void 0 ? void 0 : _a.template;
102
+ const discovered = template ? yield (0, build_1.collectStyleImports)(path_1.default.resolve(template), handoff) : [];
103
+ const declared = ((_b = data.entries) === null || _b === void 0 ? void 0 : _b.scss) ? [path_1.default.resolve(data.entries.scss)] : [];
104
+ const ordered = [];
105
+ const seen = new Set();
106
+ for (const source of [...discovered, ...declared]) {
107
+ if (!seen.has(source) && STYLE_SOURCE_EXTENSIONS.has(path_1.default.extname(source)) && fs_extra_1.default.existsSync(source)) {
108
+ seen.add(source);
109
+ ordered.push(source);
110
+ }
111
+ }
112
+ return ordered;
113
+ });
114
+ /**
115
+ * A single Sass entry that `@use`s every source in order.
116
+ *
117
+ * `@use` emits a repeated file once and inlines a plain `.css` source, which `@import` would leave
118
+ * as an unresolvable URL. Namespaces are explicit because two sources sharing a basename would
119
+ * claim the same default namespace and fail the compile.
120
+ */
121
+ const writeCombinedStyleEntry = (sources, outputPath, id) => __awaiter(void 0, void 0, void 0, function* () {
122
+ const entryPath = path_1.default.resolve(outputPath, `.${id}.entry.scss`);
123
+ const rules = sources.map((source, index) => `@use '${source.split(path_1.default.sep).join('/')}' as ns${index};`);
124
+ yield fs_extra_1.default.outputFile(entryPath, `${rules.join('\n')}\n`);
125
+ return entryPath;
126
+ });
91
127
  const buildComponentCss = (data, handoff) => __awaiter(void 0, void 0, void 0, function* () {
92
128
  var _a, _b, _c;
93
129
  const id = data.id;
94
130
  logger_1.Logger.debug(`buildComponentCss`, id);
95
131
  const outputPath = (0, component_1.getComponentOutputPath)(handoff);
96
132
  const builtCssPath = path_1.default.resolve(outputPath, `${id}.css`);
97
- const entry = (_a = data.entries) === null || _a === void 0 ? void 0 : _a.scss;
98
- if (!entry) {
133
+ const sources = yield resolveStyleSources(data, handoff);
134
+ if (sources.length === 0) {
99
135
  // Keep generated output aligned with the current component declaration.
100
136
  yield fs_extra_1.default.remove(builtCssPath);
101
137
  delete data['css'];
@@ -103,59 +139,61 @@ const buildComponentCss = (data, handoff) => __awaiter(void 0, void 0, void 0, f
103
139
  delete data['sharedStyles'];
104
140
  return data;
105
141
  }
106
- const extension = path_1.default.extname(entry);
107
- if (!extension) {
108
- return data;
109
- }
142
+ // A single source compiles directly, so its relative asset URLs and documented Sass stay as authored.
143
+ const combinedEntry = sources.length > 1 ? yield writeCombinedStyleEntry(sources, outputPath, id) : undefined;
144
+ const entry = combinedEntry !== null && combinedEntry !== void 0 ? combinedEntry : sources[0];
110
145
  try {
111
- if (extension === '.scss' || extension === '.css') {
112
- // Remove the previous artifact before rebuilding so "no emitted CSS"
113
- // doesn't silently preserve stale output from an earlier build.
114
- yield fs_extra_1.default.remove(builtCssPath);
115
- // Read the original source
116
- const sourceContent = yield fs_extra_1.default.readFile(entry, 'utf8');
117
- if (extension === '.scss') {
118
- data['sass'] = sourceContent;
119
- }
120
- // Setup SASS load paths
121
- const loadPaths = [
122
- path_1.default.resolve(handoff.workingPath),
123
- path_1.default.resolve(handoff.workingPath, handoff.exportsDirectory, handoff.getProjectId()),
124
- path_1.default.resolve(handoff.workingPath, 'node_modules'),
125
- ];
126
- if ((_c = (_b = handoff.runtimeConfig) === null || _b === void 0 ? void 0 : _b.entries) === null || _c === void 0 ? void 0 : _c.scss) {
127
- loadPaths.unshift(path_1.default.dirname(handoff.runtimeConfig.entries.scss));
128
- }
129
- yield buildCssBundle({
130
- entry,
131
- outputPath,
132
- outputFilename: `${id}.css`,
133
- loadPaths,
134
- handoff,
135
- });
136
- // Read the built CSS
137
- if (fs_extra_1.default.existsSync(builtCssPath)) {
138
- const builtCss = yield fs_extra_1.default.readFile(builtCssPath, 'utf8');
139
- data['css'] = builtCss;
140
- // Handle shared styles — if the component SCSS uses the split marker,
141
- // extract shared styles from the portion above it
142
- const splitCSS = builtCss.split('/* COMPONENT STYLES*/');
143
- if (splitCSS && splitCSS.length > 1) {
144
- data['css'] = splitCSS[1];
145
- data['sharedStyles'] = splitCSS[0];
146
- yield fs_extra_1.default.writeFile(path_1.default.resolve(outputPath, exports.SHARED_COMPONENT_CSS_FILE), data['sharedStyles']);
147
- }
148
- }
149
- else {
150
- delete data['css'];
151
- delete data['sharedStyles'];
146
+ // Remove the previous artifact before rebuilding so "no emitted CSS"
147
+ // doesn't silently preserve stale output from an earlier build.
148
+ yield fs_extra_1.default.remove(builtCssPath);
149
+ // Read the original source
150
+ const primarySource = ((_a = data.entries) === null || _a === void 0 ? void 0 : _a.scss) ? path_1.default.resolve(data.entries.scss) : sources[0];
151
+ if (path_1.default.extname(primarySource) === '.scss') {
152
+ data['sass'] = yield fs_extra_1.default.readFile(primarySource, 'utf8');
153
+ }
154
+ // Setup SASS load paths
155
+ const loadPaths = [
156
+ path_1.default.resolve(handoff.workingPath),
157
+ path_1.default.resolve(handoff.workingPath, handoff.exportsDirectory, handoff.getProjectId()),
158
+ path_1.default.resolve(handoff.workingPath, 'node_modules'),
159
+ ];
160
+ if ((_c = (_b = handoff.runtimeConfig) === null || _b === void 0 ? void 0 : _b.entries) === null || _c === void 0 ? void 0 : _c.scss) {
161
+ loadPaths.unshift(path_1.default.dirname(handoff.runtimeConfig.entries.scss));
162
+ }
163
+ yield buildCssBundle({
164
+ entry,
165
+ outputPath,
166
+ outputFilename: `${id}.css`,
167
+ loadPaths,
168
+ handoff,
169
+ });
170
+ // Read the built CSS
171
+ if (fs_extra_1.default.existsSync(builtCssPath)) {
172
+ const builtCss = yield fs_extra_1.default.readFile(builtCssPath, 'utf8');
173
+ data['css'] = builtCss;
174
+ // Handle shared styles — if the component SCSS uses the split marker,
175
+ // extract shared styles from the portion above it
176
+ const splitCSS = builtCss.split('/* COMPONENT STYLES*/');
177
+ if (splitCSS && splitCSS.length > 1) {
178
+ data['css'] = splitCSS[1];
179
+ data['sharedStyles'] = splitCSS[0];
180
+ yield fs_extra_1.default.writeFile(path_1.default.resolve(outputPath, exports.SHARED_COMPONENT_CSS_FILE), data['sharedStyles']);
152
181
  }
153
182
  }
183
+ else {
184
+ delete data['css'];
185
+ delete data['sharedStyles'];
186
+ }
154
187
  }
155
188
  catch (e) {
156
189
  logger_1.Logger.error(`Failed to build CSS for "${id}"`);
157
190
  throw e;
158
191
  }
192
+ finally {
193
+ if (combinedEntry) {
194
+ yield fs_extra_1.default.remove(combinedEntry);
195
+ }
196
+ }
159
197
  return data;
160
198
  });
161
199
  /**
@@ -48,7 +48,9 @@ const composePatternHtml = (_patternId, patternTitle, fragments, basePath, share
48
48
  cssHrefs.add((0, url_1.buildArtifactUrl)(`component/${componentId}.css`, basePath));
49
49
  }
50
50
  }
51
- const doc = (0, node_html_parser_1.parse)(html);
51
+ // React SSR separates adjacent text children with `<!-- -->`. The parser drops comments by
52
+ // default, which would make every composed React fragment fail to hydrate.
53
+ const doc = (0, node_html_parser_1.parse)(html, { comment: true });
52
54
  const suffix = `_p${i}`;
53
55
  const namespacedRootId = `root${suffix}`;
54
56
  const namespacedPropsId = `__APP_PROPS__${suffix}`;
@@ -14,6 +14,8 @@ export type PageSliceType = 'BEST_PRACTICES' | 'COMPONENT_DISPLAY' | 'VALIDATION
14
14
  export interface BasePageSlice {
15
15
  type: PageSliceType;
16
16
  }
17
+ /** A fluid preview renders at the width the reader's stage has, so it is never scaled down. */
18
+ export type PreviewWidth = 'fluid' | number;
17
19
  export interface BestPracticesPageSlice extends BasePageSlice {
18
20
  type: 'BEST_PRACTICES';
19
21
  }
@@ -22,6 +24,8 @@ export interface ComponentDisplayPageSlice extends BasePageSlice {
22
24
  showPreview?: boolean;
23
25
  showCodeHighlight?: boolean;
24
26
  defaultHeight?: string;
27
+ /** Viewport width for the preview, in pixels. Defaults to 'fluid'. */
28
+ defaultWidth?: PreviewWidth;
25
29
  filterBy?: Filter;
26
30
  }
27
31
  export interface ValidationResultsPageSlice extends BasePageSlice {
@@ -100,6 +104,8 @@ export type ComponentObject = {
100
104
  story?: string;
101
105
  /** Optional path to schema file (if available) */
102
106
  schema?: string;
107
+ /** Declaration file path; a preview `render` is read from it at build and hydration time. */
108
+ declaration?: string;
103
109
  /** Optional path to templates directory */
104
110
  templates?: string;
105
111
  };
@@ -187,6 +193,8 @@ export type TransformComponentTokensResult = {
187
193
  story?: string;
188
194
  schema?: string;
189
195
  templates?: string;
196
+ /** See `ComponentObject.entries.declaration`. */
197
+ declaration?: string;
190
198
  };
191
199
  renderer?: RendererKind;
192
200
  /** See `ComponentObject.sourceFormat`. Distinct from `format`, the preview's code language. */
@@ -208,6 +216,11 @@ export type OptionalPreviewRender = {
208
216
  };
209
217
  url: string;
210
218
  usage?: string;
219
+ /**
220
+ * Build-time only: the server renders it and the client re-reads it from the declaration module.
221
+ * `JSON.stringify` drops it from published artifacts.
222
+ */
223
+ render?: (args: Record<string, any>) => unknown;
211
224
  /** Optional source preview/story key used to derive this preview */
212
225
  sourcePreview?: string;
213
226
  };
@@ -1,4 +1,14 @@
1
1
  import esbuild from 'esbuild';
2
+ /**
3
+ * Keeps co-located stylesheet imports out of the JavaScript graph; the CSS pipeline compiles them.
4
+ *
5
+ * esbuild has no `.scss` loader, and a `.css` import fails too because these builds run with
6
+ * `write: false` and no output path. `empty` rather than a plugin: `esbuild.buildSync` rejects
7
+ * plugins, and the declaration and config loaders are synchronous.
8
+ *
9
+ * CSS Modules are not covered — the empty module leaves the class map undefined.
10
+ */
11
+ export declare const STYLE_IMPORT_LOADERS: Record<string, esbuild.Loader>;
2
12
  /**
3
13
  * Default esbuild configuration for SSR builds
4
14
  */
@@ -7,6 +17,15 @@ export declare const DEFAULT_SSR_BUILD_CONFIG: esbuild.BuildOptions;
7
17
  * Default esbuild configuration for client builds
8
18
  */
9
19
  export declare const DEFAULT_CLIENT_BUILD_CONFIG: esbuild.BuildOptions;
20
+ /** Apply after a consumer hook: a hook that replaces `loader` would otherwise drop these. */
21
+ export declare function withStyleLoaders<T extends esbuild.BuildOptions>(config: T): T;
22
+ /**
23
+ * Stylesheet paths imported anywhere in a component's module graph, in cascade order.
24
+ *
25
+ * An unbuildable graph yields an empty list rather than throwing, leaving the CSS step with the
26
+ * declared entry.
27
+ */
28
+ export declare function collectStyleImports(entryPath: string, handoff: any): Promise<string[]>;
10
29
  /**
11
30
  * Resolves a module from multiple search directories
12
31
  * @param id - Module ID to resolve