handoff-app 2.0.0-7 → 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 (125) hide show
  1. package/README.md +130 -14
  2. package/UPGRADE.md +20 -0
  3. package/dist/app-builder/build.js +10 -2
  4. package/dist/app-builder/client-config.js +3 -1
  5. package/dist/app-builder/watchers.js +17 -7
  6. package/dist/cache/build-cache.js +5 -1
  7. package/dist/catalog/normalize.js +3 -0
  8. package/dist/catalog/previews.js +5 -4
  9. package/dist/catalog/types.d.ts +8 -0
  10. package/dist/cli/auth/device.d.ts +6 -1
  11. package/dist/cli/auth/device.js +5 -2
  12. package/dist/cli/auth/store.d.ts +15 -6
  13. package/dist/cli/auth/store.js +72 -19
  14. package/dist/commands/login/index.d.ts +5 -0
  15. package/dist/commands/login/index.js +18 -6
  16. package/dist/commands/logout/index.d.ts +5 -0
  17. package/dist/commands/logout/index.js +54 -15
  18. package/dist/commands/types.d.ts +2 -0
  19. package/dist/commands/utils.d.ts +21 -7
  20. package/dist/commands/utils.js +11 -10
  21. package/dist/config/catalog-include.d.ts +2 -2
  22. package/dist/config/defaults.d.ts +3 -3
  23. package/dist/config/defaults.js +54 -48
  24. package/dist/config/env.d.ts +9 -0
  25. package/dist/config/env.js +43 -0
  26. package/dist/config/errors.d.ts +4 -0
  27. package/dist/config/errors.js +11 -0
  28. package/dist/config/from-env.d.ts +24 -0
  29. package/dist/config/from-env.js +7 -0
  30. package/dist/config/helpers.d.ts +2 -0
  31. package/dist/config/helpers.js +3 -1
  32. package/dist/config/index.d.ts +2 -1
  33. package/dist/config/index.js +6 -1
  34. package/dist/config/loader.d.ts +29 -11
  35. package/dist/config/loader.js +121 -46
  36. package/dist/config/resolve-env.d.ts +5 -0
  37. package/dist/config/resolve-env.js +102 -0
  38. package/dist/config/runtime.d.ts +3 -3
  39. package/dist/config/runtime.js +8 -10
  40. package/dist/config/validator.d.ts +2 -2
  41. package/dist/config/validator.js +2 -4
  42. package/dist/handlebars.d.ts +10 -2
  43. package/dist/index.d.ts +33 -4
  44. package/dist/index.js +60 -16
  45. package/dist/pipeline/index.d.ts +1 -1
  46. package/dist/pipeline/index.js +1 -1
  47. package/dist/registry/asset-storage/adapters/vercel-blob.js +1 -1
  48. package/dist/registry/asset-storage/resolve.d.ts +2 -2
  49. package/dist/registry/asset-storage/resolve.js +4 -1
  50. package/dist/registry/checkout/index.js +4 -4
  51. package/dist/registry/connection.d.ts +28 -24
  52. package/dist/registry/connection.js +53 -67
  53. package/dist/registry/db/driver.d.ts +4 -11
  54. package/dist/registry/db/driver.js +4 -16
  55. package/dist/registry/db/index.d.ts +1 -1
  56. package/dist/registry/db/index.js +1 -2
  57. package/dist/registry/publish/index.js +4 -4
  58. package/dist/store/filesystem.d.ts +2 -2
  59. package/dist/transformers/plugins/ssr-render.js +40 -15
  60. package/dist/transformers/preview/component/css.d.ts +1 -0
  61. package/dist/transformers/preview/component/css.js +85 -47
  62. package/dist/transformers/preview/pattern/html.js +3 -1
  63. package/dist/transformers/preview/types.d.ts +13 -0
  64. package/dist/transformers/utils/build.d.ts +19 -0
  65. package/dist/transformers/utils/build.js +83 -1
  66. package/dist/transformers/utils/module.js +2 -1
  67. package/dist/types/config.d.ts +54 -29
  68. package/docs/api.md +31 -0
  69. package/docs/cli.md +2 -2
  70. package/package.json +1 -1
  71. package/src/app/components/Component/PageSliceResolver.tsx +1 -0
  72. package/src/app/components/Component/Preview.tsx +380 -189
  73. package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +4 -0
  74. package/src/app/pages/account/tokens.tsx +3 -1
  75. package/src/app-builder/build.ts +10 -2
  76. package/src/app-builder/client-config.ts +5 -3
  77. package/src/app-builder/watchers.ts +18 -8
  78. package/src/cache/build-cache.ts +5 -1
  79. package/src/catalog/README.md +3 -0
  80. package/src/catalog/normalize.ts +2 -0
  81. package/src/catalog/previews.ts +6 -3
  82. package/src/catalog/types.ts +8 -0
  83. package/src/cli/auth/device.ts +8 -3
  84. package/src/cli/auth/store.ts +75 -18
  85. package/src/commands/login/index.ts +20 -6
  86. package/src/commands/logout/index.ts +74 -20
  87. package/src/commands/types.ts +2 -0
  88. package/src/commands/utils.ts +14 -5
  89. package/src/config/README.md +61 -1
  90. package/src/config/catalog-include.ts +2 -2
  91. package/src/config/defaults.ts +24 -14
  92. package/src/config/env.ts +41 -0
  93. package/src/config/errors.ts +7 -0
  94. package/src/config/from-env.ts +26 -0
  95. package/src/config/helpers.ts +4 -2
  96. package/src/config/index.ts +4 -1
  97. package/src/config/loader.ts +158 -50
  98. package/src/config/resolve-env.ts +94 -0
  99. package/src/config/runtime.ts +12 -7
  100. package/src/config/validator.ts +4 -6
  101. package/src/handlebars.ts +8 -2
  102. package/src/index.d.ts +2 -2
  103. package/src/index.ts +85 -8
  104. package/src/pipeline/index.ts +1 -1
  105. package/src/registry/asset-storage/adapters/vercel-blob.ts +1 -1
  106. package/src/registry/asset-storage/resolve.ts +6 -3
  107. package/src/registry/checkout/index.ts +5 -5
  108. package/src/registry/connection.ts +72 -71
  109. package/src/registry/db/driver.ts +6 -17
  110. package/src/registry/db/index.ts +0 -1
  111. package/src/registry/publish/index.ts +5 -5
  112. package/src/store/filesystem.ts +2 -2
  113. package/src/transformers/plugins/ssr-render.ts +53 -16
  114. package/src/transformers/preview/component/css.ts +93 -50
  115. package/src/transformers/preview/pattern/html.ts +3 -1
  116. package/src/transformers/preview/types.ts +14 -0
  117. package/src/transformers/utils/build.ts +80 -0
  118. package/src/transformers/utils/module.ts +4 -2
  119. package/src/types/config.ts +51 -29
  120. package/templates/default/env.tpl +10 -0
  121. package/templates/default/gitignore.tpl +7 -0
  122. package/templates/default/handoff.config.js.tpl +21 -172
  123. package/templates/default/handoff.config.ts.tpl +21 -156
  124. package/templates/default/handoff.config.with-examples.js.tpl +20 -171
  125. package/templates/default/handoff.config.with-examples.ts.tpl +20 -155
