Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/render/buildTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { runTransformers } from '../transformers/index.ts'
import { createPlaintext } from '../plaintext.ts'
import { stripForHtml, stripForPlaintext } from '../utils/output-markers.ts'
import { _setCurrentTemplate } from '../composables/useCurrentTemplate.ts'
import { cloneConfig } from '../utils/cloneConfig.ts'
import type { EventManager } from '../events/index.ts'
import type { Renderer } from './createRenderer.ts'
import type { MaizzleConfig } from '../types/index.ts'
Expand Down Expand Up @@ -57,7 +58,7 @@ export async function buildTemplate(
* preheader, injecting fetched data, etc.) stay scoped to this template
* instead of leaking into later ones through the shared config object.
*/
const renderConfig = defu({}, config) as MaizzleConfig
const renderConfig = cloneConfig(config)
const originalSource = template.source

await events.fireBeforeRender({ config: renderConfig, template })
Expand Down Expand Up @@ -133,6 +134,7 @@ export async function buildTemplate(

mkdirSync(dirname(ptOutputPath), { recursive: true })
writeFileSync(ptOutputPath, plaintext)
files.push(ptOutputPath)
}
} finally {
_setCurrentTemplate(undefined)
Expand Down
60 changes: 58 additions & 2 deletions src/tests/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,45 @@ describe('build', () => {
expect(bHtml).toContain('none')
})

it('scopes nested beforeRender config mutations per template', async () => {
writeSfc(tempDir, 'emails/a.vue', `
<script setup>
const config = useConfig()
</script>
<template>
<div>{{ config.custom.val }}</div>
</template>
`)

writeSfc(tempDir, 'emails/b.vue', `
<script setup>
const config = useConfig()
</script>
<template>
<div>{{ config.custom.val }}</div>
</template>
`)

writeFileSync(join(tempDir, 'maizzle.config.js'), `
export default {
custom: { val: 'base' },
beforeRender({ template, config }) {
if (template.path.name === 'a') config.custom.val = 'AAA'
}
}
`)

const result = await build()
const aHtml = readFileSync(result.files.find(f => f.includes('a.html'))!, 'utf-8')
const bHtml = readFileSync(result.files.find(f => f.includes('b.html'))!, 'utf-8')

// 'a' sees its own nested mutation
expect(aHtml).toContain('AAA')
// 'b' must NOT inherit 'a's nested mutation (deep per-template clone)
expect(bHtml).not.toContain('AAA')
expect(bHtml).toContain('base')
})

it('fires afterRender event and uses modified HTML', async () => {
writeSfc(tempDir, 'emails/test.vue', `
<template>
Expand Down Expand Up @@ -444,7 +483,7 @@ describe('build', () => {

const result = await build({ plaintext: true })

expect(result.files).toHaveLength(1)
expect(result.files).toHaveLength(2)

const txtPath = result.files[0].replace(/\.html$/, '.txt')
expect(existsSync(txtPath)).toBe(true)
Expand All @@ -466,7 +505,7 @@ describe('build', () => {

const result = await build({ plaintext: { destination: customPath } })

expect(result.files).toHaveLength(1)
expect(result.files).toHaveLength(2)
expect(existsSync(join(customPath, 'test.txt'))).toBe(true)
})

Expand Down Expand Up @@ -530,6 +569,23 @@ describe('build', () => {
expect(txt).not.toContain('<div>')
})

it('includes plaintext files in result.files and the afterBuild payload', async () => {
writeSfc(tempDir, 'emails/test.vue', `
<template>
<div>Hello</div>
</template>
`)

let afterBuildFiles: string[] = []
const result = await build({
plaintext: true,
afterBuild({ files }) { afterBuildFiles = files },
})

expect(result.files.some(f => f.endsWith('.txt'))).toBe(true)
expect(afterBuildFiles.some(f => f.endsWith('.txt'))).toBe(true)
})

it('usePlaintext() with custom extension', async () => {
writeSfc(tempDir, 'emails/test.vue', `
<script setup>
Expand Down
13 changes: 13 additions & 0 deletions src/tests/transformers/inlineCss.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,19 @@ describe('inlineCss', () => {
// Incomplete definition is ignored; normal inlining still happens.
expect(result).toContain('color: red')
})

it('does not leak custom code blocks into a later call', async () => {
const juice = (await import('juice')).default

run('<style>.red { color: {{% v %}}; }</style><p class="red">x</p>', {
codeBlocks: { Twig: { start: '{{%', end: '%}}' } },
})
expect(juice.codeBlocks).toHaveProperty('Twig')

// A subsequent call without codeBlocks must reset juice's global state.
run('<p>x</p>')
expect(juice.codeBlocks).not.toHaveProperty('Twig')
})
})

describe('embedded styles', () => {
Expand Down
7 changes: 6 additions & 1 deletion src/transformers/inlineCss.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import juice from 'juice'
import { walk, parse, serialize } from '../utils/ast/index.ts'

/** Juice's built-in code blocks, captured before any per-call mutation. */
const DEFAULT_CODE_BLOCKS = { ...juice.codeBlocks }
import type { ChildNode, Element } from 'domhandler'
type JuiceOptions = NonNullable<Parameters<typeof juice>[1]>

Expand Down Expand Up @@ -99,7 +102,9 @@ export function inlineCssDom(dom: ChildNode[], options: InlineCssOptions = {}):
juice.widthElements = (widthElements ?? ['img', 'video']).map(i => i.toUpperCase()) as unknown as HTMLElement[]
juice.heightElements = (heightElements ?? ['img', 'video']).map(i => i.toUpperCase()) as unknown as HTMLElement[]

// Add custom code blocks
// Reset to defaults each call so a previous template's custom code blocks
// don't accumulate on juice's module-global state, then add this call's.
juice.codeBlocks = { ...DEFAULT_CODE_BLOCKS }
if (codeBlocks && typeof codeBlocks === 'object') {
Object.entries(codeBlocks).forEach(([key, value]) => {
if (value.start && value.end) {
Expand Down
20 changes: 20 additions & 0 deletions src/utils/cloneConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Deep-clone plain objects so per-template config mutations stay isolated.
*
* Arrays, functions, and class instances (Date, RegExp, Vite plugins, …) pass
* through by reference — only nested plain-object props are copied, which is
* what `beforeRender` mutations (`config.url.base`, `config.css.inline`, …)
* touch. Sharing arrays by reference matches the parallel worker's merge.
*/
export function cloneConfig<T>(value: T): T {
if (value === null || typeof value !== 'object') return value
if (Array.isArray(value)) return value
if (Object.getPrototypeOf(value) !== Object.prototype) return value

const out: Record<string, unknown> = {}
for (const key of Object.keys(value as object)) {
out[key] = cloneConfig((value as Record<string, unknown>)[key])
}

return out as T
}
Loading