@hoast/process-postprocess
Version:
Process CSS, HTML, and JS data using PostCSS, Unified's rehype, and Babel plugins and minify using CleanCSS, Unified's rehype, and Terser.
400 lines (337 loc) • 11.8 kB
JavaScript
// Import base class.
import BaseProcess from '@hoast/base-process'
// Import build-in modules.
import fs from 'fs'
import path from 'path'
import { promisify } from 'util'
// Import external modules.
import detectiveJavascriptCommon from 'detective-cjs'
import detectiveJavascriptModule from 'detective-es6'
import detectivePostCSS from 'detective-postcss'
import detectiveTypescript from 'detective-typescript'
import planckmatch from 'planckmatch'
// Import document processors.
import createUnified from './unifiedFactories/createUnifiedMinifier.js'
// import scripts processors.
import babel from '@babel/core'
import { minify as terser } from 'terser'
import { rollup } from 'rollup'
import rollupPluginBabel from '@rollup/plugin-babel'
import rollupPluginCommonjs from '@rollup/plugin-commonjs'
import rollupPluginNodeResolve from '@rollup/plugin-node-resolve'
import rollupPluginTerser from '@rollup/plugin-terser'
import rollupPluginVirtual from '@rollup/plugin-virtual'
// import styles processors.
import cssnano from 'cssnano'
import Postcss from 'postcss'
// Import utility modules.
import deepAssign from '@hoast/utils/deepAssign.js'
import { getByPathSegments } from '@hoast/utils/get.js'
import instantiate from '@hoast/utils/instantiate.js'
import { setByPathSegments } from '@hoast/utils/set.js'
// Promisify read file.
const fsReadFile = promisify(fs.readFile)
const REGEXP_DIRECTORY = /^(\/|.\/|..\/)/
const MATCH_OPTIONS = {
extended: true,
flags: 'i',
globstar: true,
}
const READ_OPTIONS = {
encoding: 'utf8',
}
const DETECTIVE_JAVASCRIPT_OPTIONS = {
skipTypeImports: true,
}
const DETECTIVE_POSTCSS_OPTIONS = {}
const DETECTIVE_TYPESCRIPT_OPTIONS = {
skipTypeImports: true,
mixedImports: true,
jsx: true,
}
const SCRIPT_PROCESSOR_BABEL = 'babel'
const SCRIPT_PROCESSOR_ROLLUP = 'rollup'
class ProcessPostprocess extends BaseProcess {
/**
* Create package instance.
* @param {...Object} options Options objects.
*/
constructor(options) {
super({
property: 'contents',
mode: 'html',
minify: true,
documentPlugins: [],
scriptMinifyOptions: {},
scriptOptions: {},
scriptProcessor: SCRIPT_PROCESSOR_BABEL,
styleMinifyOptions: {},
styleOptions: {},
stylePlugins: [],
watchIgnore: [
'**/node_modules/**',
],
}, options)
options = this.getOptions()
if (ProcessPostprocess.MODES.indexOf(options.mode) < 0) {
this.getLogger().error('Unknown mode used. Mode: "' + options.mode + '".')
}
// Convert dot notation to path segments.
this._propertyPath = options.property.split('.')
// Parse ignore patterns.
this._watchIgnore = options.watchIgnore ? planckmatch.parse(options.watchIgnore, MATCH_OPTIONS, true) : []
this._fileUsesCache = {}
}
async initialize () {
const library = this.getLibrary()
const options = this.getOptions()
if (!this._scriptProcessor) {
// Store options.
const scriptOptions = deepAssign({}, options.scriptOptions)
const scriptMinifyOptions = deepAssign({}, options.scriptMinifyOptions)
// Create script processor.
if (options.scriptProcessor === SCRIPT_PROCESSOR_ROLLUP) {
this._scriptProcessor = async (code) => {
const bundle = await rollup({
input: 'input.js', // a temporary input placeholder
plugins: [
rollupPluginNodeResolve(),
rollupPluginCommonjs(),
rollupPluginVirtual({
'input.js': code,
}),
rollupPluginBabel(scriptOptions),
options.minify ? rollupPluginTerser(scriptMinifyOptions) : null,
].filter(Boolean),
inlineDynamicImports: true,
})
const { output } = await bundle.generate({ format: 'iife' })
return output[0].code
}
} else {
this._scriptProcessor = async (code) => {
// Process via Babel.
let result = await babel.transformAsync(code, scriptOptions)
if (result.error) {
return code
}
code = result.code
// Process via Terser.
result = await terser(code, scriptMinifyOptions)
if (result.error) {
return code
}
return result.code
}
}
}
if (!this._styleProcessor) {
// Setup Postcss plugins.
let stylePlugins = options.stylePlugins ? options.stylePlugins : []
if (stylePlugins.length >= 0) {
// Instantiate all plugins.
const pluginsTemp = []
for (let plugin of stylePlugins) {
if (Array.isArray(plugin) || typeof (plugin) === 'string') {
plugin = await instantiate(plugin)
}
pluginsTemp.push(plugin)
}
stylePlugins = pluginsTemp
}
// Add Postcss minifier.
if (options.minify) {
stylePlugins.push(cssnano(options.styleMinifyOptions || {}))
}
// Setup Postcss.
const postcss = new Postcss(stylePlugins)
// Create style processor.
this._styleProcessor = (code, filePath = null) => {
const styleOptionsTemp = Object.assign({}, options.styleOptions, {
from: filePath || undefined,
})
return new Promise((resolve, reject) => {
// Process via Postcss.
postcss.process(code, styleOptionsTemp)
.then(result => {
resolve(result.css)
})
.catch(error => {
reject(error)
})
})
}
}
if (!this._documentProcessor) {
// Create document processor.
const unified = createUnified({
minify: options.minify,
}, options.documentPlugins, this._styleProcessor, this._scriptProcessor)
this._documentProcessor = async (code) => {
return (await unified.process(code)).value
}
}
if (library.isWatching()) {
// Remove changed files from dependency cache.
const changedFiles = library.getChanged()
if (changedFiles) {
for (const changedFile of changedFiles) {
if (changedFile in this._fileUsesCache) {
delete this._fileUsesCache[changedFile]
}
}
}
}
}
async concurrent (data) {
const library = this.getLibrary()
const options = this.getOptions()
// Get value to process from data.
let value = getByPathSegments(data, this._propertyPath)
// If watching mark dependencies as accessed.
if (library.isWatching()) {
// Get all dependencies of script.
const dependencies = await this.getDependencies(data.sourceIdentifier, value, options.mode)
// Add import and dependencies as accessed.
library.addAccessed(data.sourceIdentifier, ...dependencies)
}
// Process based of type.
switch (options.mode) {
case 'html':
value = await this._documentProcessor(value)
break
case 'cjs':
case 'js':
case 'mjs':
case 'ts':
value = await this._scriptProcessor(value)
break
case 'css':
let filePathTemp
if (data.sourceType === 'filesystem') {
filePathTemp = data.sourceIdentifier
}
value = await this._styleProcessor(value, filePathTemp)
break
}
// Store value back on data.
data = setByPathSegments(data, this._propertyPath, value)
return data
}
async getDependencies (source, content, type) {
/**
* Discover dependencies of given string.
*/
const discoverDependencies = (content, type) => {
// Get dependencies based of type.
switch (type) {
case 'css':
return detectivePostCSS(content, DETECTIVE_POSTCSS_OPTIONS)
case 'cjs':
return detectiveJavascriptCommon(content)
case 'js':
return [
...detectiveJavascriptCommon(content),
...detectiveJavascriptModule(content, DETECTIVE_JAVASCRIPT_OPTIONS),
]
case 'mjs':
return detectiveJavascriptModule(content, DETECTIVE_JAVASCRIPT_OPTIONS)
case 'ts':
return detectiveTypescript(content, DETECTIVE_TYPESCRIPT_OPTIONS)
}
return []
}
// Discover dependencies of given content.
const discoveredDependencies = discoverDependencies(content, type)
// Return early if no dependencies have been found.
if (!discoveredDependencies || discoveredDependencies.length === 0) {
return []
}
// Get library options.
const libraryOptions = this.getLibrary().getOptions()
// Results list of dependencies.
const dependencies = []
/**
* Filter out dependencies that our outside the watcher or should be ignored.
*/
const filterDependencies = (importPath, discoveredDependencies) => {
if (!discoveredDependencies || discoveredDependencies.length === 0) {
return []
}
const filteredDependencies = []
for (let dependency of discoveredDependencies) {
// Exclude any non file path dependencies.
if (!REGEXP_DIRECTORY.test(dependency)) {
continue
}
// Ensure dependency is an absolute path.
if (!path.isAbsolute(dependency)) {
dependency = path.resolve(path.dirname(importPath), dependency)
}
// Continue if dependency not inside the watched directory.
if (!dependency.startsWith(libraryOptions.directory)) {
continue
}
// Continue if dependency should be ignored.
if (planckmatch.match.any(dependency, this._watchIgnore)) {
continue
}
filteredDependencies.push(dependency)
}
return filteredDependencies
}
/**
* Add dependencies to list and add sub dependencies.
*/
const addDependencies = async (importPath, filteredDependencies, type) => {
const addedDependencies = []
for (const dependency of filteredDependencies) {
// Add dependency to list.
if (dependencies.indexOf(dependency) < 0) {
dependencies.push(dependency)
addedDependencies.push(dependency)
}
}
// Add dependencies of added dependency to the list.
for (const dependency of addedDependencies) {
// Try to get it from cache.
if (this._fileUsesCache[dependency]) {
await addDependencies(dependency, this._fileUsesCache[dependency])
continue
}
// Read dependency's file contents.
let content
try {
content = await fsReadFile(dependency, READ_OPTIONS)
} catch {
throw new Error('Unable to read dependencies of file at path: "' + importPath + '".')
}
// Get type from dependency.
let dependencyType = dependency.replace(/\.[^/.]+$/, '')
if (!dependencyType) {
dependencyType = type
}
// Discover, filter, and add dependencies.
const filteredDependencies = filterDependencies(dependency,
discoverDependencies(content, dependencyType),
)
this._fileUsesCache[dependency] = filteredDependencies
await addDependencies(dependency, filteredDependencies)
}
}
// Recursively add dependencies of dependencies to the list.
await addDependencies(source, filterDependencies(source, discoveredDependencies), type)
return dependencies
}
}
ProcessPostprocess.MODES = [
'css',
'cjs',
'html',
'js',
'mjs',
'ts',
]
ProcessPostprocess.SCRIPT_PROCESSOR_BABEL = SCRIPT_PROCESSOR_BABEL
ProcessPostprocess.SCRIPT_PROCESSOR_ROLLUP = SCRIPT_PROCESSOR_ROLLUP
export default ProcessPostprocess