import { fileURLToPath } from 'node:url' import { readFileSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs' import { copyFile } from 'node:fs/promises' import { basename, dirname, join, resolve } from 'pathe' import type { Plugin } from 'vite' // @ts-expect-error https://github.com/GoogleChromeLabs/critters/pull/151 import Critters from 'critters' import { genObjectFromRawEntries } from 'knitwork' import htmlMinifier from 'html-minifier' import { globby } from 'globby' 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 { name: 'render', configResolved (config) { outputDir = r(config.build.outDir) }, enforce: 'post', async writeBundle () { const critters = new Critters({ path: outputDir }) const htmlFiles = await globby(resolve(outputDir, 'templates/**/*.html'), { 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('') if (html.includes(']*>/, '') // We no longer need references to external CSS html = html.replace(/]*>/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(/]*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 ? `` : '') } // Minify HTML html = htmlMinifier.minify(html, { collapseWhitespace: true }) if (!isCompleteHTML) { html = html.replace('', '') html = html.replace('', '') } // 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' : ''}) => {`, hasMessages ? ' messages = { ..._messages, ...messages }' : '', ` return ${templateString}`, '}', ].join('\n') const templateContent = html .match(/]*>([\s\S]*)<\/body>/)?.[0] .replace(/(?<=<\/|<)body/g, 'div') .replace(/messages\./g, '') .replace(/]*>([\s\S]*?)<\/script>/g, '') .replace(/]*)>([\s\S]*)<\/a>/g, '\n$3\n') .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 ', '', '', ].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) => 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)) } }, } }