UNPKG

utquidem

Version:

The meta-framework suite designed from scratch for frontend-focused modern web development.

549 lines (480 loc) 13.9 kB
import fs from 'fs'; import path from 'path'; import { chalk, signale as logger } from '@modern-js/utils'; import { Alias } from '@rollup/plugin-alias'; import { Plugin as RollupPlugin, SourceMap } from 'rollup'; import postcss, { AcceptedPlugin, ProcessOptions } from 'postcss'; import { codeFrameColumns } from '@babel/code-frame'; import type { LegacyImporterResult } from 'sass'; import less from 'less'; import type { IAppContext, NormalizedConfig } from '@modern-js/core'; import { getLessConfig, getPostcssConfig, getSassConfig, } from '@modern-js/css-config'; import { isCSSRequest, replaceAsync, addQuery, hasDependency } from '../utils'; import { CSS_MODULE_REGEX, CSS_URL_FUNCTION_REGEX, DEV_CLIENT_URL, } from '../constants'; import { HMRError } from '../websocket-server'; import { createAssetModule, fileToModules } from '../AssetModule'; import { normalizeAlias } from './alias'; export interface PreProcessOptions { lessOptions?: Record<string, any>; sassOptions?: Record<string, any>; additionalData?: string | ((content: string, filename: string) => string); } export interface TransformRes { css?: string; errors?: HMRError[]; deps?: string[]; map?: SourceMap; } let processorOptions: { less: PreProcessOptions; sass: PreProcessOptions; }; let nodeModulesPaths: Array<string> = []; let postcssConfig: { postcssOptions: ProcessOptions & { plugins: AcceptedPlugin[]; }; }; let tailwindConfig: any; // record css modules locals json const moduleLocalsMap = new Map(); const initProcessorOptions = (config: NormalizedConfig) => { if (processorOptions) { return; } const lessOptions = getLessConfig(config); const sassOptions = getSassConfig(config); processorOptions = { less: lessOptions, sass: sassOptions, }; }; const initTailwindConfig = (config: NormalizedConfig) => { try { if (!tailwindConfig) { // TODO: talwindcss config. tailwindConfig = (config.tools as any).tailwind; } } catch (err: any) { if (err.code !== 'MODULE_NOT_FOUND') { throw err; } // ignore err } }; const initPostcssConfig = ( config: NormalizedConfig, { appDirectory }: IAppContext, ) => { if (postcssConfig) { return; } // FIXME: type any postcssConfig = getPostcssConfig(appDirectory, config, false) as any; }; let matchAlias: (request: string) => Alias | undefined; const createIsAliasRequest = ( config: NormalizedConfig, appContext: IAppContext, defaultDeps: string[], ) => { if (!matchAlias) { const aliasOptions = normalizeAlias(config, appContext, defaultDeps); matchAlias = (request: string) => aliasOptions.find((alias: Alias) => { const { find } = alias; if (find instanceof RegExp) { return find.test(request); } else if (typeof find === 'string') { if (request.length < find.length) { return false; } if (request.length === find.length) { return true; } const requestStartsWithKey = request.startsWith(find); const requestHasSlashAfterKey = request .substring(find.length) .startsWith('/'); return requestStartsWithKey && requestHasSlashAfterKey; } return false; }); } }; function getSource( source: string, filename: string, additionalData?: string | ((content: string, filename: string) => string), ) { if (!additionalData) { return source; } if (typeof additionalData === 'function') { return additionalData(source, filename); } return `${additionalData}\n${source}`; } const isCSSModule = (config: NormalizedConfig, id: string): boolean => { if (id.includes(`node_modules`)) { return false; } const { output: { disableCssModuleExtension }, } = config; if (disableCssModuleExtension) { return true; } return CSS_MODULE_REGEX.test(id); }; export const hasTailwind = (appDirectory: string) => hasDependency(appDirectory, `@modern-js/plugin-tailwindcss`) && hasDependency(appDirectory, `tailwindcss`); export class CustomLessFileManager extends less.FileManager { async loadFile( filename: string, currentDirectory: string, options: Less.LoadFileOptions, environment: Less.Environment, ) { let result; if (filename.startsWith('~')) { filename = filename.slice(1); } try { result = await super.loadFile( filename, currentDirectory, options, environment, ); } catch (err: any) { if (err.type !== 'File') { return Promise.reject(err); } const matched = matchAlias(filename); if (matched) { return super.loadFile( filename.replace(matched.find, matched.replacement), currentDirectory, options, environment, ); } else { return Promise.reject(err); } } return result; } } const compileLess = async ( code: string, filename: string, options: PreProcessOptions, ): Promise<TransformRes> => { const less = require('less'); try { const res = await less.render( getSource(code, filename, options.additionalData), { filename, sourceMap: true, ...options.lessOptions, plugins: [ { install(lessInstance: any, pluginManager: any) { pluginManager.addFileManager(new CustomLessFileManager()); }, }, ], }, ); return { css: res.css, deps: res.imports || [], map: res.map }; } catch (err: any) { if (err.filename) { err.frame = codeFrameColumns(fs.readFileSync(err.filename, 'utf8'), { start: { line: err.line, column: err.column, }, }); } err.loc = { file: err.filename, line: err.line, column: err.column, }; return { errors: [err] }; } }; const compileSass = async ( code: string, filename: string, options: PreProcessOptions, ): Promise<TransformRes> => { const sass = require('sass'); try { const result: any = await new Promise((resolve, reject) => { sass.render( { ...options.sassOptions, data: getSource(code, filename, options.additionalData), identSyntax: filename.endsWith('.sass'), includePaths: [path.dirname(filename), ...nodeModulesPaths], outFile: filename, file: filename, sourceMap: true, importer( url: string, prev: string, done: (data: LegacyImporterResult) => void, ) { if (url.startsWith('~')) { url = url.slice(1); } const matched = matchAlias(url); done({ file: matched ? url.replace(matched.find, matched.replacement) : url, }); }, }, (err: Error | null, result: any) => { if (err) { reject(err); } else { resolve(result); } }, ); }); return { css: result.css.toString(), deps: result.stats.includedFiles || [], map: result.map.toString(), }; } catch (e: any) { e.loc = { file: e.file, line: e.line, column: e.column, }; return { errors: [e] }; } }; const transformCSS = async ( code: string, filename: string, config: NormalizedConfig, { appDirectory }: IAppContext, ): Promise<TransformRes> => { const extname = path.extname(filename); let css: string | undefined; let errors: HMRError[] | undefined; let deps: string[] | undefined = []; let map: SourceMap | undefined; // pre process switch (extname) { case '.less': { ({ css, errors, deps, map } = await compileLess( code, filename, processorOptions.less, )); break; } case '.sass': case '.scss': { ({ css, errors, deps, map } = await compileSass( code, filename, processorOptions.sass, )); break; } default: { // empty } } if (!deps) { deps = []; } if (errors?.length) { throw errors[0] as Error; } // less/sass 处理完之后空文件时,直接返回 if (extname !== '.css' && !css) { return { css: '' }; } // postcss const postcssPlugins = [...postcssConfig.postcssOptions.plugins]; if (isCSSModule(config, filename)) { postcssPlugins.push( require('postcss-modules')({ localsConvention: 'camelCase', generateScopedName: config.output.cssModuleLocalIdentName, globalModulePaths: [/\.global\.(css|scss|sass|less)$/], getJSON(_: string, _modules: Record<string, string>) { moduleLocalsMap.set(filename, _modules); }, }), ); } // As @modern-js/plugin-tailwindcss requires 'tailwindcss' as peer depenedency, // so we could safely require both plugins when using tailwind features if (hasTailwind(appDirectory)) { postcssPlugins.push(require('tailwindcss')(tailwindConfig)); } try { const result = await postcss(postcssPlugins).process(css || code, { from: filename, to: filename, map: { inline: false, annotation: false, prev: map, }, ...postcssConfig.postcssOptions, }); if (result?.messages.length) { for (const message of result.messages) { if (message.type === 'dependency') { deps.push(message.file as string); } else if (message.type === 'warning') { let tips = `[${message.plugin as string}] ${message.text}\n`; if (message.line && message.column) { tips += `${codeFrameColumns(code, { start: { line: message.line, column: message.column, }, })}`; } logger.warn(chalk.yellowBright(tips)); } } } // deps tree for css hmr const assetModule = fileToModules.get(filename); for (const dep of Array.from(new Set(deps))) { const depModule = createAssetModule(path.relative(filename, dep)); depModule.filePath = dep; depModule.id = dep; assetModule!.dependencies.add(dep); depModule.dependents.add(assetModule!.id); } return { css: result.css, map: result.map as any, }; } catch (err: any) { if (err.line !== undefined && err.column !== undefined) { err.loc = { file: err.file, line: err.line, column: err.column, }; err.frame = codeFrameColumns(err.input.source, { start: { line: err.line, column: err.column, }, }); } throw err; } }; // css url() assets rewrite const rewriteCssUrl = async ( code: string | undefined, replacer: (resource: string) => Promise<string>, ): Promise<string | undefined> => { if (!code) { return; } return replaceAsync( code, CSS_URL_FUNCTION_REGEX, async (match: string, $1: string) => { if (/^["']?(https?|data:|#)/.test($1.trim())) { return match; } const replaced = await replacer($1); return `url('${replaced}')`; }, ); }; export const cssPlugin = ( config: NormalizedConfig, appContext: IAppContext, defaultDeps: string[], ): RollupPlugin => ({ name: 'esm-css', async transform(code: string, importer: string) { if (!isCSSRequest(importer)) { return null; } const { appDirectory } = appContext; // find parent node_modules paths for sass and less nodeModulesPaths = require('find-node-modules')({ cwd: appDirectory }); initProcessorOptions(config); initPostcssConfig(config, appContext); initTailwindConfig(config); createIsAliasRequest(config, appContext, defaultDeps); const { css, map } = await transformCSS(code, importer, config, appContext); // css url() rewrite const rewrited = await rewriteCssUrl( css, async (resource: string): Promise<string> => { resource = resource.replace(/(^['"])|(['"]$)/g, '').replace(/^~/, ''); // try to resolve url request // if resolved then return relative url // if can't resolve just return original resource // should ignore error here, such as cursor: url(mycursor.cur) try { const resolved = await this.resolve(resource, path.dirname(importer)); const filePath = typeof resolved === 'object' ? resolved?.id || resource : resolved; const relativeUrl = path.relative(appDirectory, filePath); let rewrite = relativeUrl.startsWith('.') ? filePath : `/${relativeUrl}`; // resource HMR if (path.extname(filePath) === '.css') { rewrite = addQuery(rewrite, 'direct'); const assetModule = fileToModules.get(importer); assetModule!.dependencies.add(rewrite); const depModule = createAssetModule(rewrite); depModule.filePath = filePath; depModule.id = rewrite; depModule.dependents.add(assetModule!.id); } return rewrite; } catch (err) { logger.warn(`Can't resolve resource ${resource} in ${importer}`); return resource; } }, ); const wrappedCss = [ `import { updateStyle, removeStyle } from "${DEV_CLIENT_URL}"`, `const code = ${JSON.stringify(rewrited)}`, `const filename = ${JSON.stringify(importer)}`, `updateStyle(filename, code);`, moduleLocalsMap.has(importer) && `export default ${JSON.stringify(moduleLocalsMap.get(importer))};`, ] .filter(Boolean) .join('\n'); return { code: wrappedCss, map }; }, });