Skip to content

Commit 44bcd80

Browse files
authored
Merge pull request #382 from SukkaW/fix-373
Fix(#373): interop dynamically imported htmlnano module properly
2 parents 7f58e17 + b592b8c commit 44bcd80

File tree

6 files changed

+98
-25
lines changed

6 files changed

+98
-25
lines changed

eslint.config.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,8 @@ export default tseslint.config(
108108
'@typescript-eslint/switch-exhaustiveness-check': ['error', { allowDefaultCaseForExhaustiveSwitch: true, considerDefaultExhaustiveForUnions: true }],
109109
'@typescript-eslint/parameter-properties': ['warn', { prefer: 'parameter-property' }],
110110

111-
'@typescript-eslint/no-namespace': 'off'
111+
'@typescript-eslint/no-namespace': 'off',
112+
'@typescript-eslint/only-throw-error': ['error', { allowRethrowing: true, allowThrowingAny: true, allowThrowingUnknown: true }]
112113
}
113114
},
114115
{

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@
8282
"svgo": "^3.0.2",
8383
"terser": "^5.21.0",
8484
"typescript": "^5.8.3",
85-
"typescript-eslint": "^8.31.1",
85+
"typescript-eslint": "^8.44.0",
8686
"uncss": "^0.17.3"
8787
},
8888
"peerDependencies": {

src/_modules/minifyCss.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,26 @@ const mod: HtmlnanoModule<CssnanoOptions> = {
2121
return tree;
2222
}
2323

24-
const promises: (Promise<void> | undefined)[] = [];
24+
const promises: Promise<void>[] = [];
25+
26+
let p: Promise<void> | undefined;
27+
2528
tree.walk((node) => {
2629
// Skip SRI, reasons are documented in "minifyJs" module
2730
if (node.attrs && 'integrity' in node.attrs) {
2831
return node;
2932
}
3033

3134
if (isStyleNode(node)) {
32-
promises.push(processStyleNode(node, cssnanoOptions, cssnano, postcss));
35+
p = processStyleNode(node, cssnanoOptions, cssnano, postcss);
36+
if (p) {
37+
promises.push(p);
38+
}
3339
} else if (node.attrs && node.attrs.style) {
34-
promises.push(processStyleAttr(node, cssnanoOptions, cssnano, postcss));
40+
p = processStyleAttr(node, cssnanoOptions, cssnano, postcss);
41+
if (p) {
42+
promises.push(p);
43+
}
3544
}
3645

3746
return node;

src/_modules/minifyJs.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ const mod: HtmlnanoModule<MinifyOptions> = {
1212

1313
if (!terser) return tree;
1414

15-
const promises: (Promise<void> | void)[] = [];
15+
const promises: Promise<void>[] = [];
16+
17+
let p: Promise<void> | undefined;
18+
1619
tree.walk((node) => {
1720
const nodeAttrs = node.attrs || {};
1821

@@ -35,7 +38,10 @@ const mod: HtmlnanoModule<MinifyOptions> = {
3538
if (node.tag && node.tag === 'script') {
3639
const mimeType = nodeAttrs.type || 'text/javascript';
3740
if (redundantScriptTypes.has(mimeType) || mimeType === 'module') {
38-
promises.push(processScriptNode(node, terserOptions, terser));
41+
p = processScriptNode(node, terserOptions, terser);
42+
if (p) {
43+
promises.push(p);
44+
}
3945
}
4046
}
4147

src/index.ts

Lines changed: 73 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,63 @@ const optionalDependencies = {
5454
minifySvg: ['svgo']
5555
} satisfies Partial<Record<keyof HtmlnanoOptions, string[]>>;
5656

57-
const interop = <T>(imported: Promise<{ default: T }>): Promise<T> => imported.then(mod => mod.default);
57+
/**
58+
* And the old mixing named export and default export again.
59+
*
60+
* TL; DR: our bundler has bundled our mixed default/named export module into a "exports" object,
61+
* and when dynamically importing a CommonJS module using "import" instead of "require", Node.js wraps
62+
* another layer of default around the "exports" object.
63+
*
64+
* The longer version:
65+
*
66+
* The bundler we are using outputs:
67+
*
68+
* ESM: export { [named], xxx as default }
69+
* CJS: exports.default = xxx; exports.[named] = ...; exports.__esModule = true;
70+
*
71+
* With ESM, the Module object looks like this:
72+
*
73+
* ```js
74+
* Module {
75+
* default: xxx,
76+
* [named]: ...,
77+
* }
78+
* ```
79+
*
80+
* With CJS, Node.js handles dynamic import differently. Node.js doesn't respect `__esModule`,
81+
* and will wrongly treat a CommonJS module as ESM, i.e. assign the "exports" object on its
82+
* own "default" on the "Module" object.
83+
*
84+
* Now we have:
85+
*
86+
* ```js
87+
* Module {
88+
* // this is actually the "exports" inside among "exports.__esModule", "exports.[named]", and "exports.default"
89+
* default: {
90+
* __esModule: true,
91+
* // This is the actual "exports.default"
92+
* default: xxx
93+
* }
94+
* }
95+
* ```
96+
*/
97+
const interop = <T>(imported: Promise<object>): Promise<HtmlnanoModule<T>> => imported.then((mod) => {
98+
let htmlnanoModule;
99+
while ('default' in mod) {
100+
htmlnanoModule = mod;
101+
mod = mod.default as object;
102+
// If we find any htmlnano module hook methods, we know this object is a htmlnano module, return directly
103+
if ('onAttrs' in mod || 'onContent' in mod || 'onNode' in mod) {
104+
return mod as HtmlnanoModule<T>;
105+
}
106+
}
107+
108+
if (htmlnanoModule && typeof htmlnanoModule.default === 'function') {
109+
return htmlnanoModule as HtmlnanoModule<T>;
110+
}
111+
112+
throw new TypeError('The imported module is not a valid htmlnano module');
113+
});
58114

59115
const modules = {
60116
collapseAttributeWhitespace: () => interop(import('./_modules/collapseAttributeWhitespace')),
@@ -63,23 +119,23 @@ const modules = {
63119
custom: () => interop(import('./_modules/custom')),
64120
deduplicateAttributeValues: () => interop(import('./_modules/deduplicateAttributeValues')),
65121
// example: () => import('./_modules/example.mjs'),
66-
mergeScripts: () => interop(import('./_modules/mergeScripts.js')),
67-
mergeStyles: () => interop(import('./_modules/mergeStyles.js')),
68-
minifyConditionalComments: () => interop(import('./_modules/minifyConditionalComments.js')),
69-
minifyCss: () => interop(import('./_modules/minifyCss.js')),
70-
minifyJs: () => interop(import('./_modules/minifyJs.js')),
71-
minifyJson: () => interop(import('./_modules/minifyJson.js')),
72-
minifySvg: () => interop(import('./_modules/minifySvg.js')),
73-
minifyUrls: () => interop(import('./_modules/minifyUrls.js')),
122+
mergeScripts: () => interop(import('./_modules/mergeScripts')),
123+
mergeStyles: () => interop(import('./_modules/mergeStyles')),
124+
minifyConditionalComments: () => interop(import('./_modules/minifyConditionalComments')),
125+
minifyCss: () => interop(import('./_modules/minifyCss')),
126+
minifyJs: () => interop(import('./_modules/minifyJs')),
127+
minifyJson: () => interop(import('./_modules/minifyJson')),
128+
minifySvg: () => interop(import('./_modules/minifySvg')),
129+
minifyUrls: () => interop(import('./_modules/minifyUrls')),
74130
normalizeAttributeValues: () => interop(import('./_modules/normalizeAttributeValues')),
75-
removeAttributeQuotes: () => interop(import('./_modules/removeAttributeQuotes.js')),
76-
removeComments: () => interop(import('./_modules/removeComments.js')),
77-
removeEmptyAttributes: () => interop(import('./_modules/removeEmptyAttributes.js')),
78-
removeOptionalTags: () => interop(import('./_modules/removeOptionalTags.js')),
79-
removeRedundantAttributes: () => interop(import('./_modules/removeRedundantAttributes.js')),
80-
removeUnusedCss: () => interop(import('./_modules/removeUnusedCss.js')),
81-
sortAttributes: () => interop(import('./_modules/sortAttributes.js')),
82-
sortAttributesWithLists: () => interop(import('./_modules/sortAttributesWithLists.js'))
131+
removeAttributeQuotes: () => interop(import('./_modules/removeAttributeQuotes')),
132+
removeComments: () => interop(import('./_modules/removeComments')),
133+
removeEmptyAttributes: () => interop(import('./_modules/removeEmptyAttributes')),
134+
removeOptionalTags: () => interop(import('./_modules/removeOptionalTags')),
135+
removeRedundantAttributes: () => interop(import('./_modules/removeRedundantAttributes')),
136+
removeUnusedCss: () => interop(import('./_modules/removeUnusedCss')),
137+
sortAttributes: () => interop(import('./_modules/sortAttributes')),
138+
sortAttributesWithLists: () => interop(import('./_modules/sortAttributesWithLists'))
83139
} satisfies Record<string, () => Promise<HtmlnanoModule<any>>>;
84140

85141
const htmlnano = Object.assign(function htmlnano(optionsRun: HtmlnanoOptions = {}, presetRun?: HtmlnanoPreset) {

test/helpers.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Expect, expect } from 'expect';
1+
import { expect } from 'expect';
2+
import type { Expect } from 'expect';
23
import { isAmpBoilerplate, isComment, isConditionalComment, isStyleNode, extractCssFromStyleNode, optionalImport } from '../dist/helpers.mjs';
34

45
describe('[helpers]', () => {

0 commit comments

Comments
 (0)