Skip to content
This repository was archived by the owner on Aug 16, 2025. It is now read-only.

Commit 732aaf9

Browse files
committed
Add saving local transformed styles and skip bundling features
1 parent 93d89fe commit 732aaf9

File tree

9 files changed

+100
-23
lines changed

9 files changed

+100
-23
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
node_modules
22
dist
33
test/fixture/out.*
4+
*.css-local

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ htmlModulesPlugin({
8383
experimental: {
8484
extractGlobalStyles: true,
8585
extractScopedStyles: true,
86+
exportLocalStylesExtension: "css-local",
87+
skipBundlingFilter: /\.tmpl\.html$/,
8688
transformLocalStyles: async (css, { filePath }) => {
8789
const postCssConfig = await postcssrc()
8890
const postCssProcessor = postcss([...postCssConfig.plugins])
@@ -102,6 +104,14 @@ If you define `extractScopedStyles: true`, then any `style` tag featuring a `sco
102104

103105
If you define a `transformLocalStyles` function, then any local style tag contained within your HTML (not explicitly scoped) will have its contents transformed by the function. Above you can see this done using PostCSS, but you could use another processor such as Sass if you prefer. This is useful for style tags which get included in shadow DOM templates (and you wouldn't want to include those styles in the CSS bundle).
104106

107+
## Bundling Lifecycle
108+
109+
As part of transforming local styles, you can optionally export those transformed styles into "sidecar" CSS output file. This can be helpful if you would like another process to use those styles in SSR. By setting the `exportLocalStylesExtension` option, a file with the provided extension will be saved right alongside the HTML module.
110+
111+
You can also specify a filename filter for HTML modules you do _not_ wish to include in esbuild's bundled JS output. This will effectively set their exported default template to a blank fragment and ignore all script tags. You would want to do this for HTML modules which are intended purely for SSR and are not designed for inclusion in a frontend JS bundle. In the `skipBundlingFilter: /\.tmpl\.html$/` example above, any HTML module ending in the double extension `.tmpl.html` will be skipped in this fashion.
112+
113+
**Note:** this does _not_ skip the styles processing. Any local style tags will still be transformed if those options have been set, and any scoped or global styles will be extracted if those options have been set.
114+
105115
## Testing
106116

107117
Run:

index.js

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const fsLib = require("fs")
22
const fs = fsLib.promises
33

44
const { parse } = require("node-html-parser")
5+
const path = require("path")
56

67
module.exports = (options = {}) => ({
78
name: "htmlmodules",
@@ -17,7 +18,7 @@ module.exports = (options = {}) => ({
1718
const root = parse(results)
1819

1920
const scriptTags = root.getElementsByTagName("script")
20-
scriptTags.forEach(node => {
21+
scriptTags.forEach((node) => {
2122
if (node.getAttribute("type") === "module") {
2223
scripts.push(node.rawText)
2324
}
@@ -26,7 +27,7 @@ module.exports = (options = {}) => ({
2627

2728
let globalCSS = ""
2829
if (options.experimental?.extractGlobalStyles) {
29-
root.querySelectorAll("style[scope=global]").forEach(styleTag => {
30+
root.querySelectorAll("style[scope=global]").forEach((styleTag) => {
3031
globalCSS += `${styleTag.textContent}\n`
3132
styleTag.remove()
3233
})
@@ -36,14 +37,14 @@ module.exports = (options = {}) => ({
3637
const styleTransform = await import("@enhance/enhance-style-transform")
3738
const transform = styleTransform.default
3839

39-
root.querySelectorAll("style[scope]").forEach(styleTag => {
40+
root.querySelectorAll("style[scope]").forEach((styleTag) => {
4041
const scope = styleTag.getAttribute("scope")
4142
const styles = styleTag.textContent
4243

4344
styleTag.textContent = transform({
4445
tagName: scope,
45-
context: 'markup',
46-
raw: styles
46+
context: "markup",
47+
raw: styles,
4748
})
4849

4950
globalCSS += `${styleTag.textContent}\n`
@@ -53,36 +54,63 @@ module.exports = (options = {}) => ({
5354

5455
const styleTags = root.getElementsByTagName("style")
5556

57+
let styleExports = ""
5658
await Promise.all(
5759
styleTags.map(async (styleTag) => {
5860
if (options.experimental?.transformLocalStyles) {
59-
const transformedCSS = await options.experimental.transformLocalStyles(styleTag.textContent, { filePath: args.path })
61+
const transformedCSS = await options.experimental.transformLocalStyles(
62+
styleTag.textContent,
63+
{ filePath: args.path }
64+
)
6065
styleTag.textContent = transformedCSS
66+
if (options.experimental?.exportLocalStylesExtension) {
67+
styleExports += transformedCSS
68+
}
6169
}
6270
})
6371
)
72+
if (styleExports.length > 0) {
73+
const basepath = path.join(
74+
path.dirname(args.path),
75+
path.basename(args.path, path.extname(args.path))
76+
)
77+
await fs.writeFile(
78+
`${basepath}.${options.experimental.exportLocalStylesExtension}`,
79+
styleExports
80+
)
81+
}
6482

6583
const scriptContent = scripts.join("")
66-
removeNodes.forEach(node => node.remove())
84+
removeNodes.forEach((node) => node.remove())
6785

6886
const htmlFragment = root.toString()
6987

7088
let wrapper = globalCSS.length > 0 ? `import "data:text/css,${encodeURI(globalCSS)}"\n` : ""
7189

72-
wrapper += `
73-
var import_meta_document = new DocumentFragment()
74-
const htmlFrag = "<body>" + ${JSON.stringify(htmlFragment)} + "</body>"
75-
const fragment = new DOMParser().parseFromString(htmlFrag, 'text/html')
76-
import_meta_document.append(...fragment.body.childNodes)
77-
78-
${scriptContent.replace(/import[\s]*?\.meta[\s]*?\.document/g, "import_meta_document")}
79-
export default import_meta_document
80-
`
90+
if (
91+
options.experimental?.skipBundlingFilter &&
92+
args.path.match(options.experimental.skipBundlingFilter)
93+
) {
94+
// we'll export nothing
95+
wrapper += `
96+
export default new DocumentFragment()
97+
`
98+
} else {
99+
wrapper += `
100+
var import_meta_document = new DocumentFragment()
101+
const htmlFrag = "<body>" + ${JSON.stringify(htmlFragment)} + "</body>"
102+
const fragment = new DOMParser().parseFromString(htmlFrag, 'text/html')
103+
import_meta_document.append(...fragment.body.childNodes)
104+
105+
${scriptContent.replace(/import[\s]*?\.meta[\s]*?\.document/g, "import_meta_document")}
106+
export default import_meta_document
107+
`
108+
}
81109

82110
return {
83111
contents: wrapper,
84-
loader: "js"
112+
loader: "js",
85113
}
86114
})
87-
}
115+
},
88116
})

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "esbuild-plugin-html-modules",
3-
"version": "0.5.0",
3+
"version": "0.6.0",
44
"description": "An esbuild plugin to load HTML modules",
55
"main": "index.js",
66
"scripts": {
@@ -24,5 +24,10 @@
2424
"dependencies": {
2525
"@enhance/enhance-style-transform": "^0.0.1",
2626
"node-html-parser": "^5.4.2"
27+
},
28+
"prettier": {
29+
"semi": false,
30+
"singleQuote": false,
31+
"printWidth": 100
2732
}
2833
}

test/fixture/esbuild.config.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@ esbuild.build({
1717
experimental: {
1818
extractGlobalStyles: true,
1919
extractScopedStyles: true,
20+
exportLocalStylesExtension: "css-local",
21+
skipBundlingFilter: /\.tmpl\.html$/,
2022
transformLocalStyles: async (css, { filePath }) => {
2123
const postCssConfig = await postcssrc()
2224
const postCssProcessor = postcss([...postCssConfig.plugins])
2325

2426
const results = await postCssProcessor.process(css, { ...postCssConfig.options, from: filePath })
2527
return results.css
26-
}
28+
},
2729
}
2830
})
2931
]

test/fixture/main.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import HelloWorld, { howdy, yo } from "./hello-world.html" assert { type: "html" }
2+
import Nothing from "./ssr-only.tmpl.html" assert { type: "html" }
23

34
window.testing = {
45
HelloWorld,
56
howdy,
6-
yo
7+
yo,
8+
Nothing
79
}

test/fixture/ssr-only.tmpl.html

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<p>This should all get completely ignored!</p>
2+
3+
<script type="module">
4+
This should also get ignored!
5+
</script>
6+
7+
<style>
8+
.nest {
9+
& .me {
10+
--only-this-will-work: var(--value);
11+
}
12+
}
13+
</style>
14+
15+
<style scope="global">
16+
:root {
17+
--root-of-the-matter: var(--indeed);
18+
}
19+
</style>

test/test_output.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,14 @@ test('global CSS bundled', (t) => {
4646

4747
assert.match(css, /tag-name > h1/)
4848
assert.match(css, /tag-name > p\[slot=here\]/)
49+
50+
assert.match(css, /--root-of-the-matter: var\(--indeed\)/)
51+
})
52+
53+
test('ssr only output', (t) => {
54+
const testing = processBundle()
55+
const sidecarCSS = readFileSync("test/fixture/ssr-only.tmpl.css-local").toString()
56+
57+
assert.match(sidecarCSS, /\.nest \.me/)
58+
assert.equal(testing.Nothing.children.length, 0)
4959
})

0 commit comments

Comments
 (0)