2023-05-15 22:43:53 +00:00
import { pathToFileURL } from 'node:url'
2023-12-19 12:21:29 +00:00
import fs from 'node:fs'
2024-02-05 11:24:39 +00:00
import { join } from 'pathe'
2023-05-15 22:43:53 +00:00
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'
2023-12-19 12:21:29 +00:00
import { hash } from 'ohash'
import { resolvePath } from '@nuxt/kit'
2023-06-26 18:32:02 +00:00
import { isVue } from '../core/utils'
2023-05-15 22:43:53 +00:00
interface ServerOnlyComponentTransformPluginOptions {
2024-01-16 13:22:50 +00:00
getComponents : ( ) = > Component [ ]
/ * *
2024-01-16 16:33:45 +00:00
* passed down to ` NuxtTeleportIslandComponent `
2024-01-16 13:22:50 +00:00
* should be done only in dev mode as we use build :manifest result in production
* /
rootDir? : string
isDev? : boolean
/ * *
* allow using ` nuxt-client ` attribute on components
* /
2024-03-06 15:26:19 +00:00
selectiveClient? : boolean | 'deep'
2023-12-19 12:21:29 +00:00
}
interface ComponentChunkOptions {
getComponents : ( ) = > Component [ ]
buildDir : string
2023-05-15 22:43:53 +00:00
}
const SCRIPT_RE = /<script[^>]*>/g
2023-12-19 12:21:29 +00:00
const HAS_SLOT_OR_CLIENT_RE = /(<slot[^>]*>)|(nuxt-client)/
2023-06-26 18:32:02 +00:00
const TEMPLATE_RE = /<template>([\s\S]*)<\/template>/
2024-01-27 21:45:34 +00:00
const NUXTCLIENT_ATTR_RE = /\s:?nuxt-client(="[^"]*")?/g
2024-01-16 16:33:45 +00:00
const IMPORT_CODE = '\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\''
2024-01-16 13:22:50 +00:00
function wrapWithVForDiv ( code : string , vfor : string ) : string {
return ` <div v-for=" ${ vfor } " style="display: contents;"> ${ code } </div> `
}
2023-05-15 22:43:53 +00:00
2023-12-19 12:21:29 +00:00
export const islandsTransform = createUnplugin ( ( options : ServerOnlyComponentTransformPluginOptions , meta ) = > {
const isVite = meta . framework === 'vite'
const { isDev , rootDir } = options
2023-05-15 22:43:53 +00:00
return {
name : 'server-only-component-transform' ,
enforce : 'pre' ,
transformInclude ( id ) {
2023-06-26 18:32:02 +00:00
if ( ! isVue ( id ) ) { return false }
2024-03-06 15:26:19 +00:00
if ( options . selectiveClient === 'deep' ) { return true }
2023-05-15 22:43:53 +00:00
const components = options . getComponents ( )
2023-12-19 12:21:29 +00:00
2023-05-15 22:43:53 +00:00
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 ) {
2023-12-19 12:21:29 +00:00
if ( ! HAS_SLOT_OR_CLIENT_RE . test ( code ) ) { return }
2023-06-26 18:32:02 +00:00
const template = code . match ( TEMPLATE_RE )
2023-05-15 22:43:53 +00:00
if ( ! template ) { return }
2023-06-26 18:32:02 +00:00
const startingIndex = template . index || 0
2023-05-15 22:43:53 +00:00
const s = new MagicString ( code )
2024-01-11 14:40:02 +00:00
if ( ! code . match ( SCRIPT_RE ) ) {
s . prepend ( '<script setup>' + IMPORT_CODE + '</script>' )
} else {
s . replace ( SCRIPT_RE , ( full ) = > {
2024-01-16 13:22:50 +00:00
return full + IMPORT_CODE
2024-01-11 14:40:02 +00:00
} )
}
2023-12-19 12:21:29 +00:00
let hasNuxtClient = false
2023-05-15 22:43:53 +00:00
const ast = parse ( template [ 0 ] )
await walk ( ast , ( node ) = > {
2023-12-19 12:21:29 +00:00
if ( node . type === ELEMENT_NODE ) {
if ( node . name === 'slot' ) {
2024-01-16 13:22:50 +00:00
const { attributes , children , loc } = node
// pass slot fallback to NuxtTeleportSsrSlot fallback
if ( children . length ) {
const attrString = Object . entries ( attributes ) . map ( ( [ name , value ] ) = > name ? ` ${ name } =" ${ value } " ` : value ) . join ( ' ' )
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 } /><template #fallback> ${ attributes [ "v-for" ] ? wrapWithVForDiv ( slice , attributes [ 'v-for' ] ) : slice } </template> ` )
}
2023-12-19 12:21:29 +00:00
const slotName = attributes . name ? ? 'default'
let vfor : [ string , string ] | undefined
if ( attributes [ 'v-for' ] ) {
vfor = attributes [ 'v-for' ] . split ( ' in ' ) . map ( ( v : string ) = > v . trim ( ) ) as [ string , string ]
}
2024-01-16 13:22:50 +00:00
delete attributes [ 'v-for' ]
2023-12-19 12:21:29 +00:00
if ( attributes . name ) { delete attributes . name }
if ( attributes [ 'v-bind' ] ) {
attributes . _bind = attributes [ 'v-bind' ]
delete attributes [ 'v-bind' ]
}
2024-01-16 13:22:50 +00:00
const bindings = getPropsToString ( attributes , vfor )
// add the wrapper
s . appendLeft ( startingIndex + loc [ 0 ] . start , ` <NuxtTeleportSsrSlot name=" ${ slotName } " :props=" ${ bindings } "> ` )
s . appendRight ( startingIndex + loc [ 1 ] . end , '</NuxtTeleportSsrSlot>' )
2023-12-19 12:21:29 +00:00
} else if ( options . selectiveClient && ( 'nuxt-client' in node . attributes || ':nuxt-client' in node . attributes ) ) {
hasNuxtClient = true
const attributeValue = node . attributes [ ':nuxt-client' ] || node . attributes [ 'nuxt-client' ] || 'true'
if ( isVite ) {
// handle granular interactivity
const htmlCode = code . slice ( startingIndex + node . loc [ 0 ] . start , startingIndex + node . loc [ 1 ] . end )
const uid = hash ( id + node . loc [ 0 ] . start + node . loc [ 0 ] . end )
2023-05-15 22:43:53 +00:00
2024-01-16 16:33:45 +00:00
s . overwrite ( startingIndex + node . loc [ 0 ] . start , startingIndex + node . loc [ 1 ] . end , ` <NuxtTeleportIslandComponent to=" ${ node . name } - ${ uid } " ${ rootDir && isDev ? ` root-dir=" ${ rootDir } " ` : '' } :nuxt-client=" ${ attributeValue } "> ${ htmlCode . replaceAll ( NUXTCLIENT_ATTR_RE , '' ) } </NuxtTeleportIslandComponent> ` )
2023-05-15 22:43:53 +00:00
}
}
}
} )
2023-12-19 12:21:29 +00:00
if ( ! isVite && hasNuxtClient ) {
// eslint-disable-next-line no-console
console . warn ( ` nuxt-client attribute and client components within islands is only supported with Vite. file: ${ id } ` )
}
2023-05-15 22:43:53 +00:00
if ( s . hasChanged ( ) ) {
return {
code : s.toString ( ) ,
map : s.generateMap ( { source : id , includeContent : true } )
}
}
}
}
} )
function isBinding ( attr : string ) : boolean {
return attr . startsWith ( ':' )
}
2024-01-16 13:22:50 +00:00
function getPropsToString ( bindings : Record < string , string > , vfor ? : [ string , string ] ) : string {
if ( Object . keys ( bindings ) . length === 0 ) { return 'undefined' }
2023-11-09 13:45:45 +00:00
const content = Object . entries ( bindings ) . filter ( b = > b [ 0 ] && b [ 0 ] !== '_bind' ) . map ( ( [ name , value ] ) = > isBinding ( name ) ? ` ${ name . slice ( 1 ) } : ${ value } ` : ` ${ name } : \` ${ value } \` ` ) . join ( ',' )
2023-05-15 22:43:53 +00:00
const data = bindings . _bind ? ` mergeProps( ${ bindings . _bind } , { ${ content } }) ` : ` { ${ content } } `
if ( ! vfor ) {
2024-01-16 13:22:50 +00:00
return ` [ ${ data } ] `
2023-05-15 22:43:53 +00:00
} else {
2024-01-16 13:22:50 +00:00
return ` __vforToArray( ${ vfor [ 1 ] } ).map( ${ vfor [ 0 ] } => ( ${ data } )) `
2023-05-15 22:43:53 +00:00
}
}
2023-12-19 12:21:29 +00:00
export const componentsChunkPlugin = createUnplugin ( ( options : ComponentChunkOptions ) = > {
const { buildDir } = options
return {
name : 'componentsChunkPlugin' ,
vite : {
2024-01-16 13:22:50 +00:00
async config ( config ) {
2023-12-19 12:21:29 +00:00
const components = options . getComponents ( )
config . build = config . build || { }
config . build . rollupOptions = config . build . rollupOptions || { }
config . build . rollupOptions . output = config . build . rollupOptions . output || { }
config . build . rollupOptions . input = config . build . rollupOptions . input || { }
// don't use 'strict', this would create another "facade" chunk for the entry file, causing the ssr styles to not detect everything
config . build . rollupOptions . preserveEntrySignatures = 'allow-extension'
for ( const component of components ) {
if ( component . mode === 'client' || component . mode === 'all' ) {
( config . build . rollupOptions . input as Record < string , string > ) [ 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 ) } ` )
}
}
}
} )