@@ -1,22 +1,14 @@
1
1
  /**
2
- * Connected-workspace registry connection resolution.
3
- *
4
- * A connected workspace is `runtime.mode: 'workspace'` plus a `runtime.registryConnection` block
5
- * pointing at a remote registry — it is not a third runtime mode. The connection is resolved from
6
- * config + environment: the registry URL may be given inline or via an env-var *name*, and the
7
- * access token is always read from an env-var name (never persisted as a value). This is the single
8
- * source the publish (and later checkout) client reads its target + credentials from.
9
- *
10
- * The older `HANDOFF_CLOUD_*` names still work as deprecated fallbacks; see
11
- * {@link DEPRECATED_ENV_ALIASES}.
2
+ * Registry connection values resolve at config load. Saved device login credentials can fill
3
+ * missing values for CLI operations; neither source is persisted in the app's client config.
12
4
  */
13
5
 
14
- import type { Config } from '../types/config';
15
- import { cliAuthMatchesRegistry, cliAuthTokenIsValid, readCliAuth } from '../cli/auth/store';
6
+ import { configEnvName } from '../config/resolve-env';
7
+ import type { ResolvedConfig } from '../types/config';
8
+ import { type CliAuth, cliAuthMatchesRegistry, cliAuthTokenIsValid, DEFAULT_LOGIN_PROFILE, readCliAuth } from '../cli/auth/store';
16
9
  // Imported straight from the module, not the `auth` barrel, to keep the database schema off the CLI
