@localnerve/web-component-build 3.4.2 → 3.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -1
- package/lib/build.js +46 -33
- package/lib/index.js +115 -32
- package/lib/log.js +59 -0
- package/lib/replace.js +197 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ The parts are processed and written to an output directory, then exposed to a ca
|
|
|
11
11
|
|
|
12
12
|
* [Why This Exists](#why-this-exists)
|
|
13
13
|
* [Processing Possibilities](#processing-map)
|
|
14
|
+
* [Multiple Templates](#multiple-templates)
|
|
14
15
|
* [Trusted Types Helpers](#trusted-types-helpers)
|
|
15
16
|
* [Usage](#usage)
|
|
16
17
|
* [API](#api)
|
|
@@ -43,6 +44,24 @@ The following is a table of _some_ of the possible input, processing, and output
|
|
|
43
44
|
|
|
44
45
|
> By default, html minification minifies any css found therein.
|
|
45
46
|
|
|
47
|
+
## Multiple Templates
|
|
48
|
+
A component can carry several HTML templates (e.g. default / error / empty states), each referenced by its own distinct token in the javascript. Pass a `templates` array:
|
|
49
|
+
|
|
50
|
+
```javascript
|
|
51
|
+
const result = await build(outputDir, {
|
|
52
|
+
jsPath: '/some/path/file.js',
|
|
53
|
+
cssPath: '/some/path/file.css', // shared, prepended to every template
|
|
54
|
+
templates: [
|
|
55
|
+
{ name: 'default', htmlPath: '/some/path/default.html', token: '__TPL_DEFAULT__' },
|
|
56
|
+
{ name: 'error', htmlPath: '/some/path/error.html', token: '__TPL_ERROR__' }
|
|
57
|
+
]
|
|
58
|
+
});
|
|
59
|
+
// result.htmls -> [{ name, path, getHtml }, ...] in input order
|
|
60
|
+
```
|
|
61
|
+
Each entry takes `name` (output filename, defaults to the input basename), `htmlPath`, `token` (String or RegExp), and an optional per-template `cssLinkHref` override. The single-template `htmlPath` + `jsReplacement` options still work and are treated as one template.
|
|
62
|
+
|
|
63
|
+
> Injection is **syntax-aware**: markup is spliced into the token's string/template literal with escaping for that context, so it may safely contain quotes, backticks, `${`, or backslashes.
|
|
64
|
+
|
|
46
65
|
## Trusted Types Helpers
|
|
47
66
|
|
|
48
67
|
In addition to `build`, this package exports a small set of browser runtime helpers so that web components can be authored to work **with** and **without** [Trusted Types](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types) enforcement (CSP `require-trusted-types-for 'script'`).
|
|
@@ -138,11 +157,18 @@ Full path to the output directory where css, html, and javascript output are wri
|
|
|
138
157
|
+ html will be inserted into the javascript file if `jsReplacement` and `jsPath` is supplied
|
|
139
158
|
|
|
140
159
|
* **jsPath** {String} - Full path to the input javascript file
|
|
141
|
-
* **jsReplacement** {String|RegExp} - The replacement pattern for the css or html in the javascript file. See [pattern](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#pattern) for full documentation
|
|
160
|
+
* **jsReplacement** {String|RegExp} - The replacement pattern for the css or html in the javascript file (single template). See [pattern](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#pattern) for full documentation
|
|
142
161
|
If supplied:
|
|
143
162
|
+ A replacement will be attempted in the javascript file, `jsPath` must also be supplied
|
|
144
163
|
+ If **not supplied** or falsy, No replacement will be attempted and all assets are just copied to `outputDir`
|
|
145
164
|
|
|
165
|
+
* **templates** {Array} - Zero or more templates, each an object with:
|
|
166
|
+
* **name** {String} - Output filename without extension (`${name}.html`). Defaults to the input basename.
|
|
167
|
+
* **htmlPath** {String} - Full path to the input html for this template.
|
|
168
|
+
* **token** {String|RegExp} - The placeholder in the javascript to replace.
|
|
169
|
+
* **cssLinkHref** {String} - Optional per-template link href override (falls back to the shared `cssLinkHref`).
|
|
170
|
+
When supplied, takes precedence over the flat `htmlPath`/`jsReplacement` options. Shared `cssPath`/`cssLinkHref` are applied to every template. Duplicate resolved output names throw.
|
|
171
|
+
|
|
146
172
|
* **terserOptions** {Object} - The [javascript minifier options](https://github.com/terser/terser/blob/master/README.md#minify-options) object
|
|
147
173
|
Defaults:
|
|
148
174
|
```
|
|
@@ -168,6 +194,8 @@ Full path to the output directory where css, html, and javascript output are wri
|
|
|
168
194
|
|
|
169
195
|
* **minifySkip** {Boolean} - True to skip all minifications, defaults to false
|
|
170
196
|
|
|
197
|
+
* **deprecationWarnings** {Boolean} - False to disable deprecation warnings, defaults to true. If omitted, deprecation warnings are suppressed by defining environment variable `WEB_COMPONENT_BUILD_NO_DEPRECATION_WARNINGS`
|
|
198
|
+
|
|
171
199
|
### Result {Object}
|
|
172
200
|
The output of the build process. Allows access to the output paths and full output content. Format:
|
|
173
201
|
|
|
@@ -183,5 +211,7 @@ The output of the build process. Allows access to the output paths and full outp
|
|
|
183
211
|
|
|
184
212
|
+ **getJs** {asyncFunction}, gets the output javascript
|
|
185
213
|
|
|
214
|
+
+ **htmls** {Array}, ordered list (matching `templates` input order) of `{ name, path, getHtml }` — one entry per html template. `getHtml()`/`htmlPath` above are shorthands for the first entry.
|
|
215
|
+
|
|
186
216
|
## License
|
|
187
217
|
* [BSD-3 Clasuse, Alex Grant, LocalNerve](LICENSE.md)
|
package/lib/build.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Web Component Build
|
|
3
3
|
* Builds the component parts
|
|
4
4
|
*
|
|
5
|
-
* Copyright (c) 2023 -
|
|
5
|
+
* Copyright (c) 2023 - 2026 Alex Grant (@localnerve), LocalNerve LLC
|
|
6
6
|
* Copyrights licensed under the BSD License. See the accompanying LICENSE file for terms.
|
|
7
7
|
*/
|
|
8
8
|
import * as path from 'node:path';
|
|
@@ -27,36 +27,42 @@ class WebComponentBuild {
|
|
|
27
27
|
* @param {String} outputDir - The full path to output directory.
|
|
28
28
|
* @param {String} [jsPath] - The full path to the input js file.
|
|
29
29
|
* @param {String} [cssPath] - The full path to the input css file.
|
|
30
|
-
* @param {String} [htmlPath] - The full path to the input html file.
|
|
31
30
|
* @param {Boolean} [minifySkip] - flag to skip minification (debug), default false.
|
|
31
|
+
*
|
|
32
|
+
* HTML outputs are registered dynamically via addHtmlOutput(); a component may
|
|
33
|
+
* have zero or more of them (one per template).
|
|
32
34
|
*/
|
|
33
|
-
constructor (outputDir, jsPath, cssPath,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
throw new Error(
|
|
38
|
-
'One of jsPath, cssPath, or htmlPath MUST be supplied to do\
|
|
39
|
-
something meaningful. Did you forget something?'
|
|
40
|
-
);
|
|
41
|
-
}
|
|
35
|
+
constructor (outputDir, jsPath, cssPath, minifySkip = false) {
|
|
36
|
+
// Validation of "at least one meaningful input" is owned by build(), which
|
|
37
|
+
// can see the full option set (including html templates). The constructor
|
|
38
|
+
// stays lenient so an html-only component is not rejected here.
|
|
42
39
|
|
|
43
40
|
this.inputCssFile = cssPath;
|
|
44
|
-
this.inputHtmlFile = htmlPath;
|
|
45
41
|
this.inputJsFile = jsPath;
|
|
46
42
|
this.outputDir = outputDir;
|
|
47
43
|
this.minifySkip = minifySkip;
|
|
48
|
-
|
|
44
|
+
this.htmlOutputs = [];
|
|
45
|
+
|
|
49
46
|
if (jsPath) {
|
|
50
47
|
this.outputJsFile = path.join(this.outputDir, path.basename(jsPath));
|
|
51
48
|
}
|
|
52
|
-
if (htmlPath) {
|
|
53
|
-
this.outputHtmlFile = path.join(this.outputDir, path.basename(htmlPath));
|
|
54
|
-
}
|
|
55
49
|
if (cssPath) {
|
|
56
50
|
this.outputCssFile = path.join(this.outputDir, path.basename(cssPath));
|
|
57
51
|
}
|
|
58
52
|
}
|
|
59
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Register an html output file for a template.
|
|
56
|
+
*
|
|
57
|
+
* @param {String} baseName - The output filename without extension (e.g. "default").
|
|
58
|
+
* @returns {String} The full path to the output html file.
|
|
59
|
+
*/
|
|
60
|
+
addHtmlOutput (baseName) {
|
|
61
|
+
const outputPath = path.join(this.outputDir, `${baseName}.html`);
|
|
62
|
+
this.htmlOutputs.push({ name: baseName, path: outputPath });
|
|
63
|
+
return outputPath;
|
|
64
|
+
}
|
|
65
|
+
|
|
60
66
|
/**
|
|
61
67
|
* Minify the input css file and write it to the outputDir.
|
|
62
68
|
*
|
|
@@ -79,17 +85,18 @@ class WebComponentBuild {
|
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
/**
|
|
82
|
-
* Minify the given html and write it to
|
|
83
|
-
*
|
|
88
|
+
* Minify the given html and write it to a specific output file.
|
|
89
|
+
*
|
|
84
90
|
* @param {String} htmlText - The full new html text.
|
|
91
|
+
* @param {String} outputPath - The full path to write the html to.
|
|
85
92
|
* @param {Object} options - The html-minifier options.
|
|
86
93
|
* @returns {String} minified html.
|
|
87
94
|
*/
|
|
88
|
-
async
|
|
95
|
+
async writeHtml (htmlText, outputPath, options = defaultHtmlMinifyOptions) {
|
|
89
96
|
const minifiedHtml =
|
|
90
97
|
this.minifySkip ? htmlText : await _minifyHtml(htmlText, options);
|
|
91
98
|
|
|
92
|
-
await fs.writeFile(
|
|
99
|
+
await fs.writeFile(outputPath, minifiedHtml, {
|
|
93
100
|
encoding: 'utf8'
|
|
94
101
|
});
|
|
95
102
|
|
|
@@ -122,9 +129,16 @@ class WebComponentBuild {
|
|
|
122
129
|
*/
|
|
123
130
|
get output () {
|
|
124
131
|
const _cssPath = this.outputCssFile;
|
|
125
|
-
const _htmlPath = this.outputHtmlFile;
|
|
126
132
|
const _jsPath = this.outputJsFile;
|
|
127
133
|
|
|
134
|
+
const htmls = this.htmlOutputs.map(output => ({
|
|
135
|
+
name: output.name,
|
|
136
|
+
path: output.path,
|
|
137
|
+
async getHtml () {
|
|
138
|
+
return fs.readFile(output.path, { encoding: 'utf8' });
|
|
139
|
+
}
|
|
140
|
+
}));
|
|
141
|
+
|
|
128
142
|
return {
|
|
129
143
|
get cssPath () {
|
|
130
144
|
return _cssPath;
|
|
@@ -136,13 +150,14 @@ class WebComponentBuild {
|
|
|
136
150
|
}
|
|
137
151
|
return cssText;
|
|
138
152
|
},
|
|
153
|
+
// Backward-compatible shorthands resolve to the first html output.
|
|
139
154
|
get htmlPath () {
|
|
140
|
-
return
|
|
155
|
+
return htmls[0] ? htmls[0].path : undefined;
|
|
141
156
|
},
|
|
142
157
|
async getHtml () {
|
|
143
158
|
let htmlText;
|
|
144
|
-
if (
|
|
145
|
-
htmlText = await
|
|
159
|
+
if (htmls[0]) {
|
|
160
|
+
htmlText = await htmls[0].getHtml();
|
|
146
161
|
}
|
|
147
162
|
return htmlText;
|
|
148
163
|
},
|
|
@@ -155,7 +170,8 @@ class WebComponentBuild {
|
|
|
155
170
|
jsText = await fs.readFile(_jsPath, { encoding: 'utf8' });
|
|
156
171
|
}
|
|
157
172
|
return jsText;
|
|
158
|
-
}
|
|
173
|
+
},
|
|
174
|
+
htmls
|
|
159
175
|
};
|
|
160
176
|
}
|
|
161
177
|
}
|
|
@@ -163,15 +179,12 @@ class WebComponentBuild {
|
|
|
163
179
|
/**
|
|
164
180
|
* Create WebComponentBuild instance.
|
|
165
181
|
*
|
|
166
|
-
* @param {String}
|
|
167
|
-
* @param {String}
|
|
168
|
-
* @param {String}
|
|
169
|
-
* @param {String} outputDir - The full path to output directory.
|
|
182
|
+
* @param {String} outputDir - The full path to the output directory.
|
|
183
|
+
* @param {String} [jsPath] - The full path to the input js file.
|
|
184
|
+
* @param {String} [cssPath] - The full path to the input css file.
|
|
170
185
|
* @param {Boolean} [minifySkip] - True to skip minifications (debug), default false.
|
|
171
186
|
* @returns {WebComponentBuild} an instance of WebComponentBuild
|
|
172
187
|
*/
|
|
173
|
-
export function createBuild (
|
|
174
|
-
|
|
175
|
-
) {
|
|
176
|
-
return new WebComponentBuild(cssPath, htmlPath, jsPath, outputDir, minifySkip);
|
|
188
|
+
export function createBuild (outputDir, jsPath, cssPath, minifySkip = false) {
|
|
189
|
+
return new WebComponentBuild(outputDir, jsPath, cssPath, minifySkip);
|
|
177
190
|
}
|
package/lib/index.js
CHANGED
|
@@ -3,12 +3,28 @@
|
|
|
3
3
|
* Assemble and minify a web component from its parts.
|
|
4
4
|
* Expose parts back to the calling build process.
|
|
5
5
|
*
|
|
6
|
-
* Copyright (c) 2023 -
|
|
6
|
+
* Copyright (c) 2023 - 2026 Alex Grant (@localnerve), LocalNerve LLC
|
|
7
7
|
* Copyrights licensed under the BSD License. See the accompanying LICENSE file for terms.
|
|
8
8
|
*/
|
|
9
|
+
import * as path from 'node:path';
|
|
9
10
|
import * as fs from 'node:fs/promises';
|
|
10
11
|
import * as cheerio from 'cheerio';
|
|
11
12
|
import { createBuild, defaultHtmlMinifyOptions } from './build.js';
|
|
13
|
+
import { injectTokens } from './replace.js';
|
|
14
|
+
import { log, deprecationsEnabled } from './log.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Derive an html output base name (without extension) from an input path.
|
|
18
|
+
* A trailing ".html" is stripped so the registered output path ends in a single
|
|
19
|
+
* ".html". Other names are used as-is.
|
|
20
|
+
*
|
|
21
|
+
* @param {String} htmlPath - The full path to the input html file.
|
|
22
|
+
* @returns {String} The base name without extension.
|
|
23
|
+
*/
|
|
24
|
+
function htmlBaseName (htmlPath) {
|
|
25
|
+
const base = path.basename(htmlPath);
|
|
26
|
+
return base.endsWith('.html') ? base.slice(0, -'.html'.length) : base;
|
|
27
|
+
}
|
|
12
28
|
|
|
13
29
|
/**
|
|
14
30
|
* Build entry point.
|
|
@@ -18,13 +34,20 @@ import { createBuild, defaultHtmlMinifyOptions } from './build.js';
|
|
|
18
34
|
* @param {String} [options.jsPath] - full path to the javascript file
|
|
19
35
|
* @param {String} [options.cssPath] - full path to the input css file
|
|
20
36
|
* @param {String} [options.cssLinkHref] - http href to css resource
|
|
21
|
-
* @param {String} [options.htmlPath] - full path to the input html file
|
|
22
|
-
* @param {String} [options.jsReplacement] - Replacement token in the js file
|
|
37
|
+
* @param {String} [options.htmlPath] - full path to the input html file (single template)
|
|
38
|
+
* @param {String} [options.jsReplacement] - Replacement token in the js file (single template)
|
|
39
|
+
* @param {Array} [options.templates=[]] - list of templates, each {name, htmlPath, token,
|
|
40
|
+
* cssLinkHref}. When supplied, takes precedence over the flat options. Shared
|
|
41
|
+
* cssPath/cssLinkHref are applied to every template unless a template overrides
|
|
42
|
+
* its own cssLinkHref.
|
|
23
43
|
* @param {Object} [options.terserOptions] - Js minifier options (terser)
|
|
24
44
|
* @param {Object} [options.htmlminOptions] - html-minifier options
|
|
25
45
|
* @param {Object} [options.cleancssOptions] - clean-css options
|
|
26
|
-
* @param {Boolean} [options.minifySkip] - default false, flag to skip all minification (debug)
|
|
27
|
-
* @
|
|
46
|
+
* @param {Boolean} [options.minifySkip=false] - default false, flag to skip all minification (debug)
|
|
47
|
+
* @param {Boolean} [options.deprecationWarnings=true] - default true, emit console.warn
|
|
48
|
+
* messages for flat single-template options and the htmlPath/getHtml shorthands
|
|
49
|
+
* that are removed in v4.0.0. Set false (or env WEB_COMPONENT_BUILD_NO_DEPRECATION_WARNINGS) to suppress.
|
|
50
|
+
* @returns {Object} Interface to getCss, getHtml, getJs, and htmls for further processing
|
|
28
51
|
*/
|
|
29
52
|
export async function build (outputDir, {
|
|
30
53
|
jsPath,
|
|
@@ -32,52 +55,112 @@ export async function build (outputDir, {
|
|
|
32
55
|
cssLinkHref,
|
|
33
56
|
htmlPath,
|
|
34
57
|
jsReplacement,
|
|
58
|
+
templates = [],
|
|
35
59
|
terserOptions,
|
|
36
60
|
htmlminOptions,
|
|
37
61
|
cleancssOptions,
|
|
38
|
-
minifySkip = false
|
|
62
|
+
minifySkip = false,
|
|
63
|
+
deprecationWarnings
|
|
39
64
|
} = {}) {
|
|
40
|
-
|
|
41
|
-
|
|
65
|
+
// Deprecation / migration warnings for the flat single-template API. These
|
|
66
|
+
// options (and the getHtml()/htmlPath result shorthands) are removed in
|
|
67
|
+
// v4.0.0, where "templates" becomes required and html outputs become a named
|
|
68
|
+
// map (result.html[name]). Suppressed by deprecationWarnings: false or the
|
|
69
|
+
// WEB_COMPONENT_BUILD_NO_DEPRECATION_WARNINGS env var.
|
|
70
|
+
const usesFlatOptions = (!Array.isArray(templates) || templates.length === 0)
|
|
71
|
+
&& Boolean(htmlPath || jsReplacement);
|
|
72
|
+
|
|
73
|
+
if (usesFlatOptions && deprecationsEnabled({ deprecationWarnings })) {
|
|
74
|
+
log(
|
|
75
|
+
'web-component-build',
|
|
76
|
+
'The flat single-template options "htmlPath"/"jsReplacement" are deprecated and will be removed in v4.0.0. Migrate to the "templates" array, e.g. templates: [{ name, htmlPath, token }].',
|
|
77
|
+
'warn'
|
|
78
|
+
);
|
|
79
|
+
log(
|
|
80
|
+
'web-component-build',
|
|
81
|
+
'In v4.0.0 the result\'s html surface changes from getHtml()/htmlPath to a named map: result.html[name] (each { name, path, getHtml }). Per-template cssPath/cssLinkHref overrides will also be supported.',
|
|
82
|
+
'warn'
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Resolve the template list. When `templates` is absent, synthesize a single
|
|
87
|
+
// template from the flat options for backward compatibility.
|
|
88
|
+
const resolved = (Array.isArray(templates) && templates.length > 0)
|
|
89
|
+
? templates.map((t, i) => ({
|
|
90
|
+
name: t.name || (t.htmlPath ? htmlBaseName(t.htmlPath) : `template-${i}`),
|
|
91
|
+
htmlPath: t.htmlPath,
|
|
92
|
+
token: t.token,
|
|
93
|
+
cssLinkHref: t.cssLinkHref !== undefined ? t.cssLinkHref : cssLinkHref
|
|
94
|
+
}))
|
|
95
|
+
: [{
|
|
96
|
+
name: htmlPath ? htmlBaseName(htmlPath) : undefined,
|
|
97
|
+
htmlPath,
|
|
98
|
+
token: jsReplacement,
|
|
99
|
+
cssLinkHref
|
|
100
|
+
}];
|
|
101
|
+
|
|
102
|
+
const hasHtmlTemplate = resolved.some(t => t.htmlPath);
|
|
103
|
+
if (!jsPath && !cssPath && !hasHtmlTemplate) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
'One of jsPath, cssPath, or htmlPath MUST be supplied to do something\n meaningful. Did you forget something?'
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const build = createBuild(outputDir, jsPath, cssPath, minifySkip);
|
|
110
|
+
let jsText, cssText;
|
|
42
111
|
|
|
43
112
|
if (jsPath) {
|
|
44
113
|
jsText = await fs.readFile(jsPath, { encoding: 'utf8' });
|
|
45
|
-
} else if (jsReplacement) {
|
|
46
|
-
throw new Error('Invalid input,
|
|
114
|
+
} else if (jsReplacement || resolved.some(t => t.token)) {
|
|
115
|
+
throw new Error('Invalid input, a replacement token was supplied without jsPath. Did you forget \'jsPath\'?');
|
|
47
116
|
}
|
|
48
117
|
|
|
49
118
|
if (cssPath) {
|
|
50
119
|
cssText = await build.minifyCss(cleancssOptions);
|
|
51
120
|
}
|
|
52
121
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
if (cssLinkHref) {
|
|
62
|
-
$('body').prepend(`<link href="${cssLinkHref}" rel="stylesheet" />`);
|
|
122
|
+
// Detect duplicate html output names before writing anything.
|
|
123
|
+
const seenNames = new Set();
|
|
124
|
+
for (const template of resolved.filter(t => t.htmlPath)) {
|
|
125
|
+
if (seenNames.has(template.name)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Duplicate html output name "${template.name}". Give each template a distinct "name".`
|
|
128
|
+
);
|
|
63
129
|
}
|
|
64
|
-
|
|
130
|
+
seenNames.add(template.name);
|
|
65
131
|
}
|
|
66
132
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
133
|
+
// Process each template: minify its html (with shared style/link prepended) and
|
|
134
|
+
// write it, or compute an inline css/link payload when there is no html file.
|
|
135
|
+
const injection = [];
|
|
136
|
+
for (const template of resolved) {
|
|
137
|
+
let payload;
|
|
138
|
+
if (template.htmlPath) {
|
|
139
|
+
const outputPath = build.addHtmlOutput(template.name);
|
|
140
|
+
const sourceHtml = await fs.readFile(template.htmlPath, { encoding: 'utf8' });
|
|
141
|
+
const $ = cheerio.load(sourceHtml);
|
|
142
|
+
const opts = { ...(htmlminOptions || defaultHtmlMinifyOptions) };
|
|
143
|
+
if (cssText) {
|
|
144
|
+
$('body').prepend(`<style>${cssText}</style>`);
|
|
145
|
+
opts.minifyCSS = cleancssOptions;
|
|
72
146
|
}
|
|
73
|
-
|
|
147
|
+
if (template.cssLinkHref) {
|
|
148
|
+
$('body').prepend(`<link href="${template.cssLinkHref}" rel="stylesheet" />`);
|
|
149
|
+
}
|
|
150
|
+
payload = await build.writeHtml($('body').html(), outputPath, opts);
|
|
74
151
|
} else if (cssText) {
|
|
75
|
-
|
|
76
|
-
} else if (cssLinkHref) {
|
|
77
|
-
|
|
78
|
-
jsReplacement, `<link href="${cssLinkHref}" rel="stylesheet" />`
|
|
79
|
-
);
|
|
152
|
+
payload = `<style>${cssText}</style>`;
|
|
153
|
+
} else if (template.cssLinkHref) {
|
|
154
|
+
payload = `<link href="${template.cssLinkHref}" rel="stylesheet" />`;
|
|
80
155
|
}
|
|
156
|
+
|
|
157
|
+
if (payload !== undefined && template.token) {
|
|
158
|
+
injection.push({ pattern: template.token, payload });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (injection.length > 0) {
|
|
163
|
+
jsText = injectTokens(jsText, injection);
|
|
81
164
|
}
|
|
82
165
|
|
|
83
166
|
if (jsText) {
|
package/lib/log.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Component Build
|
|
3
|
+
* Colorized console logger + deprecation warning gate.
|
|
4
|
+
*
|
|
5
|
+
* Copyright (c) 2023 - 2026 Alex Grant (@localnerve), LocalNerve LLC
|
|
6
|
+
* Copyrights licensed under the BSD License. See the accompanying LICENSE file for terms.
|
|
7
|
+
*/
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Colorized console logger.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} owner - The plugin/function/owner name, the named source
|
|
14
|
+
* @param {string} message - The log message
|
|
15
|
+
* @param {'log'|'error'|'warn'} [method='log'] - console method to use
|
|
16
|
+
* @param {import('vinyl')|string} [file=null] - Vinyl file object or file/path string
|
|
17
|
+
*/
|
|
18
|
+
export function log (owner, message, method = 'log', file = null) {
|
|
19
|
+
const colors = {
|
|
20
|
+
magenta: '\x1b[35m',
|
|
21
|
+
yellow: '\x1b[33m',
|
|
22
|
+
red: '\x1b[31m',
|
|
23
|
+
green: '\x1b[32m',
|
|
24
|
+
reset: '\x1b[0m'
|
|
25
|
+
};
|
|
26
|
+
let filepath;
|
|
27
|
+
if (file) {
|
|
28
|
+
filepath = path.relative(process.cwd(), file?.path ?? file);
|
|
29
|
+
}
|
|
30
|
+
const now = new Date();
|
|
31
|
+
const TN = i => i < 10 ? `0${i}` : i;
|
|
32
|
+
const timestring = `${TN(now.getHours())}:${TN(now.getMinutes())}:${TN(now.getSeconds())}`;
|
|
33
|
+
|
|
34
|
+
console[method](
|
|
35
|
+
`[${colors.magenta}${timestring}${colors.reset}] ${owner}: ${method === 'log' ? colors.green : colors.red}${filepath ? `File ${filepath} - ` : ''}${colors.yellow}${message}${colors.reset}`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Determine whether deprecation warnings should be suppressed.
|
|
41
|
+
*
|
|
42
|
+
* Precedence (first match wins):
|
|
43
|
+
* 1. options.deprecationWarnings === false → suppress
|
|
44
|
+
* 2. process.env.WEB_COMPONENT_BUILD_NO_DEPRECATION_WARNINGS truthy → suppress
|
|
45
|
+
* 3. Otherwise → emit warnings
|
|
46
|
+
*
|
|
47
|
+
* @param {Object} [options] - the build() options object
|
|
48
|
+
* @returns {boolean} true if deprecation warnings should be emitted
|
|
49
|
+
*/
|
|
50
|
+
export function deprecationsEnabled (options) {
|
|
51
|
+
if (options && options.deprecationWarnings === false) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
const env = process.env.WEB_COMPONENT_BUILD_NO_DEPRECATION_WARNINGS;
|
|
55
|
+
if (env !== undefined && env !== '' && env !== '0' && env !== 'false') {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
package/lib/replace.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Component Build
|
|
3
|
+
* Syntax-aware injection of payloads into string/template literals in a JS source file.
|
|
4
|
+
*
|
|
5
|
+
* Replaces brittle text replacement (String.replace of a token) with an AST-based
|
|
6
|
+
* splice: the token is located inside its containing string or template literal via
|
|
7
|
+
* acorn, and the payload is escaped for that literal's quote context before being
|
|
8
|
+
* spliced in at the exact character offsets. This keeps the surrounding JavaScript
|
|
9
|
+
* valid regardless of quotes, backticks, "${" sequences, backslashes, or newlines
|
|
10
|
+
* present in the payload.
|
|
11
|
+
*
|
|
12
|
+
* Copyright (c) 2023 - 2026 Alex Grant (@localnerve), LocalNerve LLC
|
|
13
|
+
* Copyrights licensed under the BSD License. See the accompanying LICENSE file for terms.
|
|
14
|
+
*/
|
|
15
|
+
import * as acorn from 'acorn';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse javascript source, tolerating both module and script syntax.
|
|
19
|
+
*
|
|
20
|
+
* @param {String} code - The javascript source text.
|
|
21
|
+
* @returns {Object} The acorn AST.
|
|
22
|
+
*/
|
|
23
|
+
function parseJs (code) {
|
|
24
|
+
const attempts = [{ sourceType: 'module' }, { sourceType: 'script' }];
|
|
25
|
+
let lastError;
|
|
26
|
+
for (const opt of attempts) {
|
|
27
|
+
try {
|
|
28
|
+
return acorn.parse(code, { ecmaVersion: 'latest', ...opt });
|
|
29
|
+
} catch (e) {
|
|
30
|
+
lastError = e;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Failed to parse javascript for token injection: ${lastError.message}`,
|
|
35
|
+
{ cause: lastError }
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Collect all string Literal and TemplateLiteral nodes from an AST.
|
|
41
|
+
*
|
|
42
|
+
* @param {Object} ast - The acorn AST.
|
|
43
|
+
* @returns {Array} of literal nodes (absolute start/end offsets).
|
|
44
|
+
*/
|
|
45
|
+
function collectLiterals (ast) {
|
|
46
|
+
const literals = [];
|
|
47
|
+
|
|
48
|
+
(function walk (node) {
|
|
49
|
+
if (!node || typeof node.type !== 'string') return;
|
|
50
|
+
if ((node.type === 'Literal' && typeof node.value === 'string') ||
|
|
51
|
+
node.type === 'TemplateLiteral') {
|
|
52
|
+
literals.push(node);
|
|
53
|
+
}
|
|
54
|
+
for (const key in node) {
|
|
55
|
+
const value = node[key];
|
|
56
|
+
if (Array.isArray(value)) {
|
|
57
|
+
value.forEach(child => walk(child));
|
|
58
|
+
} else if (value && typeof value.type === 'string') {
|
|
59
|
+
walk(value);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
})(ast);
|
|
63
|
+
|
|
64
|
+
return literals;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the character span of a pattern within text.
|
|
69
|
+
*
|
|
70
|
+
* @param {String} text - The full source text.
|
|
71
|
+
* @param {String|RegExp} pattern - The token to locate (string or regular expression).
|
|
72
|
+
* @returns {Object|null} {start, end} offsets into text, or null when not found.
|
|
73
|
+
*/
|
|
74
|
+
function findSpan (text, pattern) {
|
|
75
|
+
if (typeof pattern === 'string') {
|
|
76
|
+
const i = text.indexOf(pattern);
|
|
77
|
+
return i < 0 ? null : { start: i, end: i + pattern.length };
|
|
78
|
+
}
|
|
79
|
+
const re = pattern.global
|
|
80
|
+
? new RegExp(pattern.source, pattern.flags.replace(/g/g, ''))
|
|
81
|
+
: pattern;
|
|
82
|
+
const m = re.exec(text);
|
|
83
|
+
if (!m) return null;
|
|
84
|
+
return { start: m.index, end: m.index + m[0].length };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Human-readable label for a pattern, used in error messages.
|
|
89
|
+
*
|
|
90
|
+
* @param {String|RegExp} pattern - The token pattern.
|
|
91
|
+
* @returns {String} A short description of the pattern.
|
|
92
|
+
*/
|
|
93
|
+
function describePattern (pattern) {
|
|
94
|
+
if (typeof pattern === 'string') {
|
|
95
|
+
const short = pattern.length > 60 ? `${pattern.slice(0, 60)}…` : pattern;
|
|
96
|
+
return `'${short}'`;
|
|
97
|
+
}
|
|
98
|
+
return String(pattern);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Escape a payload so it is valid inside the given string literal context.
|
|
103
|
+
*
|
|
104
|
+
* For single/double quoted literals the result is a one-line interior (newlines,
|
|
105
|
+
* line separators, and the delimiter are escaped). For template literals only the
|
|
106
|
+
* backtick, "${", and backslash need escaping; raw newlines are legal.
|
|
107
|
+
*
|
|
108
|
+
* @param {String} payload - The content to inject.
|
|
109
|
+
* @param {String} context - The opening quote of the containing literal: ', ", or `.
|
|
110
|
+
* @returns {String} The escaped interior text.
|
|
111
|
+
*/
|
|
112
|
+
export function escapeForContext (payload, context) {
|
|
113
|
+
let out = String(payload).replace(/\\/g, '\\\\');
|
|
114
|
+
|
|
115
|
+
if (context === '`') {
|
|
116
|
+
return out
|
|
117
|
+
.replace(/`/g, '\\`')
|
|
118
|
+
.replace(/\$\{/g, '\\${');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const delimEscape = context === '\'' ? '\\\'' : '\\"';
|
|
122
|
+
|
|
123
|
+
return out
|
|
124
|
+
.replace(context === '\'' ? /'/g : /"/g, delimEscape)
|
|
125
|
+
.replace(/\r/g, '\\r')
|
|
126
|
+
.replace(/\n/g, '\\n')
|
|
127
|
+
.replace(/\u2028/g, '\\u2028')
|
|
128
|
+
.replace(/\u2029/g, '\\u2029');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Inject payloads into the string/template literals containing the given tokens.
|
|
133
|
+
*
|
|
134
|
+
* The source is parsed once; every token's literal node and absolute span are
|
|
135
|
+
* located on the original source, then splices are applied back-to-front so that
|
|
136
|
+
* earlier offsets remain valid. Multiple tokens may share a single literal or be
|
|
137
|
+
* spread across the file.
|
|
138
|
+
*
|
|
139
|
+
* @param {String} jsText - The full javascript source text.
|
|
140
|
+
* @param {Array} entries - Array of {pattern: String|RegExp, payload: String}.
|
|
141
|
+
* @returns {String} The new javascript source text with all tokens replaced.
|
|
142
|
+
* @throws {Error} When the source does not parse, a token is missing or lies
|
|
143
|
+
* outside any string/template literal, a template token crosses a "${}"
|
|
144
|
+
* expression boundary, or token spans overlap.
|
|
145
|
+
*/
|
|
146
|
+
export function injectTokens (jsText, entries) {
|
|
147
|
+
if (!Array.isArray(entries) || entries.length === 0) return jsText;
|
|
148
|
+
|
|
149
|
+
const literals = collectLiterals(parseJs(jsText));
|
|
150
|
+
|
|
151
|
+
// locate every token on the original source before splicing anything
|
|
152
|
+
const splices = entries.map(entry => {
|
|
153
|
+
const label = describePattern(entry.pattern);
|
|
154
|
+
const span = findSpan(jsText, entry.pattern);
|
|
155
|
+
if (!span) {
|
|
156
|
+
throw new Error(`Replacement token ${label} not found in javascript source.`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const node = literals
|
|
160
|
+
.filter(n => n.start <= span.start && span.end <= n.end)
|
|
161
|
+
.sort((a, b) => (a.end - a.start) - (b.end - b.start))[0];
|
|
162
|
+
|
|
163
|
+
if (!node) {
|
|
164
|
+
throw new Error(`Replacement token ${label} is not inside a string or template literal.`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (node.type === 'TemplateLiteral') {
|
|
168
|
+
const quasi = node.quasis.find(q => q.start <= span.start && span.end <= q.end);
|
|
169
|
+
if (!quasi) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`Replacement token ${label} crosses a template expression boundary (${'{…}'}); not supported.`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { ...span, context: jsText[node.start], payload: entry.payload, label };
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// reject overlapping spans (including duplicates)
|
|
180
|
+
const ascending = [...splices].sort((a, b) => a.start - b.start);
|
|
181
|
+
for (let i = 1; i < ascending.length; i++) {
|
|
182
|
+
if (ascending[i].start < ascending[i - 1].end) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`Replacement tokens ${ascending[i - 1].label} and ${ascending[i].label} overlap in the javascript source.`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// apply splices back-to-front so earlier offsets stay valid
|
|
190
|
+
let out = jsText;
|
|
191
|
+
const descending = [...splices].sort((a, b) => b.start - a.start);
|
|
192
|
+
for (const s of descending) {
|
|
193
|
+
out = out.slice(0, s.start) + escapeForContext(s.payload, s.context) + out.slice(s.end);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return out;
|
|
197
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@localnerve/web-component-build",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.1",
|
|
4
4
|
"description": "A library to help build web components",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
},
|
|
55
55
|
"homepage": "https://github.com/localnerve/web-component-build#readme",
|
|
56
56
|
"dependencies": {
|
|
57
|
+
"acorn": "^8.18.0",
|
|
57
58
|
"cheerio": "^1.2.0",
|
|
58
59
|
"clean-css": "^5.3.3",
|
|
59
60
|
"html-minifier-terser": "^7.2.0",
|
|
@@ -61,7 +62,7 @@
|
|
|
61
62
|
},
|
|
62
63
|
"devDependencies": {
|
|
63
64
|
"@eslint/js": "^10.0.1",
|
|
64
|
-
"eslint": "^10.
|
|
65
|
+
"eslint": "^10.10.0",
|
|
65
66
|
"eslint-plugin-n": "^18.3.0",
|
|
66
67
|
"globals": "^17.12.0",
|
|
67
68
|
"he": "^1.2.0",
|