Skip to content

Commit

Permalink
feat: support bundling registry composables src
Browse files Browse the repository at this point in the history
  • Loading branch information
harlan-zw committed Apr 1, 2024
1 parent 6d5aba7 commit 00ac0ef
Show file tree
Hide file tree
Showing 10 changed files with 350 additions and 80 deletions.
68 changes: 50 additions & 18 deletions src/module.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { addBuildPlugin, addImports, addImportsDir, addPlugin, addTemplate, createResolver, defineNuxtModule } from '@nuxt/kit'
import { readPackageJSON } from 'pkg-types'
import type { Import } from 'unimport'
import { joinURL, withBase, withQuery } from 'ufo'
import { setupDevToolsUI } from './devtools'
import { NuxtScriptAssetBundlerTransformer } from './plugins/transform'
import { setupPublicAssetStrategy } from './assets'
import { logger } from './logger'
import { extendTypes } from './kit'
import type { NuxtUseScriptInput, NuxtUseScriptOptions, ScriptRegistry } from '#nuxt-scripts'
import type { IntercomInput } from '~/src/runtime/registry/intercom'
import type { SegmentInput } from '~/src/runtime/registry/segment'
import type { HotjarInput } from '~/src/runtime/registry/hotjar'
import type { NpmInput } from '~/src/runtime/registry/npm'

