@localnerve/web-component-build 3.4.2 → 3.5.0

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 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
  ```
@@ -183,5 +209,7 @@ The output of the build process. Allows access to the output paths and full outp
183
209
 
184
210
  + **getJs** {asyncFunction}, gets the output javascript
185
211
 
212
+ + **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.
213
+
186
214
  ## License
187
215
  * [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 - 2025 Alex Grant (@localnerve), LocalNerve LLC
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, htmlPath, minifySkip = false) {
34
- const minimumInput = jsPath || cssPath || htmlPath;
35
-
36
- if (!minimumInput) {
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 the outputDir.
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 minifyHtml (htmlText, options = defaultHtmlMinifyOptions) {
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(this.outputHtmlFile, minifiedHtml, {
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 _htmlPath;
155
+ return htmls[0] ? htmls[0].path : undefined;
141
156
  },
142
157
  async getHtml () {
143
158
  let htmlText;
144
- if (_htmlPath) {
145
- htmlText = await fs.readFile(_htmlPath, { encoding: 'utf8' });
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} cssPath - The full path to the input css file.
167
- * @param {String} htmlPath - The full path to the input html file.
168
- * @param {String} jsPath - The full path to the input js file.
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
- cssPath, htmlPath, jsPath, outputDir, minifySkip = false
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 - 2025 Alex Grant (@localnerve), LocalNerve LLC
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
46
  * @param {Boolean} [options.minifySkip] - default false, flag to skip all minification (debug)
27
- * @returns {Object} Interface to getCss, getHtml, getJs for further processing
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
- const build = createBuild(outputDir, jsPath, cssPath, htmlPath, minifySkip);
41
- let jsText, cssText, htmlText;
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, jsReplacement supplied without jsPath. Did you forget \'jsPath\'?');
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
- if (htmlPath) {
54
- htmlText = await fs.readFile(htmlPath, { encoding: 'utf8' });
55
- const $ = cheerio.load(htmlText);
56
- if (cssText) {
57
- $('body').prepend(`<style>${cssText}</style>`);
58
- htmlminOptions = htmlminOptions || defaultHtmlMinifyOptions;
59
- htmlminOptions.minifyCSS = cleancssOptions;
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
- htmlText = await build.minifyHtml($('body').html(), htmlminOptions);
130
+ seenNames.add(template.name);
65
131
  }
66
132
 
67
- if (jsReplacement) {
68
- if (htmlText) {
69
- htmlText = htmlText.replace(/$/mg, '\\');
70
- if (htmlText.endsWith('\\')) {
71
- htmlText = htmlText.slice(0, -1);
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
- jsText = jsText.replace(jsReplacement, htmlText);
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
- jsText = jsText.replace(jsReplacement, `<style>${cssText}</style>`);
76
- } else if (cssLinkHref) {
77
- jsText = jsText.replace(
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.4.2",
3
+ "version": "3.5.0",
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",