mirror of
https://github.com/nuxt/nuxt.git
synced 2025-01-23 11:52:38 +00:00
223 lines
9.3 KiB
TypeScript
223 lines
9.3 KiB
TypeScript
import { pathToFileURL } from 'node:url'
|
|
import fs from 'node:fs'
|
|
import { join } from 'pathe'
|
|
import type { Component } from '@nuxt/schema'
|
|
import { parseURL } from 'ufo'
|
|
import { createUnplugin } from 'unplugin'
|
|
import MagicString from 'magic-string'
|
|
import { ELEMENT_NODE, parse, walk } from 'ultrahtml'
|
|
import { hash } from 'ohash'
|
|
import { resolvePath } from '@nuxt/kit'
|
|
import defu from 'defu'
|
|
import { isVue } from '../core/utils'
|
|
|
|
interface ServerOnlyComponentTransformPluginOptions {
|
|
getComponents: () => Component[]
|
|
/**
|
|
* allow using `nuxt-client` attribute on components
|
|
*/
|
|
selectiveClient?: boolean | 'deep'
|
|
}
|
|
|
|
interface ComponentChunkOptions {
|
|
getComponents: () => Component[]
|
|
buildDir: string
|
|
}
|
|
|
|
const SCRIPT_RE = /<script[^>]*>/gi
|
|
const HAS_SLOT_OR_CLIENT_RE = /<slot[^>]*>|nuxt-client/
|
|
const TEMPLATE_RE = /<template>([\s\S]*)<\/template>/
|
|
const NUXTCLIENT_ATTR_RE = /\s:?nuxt-client(="[^"]*")?/g
|
|
const IMPORT_CODE = '\nimport { mergeProps as __mergeProps } from \'vue\'' + '\nimport { vforToArray as __vforToArray } from \'#app/components/utils\'' + '\nimport NuxtTeleportIslandComponent from \'#app/components/nuxt-teleport-island-component\'' + '\nimport NuxtTeleportSsrSlot from \'#app/components/nuxt-teleport-island-slot\''
|
|
const EXTRACTED_ATTRS_RE = /v-(?:if|else-if|else)(="[^"]*")?/g
|
|
|
|
function wrapWithVForDiv (code: string, vfor: string): string {
|
|
return `<div v-for="${vfor}" style="display: contents;">${code}</div>`
|
|
}
|
|
|
|
export const islandsTransform = createUnplugin((options: ServerOnlyComponentTransformPluginOptions, meta) => {
|
|
const isVite = meta.framework === 'vite'
|
|
return {
|
|
name: 'server-only-component-transform',
|
|
enforce: 'pre',
|
|
transformInclude (id) {
|
|
if (!isVue(id)) { return false }
|
|
if (options.selectiveClient === 'deep') { return true }
|
|
const components = options.getComponents()
|
|
|
|
const islands = components.filter(component =>
|
|
component.island || (component.mode === 'server' && !components.some(c => c.pascalName === component.pascalName && c.mode === 'client')),
|
|
)
|
|
const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href))
|
|
return islands.some(c => c.filePath === pathname)
|
|
},
|
|
async transform (code, id) {
|
|
if (!HAS_SLOT_OR_CLIENT_RE.test(code)) { return }
|
|
const template = code.match(TEMPLATE_RE)
|
|
if (!template) { return }
|
|
const startingIndex = template.index || 0
|
|
const s = new MagicString(code)
|
|
|
|
if (!code.match(SCRIPT_RE)) {
|
|
s.prepend('<script setup>' + IMPORT_CODE + '</script>')
|
|
} else {
|
|
s.replace(SCRIPT_RE, (full) => {
|
|
return full + IMPORT_CODE
|
|
})
|
|
}
|
|
|
|
let hasNuxtClient = false
|
|
|
|
const ast = parse(template[0])
|
|
await walk(ast, (node) => {
|
|
if (node.type === ELEMENT_NODE) {
|
|
if (node.name === 'slot') {
|
|
const { attributes, children, loc } = node
|
|
|
|
const slotName = attributes.name ?? 'default'
|
|
|
|
if (attributes.name) { delete attributes.name }
|
|
if (attributes['v-bind']) {
|
|
attributes._bind = extractAttributes(attributes, ['v-bind'])['v-bind']
|
|
}
|
|
const teleportAttributes = extractAttributes(attributes, ['v-if', 'v-else-if', 'v-else'])
|
|
const bindings = getPropsToString(attributes)
|
|
// add the wrapper
|
|
s.appendLeft(startingIndex + loc[0].start, `<NuxtTeleportSsrSlot${attributeToString(teleportAttributes)} name="${slotName}" :props="${bindings}">`)
|
|
|
|
if (children.length) {
|
|
// pass slot fallback to NuxtTeleportSsrSlot fallback
|
|
const attrString = attributeToString(attributes)
|
|
const slice = code.slice(startingIndex + loc[0].end, startingIndex + loc[1].start).replaceAll(/:?key="[^"]"/g, '')
|
|
s.overwrite(startingIndex + loc[0].start, startingIndex + loc[1].end, `<slot${attrString.replaceAll(EXTRACTED_ATTRS_RE, '')}/><template #fallback>${attributes['v-for'] ? wrapWithVForDiv(slice, attributes['v-for']) : slice}</template>`)
|
|
} else {
|
|
s.overwrite(startingIndex + loc[0].start, startingIndex + loc[0].end, code.slice(startingIndex + loc[0].start, startingIndex + loc[0].end).replaceAll(EXTRACTED_ATTRS_RE, ''))
|
|
}
|
|
|
|
s.appendRight(startingIndex + loc[1].end, '</NuxtTeleportSsrSlot>')
|
|
} else if (options.selectiveClient && ('nuxt-client' in node.attributes || ':nuxt-client' in node.attributes)) {
|
|
hasNuxtClient = true
|
|
const { loc, attributes } = node
|
|
const attributeValue = attributes[':nuxt-client'] || attributes['nuxt-client'] || 'true'
|
|
if (isVite) {
|
|
const uid = hash(id + node.loc[0].start + node.loc[0].end)
|
|
const wrapperAttributes = extractAttributes(attributes, ['v-if', 'v-else-if', 'v-else'])
|
|
|
|
let startTag = code.slice(startingIndex + loc[0].start, startingIndex + loc[0].end).replace(NUXTCLIENT_ATTR_RE, '')
|
|
if (wrapperAttributes) {
|
|
startTag = startTag.replaceAll(EXTRACTED_ATTRS_RE, '')
|
|
}
|
|
|
|
s.appendLeft(startingIndex + loc[0].start, `<NuxtTeleportIslandComponent${attributeToString(wrapperAttributes)} to="${node.name}-${uid}" :nuxt-client="${attributeValue}">`)
|
|
s.overwrite(startingIndex + loc[0].start, startingIndex + loc[0].end, startTag)
|
|
s.appendRight(startingIndex + loc[1].end, '</NuxtTeleportIslandComponent>')
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
if (!isVite && hasNuxtClient) {
|
|
console.warn(`nuxt-client attribute and client components within islands is only supported with Vite. file: ${id}`)
|
|
}
|
|
|
|
if (s.hasChanged()) {
|
|
return {
|
|
code: s.toString(),
|
|
map: s.generateMap({ source: id, includeContent: true }),
|
|
}
|
|
}
|
|
},
|
|
}
|
|
})
|
|
|
|
/**
|
|
* extract attributes from a node
|
|
*/
|
|
function extractAttributes (attributes: Record<string, string>, names: string[]) {
|
|
const extracted: Record<string, string> = {}
|
|
for (const name of names) {
|
|
if (name in attributes) {
|
|
extracted[name] = attributes[name]
|
|
delete attributes[name]
|
|
}
|
|
}
|
|
return extracted
|
|
}
|
|
|
|
function attributeToString (attributes: Record<string, string>) {
|
|
return Object.entries(attributes).map(([name, value]) => value ? ` ${name}="${value}"` : ` ${name}`).join('')
|
|
}
|
|
|
|
function isBinding (attr: string): boolean {
|
|
return attr.startsWith(':')
|
|
}
|
|
|
|
function getPropsToString (bindings: Record<string, string>): string {
|
|
const vfor = bindings['v-for']?.split(' in ').map((v: string) => v.trim()) as [string, string] | undefined
|
|
if (Object.keys(bindings).length === 0) { return 'undefined' }
|
|
const content = Object.entries(bindings).filter(b => b[0] && (b[0] !== '_bind' && b[0] !== 'v-for')).map(([name, value]) => isBinding(name) ? `[\`${name.slice(1)}\`]: ${value}` : `[\`${name}\`]: \`${value}\``).join(',')
|
|
const data = bindings._bind ? `__mergeProps(${bindings._bind}, { ${content} })` : `{ ${content} }`
|
|
if (!vfor) {
|
|
return `[${data}]`
|
|
} else {
|
|
return `__vforToArray(${vfor[1]}).map(${vfor[0]} => (${data}))`
|
|
}
|
|
}
|
|
|
|
export const componentsChunkPlugin = createUnplugin((options: ComponentChunkOptions) => {
|
|
const { buildDir } = options
|
|
return {
|
|
name: 'componentsChunkPlugin',
|
|
vite: {
|
|
async config (config) {
|
|
const components = options.getComponents()
|
|
|
|
config.build = defu(config.build, {
|
|
rollupOptions: {
|
|
input: {},
|
|
output: {},
|
|
},
|
|
})
|
|
|
|
const rollupOptions = config.build.rollupOptions!
|
|
|
|
if (typeof rollupOptions.input === 'string') {
|
|
rollupOptions.input = { entry: rollupOptions.input }
|
|
} else if (typeof rollupOptions.input === 'object' && Array.isArray(rollupOptions.input)) {
|
|
rollupOptions.input = rollupOptions.input.reduce<{ [key: string]: string }>((acc, input) => { acc[input] = input; return acc }, {})
|
|
}
|
|
|
|
// don't use 'strict', this would create another "facade" chunk for the entry file, causing the ssr styles to not detect everything
|
|
rollupOptions.preserveEntrySignatures = 'allow-extension'
|
|
for (const component of components) {
|
|
if (component.mode === 'client' || component.mode === 'all') {
|
|
rollupOptions.input![component.pascalName] = await resolvePath(component.filePath)
|
|
}
|
|
}
|
|
},
|
|
|
|
async generateBundle (_opts, bundle) {
|
|
const components = options.getComponents().filter(c => c.mode === 'client' || c.mode === 'all')
|
|
const pathAssociation: Record<string, string> = {}
|
|
for (const [chunkPath, chunkInfo] of Object.entries(bundle)) {
|
|
if (chunkInfo.type !== 'chunk') { continue }
|
|
|
|
for (const component of components) {
|
|
if (chunkInfo.facadeModuleId && chunkInfo.exports.length > 0) {
|
|
const { pathname } = parseURL(decodeURIComponent(pathToFileURL(chunkInfo.facadeModuleId).href))
|
|
const isPath = await resolvePath(component.filePath) === pathname
|
|
if (isPath) {
|
|
// avoid importing the component chunk in all pages
|
|
chunkInfo.isEntry = false
|
|
pathAssociation[component.pascalName] = chunkPath
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fs.writeFileSync(join(buildDir, 'components-chunk.mjs'), `export const paths = ${JSON.stringify(pathAssociation, null, 2)}`)
|
|
},
|
|
},
|
|
}
|
|
})
|