17
10
  // startup path; this file is resolved for every command.
18
11
  import { resolveRegistrySyncSecret } from './auth/sync-secret';
19
- import { Logger } from '../utils/logger';
20
12
 
21
13
  /** Default env-var name holding the remote registry base URL. */
22
14
  export const DEFAULT_REGISTRY_URL_ENV = 'HANDOFF_REGISTRY_URL';
@@ -24,38 +16,11 @@ export const DEFAULT_REGISTRY_URL_ENV = 'HANDOFF_REGISTRY_URL';
24
16
  /** Default env-var name holding the connected-workspace access token. */
25
17
  export const DEFAULT_REGISTRY_ACCESS_TOKEN_ENV = 'HANDOFF_REGISTRY_ACCESS_TOKEN';
26
18
 
27
- /**
28
- * Old env-var names we still read, keyed by the name that replaced them. Keeps workspaces set up
29
- * from the older docs working instead of silently reporting no registry.
30
- */
31
- const DEPRECATED_ENV_ALIASES: Readonly<Record<string, string>> = {
32
- [DEFAULT_REGISTRY_URL_ENV]: 'HANDOFF_CLOUD_URL',
33
- [DEFAULT_REGISTRY_ACCESS_TOKEN_ENV]: 'HANDOFF_CLOUD_TOKEN',
34
- };
35
-
36
- /** Aliases we've already warned about, so each one warns once per run and not once per lookup. */
37
- const warnedAliases = new Set<string>();
38
-
39
- /**
40
- * Value of the named env var, falling back to its deprecated alias. Only the default names have
41
- * aliases: a custom `urlEnv`/`accessTokenEnv` is a deliberate choice, so a stray `HANDOFF_CLOUD_*`
42
- * must not stand in for it. The warning goes through {@link Logger.warn}, so
43
- * `HANDOFF_LOG_LEVEL=error` silences it in CI.
44
- */
45
- const readConnectionEnv = (name: string): string => {
46
- const configured = process.env[name]?.trim();
47
- if (configured) return configured;
48
-
49
- const alias = DEPRECATED_ENV_ALIASES[name];
50
- const inherited = alias ? process.env[alias]?.trim() : '';
51
- if (!inherited) return '';
52
-
53
- if (!warnedAliases.has(alias)) {
54
- warnedAliases.add(alias);
55
- Logger.warn(`${alias} is deprecated and will be removed in a future release; set ${name} instead.`);
56
- }
57
- return inherited;
58
- };
19
+ /** A saved login and the profile it is stored under, which is not always the selected profile. */
20
+ interface SavedLogin {
21
+ profile: string;
22
+ url: string;
23
+ }
59
24
 
60
25
  /** Fully resolved connected-workspace registry connection. */
