Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 30 additions & 22 deletions src/tpc/utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { ExternalScript, Output } from 'third-party-capital'
import type { ExternalScript, Output, Script } from 'third-party-capital'
import { genImport, genTypeImport } from 'knitwork'
import { useNuxt } from '@nuxt/kit'
import type { Link, Script } from '@unhead/vue'
import type { HeadEntryOptions } from '@unhead/vue'

export interface Input {
export interface ScriptContentOpts {
data: Output
scriptFunctionName: string
tpcTypeImport: string
Expand All @@ -19,7 +19,10 @@ export interface Input {
stub: (params: { fn: string }) => any
}

export function getTpcScriptContent(input: Input) {
const HEAD_VAR = '__head'
const INJECTHEAD_CODE = `const ${HEAD_VAR} = injectHead()`

export function getTpcScriptContent(input: ScriptContentOpts) {
const nuxt = useNuxt()
if (!input.data.scripts)
throw new Error('input.data has no scripts !')
Expand All @@ -29,6 +32,8 @@ export function getTpcScriptContent(input: Input) {
if (!mainScript)
throw new Error(`no main script found for ${input.tpcKey} in third-party-capital`)

const mainScriptOptions = getScriptInputOption(mainScript)

const imports = new Set<string>([
'import { withQuery } from \'ufo\'',
'import { useRegistryScript } from \'#nuxt-scripts-utils\'',
Expand Down Expand Up @@ -59,13 +64,12 @@ declare global {
}

const clientInitCode: string[] = []
const runtimeHead: { script: Script[], link: Link[] } = {
script: [],
link: input.data.stylesheets?.map(s => ({ ref: 'stylesheet', href: s })) || [],
}

if (input.data.stylesheets) {
runtimeHead.link.push(...input.data.stylesheets.map(s => ({ href: s, ref: 'stylesheet' })))
if (!functionBody.includes(INJECTHEAD_CODE)) {
functionBody.unshift(INJECTHEAD_CODE)
}
functionBody.push(`${HEAD_VAR}.push({link: ${JSON.stringify(input.data.stylesheets.map(s => ({ ref: 'stylesheet', href: s })))}})`)
}

for (const script of input.data.scripts) {
Expand All @@ -75,21 +79,11 @@ declare global {
if (script === mainScript)
continue

if ('url' in script && script.url) {
if (!runtimeHead.script)
runtimeHead.script = []

runtimeHead.script.push({
src: script.url,
async: true,
})
if ('url' in script) {
functionBody.push(`${HEAD_VAR}.push({scripts:{ async: true, src: ${script.url} }},${JSON.stringify(getScriptInputOption(script))})`)
}
}

if (runtimeHead.script.length || runtimeHead.link.length) {
functionBody.push(`useHead(${JSON.stringify(runtimeHead)})`)
}

chunks.push(`export type Input = RegistryScriptInput${hasParams ? '<typeof OptionSchema>' : ''}`)

chunks.push(`
Expand All @@ -102,7 +96,8 @@ ${functionBody.join('\n')}
${nuxt.options.dev ? 'schema: OptionSchema,' : ''}
scriptOptions: {
use: ${input.use.toString()},
stub: import.meta.client ? undefined : ${input.stub.toString()}
stub: import.meta.client ? undefined : ${input.stub.toString()},
${mainScriptOptions ? `...(${JSON.stringify(mainScriptOptions)})` : ''}
},
${clientInitCode.length ? `clientInit: import.meta.server ? undefined : () => {${clientInitCode.join('\n')}},` : ''}
}), _options)
Expand All @@ -115,3 +110,16 @@ ${functionBody.join('\n')}
function replaceTokenToRuntime(code: string) {
return code.split(';').map(c => c.replaceAll(/'?\{\{(.*?)\}\}'?/g, 'options.$1')).join(';')
}

function getScriptInputOption(script: Script): HeadEntryOptions | undefined {
if (script.location === 'body') {
if (script.action === 'append') {
return { tagPosition: 'bodyClose' }
}
return { tagPosition: 'bodyOpen' }
}

if (script.action === 'append') {
return { tagPriority: 1 }
}
}
67 changes: 62 additions & 5 deletions test/unit/tpc.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useNuxt } from '@nuxt/kit'
import { TSESTree, parse } from '@typescript-eslint/typescript-estree'
import { getTpcScriptContent, type Input } from '../../src/tpc/utils'
import { getTpcScriptContent, type ScriptContentOpts } from '../../src/tpc/utils'

vi.mock('@nuxt/kit', async (og) => {
const mod = await og<typeof import('@nuxt/kit')>()
Expand All @@ -26,7 +26,7 @@ describe.each([
vi.mocked(useNuxt).mockReturnValue({ options: { dev: isDev } })
})

it ('expect to throw if no main scripts', () => {
it('expect to throw if no main scripts', () => {
expect(() => getTpcScriptContent({
data: {
scripts: [],
Expand All @@ -37,13 +37,13 @@ describe.each([
tpcTypeImport: 'GoogleAnalyticsInput',
augmentWindowTypes: true,
scriptFunctionName: 'useScriptGoogleAnalytics',
use: () => {},
stub: () => {},
use: () => { },
stub: () => { },
})).toThrowError('no main script found for google-analytics in third-party-capital')
})

describe('script content generation', () => {
const input: Input = {
const input: ScriptContentOpts = {
data: {
id: 'google-analytics',
scripts: [
Expand Down Expand Up @@ -123,6 +123,51 @@ describe.each([
})
})

describe('script content generation with head positioning', () => {
const inputBase: ScriptContentOpts = {
data: {
id: 'google-analytics',
scripts: [
{
key: 'google-analytics',
params: ['id'],
url: 'https://www.google-analytics.com/analytics.js',
strategy: 'client',
location: 'body',
action: 'append',
},
],
description: 'for test purpose',
},
tpcKey: 'google-analytics',
tpcTypeImport: 'GoogleAnalyticsInput',
augmentWindowTypes: true,
scriptFunctionName: 'useScriptGoogleAnalytics',
use: () => { },
stub: () => { },
}

describe('main script', () => {
it('main script post body position', () => {
const scriptOptsAst = getTpcScriptOptsASt(getTpcScriptContent(inputBase), 'useScriptGoogleAnalytics')
expect(getCodeFromAst(getTpcScriptContent(inputBase), scriptOptsAst)).toContain('"tagPosition":"bodyClose"')
})
it('main script pre body position', () => {
const scriptOptsAst = getTpcScriptOptsASt(getTpcScriptContent({
...inputBase,
data: {
...inputBase.data,
scripts: [{
...inputBase.data.scripts![0],
action: 'prepend',
}],
},
}), 'useScriptGoogleAnalytics')
expect(getCodeFromAst(getTpcScriptContent(inputBase), scriptOptsAst)).toContain('"tagPosition":"bodyClose"')
})
})
})

function getTpcScriptAst(code: string, name: string) {
const ast = parse(code, { loc: true, range: true })
const tpcScriptAst = ast.body.find((node): node is TSESTree.ExportDefaultDeclaration => node.type === TSESTree.AST_NODE_TYPES.ExportNamedDeclaration && node.declaration?.type === TSESTree.AST_NODE_TYPES.FunctionDeclaration && node.declaration.id?.name === name)
Expand All @@ -147,3 +192,15 @@ function getTpcScriptReturnStatement(code: string, name: string) {
}
return returnStatement
}

function getTpcScriptOptsASt(code: string, name: string) {
const returnStatement = getTpcScriptReturnStatement(code, name)
if (!returnStatement || returnStatement.argument?.type !== TSESTree.AST_NODE_TYPES.CallExpression || (returnStatement.argument?.callee as TSESTree.Identifier).name !== 'useRegistryScript') {
throw new Error('TPC Scripts must return a call expression of useRegistryScript')
}
const optionFnTree = returnStatement.argument.arguments[1] as TSESTree.ArrowFunctionExpression
const optionFnReturn = optionFnTree.body as TSESTree.ObjectExpression
const scriptOptionAst = optionFnReturn.properties.find((node): node is TSESTree.Property => node.type === TSESTree.AST_NODE_TYPES.Property && node.key.type === TSESTree.AST_NODE_TYPES.Identifier && node.key.name === 'scriptOptions')!
if (!scriptOptionAst) throw new Error('scriptOptions not found')
return scriptOptionAst
}