@znight/cli 0.0.0-stage → 0.1.1

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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +482 -2
  3. package/bin/znight.mjs +17 -0
  4. package/dist/cli.d.mts +1 -0
  5. package/dist/cli.mjs +13 -0
  6. package/dist/cli.mjs.map +1 -0
  7. package/dist/dev/orphan-guard.d.mts +1 -0
  8. package/dist/dev/orphan-guard.mjs +47 -0
  9. package/dist/dev/orphan-guard.mjs.map +1 -0
  10. package/dist/index.d.mts +679 -0
  11. package/dist/index.mjs +1 -0
  12. package/dist/main-BWsiUsxN.mjs +3865 -0
  13. package/dist/main-BWsiUsxN.mjs.map +1 -0
  14. package/dist/orphan-Dn-2xMBd.mjs +45 -0
  15. package/dist/orphan-Dn-2xMBd.mjs.map +1 -0
  16. package/package.json +71 -4
  17. package/src/cli.ts +11 -0
  18. package/src/command.ts +217 -0
  19. package/src/commands/build.ts +159 -0
  20. package/src/commands/dev.ts +249 -0
  21. package/src/commands/generate.ts +304 -0
  22. package/src/commands/new.ts +245 -0
  23. package/src/commands/openapi.ts +160 -0
  24. package/src/commands/schematic.ts +110 -0
  25. package/src/commands/shared.ts +106 -0
  26. package/src/commands/start.ts +79 -0
  27. package/src/context.ts +126 -0
  28. package/src/dev/orphan-guard.ts +49 -0
  29. package/src/dev/orphan.ts +74 -0
  30. package/src/dev/supervisor.ts +175 -0
  31. package/src/errors.ts +64 -0
  32. package/src/index.ts +73 -0
  33. package/src/integrations/compiler.ts +360 -0
  34. package/src/integrations/openapi.ts +97 -0
  35. package/src/integrations/process.ts +180 -0
  36. package/src/integrations/prompts.ts +108 -0
  37. package/src/integrations/resolve.ts +38 -0
  38. package/src/integrations/vite.ts +362 -0
  39. package/src/integrations/watch.ts +243 -0
  40. package/src/main.ts +195 -0
  41. package/src/names.ts +106 -0
  42. package/src/output.ts +110 -0
  43. package/src/runtime.ts +86 -0
  44. package/src/scaffold/create.ts +65 -0
  45. package/src/scaffold/options.ts +103 -0
  46. package/src/scaffold/templates.ts +771 -0
  47. package/src/scaffold/versions.ts +25 -0
  48. package/src/schematics/index.ts +422 -0
  49. package/src/version.ts +20 -0
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Interactive prompts of `znight new`, backed by `@clack/prompts` (loaded lazily). A cancelled
3
+ * prompt (Ctrl+C, Escape) throws a {@link CancelledError}.
4
+ */
5
+ import { CancelledError } from '../errors.ts'
6
+
7
+ /** A choice of a select / multiselect prompt. */
8
+ export interface PromptChoice<T extends string> {
9
+ readonly value: T
10
+ readonly label: string
11
+ readonly hint?: string
12
+ }
13
+
14
+ /** Prompts used by the CLI. */
15
+ export interface Prompts {
16
+ intro(title: string): void
17
+ outro(message: string): void
18
+ text(options: {
19
+ message: string
20
+ placeholder?: string
21
+ initialValue?: string
22
+ validate?: (value: string) => string | undefined
23
+ }): Promise<string>
24
+ select<T extends string>(options: {
25
+ message: string
26
+ choices: readonly PromptChoice<T>[]
27
+ initialValue?: T
28
+ }): Promise<T>
29
+ multiselect<T extends string>(options: {
30
+ message: string
31
+ choices: readonly PromptChoice<T>[]
32
+ initialValues?: readonly T[]
33
+ }): Promise<T[]>
34
+ confirm(options: { message: string; initialValue?: boolean }): Promise<boolean>
35
+ /** A spinner; `stop` prints the final message. */
36
+ spinner(): { start(message: string): void; stop(message: string, failed?: boolean): void }
37
+ note(message: string, title?: string): void
38
+ }
39
+
40
+ /** Loads `@clack/prompts` and adapts it to {@link Prompts}. */
41
+ export async function loadPrompts(): Promise<Prompts> {
42
+ const clack = await import('@clack/prompts')
43
+ const unwrap = <T>(value: T): Exclude<T, symbol> => {
44
+ if (clack.isCancel(value) || typeof value === 'symbol') throw new CancelledError('Operation cancelled')
45
+ return value as Exclude<T, symbol>
46
+ }
47
+ const toOptions = (choices: readonly PromptChoice<string>[]): { value: string; label: string; hint?: string }[] =>
48
+ choices.map(choice => ({
49
+ value: choice.value,
50
+ label: choice.label,
51
+ ...(choice.hint === undefined ? {} : { hint: choice.hint }),
52
+ }))
53
+ return {
54
+ intro: title => clack.intro(title),
55
+ outro: message => clack.outro(message),
56
+ text: async options =>
57
+ unwrap(
58
+ await clack.text({
59
+ message: options.message,
60
+ ...(options.placeholder === undefined ? {} : { placeholder: options.placeholder }),
61
+ ...(options.initialValue === undefined ? {} : { initialValue: options.initialValue }),
62
+ ...(options.validate === undefined
63
+ ? {}
64
+ : { validate: (value: string | undefined) => options.validate?.(value ?? '') }),
65
+ }),
66
+ ),
67
+ select: async <T extends string>(options: {
68
+ message: string
69
+ choices: readonly PromptChoice<T>[]
70
+ initialValue?: T
71
+ }): Promise<T> => {
72
+ const value = await clack.select<string>({
73
+ message: options.message,
74
+ options: toOptions(options.choices),
75
+ ...(options.initialValue === undefined ? {} : { initialValue: options.initialValue }),
76
+ })
77
+ return unwrap(value) as T
78
+ },
79
+ multiselect: async <T extends string>(options: {
80
+ message: string
81
+ choices: readonly PromptChoice<T>[]
82
+ initialValues?: readonly T[]
83
+ }): Promise<T[]> => {
84
+ const value = await clack.multiselect<string>({
85
+ message: options.message,
86
+ required: false,
87
+ options: toOptions(options.choices),
88
+ ...(options.initialValues === undefined ? {} : { initialValues: [...options.initialValues] }),
89
+ })
90
+ return unwrap(value) as T[]
91
+ },
92
+ confirm: async options =>
93
+ unwrap(
94
+ await clack.confirm({
95
+ message: options.message,
96
+ ...(options.initialValue === undefined ? {} : { initialValue: options.initialValue }),
97
+ }),
98
+ ),
99
+ spinner: () => {
100
+ const spinner = clack.spinner()
101
+ return {
102
+ start: message => spinner.start(message),
103
+ stop: (message, failed = false) => (failed ? spinner.error(message) : spinner.stop(message)),
104
+ }
105
+ },
106
+ note: (message, title) => clack.note(message, title),
107
+ }
108
+ }
@@ -0,0 +1,38 @@
1
+ import { createRequire } from 'node:module'
2
+ import { join } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+
5
+ /**
6
+ * Imports `specifier` as resolved from the project `root` (the versions the project installed, like
7
+ * `vite` and `tsc` do), or with `fallback` (resolved from the CLI) when the project does not have it.
8
+ */
9
+ export async function importFromProject(
10
+ specifier: string,
11
+ root: string,
12
+ fallback: () => Promise<unknown>,
13
+ ): Promise<unknown> {
14
+ const resolved = resolveFromProject(specifier, root)
15
+ if (resolved !== undefined) {
16
+ try {
17
+ return await import(pathToFileURL(resolved).href)
18
+ } catch (error) {
19
+ // a broken project install must not hide behind the fallback
20
+ if (!isModuleNotFound(error)) throw error
21
+ }
22
+ }
23
+ return fallback()
24
+ }
25
+
26
+ /** Absolute path of `specifier` resolved from `root`, or `undefined`. */
27
+ export function resolveFromProject(specifier: string, root: string): string | undefined {
28
+ try {
29
+ return createRequire(join(root, 'package.json')).resolve(specifier)
30
+ } catch {
31
+ return undefined
32
+ }
33
+ }
34
+
35
+ function isModuleNotFound(error: unknown): boolean {
36
+ const code = (error as { code?: unknown } | null)?.code
37
+ return code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND'
38
+ }
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Vite, loaded lazily from the project (it is a peer dependency). `znight build` and `znight dev` run
3
+ * `vite build` programmatically: the `znight()` plugin of the project's `vite.config.ts` compiles the
4
+ * container and, with its `app` defaults, configures a server build (`src/main.ts` → `dist/`).
5
+ *
6
+ * The Vite root is resolved like the Vite CLI run in the project directory: the `root` of the config
7
+ * file, relative to the project root (`--root`), else the project root ({@link projectRootPlugin}).
8
+ */
9
+ import type { InlineConfig, Plugin } from 'vite'
10
+ import type { Diagnostic, StringAliasLike, ZnightOptions } from './compiler.ts'
11
+
12
+ import { existsSync } from 'node:fs'
13
+ import { join, resolve } from 'node:path'
14
+
15
+ import { CliError } from '../errors.ts'
16
+ import { importFromProject } from './resolve.ts'
17
+
18
+ /** Vite log levels. */
19
+ export type ViteLogLevel = 'info' | 'warn' | 'error' | 'silent'
20
+
21
+ /** How Vite loads its config file (`--configLoader` of the Vite CLI). */
22
+ export type ViteConfigLoader = 'bundle' | 'runner' | 'native'
23
+
24
+ /** What the CLI reads from a config resolved by Vite (`ResolvedConfig`). */
25
+ export interface ResolvedConfigLike {
26
+ /** Absolute root. */
27
+ readonly root: string
28
+ /** The config file that was loaded. */
29
+ readonly configFile?: string | undefined
30
+ /** Every plugin, flattened. */
31
+ readonly plugins: readonly unknown[]
32
+ readonly resolve?: { readonly alias?: unknown } | undefined
33
+ }
34
+
35
+ /** Subset of the `vite` module used by the CLI. */
36
+ export interface ViteApi {
37
+ build(config: InlineConfig): Promise<unknown>
38
+ /** Resolves the config as a build would, without building (absent: `znight generate` uses defaults). */
39
+ resolveConfig?: (
40
+ config: InlineConfig,
41
+ command: 'build' | 'serve',
42
+ defaultMode?: string,
43
+ defaultNodeEnv?: string,
44
+ ) => Promise<ResolvedConfigLike>
45
+ }
46
+
47
+ /** Events of a Vite build in watch mode (Rolldown/Rollup watcher events). */
48
+ export type WatcherEvent =
49
+ | { readonly code: 'START' }
50
+ | { readonly code: 'BUNDLE_START' }
51
+ | { readonly code: 'BUNDLE_END'; readonly duration?: number; readonly result?: ClosableBuild }
52
+ | { readonly code: 'END' }
53
+ | { readonly code: 'ERROR'; readonly error: unknown; readonly result?: ClosableBuild }
54
+
55
+ interface ClosableBuild {
56
+ close(): unknown
57
+ }
58
+
59
+ /** The watcher returned by `vite.build({ build: { watch } })`. */
60
+ export interface BuildWatcher {
61
+ on(event: 'event', listener: (event: WatcherEvent) => void): unknown
62
+ close(): Promise<void>
63
+ }
64
+
65
+ /** Loads `vite` as installed in the project. */
66
+ export async function loadVite(root: string): Promise<ViteApi> {
67
+ try {
68
+ return (await importFromProject('vite', root, () => import('vite'))) as ViteApi
69
+ } catch (error) {
70
+ throw new CliError('Cannot load vite', {
71
+ cause: error,
72
+ hints: ['install it in your project: npm install -D vite @znight/compiler'],
73
+ })
74
+ }
75
+ }
76
+
77
+ /** Options shared by {@link runBuild} and {@link watchBuild}. */
78
+ export interface BuildOptions {
79
+ readonly root: string
80
+ /** Vite config file; default: auto-detected `vite.config.*`. */
81
+ readonly configFile?: string | undefined
82
+ readonly mode: string
83
+ readonly logLevel: ViteLogLevel
84
+ /** How the config file is loaded (default: Vite's, `bundle`). */
85
+ readonly configLoader?: ViteConfigLoader | undefined
86
+ /** Plugins added to the config file's ones. */
87
+ readonly plugins?: readonly Plugin[]
88
+ }
89
+
90
+ /** The outcome of the compile of the `znight()` plugin (`plugin.api.result`, @znight/compiler). */
91
+ export interface CompileOutcome {
92
+ /** No error diagnostic. */
93
+ readonly ok: boolean
94
+ readonly diagnostics: readonly Diagnostic[]
95
+ }
96
+
97
+ /** What the CLI reads from the `plugin.api` of `znight()` (structural: any compiler version). */
98
+ interface ZnightPluginApiLike {
99
+ /** The latest compile result. */
100
+ readonly result?: unknown
101
+ }
102
+
103
+ /**
104
+ * Captures what a build produced: the entry chunk written (the file `znight dev` runs) and the
105
+ * compile result of the `znight()` plugin. In watch mode the plugin does not fail the build on
106
+ * compile errors (the bundle contains a module throwing them): `znight dev` reads the result to keep
107
+ * the last good process running.
108
+ */
109
+ export interface OutputTracker {
110
+ readonly plugin: Plugin
111
+ /** Absolute path of the entry chunk written by the last build. */
112
+ entry(): string | undefined
113
+ /** `performance.now()` when the last build started (`undefined` before the first one). */
114
+ startedAt(): number | undefined
115
+ /**
116
+ * The compile result the last written build was made of (`plugin.api.result` of `znight()`),
117
+ * `undefined` when the Vite config has no `znight()` plugin exposing it.
118
+ */
119
+ compile(): CompileOutcome | undefined
120
+ }
121
+
122
+ /** Creates an {@link OutputTracker}; relative output paths are resolved against the Vite root. */
123
+ export function createOutputTracker(root: string): OutputTracker {
124
+ let entry: string | undefined
125
+ let started: number | undefined
126
+ let api: ZnightPluginApiLike | undefined
127
+ let compile: CompileOutcome | undefined
128
+ let viteRoot = root
129
+ const plugin: Plugin = {
130
+ name: 'znight:cli-output',
131
+ configResolved(config) {
132
+ api = findZnightApi(config.plugins)
133
+ if (typeof config.root === 'string') viteRoot = config.root
134
+ },
135
+ buildStart() {
136
+ started = performance.now()
137
+ compile = undefined
138
+ },
139
+ writeBundle(outputOptions, bundle) {
140
+ // the plugin compiled in its buildStart: its latest result is the one this bundle contains
141
+ compile = compileOutcome(api?.result)
142
+ const chunks = Object.values(bundle).filter(
143
+ (output): output is Extract<(typeof bundle)[string], { type: 'chunk' }> =>
144
+ output.type === 'chunk' && output.isEntry,
145
+ )
146
+ const chunk = chunks.find(candidate => candidate.name === 'main') ?? chunks[0]
147
+ if (chunk === undefined) return
148
+ entry =
149
+ outputOptions.file === undefined
150
+ ? resolve(viteRoot, outputOptions.dir ?? 'dist', chunk.fileName)
151
+ : resolve(viteRoot, outputOptions.file)
152
+ },
153
+ }
154
+ return { plugin, entry: () => entry, startedAt: () => started, compile: () => compile }
155
+ }
156
+
157
+ /** The `plugin.api` of the `znight()` plugin among the resolved plugins. */
158
+ function findZnightApi(plugins: readonly unknown[]): ZnightPluginApiLike | undefined {
159
+ for (const plugin of plugins) {
160
+ if (typeof plugin !== 'object' || plugin === null || (plugin as { name?: unknown }).name !== 'znight') continue
161
+ const api = (plugin as { api?: unknown }).api
162
+ if (typeof api === 'object' && api !== null && 'result' in api) return api as ZnightPluginApiLike
163
+ }
164
+ return undefined
165
+ }
166
+
167
+ function compileOutcome(result: unknown): CompileOutcome | undefined {
168
+ if (typeof result !== 'object' || result === null) return undefined
169
+ const { ok, diagnostics } = result as { ok?: unknown; diagnostics?: unknown }
170
+ if (typeof ok !== 'boolean') return undefined
171
+ return { ok, diagnostics: Array.isArray(diagnostics) ? (diagnostics as Diagnostic[]) : [] }
172
+ }
173
+
174
+ /** Name of {@link projectRootPlugin}. */
175
+ const ROOT_PLUGIN = 'znight:cli-root'
176
+
177
+ /**
178
+ * Resolves the Vite root like the Vite CLI run in the project directory: the `root` of the config
179
+ * file against `projectRoot`, or `projectRoot` without one. An inline `root` would override the
180
+ * config file's, so the CLI sets it from the first `config` hook instead.
181
+ */
182
+ export function projectRootPlugin(projectRoot: string): Plugin {
183
+ return {
184
+ name: ROOT_PLUGIN,
185
+ config: {
186
+ order: 'pre',
187
+ handler: config => ({ root: resolve(projectRoot, config.root ?? '.') }),
188
+ },
189
+ }
190
+ }
191
+
192
+ function inlineConfig(options: BuildOptions, watch: boolean): InlineConfig {
193
+ return {
194
+ // found in the project root (not the working directory); no config file: none is searched
195
+ configFile: findViteConfig(options.root, options.configFile) ?? false,
196
+ mode: options.mode,
197
+ logLevel: options.logLevel,
198
+ ...(options.configLoader === undefined ? {} : { configLoader: options.configLoader }),
199
+ clearScreen: false,
200
+ plugins: [projectRootPlugin(options.root), ...(options.plugins ?? [])],
201
+ build: watch ? { watch: {} } : {},
202
+ }
203
+ }
204
+
205
+ /** Runs `vite build` once. Throws the build error. */
206
+ export async function runBuild(vite: ViteApi, options: BuildOptions): Promise<void> {
207
+ const result = await vite.build(inlineConfig(options, false))
208
+ if (isWatcher(result)) await result.close()
209
+ }
210
+
211
+ /** Starts `vite build --watch`. */
212
+ export async function watchBuild(vite: ViteApi, options: BuildOptions): Promise<BuildWatcher> {
213
+ const result = await vite.build(inlineConfig(options, true))
214
+ if (!isWatcher(result)) {
215
+ throw new CliError('vite build did not start in watch mode', {
216
+ hints: ['check that your Vite config does not override "build.watch"'],
217
+ })
218
+ }
219
+ return result
220
+ }
221
+
222
+ function isWatcher(value: unknown): value is BuildWatcher {
223
+ return (
224
+ typeof value === 'object' &&
225
+ value !== null &&
226
+ typeof (value as Partial<BuildWatcher>).on === 'function' &&
227
+ typeof (value as Partial<BuildWatcher>).close === 'function'
228
+ )
229
+ }
230
+
231
+ /** Vite's `DEFAULT_CONFIG_FILES`, in its order. */
232
+ const CONFIG_FILES = [
233
+ 'vite.config.js',
234
+ 'vite.config.mjs',
235
+ 'vite.config.ts',
236
+ 'vite.config.cjs',
237
+ 'vite.config.mts',
238
+ 'vite.config.cts',
239
+ ]
240
+
241
+ /** The Vite config file of the project (the one Vite would load from `root`), if any. */
242
+ export function findViteConfig(root: string, configFile?: string): string | undefined {
243
+ if (configFile !== undefined) return resolve(root, configFile)
244
+ return CONFIG_FILES.map(file => join(root, file)).find(file => existsSync(file))
245
+ }
246
+
247
+ /** Result of {@link readZnightPluginOptions}. */
248
+ export interface ZnightPluginLookup {
249
+ /** The Vite config file that was loaded. */
250
+ readonly configFile: string
251
+ /** Whether the config registers the `znight()` plugin. */
252
+ readonly found: boolean
253
+ /**
254
+ * The options the `znight()` plugin compiles with: those given to it (`plugin.api.options`) with
255
+ * the absolute `root` (resolved against the Vite root) and, as derived from the Vite config, the
256
+ * string `aliases` of `resolve.alias` and the `runtimeConditions` (when the plugin exposes its
257
+ * compiler).
258
+ */
259
+ readonly options?: ZnightOptions | undefined
260
+ }
261
+
262
+ /**
263
+ * Resolves the project's Vite config as `znight build` does and reads the options the `znight()`
264
+ * plugin compiles with, so that `znight generate` compiles exactly like the Vite build. Returns
265
+ * `undefined` without a config file. `process.env.NODE_ENV`, which Vite sets, is left untouched.
266
+ */
267
+ export async function readZnightPluginOptions(
268
+ vite: ViteApi,
269
+ root: string,
270
+ configFile?: string,
271
+ configLoader?: ViteConfigLoader,
272
+ ): Promise<ZnightPluginLookup | undefined> {
273
+ const file = findViteConfig(root, configFile)
274
+ const resolveConfig = vite.resolveConfig
275
+ if (file === undefined || resolveConfig === undefined) return undefined
276
+ const inline: InlineConfig = {
277
+ configFile: file,
278
+ mode: 'production',
279
+ logLevel: 'silent',
280
+ ...(configLoader === undefined ? {} : { configLoader }),
281
+ plugins: [projectRootPlugin(root)],
282
+ }
283
+ const config = await preservingNodeEnv(() => resolveConfig.call(vite, inline, 'build', 'production', 'production'))
284
+ const loaded = config.configFile ?? file
285
+ for (const plugin of config.plugins) {
286
+ if (typeof plugin !== 'object' || plugin === null) continue
287
+ const name = (plugin as { name?: unknown }).name
288
+ if (typeof name !== 'string' || !isZnightPluginName(name)) continue
289
+ return { configFile: loaded, found: true, options: compileOptionsOf(plugin, config) }
290
+ }
291
+ return { configFile: loaded, found: false }
292
+ }
293
+
294
+ /** `znight` and its sub-plugins (`znight:*`), not the CLI's own plugins. */
295
+ function isZnightPluginName(name: string): boolean {
296
+ return name === 'znight' || (name.startsWith('znight:') && !name.startsWith('znight:cli-'))
297
+ }
298
+
299
+ async function preservingNodeEnv<T>(run: () => Promise<T>): Promise<T> {
300
+ const nodeEnv = process.env['NODE_ENV']
301
+ try {
302
+ return await run()
303
+ } finally {
304
+ if (nodeEnv === undefined) delete process.env['NODE_ENV']
305
+ else process.env['NODE_ENV'] = nodeEnv
306
+ }
307
+ }
308
+
309
+ const isObject = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
310
+
311
+ /** {@link ZnightPluginLookup.options}: the compiler's resolved options when exposed, else derived. */
312
+ function compileOptionsOf(plugin: object, config: ResolvedConfigLike): ZnightOptions {
313
+ const api = (plugin as { api?: unknown }).api
314
+ let options: ZnightOptions = {}
315
+ let resolved: Record<string, unknown> | undefined
316
+ if (isObject(api)) {
317
+ const value: unknown = typeof api['getOptions'] === 'function' ? api['getOptions'].call(api) : api['options']
318
+ if (isObject(value)) options = value as ZnightOptions
319
+ const compiler = api['compiler']
320
+ if (isObject(compiler) && isObject(compiler['options'])) resolved = compiler['options']
321
+ }
322
+ const root = typeof resolved?.['root'] === 'string' ? resolved['root'] : resolve(config.root, options.root ?? '.')
323
+ const aliases = Array.isArray(resolved?.['aliases'])
324
+ ? (resolved['aliases'] as StringAliasLike[])
325
+ : stringAliases(config.resolve?.alias)
326
+ const conditions = resolved?.['runtimeConditions']
327
+ return {
328
+ ...options,
329
+ root,
330
+ ...(aliases.length > 0 ? { aliases } : {}),
331
+ ...(Array.isArray(conditions) ? { runtimeConditions: conditions as string[] } : {}),
332
+ }
333
+ }
334
+
335
+ /** The string entries of Vite's (resolved) `resolve.alias`: the compiler ignores RegExp aliases. */
336
+ function stringAliases(alias: unknown): StringAliasLike[] {
337
+ if (!Array.isArray(alias)) return []
338
+ return alias.filter(
339
+ (entry): entry is StringAliasLike =>
340
+ isObject(entry) && typeof entry['find'] === 'string' && typeof entry['replacement'] === 'string',
341
+ )
342
+ }
343
+
344
+ /** Message of a Vite/Rolldown build error, with its location and code frame when available. */
345
+ export function formatBuildError(error: unknown): string {
346
+ if (!(error instanceof Error)) return String(error)
347
+ const details = error as Error & {
348
+ plugin?: string
349
+ id?: string
350
+ loc?: { file?: string; line: number; column: number }
351
+ frame?: string
352
+ }
353
+ const lines = [details.plugin === undefined ? error.message : `[${details.plugin}] ${error.message}`]
354
+ const file = details.loc?.file ?? details.id
355
+ if (file !== undefined && !error.message.includes(file)) {
356
+ lines.push(details.loc === undefined ? ` --> ${file}` : ` --> ${file}:${details.loc.line}:${details.loc.column}`)
357
+ }
358
+ if (details.frame !== undefined && details.frame !== '' && !error.message.includes(details.frame)) {
359
+ lines.push(details.frame)
360
+ }
361
+ return lines.join('\n')
362
+ }