From 746acc38d197013b818badea3624041b55ff8416 Mon Sep 17 00:00:00 2001 From: Evan You Date: Thu, 19 Nov 2020 19:28:11 -0500 Subject: [PATCH] refactor: split into smaller files --- jest.e2e.config.js | 6 - package.json | 3 +- src/index.ts | 633 ++------------------ src/script.ts | 0 src/sfc.ts | 282 +++++++++ src/style.ts | 85 +++ src/template.ts | 93 +++ src/utils/customBlockFilter.ts | 20 + src/utils/descriptorCache.ts | 15 + src/utils/error.ts | 32 + src/utils/query.ts | 68 +++ src/utils/sourceMap.ts | 19 + src/index.spec.ts => test/transform.spec.ts | 2 +- tsconfig.json | 2 +- types/hash-sum.d.ts | 3 - yarn.lock | 5 + 16 files changed, 665 insertions(+), 603 deletions(-) delete mode 100644 jest.e2e.config.js create mode 100644 src/script.ts create mode 100644 src/sfc.ts create mode 100644 src/style.ts create mode 100644 src/template.ts create mode 100644 src/utils/customBlockFilter.ts create mode 100644 src/utils/descriptorCache.ts create mode 100644 src/utils/error.ts create mode 100644 src/utils/query.ts create mode 100644 src/utils/sourceMap.ts rename src/index.spec.ts => test/transform.spec.ts (99%) delete mode 100644 types/hash-sum.d.ts diff --git a/jest.e2e.config.js b/jest.e2e.config.js deleted file mode 100644 index 8c870a8..0000000 --- a/jest.e2e.config.js +++ /dev/null @@ -1,6 +0,0 @@ -const config = require('./jest.config') - -module.exports = { - ...config, - testMatch: ['**/*.e2e.ts'], -} diff --git a/package.json b/package.json index 818c76e..0738194 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "dev": "tsc -w -p .", "test": "run-p test:*", "test:unit": "jest", - "test:e2e": "jest --config jest.e2e.config.js" + "test:e2e": "jest --testMatch '**/*.e2e.ts'" }, "dependencies": { "debug": "^4.1.1", @@ -27,6 +27,7 @@ "devDependencies": { "@rollup/plugin-node-resolve": "^9.0.0", "@types/debug": "^4.1.5", + "@types/hash-sum": "^1.0.0", "@types/jest": "^25.2.3", "@types/node": "^13.13.2", "@vue/compiler-sfc": "^3.0.2", diff --git a/src/index.ts b/src/index.ts index c548e66..6b5661c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,25 +8,20 @@ try { } import { - CompilerError, - compileStyleAsync, - compileTemplate, - parse, - compileScript, - SFCBlock, - SFCDescriptor, SFCTemplateCompileOptions, - SFCTemplateCompileResults, SFCAsyncStyleCompileOptions, - generateCssVars, } from '@vue/compiler-sfc' import fs from 'fs' import createDebugger from 'debug' -import hash from 'hash-sum' -import { basename, relative } from 'path' -import qs from 'querystring' -import { Plugin, RollupError } from 'rollup' +import { Plugin } from 'rollup' import { createFilter } from 'rollup-pluginutils' +import { transformSFCEntry } from './sfc' +import { transformTemplate } from './template' +import { transformStyle } from './style' +import { createCustomBlockFilter } from './utils/customBlockFilter' +import { getDescriptor, setDescriptor } from './utils/descriptorCache' +import { parseVuePartRequest } from './utils/query' +import { normalizeSourceMap } from './utils/sourceMap' const debug = createDebugger('rollup-plugin-vue') @@ -92,18 +87,18 @@ export default function PluginVue(userOptions: Partial = {}): Plugin { skipSelf: true, }) if (resolved) { - cache.set(resolved.id, getDescriptor(importer!)) + setDescriptor(resolved.id, getDescriptor(importer!)) const [, originalQuery] = id.split('?', 2) resolved.id += `?${originalQuery}` return resolved } } else if (!filter(query.filename)) { - return undefined + return null } debug(`resolveId(${id})`) return id } - return undefined + return null }, load(id) { @@ -133,596 +128,52 @@ export default function PluginVue(userOptions: Partial = {}): Plugin { } } } - - return undefined + return null }, async transform(code, id) { const query = parseVuePartRequest(id) - if (query.vue) { - if (!query.src && !filter(query.filename)) return null - - const descriptor = getDescriptor(query.filename) - if (query.src) { - this.addWatchFile(query.filename) - } - - if (query.type === 'template') { - debug(`transform(${id})`) - const result = compileTemplate({ - ...getTemplateCompilerOptions(options, descriptor, query.id), - source: code, - filename: query.filename, - }) - - if (result.errors.length) { - result.errors.forEach((error) => - this.error( - typeof error === 'string' - ? { id: query.filename, message: error } - : createRollupError(query.filename, error) - ) - ) - return null - } - - if (result.tips.length) { - result.tips.forEach((tip) => - this.warn({ - id: query.filename, - message: tip, - }) - ) - } - - return { - code: result.code, - map: normalizeSourceMap(result.map!, id), - } - } else if (query.type === 'style') { - debug(`transform(${id})`) - const block = descriptor.styles[query.index]! - - let preprocessOptions = options.preprocessOptions || {} - const preprocessLang = (options.preprocessStyles - ? block.lang - : undefined) as SFCAsyncStyleCompileOptions['preprocessLang'] - - if (preprocessLang) { - preprocessOptions = - preprocessOptions[preprocessLang] || preprocessOptions - // include node_modules for imports by default - switch (preprocessLang) { - case 'scss': - case 'sass': - preprocessOptions = { - includePaths: ['node_modules'], - ...preprocessOptions, - } - break - case 'less': - case 'stylus': - preprocessOptions = { - paths: ['node_modules'], - ...preprocessOptions, - } - } - } else { - preprocessOptions = {} - } - - const result = await compileStyleAsync({ - filename: query.filename, - id: `data-v-${query.id}`, - isProd: isProduction, - source: code, - scoped: block.scoped, - modules: !!block.module, - postcssOptions: options.postcssOptions, - postcssPlugins: options.postcssPlugins, - modulesOptions: options.cssModulesOptions, - preprocessLang, - preprocessCustomRequire: options.preprocessCustomRequire, - preprocessOptions, - }) - - if (result.errors.length) { - result.errors.forEach((error) => - this.error({ - id: query.filename, - message: error.message, - }) - ) - return null - } - - if (query.module) { - return { - code: `export default ${_(result.modules)}`, - map: null, - } - } else { - return { - code: result.code, - map: normalizeSourceMap(result.map!, id), - } - } - } - return null - } else if (filter(id)) { - debug(`transform(${id})`) - const { descriptor, errors } = parseSFC(code, id, rootContext) - - if (errors.length) { - errors.forEach((error) => this.error(createRollupError(id, error))) - return null - } - // module id for scoped CSS & hot-reload - const output = transformVueSFC( + // *.vue file + // generate an entry module that imports the actual blocks of the SFC + if (!query.vue && filter(id)) { + debug(`transform SFC entry (${id})`) + const output = transformSFCEntry( code, id, - descriptor, - { rootContext, isProduction, isServer, filterCustomBlock }, - options + options, + rootContext, + isProduction, + isServer, + filterCustomBlock, + this ) - debug('transient .vue file:', '\n' + output + '\n') - - return { - code: output, - map: { - mappings: '', - }, + if (output) { + debug('SFC entry code:', '\n' + output.code + '\n') } - } else { - return null + return output } - }, - } -} - -function getTemplateCompilerOptions( - options: Options, - descriptor: SFCDescriptor, - scopeId: string -): Omit | undefined { - const block = descriptor.template - if (!block) { - return - } - - const isServer = options.target === 'node' - const isProduction = - process.env.NODE_ENV === 'production' || process.env.BUILD === 'production' - const hasScoped = descriptor.styles.some((s) => s.scoped) - const preprocessLang = block.lang - const preprocessOptions = - preprocessLang && - options.templatePreprocessOptions && - options.templatePreprocessOptions[preprocessLang] - return { - filename: descriptor.filename, - inMap: block.src ? undefined : block.map, - preprocessLang, - preprocessOptions, - preprocessCustomRequire: options.preprocessCustomRequire, - compiler: options.compiler, - ssr: isServer, - compilerOptions: { - ...options.compilerOptions, - scopeId: hasScoped ? `data-v-${scopeId}` : undefined, - bindingMetadata: descriptor.scriptCompiled - ? descriptor.scriptCompiled.bindings - : undefined, - ssrCssVars: isServer - ? generateCssVars(descriptor, scopeId, isProduction) - : undefined, - }, - transformAssetUrls: options.transformAssetUrls, - } -} - -function createCustomBlockFilter( - queries?: string[] -): (type: string) => boolean { - if (!queries || queries.length === 0) return () => false - - const allowed = new Set(queries.filter((query) => /^[a-z]/i.test(query))) - const disallowed = new Set( - queries - .filter((query) => /^![a-z]/i.test(query)) - .map((query) => query.substr(1)) - ) - const allowAll = queries.includes('*') || !queries.includes('!*') - - return (type: string) => { - if (allowed.has(type)) return true - if (disallowed.has(type)) return true - - return allowAll - } -} - -type Query = - | { - filename: string - vue: false - } - | { - filename: string - vue: true - type: 'script' - src?: true - } - | { - filename: string - vue: true - type: 'template' - id: string - src?: true - } - | { - filename: string - vue: true - type: 'style' - index: number - id: string - scoped?: boolean - module?: string | boolean - src?: true - } - | { - filename: string - vue: true - type: 'custom' - index: number - src?: true - } - -function parseVuePartRequest(id: string): Query { - const [filename, query] = id.split('?', 2) - - if (!query) return { vue: false, filename } - - const raw = qs.parse(query) - - if ('vue' in raw) { - return { - ...raw, - filename, - vue: true, - index: Number(raw.index), - src: 'src' in raw, - scoped: 'scoped' in raw, - } as any - } - - return { vue: false, filename } -} - -const cache = new Map() - -function getDescriptor(id: string) { - if (cache.has(id)) { - return cache.get(id)! - } - - throw new Error(`${id} is not parsed yet`) -} - -function parseSFC(code: string, id: string, sourceRoot: string) { - const { descriptor, errors } = parse(code, { - sourceMap: true, - filename: id, - sourceRoot: sourceRoot, - }) - cache.set(id, descriptor) - return { descriptor, errors: errors } -} - -function transformVueSFC( - code: string, - resourcePath: string, - descriptor: SFCDescriptor, - { - rootContext, - isProduction, - isServer, - filterCustomBlock, - }: { - rootContext: string - isProduction: boolean - isServer: boolean - filterCustomBlock: (type: string) => boolean - }, - options: Options -) { - const shortFilePath = relative(rootContext, resourcePath) - .replace(/^(\.\.[\/\\])+/, '') - .replace(/\\/g, '/') - const scopeId = hash( - isProduction ? shortFilePath + '\n' + code : shortFilePath - ) - // feature information - const hasScoped = descriptor.styles.some((s) => s.scoped) - - const hasTemplateImport = - descriptor.template && - // script setup compiles template inline, do not import again - (isServer || !descriptor.scriptSetup) - - const templateImport = hasTemplateImport - ? getTemplateCode(descriptor, resourcePath, scopeId, isServer) - : '' - - const renderReplace = hasTemplateImport - ? isServer - ? `script.ssrRender = ssrRender` - : `script.render = render` - : '' - - const scriptImport = getScriptCode( - descriptor, - resourcePath, - scopeId, - isProduction, - isServer, - getTemplateCompilerOptions(options, descriptor, scopeId) - ) - const stylesCode = getStyleCode( - descriptor, - resourcePath, - scopeId, - options.preprocessStyles - ) - const customBlocksCode = getCustomBlock( - descriptor, - resourcePath, - filterCustomBlock - ) - const output = [ - scriptImport, - templateImport, - stylesCode, - customBlocksCode, - renderReplace, - ] - if (hasScoped) { - output.push(`script.__scopeId = ${_(`data-v-${scopeId}`)}`) - } - if (!isProduction) { - output.push(`script.__file = ${_(shortFilePath)}`) - } else if (options.exposeFilename) { - output.push(`script.__file = ${_(basename(shortFilePath))}`) - } - output.push('export default script') - return output.join('\n') -} - -function getTemplateCode( - descriptor: SFCDescriptor, - resourcePath: string, - id: string, - isServer: boolean -) { - const renderFnName = isServer ? 'ssrRender' : 'render' - let templateImport = `const ${renderFnName} = () => {}` - let templateRequest - if (descriptor.template) { - const src = descriptor.template.src || resourcePath - const idQuery = `&id=${id}` - const srcQuery = descriptor.template.src ? `&src` : `` - const attrsQuery = attrsToQuery(descriptor.template.attrs, 'js', true) - const query = `?vue&type=template${idQuery}${srcQuery}${attrsQuery}` - templateRequest = _(src + query) - templateImport = `import { ${renderFnName} } from ${templateRequest}` - } - - return templateImport -} -function getScriptCode( - descriptor: SFCDescriptor, - resourcePath: string, - id: string, - isProd: boolean, - isServer: boolean, - templateOptions?: Partial -) { - let scriptImport = `const script = {}` - if (descriptor.script || descriptor.scriptSetup) { - if (compileScript) { - descriptor.scriptCompiled = compileScript(descriptor, { - id, - isProd, - inlineTemplate: !isServer, - templateOptions, - }) - } - const script = descriptor.scriptCompiled || descriptor.script - if (script) { - const src = script.src || resourcePath - const attrsQuery = attrsToQuery(script.attrs, 'js') - const srcQuery = script.src ? `&src` : `` - const query = `?vue&type=script${srcQuery}${attrsQuery}` - const scriptRequest = _(src + query) - scriptImport = - `import script from ${scriptRequest}\n` + - `export * from ${scriptRequest}` // support named exports - } - } - return scriptImport -} - -function getStyleCode( - descriptor: SFCDescriptor, - resourcePath: string, - id: string, - preprocessStyles?: boolean -) { - let stylesCode = `` - let hasCSSModules = false - if (descriptor.styles.length) { - descriptor.styles.forEach((style, i) => { - const src = style.src || resourcePath - // do not include module in default query, since we use it to indicate - // that the module needs to export the modules json - const attrsQuery = attrsToQuery(style.attrs, 'css', preprocessStyles) - const attrsQueryWithoutModule = attrsQuery.replace( - /&module(=true|=[^&]+)?/, - '' - ) - // make sure to only pass id when necessary so that we don't inject - // duplicate tags when multiple components import the same css file - const idQuery = `&id=${id}` - const srcQuery = style.src ? `&src` : `` - const query = `?vue&type=style&index=${i}${srcQuery}${idQuery}` - const styleRequest = src + query + attrsQuery - const styleRequestWithoutModule = src + query + attrsQueryWithoutModule - if (style.module) { - if (!hasCSSModules) { - stylesCode += `\nconst cssModules = script.__cssModules = {}` - hasCSSModules = true + // sub request for blocks + if (query.vue) { + if (!query.src && !filter(query.filename)) { + return null + } + if (query.src) { + this.addWatchFile(query.filename) + } + if (query.type === 'template') { + debug(`transform template (${id})`) + return transformTemplate(code, id, options, query, this) + } else if (query.type === 'style') { + debug(`transform style (${id})`) + return transformStyle(code, id, options, query, isProduction, this) } - stylesCode += genCSSModulesCode( - id, - i, - styleRequest, - styleRequestWithoutModule, - style.module - ) - } else { - stylesCode += `\nimport ${_(styleRequest)}` } - // TODO SSR critical CSS collection - }) - } - return stylesCode -} - -function getCustomBlock( - descriptor: SFCDescriptor, - resourcePath: string, - filter: (type: string) => boolean -) { - let code = '' - - descriptor.customBlocks.forEach((block, index) => { - if (filter(block.type)) { - const src = block.src || resourcePath - const attrsQuery = attrsToQuery(block.attrs, block.type) - const srcQuery = block.src ? `&src` : `` - const query = `?vue&type=${block.type}&index=${index}${srcQuery}${attrsQuery}` - const request = _(src + query) - code += `import block${index} from ${request}\n` - code += `if (typeof block${index} === 'function') block${index}(script)\n` - } - }) - - return code -} - -function createRollupError( - id: string, - error: CompilerError | SyntaxError -): RollupError { - if ('code' in error) { - return { - id, - plugin: 'vue', - pluginCode: String(error.code), - message: error.message, - frame: error.loc!.source, - parserError: error, - loc: error.loc - ? { - file: id, - line: error.loc.start.line, - column: error.loc.start.column, - } - : undefined, - } - } else { - return { - id, - plugin: 'vue', - message: error.message, - parserError: error, - } - } -} - -// these are built-in query parameters so should be ignored -// if the user happen to add them as attrs -const ignoreList = ['id', 'index', 'src', 'type', 'lang'] - -function attrsToQuery( - attrs: SFCBlock['attrs'], - langFallback?: string, - forceLangFallback = false -): string { - let query = `` - for (const name in attrs) { - const value = attrs[name] - if (!ignoreList.includes(name)) { - query += `&${qs.escape(name)}${ - value ? `=${qs.escape(String(value))}` : `` - }` - } - } - if (langFallback || attrs.lang) { - query += - `lang` in attrs - ? forceLangFallback - ? `&lang.${langFallback}` - : `&lang.${attrs.lang}` - : `&lang.${langFallback}` - } - return query -} - -function _(any: any) { - return JSON.stringify(any) -} - -function normalizeSourceMap( - map: SFCTemplateCompileResults['map'], - id: string -): any { - if (!map) return null as any - - if (!id.includes('type=script')) { - map.file = id - map.sources[0] = id - } - - return { - ...map, - version: Number(map.version), - mappings: typeof map.mappings === 'string' ? map.mappings : '', + return null + }, } } -function genCSSModulesCode( - // @ts-ignore - id: string, - index: number, - request: string, - requestWithoutModule: string, - moduleName: string | boolean -): string { - const styleVar = `style${index}` - let code = - // first import the CSS for extraction - `\nimport ${_(requestWithoutModule)}` + - // then import the json file to expose to component... - `\nimport ${styleVar} from ${_(request + '.js')}` - - // inject variable - const name = typeof moduleName === 'string' ? moduleName : '$style' - code += `\ncssModules["${name}"] = ${styleVar}` - return code -} - // overwrite for cjs require('rollup-plugin-vue')() usage module.exports = PluginVue diff --git a/src/script.ts b/src/script.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/sfc.ts b/src/sfc.ts new file mode 100644 index 0000000..fe1cddf --- /dev/null +++ b/src/sfc.ts @@ -0,0 +1,282 @@ +import hash from 'hash-sum' +import path from 'path' +import qs from 'querystring' +import { + compileScript, + parse, + SFCBlock, + SFCDescriptor, + SFCTemplateCompileOptions, +} from '@vue/compiler-sfc' +import { Options } from '.' +import { getTemplateCompilerOptions } from './template' +import { setDescriptor } from './utils/descriptorCache' +import { TransformPluginContext } from 'rollup' +import { createRollupError } from './utils/error' + +export function transformSFCEntry( + code: string, + resourcePath: string, + options: Options, + sourceRoot: string, + isProduction: boolean, + isServer: boolean, + filterCustomBlock: (type: string) => boolean, + pluginContext: TransformPluginContext +) { + const { descriptor, errors } = parse(code, { + sourceMap: true, + filename: resourcePath, + sourceRoot, + }) + setDescriptor(resourcePath, descriptor) + + if (errors.length) { + errors.forEach((error) => + pluginContext.error(createRollupError(resourcePath, error)) + ) + return null + } + + const shortFilePath = path + .relative(sourceRoot, resourcePath) + .replace(/^(\.\.[\/\\])+/, '') + .replace(/\\/g, '/') + const scopeId = hash( + isProduction ? shortFilePath + '\n' + code : shortFilePath + ) + // feature information + const hasScoped = descriptor.styles.some((s) => s.scoped) + + const hasTemplateImport = + descriptor.template && + // script setup compiles template inline, do not import again + (isServer || !descriptor.scriptSetup) + + const templateImport = hasTemplateImport + ? genTemplateCode(descriptor, resourcePath, scopeId, isServer) + : '' + + const renderReplace = hasTemplateImport + ? isServer + ? `script.ssrRender = ssrRender` + : `script.render = render` + : '' + + const scriptImport = genScriptCode( + descriptor, + resourcePath, + scopeId, + isProduction, + isServer, + getTemplateCompilerOptions(options, descriptor, scopeId) + ) + const stylesCode = genStyleCode( + descriptor, + resourcePath, + scopeId, + options.preprocessStyles + ) + const customBlocksCode = getCustomBlock( + descriptor, + resourcePath, + filterCustomBlock + ) + const output = [ + scriptImport, + templateImport, + stylesCode, + customBlocksCode, + renderReplace, + ] + if (hasScoped) { + output.push(`script.__scopeId = ${JSON.stringify(`data-v-${scopeId}`)}`) + } + if (!isProduction) { + output.push(`script.__file = ${JSON.stringify(shortFilePath)}`) + } else if (options.exposeFilename) { + output.push( + `script.__file = ${JSON.stringify(path.basename(shortFilePath))}` + ) + } + output.push('export default script') + return { + code: output.join('\n'), + map: { + mappings: '', + }, + } +} + +function genTemplateCode( + descriptor: SFCDescriptor, + resourcePath: string, + id: string, + isServer: boolean +) { + const renderFnName = isServer ? 'ssrRender' : 'render' + let templateImport = `const ${renderFnName} = () => {}` + let templateRequest + if (descriptor.template) { + const src = descriptor.template.src || resourcePath + const idQuery = `&id=${id}` + const srcQuery = descriptor.template.src ? `&src` : `` + const attrsQuery = attrsToQuery(descriptor.template.attrs, 'js', true) + const query = `?vue&type=template${idQuery}${srcQuery}${attrsQuery}` + templateRequest = JSON.stringify(src + query) + templateImport = `import { ${renderFnName} } from ${templateRequest}` + } + + return templateImport +} + +function genScriptCode( + descriptor: SFCDescriptor, + resourcePath: string, + id: string, + isProd: boolean, + isServer: boolean, + templateOptions?: Partial +) { + let scriptImport = `const script = {}` + if (descriptor.script || descriptor.scriptSetup) { + if (compileScript) { + descriptor.scriptCompiled = compileScript(descriptor, { + id, + isProd, + inlineTemplate: !isServer, + templateOptions, + }) + } + const script = descriptor.scriptCompiled || descriptor.script + if (script) { + const src = script.src || resourcePath + const attrsQuery = attrsToQuery(script.attrs, 'js') + const srcQuery = script.src ? `&src` : `` + const query = `?vue&type=script${srcQuery}${attrsQuery}` + const scriptRequest = JSON.stringify(src + query) + scriptImport = + `import script from ${scriptRequest}\n` + + `export * from ${scriptRequest}` // support named exports + } + } + return scriptImport +} + +function genStyleCode( + descriptor: SFCDescriptor, + resourcePath: string, + id: string, + preprocessStyles?: boolean +) { + let stylesCode = `` + let hasCSSModules = false + if (descriptor.styles.length) { + descriptor.styles.forEach((style, i) => { + const src = style.src || resourcePath + // do not include module in default query, since we use it to indicate + // that the module needs to export the modules json + const attrsQuery = attrsToQuery(style.attrs, 'css', preprocessStyles) + const attrsQueryWithoutModule = attrsQuery.replace( + /&module(=true|=[^&]+)?/, + '' + ) + // make sure to only pass id when necessary so that we don't inject + // duplicate tags when multiple components import the same css file + const idQuery = `&id=${id}` + const srcQuery = style.src ? `&src` : `` + const query = `?vue&type=style&index=${i}${srcQuery}${idQuery}` + const styleRequest = src + query + attrsQuery + const styleRequestWithoutModule = src + query + attrsQueryWithoutModule + if (style.module) { + if (!hasCSSModules) { + stylesCode += `\nconst cssModules = script.__cssModules = {}` + hasCSSModules = true + } + stylesCode += genCSSModulesCode( + id, + i, + styleRequest, + styleRequestWithoutModule, + style.module + ) + } else { + stylesCode += `\nimport ${JSON.stringify(styleRequest)}` + } + // TODO SSR critical CSS collection + }) + } + return stylesCode +} + +function getCustomBlock( + descriptor: SFCDescriptor, + resourcePath: string, + filter: (type: string) => boolean +) { + let code = '' + + descriptor.customBlocks.forEach((block, index) => { + if (filter(block.type)) { + const src = block.src || resourcePath + const attrsQuery = attrsToQuery(block.attrs, block.type) + const srcQuery = block.src ? `&src` : `` + const query = `?vue&type=${block.type}&index=${index}${srcQuery}${attrsQuery}` + const request = JSON.stringify(src + query) + code += `import block${index} from ${request}\n` + code += `if (typeof block${index} === 'function') block${index}(script)\n` + } + }) + + return code +} + +function genCSSModulesCode( + // @ts-ignore + id: string, + index: number, + request: string, + requestWithoutModule: string, + moduleName: string | boolean +): string { + const styleVar = `style${index}` + let code = + // first import the CSS for extraction + `\nimport ${JSON.stringify(requestWithoutModule)}` + + // then import the json file to expose to component... + `\nimport ${styleVar} from ${JSON.stringify(request + '.js')}` + + // inject variable + const name = typeof moduleName === 'string' ? moduleName : '$style' + code += `\ncssModules["${name}"] = ${styleVar}` + return code +} + +// these are built-in query parameters so should be ignored +// if the user happen to add them as attrs +const ignoreList = ['id', 'index', 'src', 'type', 'lang'] + +function attrsToQuery( + attrs: SFCBlock['attrs'], + langFallback?: string, + forceLangFallback = false +): string { + let query = `` + for (const name in attrs) { + const value = attrs[name] + if (!ignoreList.includes(name)) { + query += `&${qs.escape(name)}${ + value ? `=${qs.escape(String(value))}` : `` + }` + } + } + if (langFallback || attrs.lang) { + query += + `lang` in attrs + ? forceLangFallback + ? `&lang.${langFallback}` + : `&lang.${attrs.lang}` + : `&lang.${langFallback}` + } + return query +} diff --git a/src/style.ts b/src/style.ts new file mode 100644 index 0000000..b91ebe9 --- /dev/null +++ b/src/style.ts @@ -0,0 +1,85 @@ +import { + compileStyleAsync, + SFCAsyncStyleCompileOptions, +} from '@vue/compiler-sfc' +import { TransformPluginContext } from 'rollup' +import { Options } from '.' +import { getDescriptor } from './utils/descriptorCache' +import { StyleBlockQuery } from './utils/query' +import { normalizeSourceMap } from './utils/sourceMap' + +export async function transformStyle( + code: string, + resourcePath: string, + options: Options, + query: StyleBlockQuery, + isProduction: boolean, + pluginContext: TransformPluginContext +) { + const descriptor = getDescriptor(query.filename) + const block = descriptor.styles[query.index]! + + let preprocessOptions = options.preprocessOptions || {} + const preprocessLang = (options.preprocessStyles + ? block.lang + : undefined) as SFCAsyncStyleCompileOptions['preprocessLang'] + + if (preprocessLang) { + preprocessOptions = preprocessOptions[preprocessLang] || preprocessOptions + // include node_modules for imports by default + switch (preprocessLang) { + case 'scss': + case 'sass': + preprocessOptions = { + includePaths: ['node_modules'], + ...preprocessOptions, + } + break + case 'less': + case 'stylus': + preprocessOptions = { + paths: ['node_modules'], + ...preprocessOptions, + } + } + } else { + preprocessOptions = {} + } + + const result = await compileStyleAsync({ + filename: query.filename, + id: `data-v-${query.id}`, + isProd: isProduction, + source: code, + scoped: block.scoped, + modules: !!block.module, + postcssOptions: options.postcssOptions, + postcssPlugins: options.postcssPlugins, + modulesOptions: options.cssModulesOptions, + preprocessLang, + preprocessCustomRequire: options.preprocessCustomRequire, + preprocessOptions, + }) + + if (result.errors.length) { + result.errors.forEach((error) => + pluginContext.error({ + id: query.filename, + message: error.message, + }) + ) + return null + } + + if (query.module) { + return { + code: `export default ${JSON.stringify(result.modules)}`, + map: null, + } + } else { + return { + code: result.code, + map: normalizeSourceMap(result.map!, resourcePath), + } + } +} diff --git a/src/template.ts b/src/template.ts new file mode 100644 index 0000000..821094a --- /dev/null +++ b/src/template.ts @@ -0,0 +1,93 @@ +import { + generateCssVars, + compileTemplate, + SFCDescriptor, + SFCTemplateCompileOptions, +} from '@vue/compiler-sfc' +import { TransformPluginContext } from 'rollup' +import { Options } from '.' +import { getDescriptor } from './utils/descriptorCache' +import { createRollupError } from './utils/error' +import { TemplateBlockQuery } from './utils/query' +import { normalizeSourceMap } from './utils/sourceMap' + +export function transformTemplate( + code: string, + resourcePath: string, + options: Options, + query: TemplateBlockQuery, + pluginContext: TransformPluginContext +) { + const descriptor = getDescriptor(query.filename) + const result = compileTemplate({ + ...getTemplateCompilerOptions(options, descriptor, query.id), + source: code, + filename: query.filename, + }) + + if (result.errors.length) { + result.errors.forEach((error) => + pluginContext.error( + typeof error === 'string' + ? { id: query.filename, message: error } + : createRollupError(query.filename, error) + ) + ) + return null + } + + if (result.tips.length) { + result.tips.forEach((tip) => + pluginContext.warn({ + id: query.filename, + message: tip, + }) + ) + } + + return { + code: result.code, + map: normalizeSourceMap(result.map!, resourcePath), + } +} + +export function getTemplateCompilerOptions( + options: Options, + descriptor: SFCDescriptor, + scopeId: string +): Omit | undefined { + const block = descriptor.template + if (!block) { + return + } + + const isServer = options.target === 'node' + const isProduction = + process.env.NODE_ENV === 'production' || process.env.BUILD === 'production' + const hasScoped = descriptor.styles.some((s) => s.scoped) + const preprocessLang = block.lang + const preprocessOptions = + preprocessLang && + options.templatePreprocessOptions && + options.templatePreprocessOptions[preprocessLang] + return { + filename: descriptor.filename, + inMap: block.src ? undefined : block.map, + preprocessLang, + preprocessOptions, + preprocessCustomRequire: options.preprocessCustomRequire, + compiler: options.compiler, + ssr: isServer, + compilerOptions: { + ...options.compilerOptions, + scopeId: hasScoped ? `data-v-${scopeId}` : undefined, + bindingMetadata: descriptor.scriptCompiled + ? descriptor.scriptCompiled.bindings + : undefined, + ssrCssVars: isServer + ? generateCssVars(descriptor, scopeId, isProduction) + : undefined, + }, + transformAssetUrls: options.transformAssetUrls, + } +} diff --git a/src/utils/customBlockFilter.ts b/src/utils/customBlockFilter.ts new file mode 100644 index 0000000..cb1b7d1 --- /dev/null +++ b/src/utils/customBlockFilter.ts @@ -0,0 +1,20 @@ +export function createCustomBlockFilter( + queries?: string[] +): (type: string) => boolean { + if (!queries || queries.length === 0) return () => false + + const allowed = new Set(queries.filter((query) => /^[a-z]/i.test(query))) + const disallowed = new Set( + queries + .filter((query) => /^![a-z]/i.test(query)) + .map((query) => query.substr(1)) + ) + const allowAll = queries.includes('*') || !queries.includes('!*') + + return (type: string) => { + if (allowed.has(type)) return true + if (disallowed.has(type)) return true + + return allowAll + } +} diff --git a/src/utils/descriptorCache.ts b/src/utils/descriptorCache.ts new file mode 100644 index 0000000..a3424f3 --- /dev/null +++ b/src/utils/descriptorCache.ts @@ -0,0 +1,15 @@ +import { SFCDescriptor } from '@vue/compiler-sfc' + +const cache = new Map() + +export function setDescriptor(id: string, entry: SFCDescriptor) { + cache.set(id, entry) +} + +export function getDescriptor(id: string) { + if (cache.has(id)) { + return cache.get(id)! + } + + throw new Error(`${id} is not parsed yet`) +} diff --git a/src/utils/error.ts b/src/utils/error.ts new file mode 100644 index 0000000..4977e1b --- /dev/null +++ b/src/utils/error.ts @@ -0,0 +1,32 @@ +import { CompilerError } from '@vue/compiler-sfc' +import { RollupError } from 'rollup' + +export function createRollupError( + id: string, + error: CompilerError | SyntaxError +): RollupError { + if ('code' in error) { + return { + id, + plugin: 'vue', + pluginCode: String(error.code), + message: error.message, + frame: error.loc!.source, + parserError: error, + loc: error.loc + ? { + file: id, + line: error.loc.start.line, + column: error.loc.start.column, + } + : undefined, + } + } else { + return { + id, + plugin: 'vue', + message: error.message, + parserError: error, + } + } +} diff --git a/src/utils/query.ts b/src/utils/query.ts new file mode 100644 index 0000000..f0bd678 --- /dev/null +++ b/src/utils/query.ts @@ -0,0 +1,68 @@ +import qs from 'querystring' + +export interface ScriptBlockQuery { + filename: string + vue: true + type: 'script' + src?: true +} + +export interface TemplateBlockQuery { + filename: string + vue: true + type: 'template' + id: string + src?: true +} + +export interface StyleBlockQuery { + filename: string + vue: true + type: 'style' + index: number + id: string + scoped?: boolean + module?: string | boolean + src?: true +} + +export interface CustomBlockQuery { + filename: string + vue: true + type: 'custom' + index: number + src?: true +} + +export interface NonVueQuery { + filename: string + vue: false +} + +export type Query = + | NonVueQuery + | ScriptBlockQuery + | TemplateBlockQuery + | StyleBlockQuery + | CustomBlockQuery + +export function parseVuePartRequest(id: string): Query { + const [filename, query] = id.split('?', 2) + + if (!query) return { vue: false, filename } + + const raw = qs.parse(query) + + if ('vue' in raw) { + return { + ...raw, + filename, + vue: true, + index: Number(raw.index), + src: 'src' in raw, + scoped: 'scoped' in raw, + } as any + } + + return { vue: false, filename } +} diff --git a/src/utils/sourceMap.ts b/src/utils/sourceMap.ts new file mode 100644 index 0000000..9d52e9d --- /dev/null +++ b/src/utils/sourceMap.ts @@ -0,0 +1,19 @@ +import { SFCTemplateCompileResults } from '@vue/compiler-sfc' + +export function normalizeSourceMap( + map: SFCTemplateCompileResults['map'], + id: string +): any { + if (!map) return null as any + + if (!id.includes('type=script')) { + map.file = id + map.sources[0] = id + } + + return { + ...map, + version: Number(map.version), + mappings: typeof map.mappings === 'string' ? map.mappings : '', + } +} diff --git a/src/index.spec.ts b/test/transform.spec.ts similarity index 99% rename from src/index.spec.ts rename to test/transform.spec.ts index 44e1cd0..30dc4a1 100644 --- a/src/index.spec.ts +++ b/test/transform.spec.ts @@ -1,4 +1,4 @@ -import PluginVue from './index' +import PluginVue from '../src/index' describe('Rollup Plugin Vue', () => { describe('transform', () => { diff --git a/tsconfig.json b/tsconfig.json index 456d376..c5dc988 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["src", "types"], + "include": ["src"], "exclude": ["**/*.spec.ts"], "compilerOptions": { "target": "esnext", diff --git a/types/hash-sum.d.ts b/types/hash-sum.d.ts deleted file mode 100644 index 439e04c..0000000 --- a/types/hash-sum.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module 'hash-sum' { - export default function hash(contents: string): string -} diff --git a/yarn.lock b/yarn.lock index f263b40..2e3ee1d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -599,6 +599,11 @@ dependencies: "@types/node" "*" +"@types/hash-sum@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@types/hash-sum/-/hash-sum-1.0.0.tgz#838f4e8627887d42b162d05f3d96ca636c2bc504" + integrity sha512-FdLBT93h3kcZ586Aee66HPCVJ6qvxVjBlDWNmxSGSbCZe9hTsjRKdSsl4y1T+3zfujxo9auykQMnFsfyHWD7wg== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.2" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.2.tgz#79d7a78bad4219f4c03d6557a1c72d9ca6ba62d5"