Skip to content

Commit f8d8158

Browse files
committed
ADD: webpack-json-compact-loader.js
1 parent 4870bd3 commit f8d8158

File tree

7 files changed

+194
-0
lines changed

7 files changed

+194
-0
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import ADDITIONAL_JSON from './additional.json'
2+
import { a, b, c } from './additional-import'
3+
// import ADDITIONAL_UNSUPPORTED from './additional-unsupported.txt' // uncomment to see error
4+
5+
const DATA = {
6+
ADDITIONAL_JSON,
7+
'additional-import': { a, b, c }
8+
}
9+
10+
export { DATA }
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const a = 1
2+
const b = []
3+
const c = "'".repeat(32) // this will make the compact output use double quote, try change the number and check the output
4+
5+
export { a, b, c }
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
do not import this file, only ".js/json" for now!
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"a": 1,
3+
"b": [ "b" ]
4+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { DATA } from './DATA.@json'
2+
3+
const TEST = true
4+
// console.log(DATA)
5+
6+
export { TEST, DATA }

source/webpack-json-compact-loader.js

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { dirname } from 'node:path'
2+
import { readFileSync } from 'node:fs'
3+
import { tryRequire } from '@dr-js/core/module/env/tryRequire.js'
4+
import { isBasicObject } from '@dr-js/core/module/common/check.js'
5+
import { run } from '@dr-js/core/module/node/run.js'
6+
7+
const GET_BABEL_PARSER_PARSE = (log = console.warn) => {
8+
const BabelParser = tryRequire('@babel/parser')
9+
if (BabelParser && BabelParser.parse) return BabelParser.parse
10+
const error = new Error('[JSONCompactLoader] failed to load package "@babel/parser"')
11+
log(error)
12+
throw error
13+
}
14+
15+
const DEFAULT_BABEL_CONFIG = {
16+
configFile: false, babelrc: false,
17+
plugins: [ [ '@babel/plugin-transform-modules-commonjs' ] ] // NOTE: change this to `@babel/preset-env` if more complex js is used
18+
}
19+
const cleanBabelRequire = async (babelConfig, filePath, exportName) => {
20+
// NOTE: webpack loader share the global, to avoid global pollution,
21+
// `require` is run in another node process,
22+
// the good thing is `require` cache is clean every time
23+
const { promise, stdoutPromise } = run([
24+
process.execPath, // node
25+
'--eval',
26+
[
27+
`require('@babel/register')(${JSON.stringify(babelConfig || DEFAULT_BABEL_CONFIG)})`,
28+
`const data = require(${JSON.stringify(filePath)})[ ${JSON.stringify(exportName)} ]`,
29+
'process.stdout.write(JSON.stringify(data))' // use this instead of `console.log` to prevent tailing `\n`
30+
].join(';')
31+
], { quiet: true })
32+
await promise.catch(async (error) => {
33+
console.error('[JSONCompactLoader] error in cleanBabelRequire:', error)
34+
throw error
35+
})
36+
return String(await stdoutPromise) // valueJSONString
37+
}
38+
39+
const DEFAULT_PARSER_PLUGIN_LIST = [ 'objectRestSpread', 'exportDefaultFrom', 'exportNamespaceFrom' ] // should be enough for most JSON source
40+
const getImportListOfFileString = (fileString) => {
41+
const resultAST = GET_BABEL_PARSER_PARSE()(fileString, { sourceType: 'module', plugins: DEFAULT_PARSER_PLUGIN_LIST })
42+
// Sample output, check: https://github.com/babel/babel/blob/main/packages/babel-parser/ast/spec.md#importdeclaration
43+
// Node {
44+
// type: 'ImportDeclaration',
45+
// source: Node {
46+
// value: '@dr-js/dev/module/main'
47+
const importNodeList = resultAST.program.body.filter(({ type }) => type === 'ImportDeclaration')
48+
return [].concat(...importNodeList.map(({ source: { value } }) => value))
49+
}
50+
const getImportDependencyFileSet = async (pendingPathList, requireResolveAsync) => {
51+
// console.log({ pendingPathList })
52+
const parsedFileSet = new Set()
53+
const importFileSet = new Set()
54+
while (pendingPathList.length !== 0) {
55+
const pendingPath = pendingPathList.pop()
56+
if (parsedFileSet.has(pendingPath)) continue
57+
parsedFileSet.add(pendingPath)
58+
const basePath = dirname(pendingPath)
59+
for (const importPath of getImportListOfFileString(String(readFileSync(pendingPath)))) {
60+
if (!importPath.startsWith('.')) continue // skip package import
61+
const importFile = await requireResolveAsync(basePath, importPath)
62+
if (importFile.endsWith('.js')) pendingPathList.push(importFile)
63+
else if (!importFile.endsWith('.json')) throw new Error(`[JSONCompactLoader] only ".js/json" is supported, got: ${importFile}`)
64+
importFileSet.add(importFile)
65+
}
66+
}
67+
// console.log({ importFileSet })
68+
return importFileSet
69+
}
70+
71+
// Turn JSON value to js String, and select better quote
72+
// console.log(toJSString({ a: 1 })) // '{"a":1}'
73+
// console.log(toJSString({ a: "''''''''''''''''''''" })) // "{\"a\":\"''''''''''''''''''''\"}"
74+
// console.log(toJSString({ a: '""""""""""""""""""""' })) // '{"a":"\\"\\"\\"\\"\\"\\"\\"\\"\\"\\"\\"\\"\\"\\"\\"\\"\\"\\"\\"\\""}'
75+
// On why use `JSON.parse()` in js:
76+
// https://www.youtube.com/watch?v=ff4fgQxPaO0
77+
// https://github.com/webpack/webpack/pull/9349
78+
const toJSString = (value, valueJSONString = JSON.stringify(value)) => {
79+
let mostlyDoubleQuote = 0
80+
let result
81+
const REGEXP_QUOTE = /['"]/g
82+
while ((result = REGEXP_QUOTE.exec(valueJSONString))) (result[ 0 ] === '"') ? mostlyDoubleQuote++ : mostlyDoubleQuote--
83+
const QUOTE_CHAR = (mostlyDoubleQuote < 0) ? '"' : '\''
84+
85+
return `${QUOTE_CHAR}${
86+
valueJSONString
87+
.replace(/\\/g, '\\\\') // escape all escape (\)
88+
.replace(/\u2028|\u2029/g, (v) => v === '\u2029' ? '\\u2029' : '\\u2028') // invalid in JavaScript but valid JSON
89+
.replace(new RegExp(QUOTE_CHAR, 'g'), '\\' + QUOTE_CHAR)
90+
}${QUOTE_CHAR}`
91+
}
92+
93+
const JSONCompactLoader = async function (sourceString) {
94+
let { query: options } = this // https://webpack.js.org/api/loaders/#thisquery
95+
if (!options) options = { babelConfig: null, useConst: false } // may get empty string, check: https://github.com/webpack/loader-utils/blob/v2.0.0/lib/getOptions.js#L8
96+
if (!isBasicObject(options)) throw new Error(`[JSONCompactLoader] only JSON option supported, got: ${String(options)}`) // https://github.com/webpack/loader-utils/blob/v2.0.0/lib/getOptions.js#L12-L15
97+
98+
const callback = this.async()
99+
try {
100+
// find the export name
101+
const result = /export\s*{\s*([\w-]+)\s*}/.exec(sourceString)
102+
if (!result) throw new Error('[JSONCompactLoader] missing "export { NAME }"')
103+
const [ , exportName ] = result
104+
105+
// add import to watch list
106+
const importFileSet = await getImportDependencyFileSet(
107+
[ this.resourcePath ],
108+
(context, path) => new Promise((resolve, reject) => this.resolve(context, path, (error, result) => error ? reject(error) : resolve(result)))
109+
)
110+
importFileSet.forEach((importFile) => this.addDependency(importFile))
111+
112+
// run with sandbox-ed require
113+
const valueJSONString = await cleanBabelRequire(options.babelConfig, this.resourcePath, exportName)
114+
// console.log('[JSONCompactLoader]', { valueJSONString })
115+
116+
// output compact
117+
callback(null, [
118+
`${options.useConst ? 'const' : 'var'} ${exportName} = JSON.parse(${toJSString(null, valueJSONString)})`,
119+
`export { ${exportName} }`
120+
].join('\n'))
121+
} catch (error) { return callback(error) }
122+
}
123+
124+
module.exports = JSONCompactLoader
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { deepStrictEqual } from 'node:assert'
2+
import { resolve } from 'node:path'
3+
import { resetDirectory } from '@dr-js/core/module/node/fs/Directory.js'
4+
import { modifyDelete } from '@dr-js/core/module/node/fs/Modify.js'
5+
import { runStdout } from '@dr-js/core/module/node/run.js'
6+
import { getKit } from '@dr-js/core/module/node/kit.js'
7+
import { compileWithWebpack, commonFlag } from './webpack.js'
8+
import { getWebpackBabelConfig } from './babel.js'
9+
10+
const { describe, it, before, after, info = console.log } = globalThis
11+
12+
const PATH_TEST_ROOT = resolve(__dirname, 'test-webpack-json-compact-loader-gitignore/')
13+
before(() => resetDirectory(PATH_TEST_ROOT))
14+
after(() => modifyDelete(PATH_TEST_ROOT))
15+
16+
describe('webpack-json-compact-loader', () => {
17+
it('test webpack build', async () => {
18+
const kit = getKit({ PATH_TEMP: PATH_TEST_ROOT, logFunc: info })
19+
const { mode, isWatch, isProduction, profileOutput, getCommonWebpackConfig } = await commonFlag({ kit })
20+
const config = getCommonWebpackConfig({
21+
babelOption: null, // do not use default
22+
output: { path: kit.fromTemp(), filename: 'index.js', libraryTarget: 'commonjs2' },
23+
entry: { 'index': kit.fromRoot('script/webpack-json-compact-loader.test/index.js') },
24+
extraModuleRuleList: [
25+
{ test: /\.js$/, exclude: /\.@json\.js$/, use: { loader: 'babel-loader', options: getWebpackBabelConfig({ isProduction }) } },
26+
{ test: /\.@json\.js$/, use: { loader: resolve(__dirname, './webpack-json-compact-loader.js'), options: { useConst: true } } }
27+
]
28+
})
29+
kit.padLog(`compile with webpack mode: ${mode}, isWatch: ${Boolean(isWatch)}`)
30+
await compileWithWebpack({ config, isWatch, profileOutput, kit })
31+
32+
const runNodeForStdout = async (...argList) => {
33+
const stdoutString = String(await runStdout([ ...argList ], { cwd: kit.fromRoot() }))
34+
kit.log(stdoutString)
35+
return stdoutString
36+
}
37+
38+
kit.padLog('run test output')
39+
const testOutputString = await runNodeForStdout(process.execPath, '-p', 'JSON.stringify(require(process.argv[1]))', kit.fromTemp('index.js'))
40+
kit.padLog('run test source')
41+
const testSourceString = await runNodeForStdout(process.execPath, '-r', '@babel/register', '-p', 'JSON.stringify(require(process.argv[1]))', kit.fromRoot('script/webpack-json-compact-loader.test/index.js'))
42+
deepStrictEqual(JSON.parse(testOutputString), JSON.parse(testSourceString), 'the data should be the same through webpack')
43+
})
44+
})

0 commit comments

Comments
 (0)