Nuxt/packages/ui-templates/lib/render.ts

167 lines
7.1 KiB
TypeScript
Raw Normal View History

import { readFileSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs'
import { basename, dirname, join, resolve } from 'pathe'
2021-12-22 13:04:06 +00:00
import type { Plugin } from 'vite'
// @ts-expect-error Invalid types in beastcss
import _BeastCSS from 'beastcss'
import { template } from 'lodash-es'
import { genObjectFromRawEntries } from 'knitwork'
2021-12-22 13:04:06 +00:00
import htmlMinifier from 'html-minifier'
import { globby } from 'globby'
2021-12-22 13:04:06 +00:00
import { camelCase } from 'scule'
2021-12-22 13:04:06 +00:00
import genericMessages from '../templates/messages.json'
const BeastCSS = (_BeastCSS.default || _BeastCSS) as typeof import('beastcss')
2021-12-22 13:04:06 +00:00
const r = (...path: string[]) => resolve(join(__dirname, '..', ...path))
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 = () => {
return <Plugin> {
2021-12-22 13:04:06 +00:00
name: 'render',
enforce: 'post',
async writeBundle () {
const distDir = r('dist')
const beast = new BeastCSS({ path: distDir })
const htmlFiles = await globby(r('dist/templates/**/*.html'))
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
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
if (html.includes('<html')) {
// Apply critters to inline styles
html = await beast.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) {
const svg = readFileSync(r('dist', 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) {
let contents = readFileSync(r('dist', 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
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
const jsCode = [
`const _messages = ${JSON.stringify({ ...genericMessages, ...messages })}`,
`const _render = ${template(html, { variable: '__var__', interpolate: /{{{?([\s\S]+?)}?}}/g }).toString().replace('__var__', '{ messages }')}`,
'const _template = (messages) => _render({ messages: { ..._messages, ...messages } })',
2021-12-22 13:04:06 +00:00
].join('\n').trim()
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 = Array.from(html.matchAll(/<script>([\s\S]*?)<\/script>/g))
.map(block => block[1])
.filter(i => !i.includes('const t=document.createElement("link")'))
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 = [
2022-03-10 22:41:01 +00:00
'<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()
2021-12-22 13:04:06 +00:00
// Generate types
const types = [
`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',
'export { template }',
2021-12-22 13:04:06 +00:00
].join('\n')
// Register exports
templateExports.push({
exportName: camelCase(templateName),
templateName,
types,
2021-12-22 13:04:06 +00:00
})
// Write new template
writeFileSync(fileName.replace('/index.html', '.js'), `${jsCode}\nexport const template = _template`)
writeFileSync(fileName.replace('/index.html', '.vue'), vueCode)
writeFileSync(fileName.replace('/index.html', '.d.ts'), `${types}`)
2021-12-22 13:04:06 +00:00
// Remove original html file
unlinkSync(fileName)
rmdirSync(dirname(fileName))
2021-12-22 13:04:06 +00:00
}
// Write an index file with named exports for each template
const contents = templateExports.map(exp => `export { template as ${exp.exportName} } from './templates/${exp.templateName}.js'`).join('\n')
writeFileSync(r('dist/index.js'), contents, 'utf8')
2021-12-22 13:04:06 +00:00
writeFileSync(r('dist/index.d.ts'), replaceAll(contents, /\.js/g, ''), 'utf8')
},
2021-12-22 13:04:06 +00:00
}
}