61
26
  export interface ResolvedRegistryConnection {
@@ -67,55 +32,91 @@ export interface ResolvedRegistryConnection {
67
32
  urlEnv: string;
68
33
  /** Name of the env var the access token is read from. */
69
34
  accessTokenEnv: string;
35
+ /** Profile the saved login was looked up under. */
36
+ loginProfile: string;
37
+ /** Set when a saved login was skipped because it was issued for another registry. */
38
+ unmatchedLogin?: SavedLogin;
70
39
  }
71
40
 
72
- /**
73
- * Resolve the connected-workspace registry connection from config + environment. Never throws —
74
- * Callers inspect the resolved URL and access token to surface specific configuration errors.
75
- *
76
- * `urlEnv` and `accessTokenEnv` always report the canonical name, even when an alias supplied the
77
- * value, so errors built from them point at the variable to adopt.
78
- */
79
- export const resolveRegistryConnection = (config: Config | null | undefined): ResolvedRegistryConnection => {
41
+ /** Read values resolved at config load, retaining variable names for CLI diagnostics. */
42
+ export const resolveRegistryConnection = (config: ResolvedConfig | null | undefined): ResolvedRegistryConnection => {
80
43
  const connection = config?.runtime?.registryConnection;
81
- const urlEnv = connection?.urlEnv?.trim() || DEFAULT_REGISTRY_URL_ENV;
82
- const accessTokenEnv = connection?.accessTokenEnv?.trim() || DEFAULT_REGISTRY_ACCESS_TOKEN_ENV;
83
-
84
- const inlineUrl = connection?.url?.trim();
85
- const url = inlineUrl || readConnectionEnv(urlEnv);
86
- const accessToken = readConnectionEnv(accessTokenEnv);
44
+ const urlEnv = configEnvName(config, 'runtime.registryConnection.url') || DEFAULT_REGISTRY_URL_ENV;
45
+ const accessTokenEnv = configEnvName(config, 'runtime.registryConnection.accessToken') || DEFAULT_REGISTRY_ACCESS_TOKEN_ENV;
46
+ const url = connection?.url?.trim() || '';
47
+ const accessToken = connection?.accessToken?.trim() || '';
87
48
 
88
49
  return {
89
50
  url,
90
51
  accessToken,
91
52
  urlEnv,
92
53
  accessTokenEnv,
54
+ loginProfile: DEFAULT_LOGIN_PROFILE,
93
55
  };
94
56
  };
95
57
 
58
+ /**
59
+ * The valid login to use for a profile. A profile without its own login falls back to the default
60
+ * login, so a workspace that names profiles for other reasons keeps working after one plain
61
+ * `handoff-app login`. The fallback is safe because a token is only ever used for the registry URL
62
+ * it was issued for.
63
+ */
64
+ const readProfileLogin = async (workingPath: string, profile: string): Promise<{ auth: CliAuth; profile: string } | null> => {
65
+ const own = await readCliAuth(workingPath, profile);
66
+ if (cliAuthTokenIsValid(own)) return { auth: own!, profile };
67
+
68
+ const fallback = profile === DEFAULT_LOGIN_PROFILE ? null : await readCliAuth(workingPath);
69
+ return cliAuthTokenIsValid(fallback) ? { auth: fallback!, profile: DEFAULT_LOGIN_PROFILE } : null;
70
+ };
71
+
96
72
  /**
97
73
  * Resolve the same connection for an authenticated CLI operation, letting a saved device login fill
98
74
  * in what the environment does not provide. A valid login supplies both the registry URL and the
99
- * access token, so `handoff-app login` alone is enough to publish and checkout. Environment values
100
- * keep precedence for deterministic CI: `HANDOFF_REGISTRY_URL` (or inline config) wins over the
101
- * saved URL, and `HANDOFF_REGISTRY_ACCESS_TOKEN` wins over the saved token. The saved token only
102
- * applies to the URL it was issued for.
75
+ * access token, so `handoff-app login` alone is enough to publish and checkout. The login is the one
76
+ * saved under the selected profile, which is how one workspace addresses several registries.
77
+ * Environment values keep precedence for deterministic CI: `HANDOFF_REGISTRY_URL` (or inline config)
78
+ * wins over the saved URL, and `HANDOFF_REGISTRY_ACCESS_TOKEN` wins over the saved token. The saved
79
+ * token only applies to the URL it was issued for.
103
80
  *
104
81
  * A configured `HANDOFF_SYNC_SECRET` is the last resort. Being deployment-wide rather than personal,
105
82
  * it fills in only where there is no explicit token and no login (CI, in practice), instead of quietly
106
83
  * speaking for a developer who is signed in.
107
84
  */
108
85
  export const resolveAuthenticatedRegistryConnection = async (
109
- config: Config | null | undefined,
110
- workingPath = process.cwd()
86
+ config: ResolvedConfig | null | undefined,
87
+ workingPath = process.cwd(),
88
+ profile?: string
111
89
  ): Promise<ResolvedRegistryConnection> => {
112
90
  const connection = resolveRegistryConnection(config);
91
+ const loginProfile = profile?.trim() || DEFAULT_LOGIN_PROFILE;
113
92
 
114
- const auth = await readCliAuth(workingPath);
115
- const login = cliAuthTokenIsValid(auth) ? auth! : null;
93
+ const login = await readProfileLogin(workingPath, loginProfile);
94
+ const url = connection.url || login?.auth.remoteUrl || '';
95
+ const matches = Boolean(login) && cliAuthMatchesRegistry(login!.auth, url);
96
+ const accessToken = connection.accessToken || (matches ? login!.auth.accessToken : '') || resolveRegistrySyncSecret();
116
97
 
117
- const url = connection.url || login?.remoteUrl || '';
118
- const savedToken = login && cliAuthMatchesRegistry(login, url) ? login.accessToken : '';
119
- const accessToken = connection.accessToken || savedToken || resolveRegistrySyncSecret();
120
- return { ...connection, url, accessToken };
98
+ return {
99
+ ...connection,
100
+ url,
101
+ accessToken,
102
+ loginProfile,
103
+ unmatchedLogin: login && !matches ? { profile: login.profile, url: login.auth.remoteUrl } : undefined,
104
+ };
121
105
  };
106
+
107
+ /** The `handoff-app login` call that would authorize this connection, naming the selected profile. */
108
+ export const loginCommandHint = (connection: ResolvedRegistryConnection): string =>
109
+ [
110
+ 'handoff-app login',
111
+ connection.loginProfile === DEFAULT_LOGIN_PROFILE ? '' : ` --profile ${connection.loginProfile}`,
112
+ ` --url ${connection.url || '<registry-url>'}`,
113
+ ].join('');
114
+
115
+ /**
116
+ * Names a saved login that was skipped because it belongs to another registry. Without it, a run
117
+ * that resolves its URL from the environment reports a missing token while a login is in place.
118
+ */
119
+ export const unusedLoginNote = (connection: ResolvedRegistryConnection): string =>
120
+ connection.unmatchedLogin
121
+ ? ` The login saved for profile "${connection.unmatchedLogin.profile}" is for ${connection.unmatchedLogin.url}, not ${connection.url}.`
122
+ : '';
@@ -10,7 +10,7 @@
10
10
  * the value is never stored in config.
11
11
  */
12
12
 
13
- import type { Config } from '../../types/config';
13
+ import type { ResolvedConfig } from '../../types/config';
14
14
 
15
15
  /** Built-in registry database connection driver. Both ship with the package and target Postgres. */
16
16
  export type RegistryDatabaseDriver = 'pg' | 'neon';
@@ -35,24 +35,13 @@ export interface ResolvedRegistryDatabase {
35
35
  }
36
36
 
37
37
  /** Resolve the env-var name holding the database URL (defaults to `DATABASE_URL`). */
38
- export const resolveDatabaseUrlEnv = (config: Config | null | undefined): string => {
39
- const configured = config?.runtime?.registry?.databaseUrlEnv?.trim();
38
+ export const resolveDatabaseUrlEnv = (config: ResolvedConfig | null | undefined): string => {
39
+ const configured = config?.runtime?.registry?.databaseUrl?.$env?.trim();
40
40
  return configured || DEFAULT_DATABASE_URL_ENV;
41
41
  };
42
42
 
43
- /**
44
- * Resolve the legacy fixed-token env-var name for configuration compatibility.
45
- *
46
- * @deprecated The returned env var is intentionally ignored by registry authorization. Use a
47
- * user-issued access token or `handoff-app login`.
48
- */
49
- export const resolveApiTokenEnv = (config: Config | null | undefined): string => {
50
- const configured = config?.runtime?.registry?.apiTokenEnv?.trim();
51
- return configured || DEFAULT_REGISTRY_API_TOKEN_ENV;
52
- };
53
-
54
43
  /** Resolve the configured registry database driver (defaults to `pg`). */
55
- export const resolveRegistryDriver = (config: Config | null | undefined): RegistryDatabaseDriver => {
44
+ export const resolveRegistryDriver = (config: ResolvedConfig | null | undefined): RegistryDatabaseDriver => {
56
45
  return config?.runtime?.registry?.database?.driver ?? DEFAULT_REGISTRY_DRIVER;
57
46
  };
58
47
 
@@ -61,7 +50,7 @@ export const resolveRegistryDriver = (config: Config | null | undefined): Regist
61
50
  * when the configured env var is unset so the CLI can surface a clear "missing database URL"
62
51
  * message instead of failing deep inside the driver.
63
52
  */
64
- export const resolveRegistryDatabase = (config: Config | null | undefined): ResolvedRegistryDatabase => {
53
+ export const resolveRegistryDatabase = (config: ResolvedConfig | null | undefined): ResolvedRegistryDatabase => {
65
54
  const driver = resolveRegistryDriver(config);
66
55
  const databaseUrlEnv = resolveDatabaseUrlEnv(config);
67
56
  const connectionString = process.env[databaseUrlEnv]?.trim();
@@ -69,7 +58,7 @@ export const resolveRegistryDatabase = (config: Config | null | undefined): Reso
69
58
  if (!connectionString) {
70
59
  throw new Error(
71
60
  `Registry database URL is not configured. Set the "${databaseUrlEnv}" environment variable to a ` +
72
- `PostgreSQL connection string (or change "runtime.registry.databaseUrlEnv" to point at the env var you use).`
61
+ `PostgreSQL connection string (or change "runtime.registry.databaseUrl" to point at the env var you use).`
73
62
  );
74
63
  }
75
64
 
@@ -29,7 +29,6 @@ export {
29
29
  DEFAULT_DATABASE_URL_ENV,
30
30
  DEFAULT_REGISTRY_DRIVER,
31
31
  DEFAULT_REGISTRY_API_TOKEN_ENV,
32
- resolveApiTokenEnv,
33
32
  resolveDatabaseUrlEnv,
34
33
  resolveRegistryDriver,
35
34
  resolveRegistryDatabase,
@@ -17,7 +17,7 @@ import processComponents from '../../transformers/preview/component/builder';
17
17
  import { processPatterns } from '../../transformers/preview/pattern/builder';
18
18
  import { Logger } from '../../utils/logger';
19
19
  import { createRegistryClient, RegistryClientError, type RegistryClient } from '../client';
20
- import { resolveAuthenticatedRegistryConnection } from '../connection';
20
+ import { loginCommandHint, resolveAuthenticatedRegistryConnection, unusedLoginNote } from '../connection';
21
21
  import type { EntitySummary, TransferEntityKind } from '../transfer';
22
22
  import { quoteIds, selectIds, splitCatalogIds } from '../selection';
23
23
  import { describePublishError, describeUploadFailure } from './errors';
@@ -74,17 +74,17 @@ const assertUniqueCatalogIds = (handoff: Handoff): void => {
74
74
  export const resolveConnectionOrThrow = async (handoff: Handoff) => {
75
75
  assertPublishableWorkspace(handoff);
76
76
 
77
- const connection = await resolveAuthenticatedRegistryConnection(handoff.config, handoff.workingPath);
77
+ const connection = await resolveAuthenticatedRegistryConnection(handoff.config, handoff.workingPath, handoff.getProfile());
78
78
  if (!connection.url) {
79
79
  throw new PublishError(
80
- `No registry is configured. Run \`handoff-app login --url <registry-url>\`, set runtime.registryConnection.url, ` +
80
+ `No registry is configured. Run \`${loginCommandHint(connection)}\`, set runtime.registryConnection.url, ` +
81
81
  `or set the "${connection.urlEnv}" environment variable to the base URL of the registry to publish to.`
82
82
  );
83
83
  }
84
84
  if (!connection.accessToken) {
85
85
  throw new PublishError(
86
- `No registry access token is configured. Run \`handoff-app login --url ${connection.url}\`, or set the ` +
87
- `"${connection.accessTokenEnv}" environment variable to a user-issued token for CI.`
86
+ `No registry access token is configured. Run \`${loginCommandHint(connection)}\`, or set the ` +
87
+ `"${connection.accessTokenEnv}" environment variable to a user-issued token for CI.${unusedLoginNote(connection)}`
88
88
  );
89
89
  }
90
90
  return connection;
@@ -14,7 +14,7 @@ import path from 'path';
14
14
  import { resolveTokenTransformers, tokenArtifactPathsForSet } from '../pipeline/token-transformers';
15
15
  import { deriveTokenSets, isComponentSet, setNameForId } from '../registry/tokens/sets';
16
16
  import type { ComponentListObject, PageListObject, PatternListObject } from '../transformers/preview/types';
17
- import type { Config, RuntimeConfig } from '../types/config';
17
+ import type { ResolvedConfig, RuntimeConfig } from '../types/config';
18
18
  import { getRelatedSourceFilesForRecord, sourceContentTypeForPath } from './source-files';
19
19
  import type {
20
20
  ComponentStore,
@@ -34,7 +34,7 @@ import type {
34
34
  */
35
35
  export interface FilesystemStoreContext {
36
36
  runtimeConfig?: RuntimeConfig | null;
37
- config?: Config | null;
37
+ config?: ResolvedConfig | null;
38
38
  /** Workspace root (`Handoff.workingPath`), holding `public/` and `fonts/`. */
39
39
  workingPath?: string;
40
40
  /** Absolute path to the generated `tokens.json` (`Handoff.getTokensFilePath()`). */
@@ -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}`;