@ornery/web-components 1.1.8 → 4.0.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.
@@ -1,91 +1,69 @@
1
- const htmlMinifier = require('html-minifier');
2
- const attrParse = require('./attrs-parser');
3
- const loaderUtils = require('loader-utils');
4
- const url = require('url');
5
- const fs = require('fs');
6
- const path = require('path');
7
- const {compile} = require('es6-templates');
1
+ import { minify } from 'html-minifier-terser';
2
+ import attrParse from './attrs-parser.js';
3
+ import * as sass from 'sass';
4
+ import fs from 'fs';
5
+ import path from 'path';
8
6
 
9
- const templateWrapStart = 'const {bindEvents, setupConnect} = require("@ornery/web-components/templates"); module.exports = (p = {})=> { const parsed = new DOMParser().parseFromString(function(props){return ';
10
- const templateWrapEnd = '}.call(p, p), \'text/html\'); const elements = [...parsed.head.children, ...bindEvents(parsed.body, p).childNodes]; return setupConnect(elements, p)}';
7
+ const RUNTIME_HELPERS = `
8
+ const __idRe = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
9
+ const __reserved = new Set(['props', 'arguments', 'this']);
10
+ function __render(__p, __body) {
11
+ const keys = Object.keys(__p).filter((k) => __idRe.test(k) && !__reserved.has(k));
12
+ const fn = new Function('props', ...keys, 'return \`' + __body + '\`');
13
+ return fn.apply(__p, [__p, ...keys.map((k) => __p[k])]);
14
+ }
15
+ `;
11
16
 
