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.
- package/README.md +19 -3
- package/dist/catalog/normalize.js +3 -0
- package/dist/catalog/previews.js +5 -4
- package/dist/catalog/types.d.ts +8 -0
- package/dist/config/loader.js +2 -0
- package/dist/config/runtime.js +8 -10
- package/dist/handlebars.d.ts +10 -2
- package/dist/transformers/plugins/ssr-render.js +40 -15
- package/dist/transformers/preview/component/css.d.ts +1 -0
- package/dist/transformers/preview/component/css.js +85 -47
- package/dist/transformers/preview/pattern/html.js +3 -1
- package/dist/transformers/preview/types.d.ts +13 -0
- package/dist/transformers/utils/build.d.ts +19 -0
- package/dist/transformers/utils/build.js +83 -1
- package/dist/transformers/utils/module.js +2 -1
- package/dist/types/config.d.ts +6 -0
- package/package.json +1 -1
- package/src/app/components/Component/PageSliceResolver.tsx +1 -0
- package/src/app/components/Component/Preview.tsx +380 -189
- package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +4 -0
- package/src/catalog/README.md +3 -0
- package/src/catalog/normalize.ts +2 -0
- package/src/catalog/previews.ts +6 -3
- package/src/catalog/types.ts +8 -0
- package/src/config/loader.ts +2 -0
- package/src/config/runtime.ts +9 -4
- package/src/handlebars.ts +8 -2
- package/src/transformers/plugins/ssr-render.ts +53 -16
- package/src/transformers/preview/component/css.ts +93 -50
- package/src/transformers/preview/pattern/html.ts +3 -1
- package/src/transformers/preview/types.ts +14 -0
- package/src/transformers/utils/build.ts +80 -0
- package/src/transformers/utils/module.ts +4 -2
- package/src/types/config.ts +6 -0
package/src/catalog/README.md
CHANGED
|
@@ -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`
|
package/src/catalog/normalize.ts
CHANGED
|
@@ -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);
|
package/src/catalog/previews.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/src/catalog/types.ts
CHANGED
|
@@ -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
|
/**
|
package/src/config/loader.ts
CHANGED
|
@@ -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;
|
package/src/config/runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
368
|
-
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
const
|
|
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
|
-
|
|
376
|
-
|
|
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
|
-
//
|
|
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
|
|
149
|
+
const sources = await resolveStyleSources(data, handoff);
|
|
107
150
|
|
|
108
|
-
if (
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
data['
|
|
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
|
-
|
|
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
|