Skip to content

Commit eed5f45

Browse files
authored
Merge pull request #1748 from maizzle/fix/general-bugs
fix: general framework bugs
2 parents b17b6ba + 3091467 commit eed5f45

6 files changed

Lines changed: 237 additions & 65 deletions

File tree

src/build.ts

Lines changed: 89 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { 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'
33
import { fileURLToPath } from 'node:url'
44
import { availableParallelism } from 'node:os'
55
import { 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+
}

src/serve.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -199,23 +199,37 @@ function maizzleDevPlugin(
199199
server.watcher.add(watchPath)
200200
}
201201

202-
server.watcher.on('add', async (file) => {
202+
/**
203+
* Serialize watcher work onto one chain. The change handler closes and
204+
* recreates the renderer across awaits; without serialization a second
205+
* event firing mid-reload closes a stale renderer and leaks the new one.
206+
* Errors are caught so one failed task doesn't break the chain.
207+
*/
208+
let watcherChain: Promise<void> = Promise.resolve()
209+
const enqueue = (task: () => Promise<void>): Promise<void> => {
210+
watcherChain = watcherChain.then(task).catch((err) => {
211+
console.error('[maizzle] watcher task failed:', err)
212+
})
213+
return watcherChain
214+
}
215+
216+
server.watcher.on('add', file => enqueue(async () => {
203217
if (isTemplateFile(file)) {
204218
await renderer.invalidateAll()
205219
bumpGeneration()
206220
server.ws.send({ type: 'custom', event: 'maizzle:templates-changed' })
207221
}
208-
})
222+
}))
209223

210-
server.watcher.on('unlink', async (file) => {
224+
server.watcher.on('unlink', file => enqueue(async () => {
211225
if (isTemplateFile(file)) {
212226
await renderer.invalidateAll()
213227
bumpGeneration()
214228
server.ws.send({ type: 'custom', event: 'maizzle:templates-changed' })
215229
}
216-
})
230+
}))
217231

218-
server.watcher.on('change', async (file) => {
232+
server.watcher.on('change', file => enqueue(async () => {
219233
if (isWatchedFile(file)) {
220234
config = await resolveConfig(configInput)
221235

@@ -249,7 +263,7 @@ function maizzleDevPlugin(
249263
) {
250264
server.ws.send({ type: 'custom', event: 'maizzle:template-updated', data: { file } })
251265
}
252-
})
266+
}))
253267

254268
// API middleware (before Vite's middleware)
255269
server.middlewares.use(async (req: any, res: any, next: any) => {

src/tests/build.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@ import { tmpdir, availableParallelism } from 'node:os'
55
import { build, resolveParallel } from '../build.ts'
66
import { computeContentBase } from '../render/buildTemplate.ts'
77

8+
// Track ora.fail() calls so we can assert the spinner is stopped on error.
9+
const oraState = vi.hoisted(() => ({ failCalls: 0 }))
10+
vi.mock('ora', () => ({
11+
default: () => {
12+
const s: any = {
13+
isSpinning: false,
14+
text: '',
15+
start() { s.isSpinning = true; return s },
16+
stopAndPersist() { s.isSpinning = false; return s },
17+
succeed() { s.isSpinning = false; return s },
18+
fail() { s.isSpinning = false; oraState.failCalls++; return s },
19+
}
20+
return s
21+
},
22+
}))
23+
824
function createTempProject() {
925
const dir = mkdtempSync(join(tmpdir(), 'maizzle-build-'))
1026
return dir
@@ -64,6 +80,34 @@ describe('build', () => {
6480
expect(result.files[0]).toContain('/dist/')
6581
})
6682

83+
it('stops the spinner when the build throws', async () => {
84+
writeSfc(tempDir, 'emails/test.vue', `
85+
<template><div>Test</div></template>
86+
`)
87+
88+
oraState.failCalls = 0
89+
90+
await expect(build({ beforeCreate() { throw new Error('boom') } })).rejects.toThrow('boom')
91+
92+
// Without the try/catch the spinner would still be spinning (fail uncalled).
93+
expect(oraState.failCalls).toBe(1)
94+
})
95+
96+
it('refuses to clear an output path that is the cwd', async () => {
97+
writeSfc(tempDir, 'emails/test.vue', `
98+
<template>
99+
<p>Test</p>
100+
</template>
101+
`)
102+
103+
// output.path '.' resolves to the project root (cwd) — clearing it would
104+
// wipe the whole project, so the build must reject instead.
105+
await expect(build({ output: { path: '.' } })).rejects.toThrow(/Refusing to clear output path/)
106+
107+
// The source template must still be intact (not deleted).
108+
expect(existsSync(join(tempDir, 'emails/test.vue'))).toBe(true)
109+
})
110+
67111
it('respects output.extension config', async () => {
68112
writeSfc(tempDir, 'emails/test.vue', `
69113
<template>
@@ -762,6 +806,31 @@ describe('build', () => {
762806
expect(readFileSync(join(outputDir, 'public/images/logo.png'), 'utf-8')).toBe('fake-png-data')
763807
})
764808

809+
it('copies static assets from multiple source roots using each pattern base', async () => {
810+
writeSfc(tempDir, 'emails/test.vue', `
811+
<template><div>Test</div></template>
812+
`)
813+
814+
mkdirSync(join(tempDir, 'public/img'), { recursive: true })
815+
writeFileSync(join(tempDir, 'public/img/logo.png'), 'logo')
816+
mkdirSync(join(tempDir, 'src/assets'), { recursive: true })
817+
writeFileSync(join(tempDir, 'src/assets/app.css'), 'css')
818+
819+
await build({
820+
static: {
821+
source: [join(tempDir, 'public/**/*.*'), join(tempDir, 'src/assets/**/*.*')],
822+
destination: 'assets',
823+
},
824+
})
825+
826+
const out = join(tempDir, 'dist/assets')
827+
// Each file keeps the structure under its OWN pattern's base.
828+
expect(existsSync(join(out, 'img/logo.png'))).toBe(true)
829+
expect(existsSync(join(out, 'app.css'))).toBe(true)
830+
// The second-root file must not escape the destination via ../ traversal.
831+
expect(existsSync(join(tempDir, 'dist/src/assets/app.css'))).toBe(false)
832+
})
833+
765834
it('clears existing output directory before building', async () => {
766835
const outputDir = join(tempDir, 'dist')
767836
mkdirSync(outputDir, { recursive: true })

src/tests/serve.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import { tmpdir } from 'node:os'
55
import type { ViteDevServer } from 'vite'
66
import { serve } from '../serve.ts'
77
import { getActiveRenderer } from '../render/active.ts'
8+
import * as rendererMod from '../render/createRenderer.ts'
9+
import type { Renderer } from '../render/createRenderer.ts'
10+
11+
const realCreateRenderer = rendererMod.createRenderer
812

913
describe('serve dev server', () => {
1014
let tempDir: string
@@ -57,6 +61,49 @@ describe('serve dev server', () => {
5761
}, { timeout: 15000, interval: 100 })
5862
}, 30000)
5963

64+
it('serializes rapid config changes so no renderer is leaked', async () => {
65+
writeFileSync(join(tempDir, 'maizzle.config.js'), 'export default {}\n')
66+
67+
// Wrap createRenderer to record every instance and whether it was closed.
68+
// The race closes the same (stale) renderer repeatedly and leaks the
69+
// intermediate ones; serialized reloads close each before the next.
70+
const created: Renderer[] = []
71+
const closed = new Set<Renderer>()
72+
const spy = vi.spyOn(rendererMod, 'createRenderer').mockImplementation(async (opts) => {
73+
const r = await realCreateRenderer(opts)
74+
created.push(r)
75+
const origClose = r.close.bind(r)
76+
r.close = async () => { closed.add(r); return origClose() }
77+
return r
78+
})
79+
80+
try {
81+
server = await serve({ port: 3157, silent: true })
82+
const before = getActiveRenderer()
83+
expect(before).toBeTruthy()
84+
85+
const cfg = resolve(tempDir, 'maizzle.config.js')
86+
server.watcher.emit('change', cfg)
87+
server.watcher.emit('change', cfg)
88+
89+
await vi.waitFor(() => {
90+
expect(getActiveRenderer()).not.toBe(before)
91+
}, { timeout: 20000, interval: 100 })
92+
93+
// Let in-flight reloads settle (close() carries a 600ms dts drain).
94+
await new Promise(r => setTimeout(r, 2000))
95+
96+
// Every renderer ever created except the current active one must have
97+
// been closed. A leaked intermediate renderer fails this.
98+
const active = getActiveRenderer()
99+
for (const r of created) {
100+
if (r !== active) expect(closed.has(r)).toBe(true)
101+
}
102+
} finally {
103+
spy.mockRestore()
104+
}
105+
}, 40000)
106+
60107
it('clears the active renderer on close', async () => {
61108
server = await serve({ port: 3157, silent: true })
62109
expect(getActiveRenderer()).toBeTruthy()

0 commit comments

Comments
 (0)