12
- const getLoaderConfig = function(context) {
13
- const query = loaderUtils.getOptions(context) || {};
14
- const configKey = query.config || 'htmlLoader';
15
- const config = (context.options && context.options[configKey]) || {};
16
- return {...{
17
- minimize: true,
18
- removeComments: true,
19
- collapseWhitespace: true,
20
- exportAsEs6Default: true,
21
- attributes: [],
22
- interpolate: false,
23
- urlRoot: '',
24
- removeCommentsFromCDATA: true,
25
- removeCDATASectionsFromCDATA: true,
26
- conservativeCollapse: true,
27
- useShortDoctype: true,
28
- keepClosingSlash: true,
29
- removeScriptTypeAttributes: true,
30
- removeStyleTypeAttributes: true,
31
- }, ...query, ...config};
17
+ const defaultOptions = {
18
+ minimize: true,
19
+ removeComments: true,
20
+ collapseWhitespace: true,
21
+ exportAsEs6Default: true,
22
+ attributes: [],
23
+ interpolate: false,
24
+ urlRoot: '',
25
+ removeCommentsFromCDATA: true,
26
+ removeCDATASectionsFromCDATA: true,
27
+ conservativeCollapse: true,
28
+ useShortDoctype: true,
29
+ keepClosingSlash: true,
30
+ removeScriptTypeAttributes: true,
31
+ removeStyleTypeAttributes: true,
32
32
  };
33
33
 
34
34
  /**
35
- * @class htmlLoader
36
- * @param {String} content fileContent from webpack
37
- * @return {String} it returns the HTML content wrapped as a module function
38
- * @description The HTML file is converted into a module that exports a function.
35
+ * @function transformHTML
36
+ * @param {String} content the HTML source content
37
+ * @param {String} id the file path of the HTML file being transformed
38
+ * @param {Object} options plugin options
39
+ * @return {Promise<String>} the HTML content wrapped as an ESM module function
40
+ * @description Transforms an HTML file into an ES module that exports a function.
39
41
  * That function takes a single argument (p shorthand for "props").
40
- * Also provides sass support by incliding a `link` tag in your html file to the scss file.
41
42
  *
42
43
  * We use the builtin DOMParser to parse the HTML template to reduce runtime dependencies.
43
- * an IIFE that takes a single argument (props) and returns the compiled template literal tring and passes
44
- * it into the DOMParser.parseFromString fn.
45
- *
46
- * The IIFE ends with fn.call(p, p) which ensures that the es6 template context supports
44
+ * The function ends with fn.call(p, p) which ensures that the es6 template context supports
47
45
  * both "this" and "props" within the template.
48
46
  *
49
- * ${this.myValue} and ${props.myValue} are treated identically and can be used interchangably
50
- * For on*="" HTML5 event attributes, the loader replaces any ES6 syntax before babel conversion
51
- * to the es6 template literal. This way, the interaction between the on* events and the ContextBinding mixin
52
- * does not break. @see ContextBinding for more details.
47
+ * ${this.myValue} and ${props.myValue} are treated identically and can be used interchangeably.
53
48
  *
54
- * @example @lang js <caption>webpack.config.js</caption>
55
- {
56
- module: {
57
- rules: [
58
- {
59
- // set this to match the paths of the html files you want to import as functions.
60
- test: /web-components\/.+\.html$/,
61
- exclude: /node_modules/,
62
- use: [{
63
- loader: '@ornery/web-components/loader',
64
- options: {
65
- minimize: true,
66
- removeComments: true,
67
- collapseWhitespace: true,
68
- exportAsEs6Default: true,
69
- attrs: false,
70
- interpolate: false
71
- }
72
- }]
73
- }
74
- ]
75
- }
76
- }
49
+ * @example @lang js <caption>vite.config.js</caption>
50
+ * import { defineConfig } from 'vite';
51
+ * import webComponents from '@ornery/web-components/vite';
77
52
  *
78
- * @example @lang scss <caption>example.scss</caption>
79
- * .example-list {
80
- * padding: 0;
81
- * margin: 0;
53
+ * export default defineConfig({
54
+ * plugins: [
55
+ * webComponents({ include: /\.html$/ })
56
+ * ]
57
+ * });
82
58
  *
83
- * .example-list-item {
84
- * line-height: 1rem;
85
- * margin: .5rem;
86
- * }
87
- * }
59
+ * @example @lang js <caption>webpack.config.js</caption>
60
+ * import webComponents from '@ornery/web-components/webpack';
88
61
  *
62
+ * export default {
63
+ * plugins: [
64
+ * webComponents({ include: /\.html$/ })
65
+ * ]
66
+ * };
89
67
  *
90
68
  * @example @lang html <caption>example.html</caption>
91
69
  * <link src="./example.scss" />
@@ -95,23 +73,6 @@ const getLoaderConfig = function(context) {
95
73
  * </ul>
96
74
  *
97
75
  * @example @lang js
98
- * // becomes converted into:
99
- * const {bindEvents, setupConnect} = require("@ornery/web-components/templates");
100
- * module.exports = (p = {})=> {
101
- * const parsed = new DOMParser().parseFromString(function(props){
102
- * return "<style>.example-list{padding: 0;margin: 0;} .example-list .example-list-item{line-height: 1rem;margin: .5rem;}</style>" +
103
- * "<h3>" + this.headerText + "</h3>" +
104
- * "<ul>" +
105
- * this.items.map(function(item){return "<li>" + item + "</li>"; })
106
- * .join("") +
107
- * "</ul>"
108
- * }.call(p, p), 'text/html');
109
- *
110
- * const elements = [...parsed.head.children, ...bindEvents(parsed.body, p).childNodes];
111
- * return setupConnect(elements, p)
112
- * }
113
- *
114
- * @example @lang js
115
76
  * import listTemplate from './example.html';
116
77
  * const fruits = ["apple", "orange", "banana"];
117
78
  *
@@ -119,23 +80,11 @@ const getLoaderConfig = function(context) {
119
80
  * headerText: "List of fruits.",
120
81
  * items: fruits
121
82
  * });
122
- *
123
- * console.log(compiledDOMNodeArray.length) // 2
124
- * console.log(compiledDOMNodeArray[0].tagName) // "h3"
125
- * console.log(compiledDOMNodeArray[0].innerHTML) // "List of fruits."
126
- * console.log(compiledDOMNodeArray[1].tagName) // "ul"
127
- * console.log(compiledDOMNodeArray[1].children[0].tagName) // "li"
128
- * console.log(compiledDOMNodeArray[1].children[0].innerHTML) // "apple"
129
- * console.log(compiledDOMNodeArray[1].children[1].tagName) // "li"
130
- * console.log(compiledDOMNodeArray[1].children[1].innerHTML) // "orange"
131
- * console.log(compiledDOMNodeArray[1].children[2].tagName) // "li"
132
- * console.log(compiledDOMNodeArray[1].children[2].innerHTML) // "banana"
133
- *
134
- *
135
- *
136
83
  */
137
- const htmlLoader = function(content) {
138
- const config = getLoaderConfig(this);
84
+ export async function transformHTML(content, id, options = {}) {
85
+ const config = { ...defaultOptions, ...options };
86
+ const fileDir = path.dirname(id);
87
+
139
88
  const links = attrParse(content, function(tag, attr) {
140
89
  const res = config.attributes.find(function(a) {
141
90
  if (a.charAt(0) === ':') {
@@ -150,16 +99,8 @@ const htmlLoader = function(content) {
150
99
  const data = {};
151
100
  content = [content];
152
101
  links.forEach(function(link) {
153
- if (!loaderUtils.isUrlRequest(link.value, config.urlRoot)) return;
154
-
155
- if (link.value.indexOf('mailto:') > -1 ) return;
102
+ if (link.value.indexOf('mailto:') > -1) return;
156
103
 
157
- const uri = url.parse(link.value);
158
- if (uri.hash !== null && uri.hash !== undefined) {
159
- uri.hash = null;
160
- link.value = uri.format();
161
- link.length = link.value.length;
162
- }
163
104
  let ident;
164
105
  while (data[ident]) {
165
106
  ident = '~~~HTMLLINK~~~' + Math.random() + Math.random() + '~~~';
@@ -172,25 +113,60 @@ const htmlLoader = function(content) {
172
113
  });
173
114
  content.reverse();
174
115
  content = content.join('');
175
- const linkregex = /^\s*<link.+href="(.+?s*css)".*>$/gmi;
176
- const imports = [...content.matchAll(linkregex)];
177
116
  content = content.replace(/on(\w+?)=["']\$\{(.+?)\}.*?["']/gmi, (m, cg1, cg2) => `on${cg1}="${cg2}"`);
178
- content = htmlMinifier.minify(content, {...config});
117
+ content = await minify(content, { ...config });
179
118
  content = content.replace(/\\"/g, '\\\\"');
180
119
  content = content.replace(/\\'/g, '\\\\\'');
181
- content = compile('`' + `<style>${imports.map((m)=>{
182
- if (typeof m[1] === 'string') {
183
- const filePath = path.resolve(this.context || '', m[1]);
184
- if (typeof filePath === 'string' && fs.existsSync(filePath)) {
185
- content = content.replace(m[0], '');
186
- return require('node-sass').renderSync({
187
- file: filePath,
188
- }).css.toString('utf8');
189
- }
120
+
121
+ const linkregex = /<link[^>]+href=["']([^"']+?\.s?css)["'][^>]*>/gi;
122
+ const imports = [...content.matchAll(linkregex)];
123
+ const styleContent = imports.map((m) => {
124
+ const filePath = path.resolve(fileDir, m[1]);
125
+ if (fs.existsSync(filePath)) {
126
+ content = content.replace(m[0], '');
127
+ return sass.compile(filePath).css;
190
128
  }
191
129
  return '';
192
- }).join('\n')}</style>\n` + content + '`').code;
193
- return `${templateWrapStart}${content}${templateWrapEnd}`;
130
+ }).join('\n');
131
+
132
+ content = escapeBackticksOutsideInterpolation(content);
133
+ const stylePrefix = styleContent ? `<style>${styleContent.replace(/`/g, '\\`')}</style>` : '';
134
+ const body = JSON.stringify(stylePrefix + content);
135
+ return `import {bindEvents, setupConnect} from "@ornery/web-components/templates";
136
+ ${RUNTIME_HELPERS}
137
+ export default (p = {}) => {
138
+ const parsed = new DOMParser().parseFromString(__render(p, ${body}), 'text/html');
139
+ const elements = [...parsed.head.children, ...bindEvents(parsed.body, p).childNodes];
140
+ return setupConnect(elements, p);
194
141
  };
142
+ `;
143
+ }
144
+
145
+ function escapeBackticksOutsideInterpolation(str) {
146
+ let out = '';
147
+ let i = 0;
148
+ while (i < str.length) {
149
+ const ch = str[i];
150
+ if (ch === '$' && str[i + 1] === '{') {
151
+ let depth = 1;
152
+ let j = i + 2;
153
+ while (j < str.length && depth > 0) {
154
+ if (str[j] === '{') depth++;
155
+ else if (str[j] === '}') depth--;
156
+ if (depth === 0) break;
157
+ j++;
158
+ }
159
+ out += str.slice(i, j + 1);
160
+ i = j + 1;
161
+ } else if (ch === '`') {
162
+ out += '\\`';
163
+ i++;
164
+ } else {
165
+ out += ch;
166
+ i++;
167
+ }
168
+ }
169
+ return out;
170
+ }
195
171
 
196
- module.exports = htmlLoader;
172
+ export default transformHTML;
package/src/plugin.js ADDED
@@ -0,0 +1,14 @@
1
+ import { createUnplugin } from 'unplugin';
2
+ import { transformHTML } from './loaders/html-loader.js';
3
+
4
+ export const unplugin = createUnplugin((options = {}) => ({
5
+ name: 'ornery-web-components',
6
+ transformInclude(id) {
7
+ return (options.include || /\.html$/).test(id);
8
+ },
9
+ transform(code, id) {
10
+ return transformHTML(code, id, options);
11
+ },
12
+ }));
13
+
14
+ export default unplugin;
@@ -1,4 +1,4 @@
1
- module.exports = function (nodeList, context) {
1
+ export default function setupConnect(nodeList, context) {
2
2
  nodeList.connect = function (root) {
3
3
  if (typeof HTMLElement !== "undefined") {
4
4
  if (!root && context instanceof HTMLElement) {
package/src/utils.js CHANGED
@@ -14,21 +14,21 @@
14
14
  *
15
15
  * result == 'bar';
16
16
  */
17
- const keyRegexp = /^[\w\-]+(\.[\w\-]+)+$/g
17
+ const keyRegexp = /^[\w-]+(\.[\w-]+)+$/g;
18
18
  const getFromObj = (path, obj = {}) => {
19
19
  path = path && path.trim();
20
- if (path != null){
20
+ if (path != null) {
21
21
  if (obj[path] != null) {
22
22
  return obj[path];
23
23
  } else if (keyRegexp.test(path)) {
24
24
  return path.split('.').reduce((res, key) => res[key] != null ? res[key] : path, obj);
25
- }
25
+ }
26
26
  }
27
27
  return path;
28
28
  };
29
29
 
30
30
 
31
- const thisRegex = /^[this|props]\./gi;
31
+ const thisRegex = /^(this|props)\./i;
32
32
  const nestedES6 = /\$\{.*(\$\{(.+?)\}).*\}/g;
33
33
  const es6Regex = /\$\{(.+?)\}/g;
34
34
  /**
@@ -44,11 +44,11 @@ const stripES6 = function(expr, context) {
44
44
  let result = expr.replace(thisRegex, '');
45
45
  let matchArr;
46
46
  while (matchArr = nestedES6.exec(result)) {
47
- let [wholeMatch, outerMatch, key] = matchArr;
48
- const replacement = getFromObj(key, context);
49
- result = stripES6(result.replace(outerMatch, replacement).trim(), context);
47
+ const [, outerMatch, key] = matchArr;
48
+ const replacement = getFromObj(key.replace(thisRegex, ''), context);
49
+ result = stripES6(result.replace(outerMatch, replacement).trim(), context);
50
50
  }
51
- return result.replace(es6Regex, (match, $1)=> getFromObj($1, context));
51
+ return result.replace(es6Regex, (match, $1) => getFromObj($1.replace(thisRegex, ''), context));
52
52
  };
53
53
 
54
54
  /**
@@ -104,12 +104,12 @@ const template = stripES6;
104
104
  const arrayParser = (val, key, params) => {
105
105
  let current = params[key];
106
106
  if (current) {
107
- if (!Array.isArray(current)) {
108
- current = [current];
109
- }
110
- current.push(val);
107
+ if (!Array.isArray(current)) {
108
+ current = [current];
109
+ }
110
+ current.push(val);
111
111
  } else {
112
- current = val;
112
+ current = val;
113
113
  }
114
114
  return current;
115
115
  };
@@ -144,12 +144,12 @@ const toParams = (str, options = {}) => {
144
144
  const queryString = parts[1] || '';
145
145
  const params = {};
146
146
  queryString.split('&').forEach((val) => {
147
- const innerParts = val.split('=');
148
- if (innerParts.length !== 2) return;
149
- const paramKey = decodeURIComponent(innerParts[0]);
150
- const paramVal = decodeURIComponent(innerParts[1]);
151
- const parser = options[paramKey] || (() => paramVal);
152
- params[paramKey] = arrayParser(parser(paramVal, paramKey, params), paramKey, params);
147
+ const innerParts = val.split('=');
148
+ if (innerParts.length !== 2) return;
149
+ const paramKey = decodeURIComponent(innerParts[0]);
150
+ const paramVal = decodeURIComponent(innerParts[1]);
151
+ const parser = options[paramKey] || (() => paramVal);
152
+ params[paramKey] = arrayParser(parser(paramVal, paramKey, params), paramKey, params);
153
153
  });
154
154
  return params;
155
155
  };
@@ -176,11 +176,11 @@ const toParams = (str, options = {}) => {
176
176
  const toSearch = (options) => {
177
177
  const filtered = Object.entries(options).filter((ent) => !!ent[1]);
178
178
  return encodeURI(`?${filtered.map((ent) => {
179
- if (Array.isArray(ent[1])) {
180
- return ent[1].map((val) => [ent[0], val].join('=')).join('&');
181
- } else {
182
- return ent.join('=');
183
- }
179
+ if (Array.isArray(ent[1])) {
180
+ return ent[1].map((val) => [ent[0], val].join('=')).join('&');
181
+ } else {
182
+ return ent.join('=');
183
+ }
184
184
  }).join('&')}`);
185
185
  };
186
186
 
@@ -210,13 +210,13 @@ const toSearch = (options) => {
210
210
  const prefixKeys = (obj, prefix) => {
211
211
  let keys = [];
212
212
  if (Array.isArray(obj)) {
213
- keys = obj.map((val, i) => i);
213
+ keys = obj.map((val, i) => i);
214
214
  } else {
215
- keys = Object.keys(obj);
215
+ keys = Object.keys(obj);
216
216
  }
217
217
  return Object.assign(
218
218
  {},
219
- ...keys.map((key) => ({[prefix + key]: obj[key]}))
219
+ ...keys.map((key) => ({[prefix + key]: obj[key]})),
220
220
  );
221
221
  };
222
222
 
@@ -257,18 +257,27 @@ const toDataAttrs = (obj) => {
257
257
  return prefixKeys(obj, 'data-');
258
258
  };
259
259
  const HTMLEncodable = /[\u00A0-\u9999<>]/g;
260
- const encodeHTML = (stringVal = "") => stringVal.replace(HTMLEncodable, i => `&#${i.charCodeAt(0)};`)
260
+ const encodeHTML = (stringVal = '') => stringVal.replace(HTMLEncodable, (i) => `&#${i.charCodeAt(0)};`);
261
261
 
262
262
  const withClosing = /<([^>]+?)([^>]*?)>(.*?)<\/\1>/gi;
263
263
  const selfClosing = /(<([^>]+)\/>)/ig;
264
- const shouldEncode = (str) => {
265
- return (str || '')
266
- .replace(withClosing, '')
267
- .replace(selfClosing, '')
268
- .trim();
269
- }
264
+ const shouldEncode = (str) => (str || '').replace(withClosing, '').replace(selfClosing, '').trim();
270
265
 
271
- module.exports = {
266
+ const toLowerMap = (obj = {}) => {
267
+ if (Array.isArray(obj)) {
268
+ return obj.map(toLowerMap);
269
+ }
270
+ if (typeof obj === 'string') {
271
+ return obj.toLowerCase();
272
+ }
273
+ return Object.entries(obj).reduce((acc, [key, val]) => {
274
+ const lck = key.toLowerCase();
275
+ acc[lck] = toLowerMap(val);
276
+ return acc;
277
+ }, {});
278
+ };
279
+
280
+ export {
272
281
  getFromObj,
273
282
  template,
274
283
  stripES6,
@@ -278,5 +287,6 @@ module.exports = {
278
287
  prefixKeys,
279
288
  toDataAttrs,
280
289
  shouldEncode,
281
- encodeHTML
290
+ encodeHTML,
291
+ toLowerMap,
282
292
  };
package/templates.js CHANGED
@@ -1,7 +1,2 @@
1
- const bindEvents = require('./src/bind-events');
2
- const setupConnect = require('./src/setup-connect');
3
-
4
- module.exports = {
5
- bindEvents,
6
- setupConnect,
7
- };
1
+ export { default as bindEvents } from './src/bind-events.js';
2
+ export { default as setupConnect } from './src/setup-connect.js';
@@ -0,0 +1,10 @@
1
+ <link href="./list.scss" />
2
+ <h3 id="list-title">${this.headerText}</h3>
3
+ <p id="subtitle-props">${props.subtitle}</p>
4
+ <p id="subtitle-bare">${subtitle}</p>
5
+ <ul class="example-list">
6
+ ${this.items.map((item) => `<li class="example-list-item">${item}</li>`).join("")}
7
+ </ul>
8
+ <button id="add-this" onclick="${this.onAdd}">Add via this</button>
9
+ <button id="add-props" onclick="${props.onAdd}">Add via props</button>
10
+ <button id="add-bare" onclick="${onAdd}">Add via bare</button>
@@ -0,0 +1,12 @@
1
+ $accent: #2563eb;
2
+ $radius: 4px;
3
+
4
+ .example-list {
5
+ list-style: none;
6
+ padding: 0;
7
+
8
+ &-item {
9
+ color: $accent;
10
+ border-radius: $radius;
11
+ }
12
+ }
@@ -0,0 +1,127 @@
1
+ import { describe, it, before } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { pathToFileURL, fileURLToPath } from 'node:url';
6
+ import { JSDOM } from 'jsdom';
7
+
8
+ import vitePlugin from '@ornery/web-components/vite';
9
+ import webpackPlugin from '@ornery/web-components/webpack';
10
+ import rollupPlugin from '@ornery/web-components/rollup';
11
+ import esbuildPlugin from '@ornery/web-components/esbuild';
12
+ import rspackPlugin from '@ornery/web-components/rspack';
13
+
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+ const fixture = path.join(__dirname, 'fixtures', 'list.html');
16
+
17
+ // Exercise the bundler plugin's transform hook the same way Vite/Webpack would.
18
+ async function runTransform(id) {
19
+ const plugin = vitePlugin();
20
+ const code = await readFile(id, 'utf8');
21
+ assert.equal(plugin.transformInclude(id), true, 'plugin opts into .html files');
22
+ const result = await plugin.transform.call({}, code, id);
23
+ return typeof result === 'string' ? result : result.code;
24
+ }
25
+
26
+ describe('@ornery/web-components loader', () => {
27
+ let dom;
28
+ before(() => {
29
+ dom = new JSDOM('<!doctype html><html><body></body></html>');
30
+ globalThis.window = dom.window;
31
+ globalThis.document = dom.window.document;
32
+ globalThis.DOMParser = dom.window.DOMParser;
33
+ globalThis.HTMLElement = dom.window.HTMLElement;
34
+ globalThis.Node = dom.window.Node;
35
+ globalThis.Element = dom.window.Element;
36
+ globalThis.Event = dom.window.Event;
37
+ });
38
+
39
+ it('transforms an HTML file into an ESM module that matches the documented shape', async () => {
40
+ const html = await readFile(fixture, 'utf8');
41
+ const out = await runTransform(fixture);
42
+
43
+ assert.match(
44
+ out,
45
+ /import \{bindEvents, setupConnect\} from "@ornery\/web-components\/templates"/,
46
+ 'output imports bindEvents/setupConnect from the public entry',
47
+ );
48
+ assert.match(out, /export default \(p = \{\}\)\s*=>/, 'default-exports a props-taking function');
49
+ assert.match(out, /new DOMParser\(\)\.parseFromString/, 'uses DOMParser for template assembly');
50
+ assert.match(out, /<style>/, 'inlines a <style> tag');
51
+
52
+ assert.match(out, /\.example-list/, 'compiled SCSS selector is present');
53
+ assert.match(out, /\.example-list-item\s*\{[^}]*color:\s*#2563eb/, 'SCSS was compiled to CSS with resolved $accent');
54
+
55
+ assert.doesNotMatch(out, /\$accent/, 'SCSS variables are resolved');
56
+ assert.doesNotMatch(out, /<link[^>]+\.scss/, 'link tag is stripped after SCSS inline');
57
+ });
58
+
59
+ it('exposes instantiable plugins for every supported bundler', () => {
60
+ for (const factory of [vitePlugin, webpackPlugin, rollupPlugin, esbuildPlugin, rspackPlugin]) {
61
+ const plugin = factory({});
62
+ assert.ok(plugin, `${factory.name || 'plugin'} returned a value`);
63
+ }
64
+ });
65
+
66
+ it('evaluating the module and calling it with props renders nodes, styles, and wires events', async () => {
67
+ const out = await runTransform(fixture);
68
+
69
+ // Write the compiled module inside the repo so Node can resolve
70
+ // `@ornery/web-components/templates` through the symlinked node_modules entry
71
+ // — exactly what a consumer project would see after `npm install`.
72
+ const outDir = path.join(__dirname, '.compiled');
73
+ await rm(outDir, { recursive: true, force: true });
74
+ await mkdir(outDir, { recursive: true });
75
+ const modFile = path.join(outDir, 'list.compiled.mjs');
76
+ await writeFile(modFile, out, 'utf8');
77
+ const mod = await import(pathToFileURL(modFile).href);
78
+
79
+ assert.equal(typeof mod.default, 'function', 'default export is a function');
80
+
81
+ let clicks = 0;
82
+ const props = {
83
+ headerText: 'List of fruits.',
84
+ subtitle: 'Fresh picks',
85
+ items: ['apple', 'orange', 'banana'],
86
+ onAdd() { clicks++; },
87
+ };
88
+ const result = mod.default(props);
89
+
90
+ assert.ok(Array.isArray(result), 'returns an array-like node list');
91
+ assert.equal(typeof result.connect, 'function', 'node list carries a connect() function (setupConnect)');
92
+
93
+ const host = document.createElement('div');
94
+ host.append(...result);
95
+
96
+ const title = host.querySelector('#list-title');
97
+ assert.ok(title, 'rendered header');
98
+ assert.equal(title.textContent, 'List of fruits.', 'interpolated ${this.headerText}');
99
+
100
+ // Per docs: ${this.X}, ${props.X}, and ${X} are interchangeable.
101
+ assert.equal(host.querySelector('#subtitle-props').textContent, 'Fresh picks', '${props.subtitle} interpolates');
102
+ assert.equal(host.querySelector('#subtitle-bare').textContent, 'Fresh picks', '${subtitle} (bare) interpolates');
103
+
104
+ const items = host.querySelectorAll('.example-list-item');
105
+ assert.equal(items.length, 3, 'rendered all mapped items');
106
+ assert.deepEqual(
107
+ Array.from(items, (li) => li.textContent),
108
+ ['apple', 'orange', 'banana'],
109
+ 'each item value interpolated inside the mapped template',
110
+ );
111
+
112
+ for (const id of ['add-this', 'add-props', 'add-bare']) {
113
+ const button = host.querySelector('#' + id);
114
+ assert.ok(button, `rendered ${id}`);
115
+ assert.equal(button.getAttribute('onclick'), null, `onclick stripped on ${id}`);
116
+ }
117
+
118
+ host.querySelector('#add-this').dispatchEvent(new dom.window.Event('click'));
119
+ host.querySelector('#add-props').dispatchEvent(new dom.window.Event('click'));
120
+ host.querySelector('#add-bare').dispatchEvent(new dom.window.Event('click'));
121
+ assert.equal(clicks, 3, '${this.fn}, ${props.fn}, and ${fn} all bind the same handler');
122
+
123
+ const style = Array.from(host.children).find((n) => n.tagName === 'STYLE');
124
+ assert.ok(style, '<style> landed in the connected output');
125
+ assert.match(style.textContent, /\.example-list-item/, 'compiled CSS reached the DOM');
126
+ });
127
+ });
package/vite.js ADDED
@@ -0,0 +1,2 @@
1
+ import { unplugin } from './src/plugin.js';
2
+ export default unplugin.vite;
package/webpack.js ADDED
@@ -0,0 +1,2 @@
1
+ import { unplugin } from './src/plugin.js';
2
+ export default unplugin.webpack;
@@ -1,17 +0,0 @@
1
- const path = require('path');
2
-
3
- module.exports = [
4
- {
5
- test: /\.scss$/,
6
- use: [
7
- 'css-loader', // translates CSS into CommonJS
8
- 'sass-loader', // compiles Sass to CSS, using Node Sass by default
9
- ],
10
- },
11
- {
12
- test: /\.html$/,
13
- use: [{
14
- loader: path.resolve(__dirname, '../loaders/html-loader.js'),
15
- }],
16
- },
17
- ];