2024-05-07 21:45:03 +00:00
|
|
|
import { fileURLToPath } from 'node:url'
|
2024-05-02 08:51:39 +00:00
|
|
|
import { readFileSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
2024-05-01 13:10:33 +00:00
|
|
|
import { basename, dirname, join, resolve } from 'pathe'
|
2021-12-22 13:04:06 +00:00
|
|
|
import type { Plugin } from 'vite'
|
2024-05-01 13:10:33 +00:00
|
|
|
// @ts-expect-error https://github.com/GoogleChromeLabs/critters/pull/151
|
2021-12-22 13:04:06 +00:00
|
|
|
import Critters from 'critters'
|
2022-03-10 20:25:58 +00:00
|
|
|
import { genObjectFromRawEntries } from 'knitwork'
|
2021-12-22 13:04:06 +00:00
|
|
|
import htmlMinifier from 'html-minifier'
|
2024-05-02 08:51:39 +00:00
|
|
|
import { globby } from 'globby'
|
2021-12-22 13:04:06 +00:00
|
|
|
import { camelCase } from 'scule'
|
2024-05-01 13:10:33 +00:00
|
|
|
|
2021-12-22 13:04:06 +00:00
|
|
|
import genericMessages from '../templates/messages.json'
|
|
|
|
|
2024-05-07 21:45:03 +00:00
|
|
|
const r = (path: string) => fileURLToPath(new URL(join('..', path), import.meta.url))
|
2024-05-01 13:10:33 +00:00
|
|
|
const replaceAll = (input: string, search: string | RegExp, replace: string) => input.split(search).join(replace)
|
2021-12-22 13:04:06 +00:00
|
|
|
|
|
|
|
export const RenderPlugin = () => {
|
2024-05-07 21:45:03 +00:00
|
|
|
let outputDir: string
|
2022-09-02 08:46:02 +00:00
|
|
|
return <Plugin> {
|
2021-12-22 13:04:06 +00:00
|
|
|
name: 'render',
|
2024-05-07 21:45:03 +00:00
|
|
|
configResolved (config) {
|
|
|
|
outputDir = r(config.build.outDir)
|
|
|
|
},
|
2021-12-22 13:04:06 +00:00
|
|
|
enforce: 'post',
|
|
|
|
async writeBundle () {
|
2024-05-07 21:45:03 +00:00
|
|
|
const critters = new Critters({ path: outputDir })
|
|
|
|
const htmlFiles = await globby(resolve(outputDir, 'templates/**/*.html'), { absolute: true })
|
2021-12-22 13:04:06 +00:00
|
|
|
|
|
|
|
const templateExports = []
|
|
|
|
|
|
|
|
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
|
2024-05-02 08:51:39 +00:00
|
|
|
let html = readFileSync(fileName, 'utf-8')
|
2023-06-20 11:27:42 +00:00
|
|
|
const isCompleteHTML = html.includes('<!DOCTYPE html>')
|
2021-12-22 13:04:06 +00:00
|
|
|
|
2023-07-28 16:37:20 +00:00
|
|
|
if (html.includes('<html')) {
|
|
|
|
// Apply critters to inline styles
|
|
|
|
html = await critters.process(html)
|
|
|
|
}
|
2021-12-22 13:04:06 +00:00
|
|
|
// We no longer need references to external CSS
|
|
|
|
html = html.replace(/<link[^>]*>/g, '')
|
|
|
|
|
|
|
|
// Inline SVGs
|
|
|
|
const svgSources = Array.from(html.matchAll(/src="([^"]+)"|url([^)]+)/g))
|
|
|
|
.map(m => m[1])
|
|
|
|
.filter(src => src?.match(/\.svg$/))
|
|
|
|
|
|
|
|
for (const src of svgSources) {
|
2024-05-07 21:45:03 +00:00
|
|
|
const svg = readFileSync(join(outputDir, src), 'utf-8')
|
2021-12-22 13:04:06 +00:00
|
|
|
const base64Source = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
|
|
|
html = replaceAll(html, src, base64Source)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Inline our scripts
|
|
|
|
const scriptSources = Array.from(html.matchAll(/<script[^>]*src="(.*)"[^>]*>[\s\S]*?<\/script>/g))
|
|
|
|
.filter(([_block, src]) => src?.match(/^\/.*\.js$/))
|
|
|
|
|
|
|
|
for (const [scriptBlock, src] of scriptSources) {
|
2024-05-07 21:45:03 +00:00
|
|
|
let contents = readFileSync(join(outputDir, src), 'utf-8')
|
2021-12-22 13:04:06 +00:00
|
|
|
contents = replaceAll(contents, '/* empty css */', '').trim()
|
|
|
|
html = html.replace(scriptBlock, contents.length ? `<script>${contents}</script>` : '')
|
|
|
|
}
|
|
|
|
|
|
|
|
// Minify HTML
|
|
|
|
html = htmlMinifier.minify(html, { collapseWhitespace: true })
|
|
|
|
|
2023-06-20 11:27:42 +00:00
|
|
|
if (!isCompleteHTML) {
|
|
|
|
html = html.replace('<html><head></head><body>', '')
|
|
|
|
html = html.replace('</body></html>', '')
|
|
|
|
}
|
|
|
|
|
2021-12-22 13:04:06 +00:00
|
|
|
// Load messages
|
2024-05-02 08:51:39 +00:00
|
|
|
const messages = JSON.parse(readFileSync(r(`templates/${templateName}/messages.json`), 'utf-8'))
|
2021-12-22 13:04:06 +00:00
|
|
|
|
|
|
|
// Serialize into a js function
|
2024-05-07 21:45:03 +00:00
|
|
|
const chunks = html.split(/\{{2,3}\s*[^{}]+\s*\}{2,3}/g).map(chunk => JSON.stringify(chunk))
|
2024-05-08 12:52:19 +00:00
|
|
|
let hasMessages = chunks.length > 1
|
2024-05-07 21:45:03 +00:00
|
|
|
let templateString = chunks.shift()
|
|
|
|
for (const expression of html.matchAll(/\{{2,3}(\s*[^{}]+\s*)\}{2,3}/g)) {
|
|
|
|
templateString += ` + (${expression[1].trim()}) + ${chunks.shift()}`
|
|
|
|
}
|
|
|
|
if (chunks.length > 0) {
|
|
|
|
templateString += ' + ' + chunks.join(' + ')
|
|
|
|
}
|
|
|
|
const functionalCode = [
|
2024-05-08 12:52:19 +00:00
|
|
|
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 }' : '',
|
2024-05-07 21:45:03 +00:00
|
|
|
` return ${templateString}`,
|
|
|
|
'}',
|
|
|
|
].join('\n')
|
2021-12-22 13:04:06 +00:00
|
|
|
|
2022-03-10 20:25:58 +00:00
|
|
|
const templateContent = html
|
|
|
|
.match(/<body.*?>([\s\S]*)<\/body>/)?.[0]
|
|
|
|
.replace(/(?<=<|<\/)body/g, 'div')
|
|
|
|
.replace(/messages\./g, '')
|
2022-03-10 20:55:31 +00:00
|
|
|
.replace(/<script[^>]*>([\s\S]*?)<\/script>/g, '')
|
2022-03-10 22:27:16 +00:00
|
|
|
.replace(/<a href="(\/[^"]*)"([^>]*)>([\s\S]*)<\/a>/g, '<NuxtLink to="$1"$2>\n$3\n</NuxtLink>')
|
2024-05-01 13:10:33 +00:00
|
|
|
|
2022-09-02 08:46:02 +00:00
|
|
|
.replace(/<([^>]+) ([a-z]+)="([^"]*)({{\s*(\w+?)\s*}})([^"]*)"([^>]*)>/g, '<$1 :$2="`$3${$5}$6`"$7>')
|
2022-05-07 17:58:08 +00:00
|
|
|
.replace(/>{{\s*(\w+?)\s*}}<\/[\w-]*>/g, ' v-text="$1" />')
|
|
|
|
.replace(/>{{{\s*(\w+?)\s*}}}<\/[\w-]*>/g, ' v-html="$1" />')
|
2022-03-10 20:25:58 +00:00
|
|
|
// 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.')
|
|
|
|
})
|
2022-03-10 22:23:54 +00:00
|
|
|
const styleContent = Array.from(html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)).map(block => block[1]).join('\n')
|
2022-08-09 21:25:46 +00:00
|
|
|
const globalStyles = styleContent.replace(/(\.[^{\d][^{]*?\{[^}]*?\})+.?/g, (r) => {
|
2022-07-20 14:48:53 +00:00
|
|
|
const lastChar = r[r.length - 1]
|
|
|
|
if (lastChar && !['}', '.', '@', '*', ':'].includes(lastChar)) {
|
|
|
|
return ';' + lastChar
|
|
|
|
}
|
|
|
|
return lastChar
|
2022-08-09 21:25:46 +00:00
|
|
|
}).replace(/@media[^{]*?\{\}/g, '')
|
2022-03-10 20:55:31 +00:00
|
|
|
const inlineScripts = Array.from(html.matchAll(/<script>([\s\S]*?)<\/script>/g))
|
|
|
|
.map(block => block[1])
|
|
|
|
.filter(i => !i.includes('const t=document.createElement("link")'))
|
2022-03-10 20:25:58 +00:00
|
|
|
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',
|
2024-05-01 13:10:33 +00:00
|
|
|
default: JSON.stringify(value),
|
2022-03-10 20:25:58 +00:00
|
|
|
}]))
|
|
|
|
const vueCode = [
|
2022-03-10 22:41:01 +00:00
|
|
|
'<script setup>',
|
2022-04-07 10:57:16 +00:00
|
|
|
title && 'import { useHead } from \'#imports\'',
|
2022-03-10 20:55:31 +00:00
|
|
|
`const props = defineProps(${props})`,
|
2022-04-07 10:57:16 +00:00
|
|
|
title && 'useHead(' + genObjectFromRawEntries([
|
2022-03-10 20:55:31 +00:00
|
|
|
['title', `\`${title}\``],
|
2022-03-10 22:23:54 +00:00
|
|
|
['script', inlineScripts.map(s => ({ children: `\`${s}\`` }))],
|
2024-05-01 13:10:33 +00:00
|
|
|
['style', [{ children: `\`${globalStyles}\`` }]],
|
2022-03-10 20:55:31 +00:00
|
|
|
]) + ')',
|
2022-03-10 20:25:58 +00:00
|
|
|
'</script>',
|
|
|
|
'<template>',
|
|
|
|
templateContent,
|
|
|
|
'</template>',
|
2022-03-10 22:23:54 +00:00
|
|
|
'<style scoped>',
|
|
|
|
styleContent.replace(globalStyles, ''),
|
2024-05-01 13:10:33 +00:00
|
|
|
'</style>',
|
2022-03-10 20:25:58 +00:00
|
|
|
].filter(Boolean).join('\n').trim()
|
|
|
|
|
2021-12-22 13:04:06 +00:00
|
|
|
// Generate types
|
|
|
|
const types = [
|
2023-07-26 09:08:47 +00:00
|
|
|
`export type DefaultMessages = Record<${Object.keys(messages).map(a => `"${a}"`).join(' | ') || 'string'}, string | boolean | number >`,
|
2021-12-22 13:04:06 +00:00
|
|
|
'declare const template: (data: Partial<DefaultMessages>) => string',
|
2024-05-01 13:10:33 +00:00
|
|
|
'export { template }',
|
2021-12-22 13:04:06 +00:00
|
|
|
].join('\n')
|
|
|
|
|
|
|
|
// Register exports
|
|
|
|
templateExports.push({
|
|
|
|
exportName: camelCase(templateName),
|
|
|
|
templateName,
|
2024-05-01 13:10:33 +00:00
|
|
|
types,
|
2021-12-22 13:04:06 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
// Write new template
|
2024-05-07 21:45:03 +00:00
|
|
|
writeFileSync(fileName.replace('/index.html', '.ts'), functionalCode)
|
2024-05-02 08:51:39 +00:00
|
|
|
writeFileSync(fileName.replace('/index.html', '.vue'), vueCode)
|
2021-12-22 13:04:06 +00:00
|
|
|
|
|
|
|
// Remove original html file
|
2024-05-02 08:51:39 +00:00
|
|
|
unlinkSync(fileName)
|
|
|
|
rmdirSync(dirname(fileName))
|
2021-12-22 13:04:06 +00:00
|
|
|
}
|
2024-05-01 13:10:33 +00:00
|
|
|
},
|
2021-12-22 13:04:06 +00:00
|
|
|
}
|
|
|
|
}
|