import { fileURLToPath } from 'node:url' import { readFileSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs' import { copyFile } from 'node:fs/promises' import { basename, dirname, join } from 'pathe' import type { Plugin } from 'vite' import Beasties from 'beasties' import { genObjectFromRawEntries } from 'knitwork' import htmlnano from 'htmlnano' import { glob } from 'tinyglobby' import { camelCase } from 'scule' import genericMessages from '../templates/messages.json' const r = (path: string) => fileURLToPath(new URL(join('..', path), import.meta.url)) const replaceAll = (input: string, search: string | RegExp, replace: string) => input.split(search).join(replace) export const RenderPlugin = () => { let outputDir: string return <Plugin> { name: 'render', configResolved (config) { outputDir = r(config.build.outDir) }, enforce: 'post', async writeBundle () { const critters = new Beasties({ path: outputDir }) const htmlFiles = await glob(['templates/**/*.html'], { cwd: outputDir, absolute: true, }) const templateExports: Array<{ exportName: string templateName: string types: string }> = [] for (const fileName of htmlFiles) { // Infer template name const templateName = basename(dirname(fileName)) // eslint-disable-next-line no-console console.log('Processing', templateName) // Read source template let html = readFileSync(fileName, 'utf-8') const isCompleteHTML = html.includes('<!DOCTYPE html>') if (html.includes('<html')) { // Apply critters to inline styles html = await critters.process(html) } html = html.replace(/<html[^>]*>/, '<html lang="en">') // We no longer need references to external CSS html = html.replace(/<link[^>]*>/g, '') // Inline SVGs const svgSources: string[] = [] for (const [_, src] of html.matchAll(/src="([^"]+)"|url([^)]+)/g)) { if (src?.match(/\.svg$/)) { svgSources.push(src) } } for (const src of svgSources) { const svg = readFileSync(join(outputDir, src), 'utf-8') const base64Source = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` html = replaceAll(html, src, base64Source) } // Inline our scripts const scriptSources: [string, string][] = [] for (const [block, src] of html.matchAll(/<script[^>]*src="([^"]*)"[^>]*>[\s\S]*?<\/script>/g)) { if (src?.match(/^\/.*\.js$/)) { scriptSources.push([block, src]) } } for (const [scriptBlock, src] of scriptSources) { let contents = readFileSync(join(outputDir, src), 'utf-8') contents = replaceAll(contents, '/* empty css */', '').trim() html = html.replace(scriptBlock, contents.length ? `<script>${contents}</script>` : '') } // Minify HTML html = await htmlnano.process(html, { collapseWhitespace: 'aggressive' }).then(r => r.html) if (!isCompleteHTML) { html = html.replace('<html><head></head><body>', '') html = html.replace('</body></html>', '') } // Load messages const messages = JSON.parse(readFileSync(r(`templates/${templateName}/messages.json`), 'utf-8')) // Serialize into a js function const chunks = html.split(/\{{2,3}[^{}]+\}{2,3}/g).map(chunk => JSON.stringify(chunk)) const hasMessages = chunks.length > 1 let templateString = chunks.shift() for (const [_, expression] of html.matchAll(/\{{2,3}([^{}]+)\}{2,3}/g)) { if (expression) { templateString += ` + (${expression.trim()}) + ${chunks.shift()}` } } if (chunks.length > 0) { templateString += ' + ' + chunks.join(' + ') } const functionalCode = [ hasMessages ? `export type DefaultMessages = Record<${Object.keys({ ...genericMessages, ...messages }).map(a => `"${a}"`).join(' | ') || 'string'}, string | boolean | number >` : '', hasMessages ? `const _messages = ${JSON.stringify({ ...genericMessages, ...messages })}` : '', `export const template = (${hasMessages ? 'messages: Partial<DefaultMessages>' : ''}) => {`, hasMessages ? ' messages = { ..._messages, ...messages }' : '', ` return ${templateString}`, '}', ].join('\n') const templateContent = html .match(/<body[^>]*>([\s\S]*)<\/body>/)?.[0] .replace(/(?<=<\/|<)body/g, 'div') .replace(/messages\./g, '') .replace(/<script[^>]*>([\s\S]*?)<\/script>/g, '') .replace(/<a href="(\/[^"]*)"([^>]*)>([\s\S]*)<\/a>/g, '<NuxtLink to="$1"$2>\n$3\n</NuxtLink>') .replace(/<([^>]+) ([a-z]+)="([^"]*)(\{\{\s*(\w+)\s*\}\})([^"]*)"([^>]*)>/g, '<$1 :$2="`$3${$5}$6`"$7>') .replace(/>\{\{\s*(\w+)\s*\}\}<\/[\w-]*>/g, ' v-text="$1" />') .replace(/>\{\{\{\s*(\w+)\s*\}\}\}<\/[\w-]*>/g, ' v-html="$1" />') // We are not matching <link> <script> and <meta> tags as these aren't used yet in nuxt/ui // and should be taken care of wherever this SFC is used const title = html.match(/<title[^>]*>([\s\S]*)<\/title>/)?.[1]?.replace(/\{\{([\s\S]+?)\}\}/g, (r) => { return `\${${r.slice(2, -2)}}`.replace(/messages\./g, 'props.') }) const styleContent = Array.from(html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)).map(block => block[1]).join('\n') const globalStyles = styleContent.replace(/(\.[^{\d][^{]*\{[^}]*\})+.?/g, (r) => { const lastChar = r[r.length - 1] if (lastChar && !['}', '.', '@', '*', ':'].includes(lastChar)) { return ';' + lastChar } return lastChar || '' }).replace(/@media[^{]*\{\}/g, '') const inlineScripts: string[] = [] for (const [_, i] of html.matchAll(/<script>([\s\S]*?)<\/script>/g)) { if (i && !i.includes('const t=document.createElement("link")')) { inlineScripts.push(i) } } const props = genObjectFromRawEntries(Object.entries({ ...genericMessages, ...messages }).map(([key, value]) => [key, { type: typeof value === 'string' ? 'String' : typeof value === 'number' ? 'Number' : typeof value === 'boolean' ? 'Boolean' : 'undefined', default: JSON.stringify(value), }])) const vueCode = [ '<script setup>', title && 'import { useHead } from \'#imports\'', `const props = defineProps(${props})`, title && 'useHead(' + genObjectFromRawEntries([ ['title', `\`${title}\``], ['script', inlineScripts.map(s => ({ children: `\`${s}\`` }))], ['style', [{ children: `\`${globalStyles}\`` }]], ]) + ')', '</script>', '<template>', templateContent, '</template>', '<style scoped>', styleContent.replace(globalStyles, ''), '</style>', ].filter(Boolean).join('\n').trim() // Generate types const types = [ `export type DefaultMessages = Record<${Object.keys(messages).map(a => `"${a}"`).join(' | ') || 'string'}, string | boolean | number >`, 'declare const template: (data: Partial<DefaultMessages>) => string', 'export { template }', ].join('\n') // Register exports templateExports.push({ exportName: camelCase(templateName), templateName, types, }) // Write new template writeFileSync(fileName.replace('/index.html', '.ts'), functionalCode) writeFileSync(fileName.replace('/index.html', '.vue'), vueCode) // Remove original html file unlinkSync(fileName) rmdirSync(dirname(fileName)) } // we manually copy files across rather than using symbolic links for better windows support const nuxtRoot = r('../nuxt') for (const file of ['error-404.vue', 'error-500.vue', 'error-dev.vue', 'welcome.vue']) { await copyFile(r(`dist/templates/${file}`), join(nuxtRoot, 'src/app/components', file)) } for (const file of ['error-500.ts', 'error-dev.ts']) { await copyFile(r(`dist/templates/${file}`), join(nuxtRoot, 'src/core/runtime/nitro', file)) } }, } }