diff --git a/src/render/createRenderer.ts b/src/render/createRenderer.ts index c2bb1e56..9d89e0c6 100644 --- a/src/render/createRenderer.ts +++ b/src/render/createRenderer.ts @@ -44,7 +44,7 @@ export interface RenderedTemplate { } export interface Renderer { - render(input: string | Component, config: MaizzleConfig, opts?: { source?: string }): Promise + render(input: string | Component, config: MaizzleConfig, opts?: { source?: string; props?: Record }): Promise invalidate(filePath: string): Promise invalidateAll(): Promise close(): Promise @@ -412,7 +412,7 @@ export async function createRenderer( const server = await createServer(finalConfig) return { - async render(input: string | Component, config: MaizzleConfig, opts?: { source?: string }): Promise { + async render(input: string | Component, config: MaizzleConfig, opts?: { source?: string; props?: Record }): Promise { let component: Component let configKey: InjectionKey let contextKey: InjectionKey @@ -469,7 +469,7 @@ export async function createRenderer( } const head = createHead({ disableDefaults: true }) - const app = createSSRApp(component) + const app = createSSRApp(component, opts?.props) app.use(head) // Register user Vue plugins, directives, and global properties diff --git a/src/render/index.ts b/src/render/index.ts index 9dc2e5b3..46738906 100644 --- a/src/render/index.ts +++ b/src/render/index.ts @@ -39,6 +39,7 @@ export async function render( } const resolvedConfig = resolveConfigObject(config) + const { props, ...templateConfig } = resolvedConfig /** * Reuse a renderer started by the Vite plugin when one is active. @@ -59,7 +60,7 @@ export async function render( && ['.vue', '.md'].includes(extname(template)) && !template.includes('\n') - const rendered = await renderer.render(isFile ? resolve(template) : template, resolvedConfig) + const rendered = await renderer.render(isFile ? resolve(template) : template, templateConfig, { props }) let html = rendered.html const doctype = rendered.doctype ?? rendered.templateConfig.doctype ?? '' diff --git a/src/tests/render/props.test.ts b/src/tests/render/props.test.ts new file mode 100644 index 00000000..8f05ff2b --- /dev/null +++ b/src/tests/render/props.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { rmSync } from 'node:fs' +import { render } from '../../render/index.ts' +import { createTempProject } from './_helpers.ts' + +describe('render props', () => { + let tempDir: string + const originalCwd = process.cwd() + + beforeEach(() => { + tempDir = createTempProject() + process.chdir(tempDir) + }) + + afterEach(() => { + process.chdir(originalCwd) + rmSync(tempDir, { recursive: true, force: true }) + }) + + it('passes props to the template via defineProps', async () => { + const result = await render(` + + + `, { + props: { name: 'Ava' }, + }) + + expect(result.html).toContain('Hello Ava') + }) + + it('does not leak props into useConfig() or the returned config', async () => { + const result = await render(` + + + `, { + props: { name: 'Ava' }, + }) + + expect(result.html).toContain('clean') + expect(result.config.props).toBeUndefined() + }) + + it('does not render a declared prop as a fallthrough attribute', async () => { + const result = await render(` + + + `, { + props: { name: 'Ava' }, + }) + + expect(result.html).not.toContain('name="Ava"') + }) +}) diff --git a/src/types/config.ts b/src/types/config.ts index 344ac2cf..ae1d5528 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -744,6 +744,18 @@ export interface MaizzleConfig { * } */ vue?: VueConfig + /** + * Props passed to the template's root component when rendering + * programmatically. Map 1:1 to the template's `defineProps`. Not + * merged into `useConfig()`. + * + * Props not declared via `defineProps` fall through as HTML + * attributes on the root element, so declare every prop you pass. + * + * @example + * render('./welcome.vue', { props: { name: 'Ava', plan: 'Pro' } }) + */ + props?: Record // Events