11import { mkdirSync , cpSync , existsSync , rmSync } from 'node:fs'
2- import { resolve , dirname , relative , join } from 'node:path'
2+ import { resolve , dirname , relative , join , parse as parsePath , sep } from 'node:path'
33import { fileURLToPath } from 'node:url'
44import { availableParallelism } from 'node:os'
55import { glob } from 'tinyglobby'
@@ -30,80 +30,93 @@ export async function build(configInput?: Partial<MaizzleConfig> | string): Prom
3030 const start = Date . now ( )
3131 const spinner = ora ( { text : 'Building templates...' , spinner : 'circleHalves' } ) . start ( )
3232
33- const config = await resolveConfig ( configInput )
34-
35- const events = new EventManager ( )
36- events . registerConfig ( config )
37- await events . fireBeforeCreate ( { config } )
33+ try {
34+ const config = await resolveConfig ( configInput )
3835
39- const outputPath = resolve ( config . output ?. path ?? 'dist' )
40- const outputExtension = config . output ?. extension ?? 'html'
36+ const events = new EventManager ( )
37+ events . registerConfig ( config )
38+ await events . fireBeforeCreate ( { config } )
4139
42- const contentPatterns = config . content ?? [ 'emails/**/*.vue' ]
43- const contentBase = computeContentBase ( contentPatterns )
44- const templateFiles = await glob ( contentPatterns )
40+ const outputPath = resolve ( config . output ?. path ?? 'dist' )
41+ const outputExtension = config . output ?. extension ?? 'html'
4542
46- if ( templateFiles . length === 0 ) {
47- spinner . succeed ( 'No templates found' )
48- return { files : [ ] , config }
49- }
43+ const contentPatterns = config . content ?? [ 'emails/**/*.vue' ]
44+ const contentBase = computeContentBase ( contentPatterns )
45+ const templateFiles = await glob ( contentPatterns )
5046
51- // Clear the output directory before writing fresh output
52- if ( existsSync ( outputPath ) ) {
53- rmSync ( outputPath , { recursive : true , force : true } )
54- }
47+ if ( templateFiles . length === 0 ) {
48+ spinner . succeed ( 'No templates found' )
49+ return { files : [ ] , config }
50+ }
5551
56- const outputFiles : string [ ] = [ ]
57- let droppedAfterBuild = 0
52+ // Clear the output directory before writing fresh output. Guard against a
53+ // misconfigured output.path (e.g. '.', '', '../..') that resolves to the
54+ // project root or a parent of it — rmSync would wipe the whole project.
55+ const cwd = process . cwd ( )
56+ if ( outputPath === parsePath ( outputPath ) . root || outputPath === cwd || cwd . startsWith ( outputPath + sep ) ) {
57+ throw new Error ( `Refusing to clear output path "${ outputPath } ": it is the filesystem root, the current working directory, or a parent of it. Set output.path to a subdirectory like "dist".` )
58+ }
5859
59- const parallel = resolveParallel ( config , templateFiles . length , configInput )
60+ if ( existsSync ( outputPath ) ) {
61+ rmSync ( outputPath , { recursive : true , force : true } )
62+ }
6063
61- if ( parallel . enabled ) {
62- spinner . text = `Building ${ templateFiles . length } templates across ${ parallel . workers } workers...`
64+ const outputFiles : string [ ] = [ ]
65+ let droppedAfterBuild = 0
6366
64- const result = await runParallelBuild ( {
65- templateFiles,
66- workers : parallel . workers ,
67- config,
68- configInput,
69- outputPath,
70- outputExtension,
71- contentBase,
72- } )
67+ const parallel = resolveParallel ( config , templateFiles . length , configInput )
7368
74- outputFiles . push ( ... result . files )
75- droppedAfterBuild = result . sfcAfterBuildCount
69+ if ( parallel . enabled ) {
70+ spinner . text = `Building ${ templateFiles . length } templates across ${ parallel . workers } workers...`
7671
77- await copyStatic ( config , outputPath )
78- await events . fireAfterBuild ( { files : outputFiles , config } )
79- } else {
80- const renderer = await createRenderer ( { markdown : config . markdown , root : config . root , componentDirs : normalizeComponentSources ( config . components ?. source , process . cwd ( ) ) , vite : config . vite } )
72+ const result = await runParallelBuild ( {
73+ templateFiles,
74+ workers : parallel . workers ,
75+ config,
76+ configInput,
77+ outputPath,
78+ outputExtension,
79+ contentBase,
80+ } )
8181
82- try {
83- for ( const templatePath of templateFiles ) {
84- const { files } = await buildTemplate ( templatePath , { config, renderer, events, outputPath, outputExtension, contentBase } )
85- outputFiles . push ( ...files )
86- }
82+ outputFiles . push ( ...result . files )
83+ droppedAfterBuild = result . sfcAfterBuildCount
8784
8885 await copyStatic ( config , outputPath )
8986 await events . fireAfterBuild ( { files : outputFiles , config } )
90- } finally {
91- await renderer . close ( )
87+ } else {
88+ const renderer = await createRenderer ( { markdown : config . markdown , root : config . root , componentDirs : normalizeComponentSources ( config . components ?. source , process . cwd ( ) ) , vite : config . vite } )
89+
90+ try {
91+ for ( const templatePath of templateFiles ) {
92+ const { files } = await buildTemplate ( templatePath , { config, renderer, events, outputPath, outputExtension, contentBase } )
93+ outputFiles . push ( ...files )
94+ }
95+
96+ await copyStatic ( config , outputPath )
97+ await events . fireAfterBuild ( { files : outputFiles , config } )
98+ } finally {
99+ await renderer . close ( )
100+ }
92101 }
93- }
94102
95- if ( droppedAfterBuild > 0 ) {
96- console . warn ( `[maizzle] Skipped ${ droppedAfterBuild } SFC-registered afterBuild handler(s): afterBuild can't run inside a parallel build worker. Move build-completion logic to the config's afterBuild hook.` )
97- }
103+ if ( droppedAfterBuild > 0 ) {
104+ console . warn ( `[maizzle] Skipped ${ droppedAfterBuild } SFC-registered afterBuild handler(s): afterBuild can't run inside a parallel build worker. Move build-completion logic to the config's afterBuild hook.` )
105+ }
98106
99- const duration = ( ( Date . now ( ) - start ) / 1000 ) . toFixed ( 2 )
100- const count = outputFiles . length
101- spinner . stopAndPersist ( {
102- symbol : '✅' ,
103- text : `Built ${ count } template${ count !== 1 ? 's' : '' } in ${ duration } s` ,
104- } )
107+ const duration = ( ( Date . now ( ) - start ) / 1000 ) . toFixed ( 2 )
108+ const count = outputFiles . length
109+ spinner . stopAndPersist ( {
110+ symbol : '✅' ,
111+ text : `Built ${ count } template${ count !== 1 ? 's' : '' } in ${ duration } s` ,
112+ } )
105113
106- return { files : outputFiles , config }
114+ return { files : outputFiles , config }
115+ } catch ( err ) {
116+ // Stop the spinner so a thrown error doesn't leave it spinning forever.
117+ if ( spinner . isSpinning ) spinner . fail ( 'Build failed' )
118+ throw err
119+ }
107120}
108121
109122/**
@@ -225,10 +238,21 @@ async function copyStatic(config: MaizzleConfig, outputPath: string): Promise<vo
225238 const sources = config . static ?. source ?? [ 'public/**/*.*' ]
226239 const destination = config . static ?. destination ?? 'public'
227240
241+ // One glob call so negation patterns still apply across the whole set.
228242 const files = await glob ( sources )
229243
244+ // Absolute base dir to strip, per positive source pattern. Each file keeps
245+ // the structure under its own pattern's base — using only sources[0] sends
246+ // files from other roots to the wrong place (or escaping the output dir).
247+ const bases = sources . filter ( s => ! s . startsWith ( '!' ) ) . map ( staticBase )
248+
230249 for ( const file of files ) {
231- const destPath = join ( outputPath , destination , relative ( dirname ( sources [ 0 ] ) . replace ( / \* .* $ / , '' ) , file ) )
250+ const abs = resolve ( file )
251+ const base = bases
252+ . filter ( b => abs === b || abs . startsWith ( b + sep ) )
253+ . sort ( ( a , b ) => b . length - a . length ) [ 0 ] ?? bases [ 0 ] ?? resolve ( '.' )
254+
255+ const destPath = join ( outputPath , destination , relative ( base , abs ) )
232256 const destDir = dirname ( destPath )
233257
234258 if ( ! existsSync ( destDir ) ) {
@@ -238,3 +262,10 @@ async function copyStatic(config: MaizzleConfig, outputPath: string): Promise<vo
238262 cpSync ( file , destPath )
239263 }
240264}
265+
266+ /** Absolute static (non-glob) prefix of a source pattern, used as the strip base. */
267+ function staticBase ( pattern : string ) : string {
268+ const staticPart = pattern . split ( / [ * { ? [ ] / ) [ 0 ]
269+ // Treat both separators as trailing: resolved patterns use '\' on Windows.
270+ return resolve ( / [ / \\ ] $ / . test ( staticPart ) ? staticPart : dirname ( staticPart ) )
271+ }
0 commit comments