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.
- package/README.md +130 -14
- package/UPGRADE.md +20 -0
- package/dist/app-builder/build.js +10 -2
- package/dist/app-builder/client-config.js +3 -1
- package/dist/app-builder/watchers.js +17 -7
- package/dist/cache/build-cache.js +5 -1
- package/dist/catalog/normalize.js +3 -0
- package/dist/catalog/previews.js +5 -4
- package/dist/catalog/types.d.ts +8 -0
- package/dist/cli/auth/device.d.ts +6 -1
- package/dist/cli/auth/device.js +5 -2
- package/dist/cli/auth/store.d.ts +15 -6
- package/dist/cli/auth/store.js +72 -19
- package/dist/commands/login/index.d.ts +5 -0
- package/dist/commands/login/index.js +18 -6
- package/dist/commands/logout/index.d.ts +5 -0
- package/dist/commands/logout/index.js +54 -15
- package/dist/commands/types.d.ts +2 -0
- package/dist/commands/utils.d.ts +21 -7
- package/dist/commands/utils.js +11 -10
- package/dist/config/catalog-include.d.ts +2 -2
- package/dist/config/defaults.d.ts +3 -3
- package/dist/config/defaults.js +54 -48
- package/dist/config/env.d.ts +9 -0
- package/dist/config/env.js +43 -0
- package/dist/config/errors.d.ts +4 -0
- package/dist/config/errors.js +11 -0
- package/dist/config/from-env.d.ts +24 -0
- package/dist/config/from-env.js +7 -0
- package/dist/config/helpers.d.ts +2 -0
- package/dist/config/helpers.js +3 -1
- package/dist/config/index.d.ts +2 -1
- package/dist/config/index.js +6 -1
- package/dist/config/loader.d.ts +29 -11
- package/dist/config/loader.js +121 -46
- package/dist/config/resolve-env.d.ts +5 -0
- package/dist/config/resolve-env.js +102 -0
- package/dist/config/runtime.d.ts +3 -3
- package/dist/config/runtime.js +8 -10
- package/dist/config/validator.d.ts +2 -2
- package/dist/config/validator.js +2 -4
- package/dist/handlebars.d.ts +10 -2
- package/dist/index.d.ts +33 -4
- package/dist/index.js +60 -16
- package/dist/pipeline/index.d.ts +1 -1
- package/dist/pipeline/index.js +1 -1
- package/dist/registry/asset-storage/adapters/vercel-blob.js +1 -1
- package/dist/registry/asset-storage/resolve.d.ts +2 -2
- package/dist/registry/asset-storage/resolve.js +4 -1
- package/dist/registry/checkout/index.js +4 -4
- package/dist/registry/connection.d.ts +28 -24
- package/dist/registry/connection.js +53 -67
- package/dist/registry/db/driver.d.ts +4 -11
- package/dist/registry/db/driver.js +4 -16
- package/dist/registry/db/index.d.ts +1 -1
- package/dist/registry/db/index.js +1 -2
- package/dist/registry/publish/index.js +4 -4
- package/dist/store/filesystem.d.ts +2 -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 +54 -29
- package/docs/api.md +31 -0
- package/docs/cli.md +2 -2
- 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/app/pages/account/tokens.tsx +3 -1
- package/src/app-builder/build.ts +10 -2
- package/src/app-builder/client-config.ts +5 -3
- package/src/app-builder/watchers.ts +18 -8
- package/src/cache/build-cache.ts +5 -1
- 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/cli/auth/device.ts +8 -3
- package/src/cli/auth/store.ts +75 -18
- package/src/commands/login/index.ts +20 -6
- package/src/commands/logout/index.ts +74 -20
- package/src/commands/types.ts +2 -0
- package/src/commands/utils.ts +14 -5
- package/src/config/README.md +61 -1
- package/src/config/catalog-include.ts +2 -2
- package/src/config/defaults.ts +24 -14
- package/src/config/env.ts +41 -0
- package/src/config/errors.ts +7 -0
- package/src/config/from-env.ts +26 -0
- package/src/config/helpers.ts +4 -2
- package/src/config/index.ts +4 -1
- package/src/config/loader.ts +158 -50
- package/src/config/resolve-env.ts +94 -0
- package/src/config/runtime.ts +12 -7
- package/src/config/validator.ts +4 -6
- package/src/handlebars.ts +8 -2
- package/src/index.d.ts +2 -2
- package/src/index.ts +85 -8
- package/src/pipeline/index.ts +1 -1
- package/src/registry/asset-storage/adapters/vercel-blob.ts +1 -1
- package/src/registry/asset-storage/resolve.ts +6 -3
- package/src/registry/checkout/index.ts +5 -5
- package/src/registry/connection.ts +72 -71
- package/src/registry/db/driver.ts +6 -17
- package/src/registry/db/index.ts +0 -1
- package/src/registry/publish/index.ts +5 -5
- package/src/store/filesystem.ts +2 -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 +51 -29
- package/templates/default/env.tpl +10 -0
- package/templates/default/gitignore.tpl +7 -0
- package/templates/default/handoff.config.js.tpl +21 -172
- package/templates/default/handoff.config.ts.tpl +21 -156
- package/templates/default/handoff.config.with-examples.js.tpl +20 -171
- package/templates/default/handoff.config.with-examples.ts.tpl +20 -155
|
@@ -1,22 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
|
15
|
-
import {
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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 =
|
|
82
|
-
const accessTokenEnv =
|
|
83
|
-
|
|
84
|
-
const
|
|
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.
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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:
|
|
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
|
|
115
|
-
const
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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 {
|
|
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:
|
|
39
|
-
const configured = config?.runtime?.registry?.
|
|
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:
|
|
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:
|
|
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.
|
|
61
|
+
`PostgreSQL connection string (or change "runtime.registry.databaseUrl" to point at the env var you use).`
|
|
73
62
|
);
|
|
74
63
|
}
|
|
75
64
|
|
package/src/registry/db/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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;
|
package/src/store/filesystem.ts
CHANGED
|
@@ -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 {
|
|
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?:
|
|
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(
|
|
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}`;
|