export interface ModuleOptions {
/**
Expand All @@ -24,9 +29,7 @@ export interface ModuleOptions {
/**
* Override the static script options for specific scripts based on their provided `key` or `src`.
*/
overrides?: {
[key: string]: Pick<NuxtUseScriptOptions, 'assetStrategy'>
}
overrides?: Record<keyof ScriptRegistry, Pick<NuxtUseScriptOptions, 'assetStrategy'>>
/** Configure the way scripts assets are exposed */
assets?: {
/**
Expand Down Expand Up @@ -96,50 +99,79 @@ export default defineNuxtModule<ModuleOptions>({
])

nuxt.hooks.hook('modules:done', async () => {
const registry: Import[] = [
const registry: (Import & { transformSrc?: string })[] = [
{
name: 'useScriptCloudflareTurnstile',
key: 'cloudflareTurnstile',
from: resolve('./runtime/registry/cloudflare-turnstile'),
},
{
name: 'useScriptCloudflareWebAnalytics',
key: 'cloudflareWebAnalytics',
from: resolve('./runtime/registry/cloudflare-web-analytics'),
src: 'https://static.cloudflareinsights.com/beacon.min.js',
},
{
name: 'useScriptConfetti',
key: 'confetti',
from: resolve('./runtime/registry/confetti'),
src: 'https://unpkg.com/js-confetti@latest/dist/js-confetti.browser.js',
},
{
name: 'useScriptFacebookPixel',
key: 'facebookPixel',
from: resolve('./runtime/registry/facebook-pixel'),
src: 'https://connect.facebook.net/en_US/fbevents.js',
},
{
name: 'useScriptFathomAnalytics',
key: 'fathomAnalytics',
from: resolve('./runtime/registry/fathom-analytics'),
src: 'https://cdn.usefathom.com/script.js',
},
{
name: 'useScriptGoogleAnalytics',
key: 'googleAnalytics',
from: resolve('./runtime/registry/google-analytics'),
},
{
name: 'useScriptGoogleTagManager',
key: 'googleTagmanager',
from: resolve('./runtime/registry/google-tag-manager'),
},
{
name: 'useScriptHotjar',
from: resolve('./runtime/registry/hotjar'),
key: 'hotjar',
transform(options?: HotjarInput) {
return withQuery(`https://static.hotjar.com/c/hotjar-${options?.id || ''}.js`, {
sv: options?.sv || '6',
})
},
},
{
name: 'useScriptIntercom',
from: resolve('./runtime/registry/intercom'),
key: 'intercom',
transform(options?: IntercomInput) {
return joinURL(`https://widget.intercom.io/widget`, options?.app_id || '')
},
},
{
name: 'useScriptSegment',
from: resolve('./runtime/registry/segment'),
key: 'segment',
transform(options?: SegmentInput) {
return joinURL('https://cdn.segment.com/analytics.js/v1', options?.writeKey || '', 'analytics.min.js')
},
},
{
name: 'useScriptNpm',
// key is based on package name
from: resolve('./runtime/registry/npm'),
transform(options?: NpmInput) {
return withBase(options?.file || '', `https://unpkg.com/${options?.packageName || ''}@${options?.version || 'latest'}`)
},
},
].map((i: Import) => {
i.priority = -1
Expand Down Expand Up @@ -185,6 +217,20 @@ ${(config.globals || []).map(g => !Array.isArray(g)
src: template.dst,
})
}
const scriptMap = new Map<string, string>()
const { normalizeScriptData } = setupPublicAssetStrategy(config.assets)

addBuildPlugin(NuxtScriptAssetBundlerTransformer({
registry,
defaultBundle: config.defaultScriptOptions?.assetStrategy === 'bundle',
resolveScript(src) {
if (scriptMap.has(src))
return scriptMap.get(src) as string
const url = normalizeScriptData(src)
scriptMap.set(src, url)
return url
},
}))
})

extendTypes(name!, async () => {
Expand All @@ -197,20 +243,6 @@ declare module '#app' {
`
})

const scriptMap = new Map<string, string>()
const { normalizeScriptData } = setupPublicAssetStrategy(config.assets)

addBuildPlugin(NuxtScriptAssetBundlerTransformer({
overrides: config.overrides,
resolveScript(src) {
if (scriptMap.has(src))
return scriptMap.get(src) as string
const url = normalizeScriptData(src)
scriptMap.set(src, url)
return url
},
}))

if (nuxt.options.dev)
setupDevToolsUI(config, resolve)
},
Expand Down
105 changes: 79 additions & 26 deletions src/plugins/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ import type { SourceMapInput } from 'rollup'
import type { Node } from 'estree-walker'
import { walk } from 'estree-walker'
import type { SimpleCallExpression } from 'estree'
import type { Import } from 'unimport'
import type { Input } from 'valibot'
import type { ModuleOptions } from '../module'

export interface AssetBundlerTransformerOptions {
overrides?: ModuleOptions['overrides']
resolveScript: (src: string) => string
defaultBundle?: boolean
registry?: (Import & { src?: string, key?: string, transform?: (options: any) => string })[]
}

export function NuxtScriptAssetBundlerTransformer(options: AssetBundlerTransformerOptions) {
Expand Down Expand Up @@ -38,7 +42,7 @@ export function NuxtScriptAssetBundlerTransformer(options: AssetBundlerTransform
},

async transform(code, id) {
if (!code.includes('useScript'))
if (!code.includes('useScript')) // all integrations should start with useScriptX
return

const ast = this.parse(code)
Expand All @@ -48,47 +52,96 @@ export function NuxtScriptAssetBundlerTransformer(options: AssetBundlerTransform
if (
_node.type === 'CallExpression'
&& _node.callee.type === 'Identifier'
&& _node.callee?.name === 'useScript') {
&& _node.callee?.name.startsWith('useScript')) {
// we're either dealing with useScript or an integration such as useScriptHotjar, we need to handle
// both cases
const fnName = _node.callee?.name
const node = _node as SimpleCallExpression
let scriptKey: string | undefined
let scriptNode: any | undefined
// do easy case first where first argument is a literal
if (node.arguments[0].type === 'Literal') {
scriptNode = node.arguments[0]
scriptKey = scriptNode.value as string
let scriptSrcNode: any | undefined
let src: string | undefined
if (fnName === 'useScript') {
// do easy case first where first argument is a literal
if (node.arguments[0].type === 'Literal') {
scriptSrcNode = node.arguments[0]
scriptKey = scriptSrcNode.value as string
}
else if (node.arguments[0].type === 'ObjectExpression') {
const srcProperty = node.arguments[0].properties.find(
(p: any) => p.key?.name === 'src' || p.key?.value === 'src',
)
const keyProperty = node.arguments[0].properties.find(
(p: any) => p.key?.name === 'key' || p.key?.value === 'key',
)
scriptKey = keyProperty?.value?.value || srcProperty?.value
scriptSrcNode = srcProperty?.value
}
}
else if (node.arguments[0].type === 'ObjectExpression') {
const srcProperty = node.arguments[0].properties.find(
(p: any) => p.key?.name === 'src' || p.key?.value === 'src',
)
const keyProperty = node.arguments[0].properties.find(
(p: any) => p.key?.name === 'key' || p.key?.value === 'key',
)
scriptKey = keyProperty?.value?.value || srcProperty?.value
scriptNode = srcProperty?.value
else {
// find the registry node
const registryNode = options.registry?.find(i => i.name === fnName)
if (!registryNode) {
console.error(`[Nuxt Scripts] Integration ${fnName} not found in registry`)
return
}
// this is only needed when we have a dynamic src that we need to compute
if (!registryNode.transform && !registryNode.src)
return

// integration case
// extract the options as the first argument that we'll use to reconstruct the src
const optionsNode = node.arguments[0]
if (optionsNode?.type === 'ObjectExpression') {
const fnArg0 = {}
// extract literal values from the object to reconstruct the options
for (const prop of optionsNode.properties) {
if (prop.value.type === 'Literal')
fnArg0[prop.key.name] = prop.value.value
}
const srcProperty = node.arguments[0].properties.find(
(p: any) => p.key?.name === 'src' || p.key?.value === 'src',
)
if (srcProperty?.value?.value)
scriptSrcNode = srcProperty.value
else
src = registryNode.src || registryNode.transform?.(fnArg0 as any as Input<any>)
scriptKey = registryNode.key
}
}

if (scriptNode) {
const src = scriptNode.value
if (scriptSrcNode || src) {
src = src || scriptSrcNode.value
if (src) {
let hasAssetStrategy = false
let canBundle = options.defaultBundle
if (node.arguments[1]?.type === 'ObjectExpression') {
// second node needs to be an object with an property of assetStrategy and a value of 'bundle'
const assetStrategyProperty = node.arguments[1]?.properties.find(
(p: any) => p.key?.name === 'assetStrategy' || p.key?.value === 'assetStrategy',
)
if (assetStrategyProperty) {
if (assetStrategyProperty?.value?.value !== 'bundle')
if (assetStrategyProperty?.value?.value !== 'bundle') {
canBundle = false
return
// remove the property
s.remove(assetStrategyProperty.start, assetStrategyProperty.end + 1) // need to strip the comma
hasAssetStrategy = true
}
if (node.arguments[1]?.properties.length === 1)
s.remove(node.arguments[1].start, node.arguments[1].end)
else
s.remove(assetStrategyProperty.start, assetStrategyProperty.end)

canBundle = true
}
}
hasAssetStrategy = hasAssetStrategy || options.overrides?.[scriptKey]?.assetStrategy === 'bundle'
if (hasAssetStrategy) {
canBundle = canBundle || options.overrides?.[scriptKey]?.assetStrategy === 'bundle'
if (canBundle) {
const newSrc = options.resolveScript(src)
s.overwrite(scriptNode.start, scriptNode.end, `'${newSrc}'`)
if (scriptSrcNode) {
s.overwrite(scriptSrcNode.start, scriptSrcNode.end, `'${newSrc}'`)
}
else {
// otherwise we need to append a `src: ${src}` after the last property
const lastProperty = node.arguments[0].properties[node.arguments[0].properties.length - 1]
s.appendRight(lastProperty.end, `, src: '${newSrc}'`)
}
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/runtime/composables/validateScriptInputSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ export function validateScriptInputSchema<T extends BaseSchema<any>>(schema: T,
if (import.meta.dev) {
try {
parse(schema, options)
} catch (e) {
}
catch (e) {
// TODO nicer error handling
createError({
cause: e,
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/registry/facebook-pixel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function useScriptFacebookPixel<T extends FacebookPixelApi>(options?: Inp
}
}
return useScript<T>({
key: 'facebook-pixel',
key: 'facebookPixel',
src: 'https://connect.facebook.net/en_US/fbevents.js',
}, {
...scriptOptions,
Expand Down
10 changes: 6 additions & 4 deletions src/runtime/registry/hotjar.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type Input, number, object, optional } from 'valibot'
import { number, object, optional } from 'valibot'
import { useScript, validateScriptInputSchema } from '#imports'
import type { NuxtUseScriptOptions } from '#nuxt-scripts'
import type { NuxtUseScriptOptions, ScriptDynamicSrcInput } from '#nuxt-scripts'

export interface HotjarApi {
hj: ((event: 'identify', userId: string, attributes?: Record<string, any>) => void) & ((event: 'stateChange', path: string) => void) & ((event: 'event', eventName: string) => void) & ((event: string, arg?: string) => void) & ((...params: any[]) => void) & {
Expand All @@ -19,7 +19,9 @@ export const HotjarOptions = object({
sv: optional(number()),
})

export function useScriptHotjar<T extends HotjarApi>(options?: Input<typeof HotjarOptions>, _scriptOptions?: Omit<NuxtUseScriptOptions<T>, 'assetStrategy' | 'beforeInit' | 'use'>) {
export type HotjarInput = ScriptDynamicSrcInput<typeof HotjarOptions>

export function useScriptHotjar<T extends HotjarApi>(options?: HotjarInput, _scriptOptions?: Omit<NuxtUseScriptOptions<T>, 'assetStrategy' | 'beforeInit' | 'use'>) {
const scriptOptions: NuxtUseScriptOptions<T> = _scriptOptions || {}
scriptOptions.beforeInit = () => {
import.meta.dev && validateScriptInputSchema(HotjarOptions, options)
Expand All @@ -34,7 +36,7 @@ export function useScriptHotjar<T extends HotjarApi>(options?: Input<typeof Hotj
return useScript<T>({
key: 'hotjar',
// requires extra steps to bundle
src: `https://static.hotjar.com/c/hotjar-${options?.id}.js?sv=${options?.sv}`,
src: options?.src || `https://static.hotjar.com/c/hotjar-${options?.id}.js?sv=${options?.sv}`,
}, {
...scriptOptions,
use() {
Expand Down
11 changes: 7 additions & 4 deletions src/runtime/registry/intercom.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type Input, literal, number, object, optional, string, union } from 'valibot'
import { joinURL } from 'ufo'
import { useScript, validateScriptInputSchema } from '#imports'
import type { NuxtUseScriptOptions } from '#nuxt-scripts'
import type { NuxtUseScriptOptions, ScriptDynamicSrcInput } from '#nuxt-scripts'

export const IntercomOptions = object({
app_id: string(),
Expand All @@ -14,6 +15,8 @@ export const IntercomOptions = object({
vertical_padding: optional(number()),
})

export type IntercomInput = ScriptDynamicSrcInput<typeof IntercomOptions>

export interface IntercomApi {
Intercom: ((event: 'boot', data?: Input<typeof IntercomOptions>) => void)
& ((event: 'shutdown') => void)
Expand Down Expand Up @@ -41,11 +44,11 @@ export interface IntercomApi {

declare global {
interface Window extends IntercomApi {
intercomSettings?: Input<typeof IntercomOptions>
intercomSettings?: IntercomInput
}
}

export function useScriptIntercom<T extends IntercomApi>(options?: Input<typeof IntercomOptions>, _scriptOptions?: Omit<NuxtUseScriptOptions<T>, 'beforeInit' | 'use'>) {
export function useScriptIntercom<T extends IntercomApi>(options?: IntercomInput, _scriptOptions?: Omit<NuxtUseScriptOptions<T>, 'beforeInit' | 'use'>) {
const scriptOptions: NuxtUseScriptOptions<T> = _scriptOptions || {}
scriptOptions.beforeInit = () => {
import.meta.dev && validateScriptInputSchema(IntercomOptions, options)
Expand All @@ -55,7 +58,7 @@ export function useScriptIntercom<T extends IntercomApi>(options?: Input<typeof
}
return useScript<T>({
key: 'intercom',
src: `https://widget.intercom.io/widget/${options?.app_id}`,
src: options?.src || joinURL(`https://widget.intercom.io/widget`, options?.app_id || ''),
}, {
...scriptOptions,
use() {
Expand Down
Loading

0 comments on commit 00ac0ef

Please sign in to comment.