@ornery/web-components 2.0.0 → 4.0.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.
@@ -0,0 +1,25 @@
1
+ name: Publish to npm
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - uses: actions/setup-node@v4
17
+ with:
18
+ node-version: '20'
19
+ registry-url: 'https://registry.npmjs.org'
20
+
21
+ - run: npm install
22
+
23
+ - run: npm publish --access public
24
+ env:
25
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/esbuild.js ADDED
@@ -0,0 +1,2 @@
1
+ import { unplugin } from './src/plugin.js';
2
+ export default unplugin.esbuild;
package/html.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ declare module '*.html' {
2
+ type ConnectableNodeList = Node[] & {
3
+ connect(root?: Element | DocumentFragment): Element | DocumentFragment | undefined;
4
+ };
5
+ const template: (props?: Record<string, unknown>) => ConnectableNodeList;
6
+ export default template;
7
+ }
package/index.js CHANGED
@@ -1,17 +1,7 @@
1
- const ContextBinding = require('./src/context-binding');
2
- const DataManager = require('./src/data-manager');
3
- const EventMap = require('./src/event-map');
4
- const i18n = require('./src/i18n');
5
- const utils = require('./src/utils');
6
- const bindEvents = require('./src/bind-events');
7
- const setupConnect = require('./src/setup-connect');
8
-
9
- module.exports = {
10
- ContextBinding,
11
- DataManager,
12
- EventMap,
13
- bindEvents,
14
- setupConnect,
15
- ...i18n,
16
- ...utils,
17
- };
1
+ export { default as ContextBinding } from './src/context-binding.js';
2
+ export { default as DataManager } from './src/data-manager.js';
3
+ export { default as EventMap } from './src/event-map.js';
4
+ export { I18n } from './src/i18n.js';
5
+ export { default as bindEvents } from './src/bind-events.js';
6
+ export { default as setupConnect } from './src/setup-connect.js';
7
+ export * from './src/utils.js';
package/jestLoader.js CHANGED
@@ -1,5 +1,7 @@
1
- const HtmlLoader = require('./src/loaders/html-loader');
1
+ import { transformHTML } from './src/loaders/html-loader.js';
2
2
 
3
- module.exports = {
4
- process: (src) => HtmlLoader(src),
3
+ export default {
4
+ processAsync: async (src, filename) => ({
5
+ code: await transformHTML(src, filename),
6
+ }),
5
7
  };
package/loader.js CHANGED
@@ -1,2 +1 @@
1
- const HtmlLoader = require('./src/loaders/html-loader');
2
- module.exports = HtmlLoader;
1
+ export { default } from './webpack.js';
package/package.json CHANGED
@@ -1,31 +1,39 @@
1
1
  {
2
2
  "name": "@ornery/web-components",
3
- "version": "2.0.0",
3
+ "version": "4.0.1",
4
4
  "description": "WebComponents html loader and optional runtime mixins to enable creation of custom HTML elements using es6 template literal syntax in *.html files.",
5
+ "type": "module",
5
6
  "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./templates": "./templates.js",
10
+ "./loader": "./loader.js",
11
+ "./vite": "./vite.js",
12
+ "./webpack": "./webpack.js",
13
+ "./rollup": "./rollup.js",
14
+ "./esbuild": "./esbuild.js",
15
+ "./rspack": "./rspack.js",
16
+ "./html.d.ts": "./html.d.ts"
17
+ },
18
+ "types": "./html.d.ts",
6
19
  "scripts": {
7
- "docs": "jsdoc2md src/loaders/*.js src/*.js > README.md"
20
+ "docs": "jsdoc2md src/loaders/*.js src/*.js > README.md",
21
+ "test": "node --test tests/*.test.mjs"
8
22
  },
9
23
  "author": "timothyswt@gmail.com",
10
24
  "license": "MIT",
11
25
  "dependencies": {
12
- "es6-templates": "^0.2.3",
13
- "fastparse": "^1.1.1",
14
- "html-minifier": "^3.5.8",
15
- "html-webpack-plugin": "^3.2.0",
16
- "loader-utils": "^1.1.0",
17
- "node-sass": "^6.0.0",
18
- "sass": "^1.34.0"
26
+ "fastparse": "^1.1.2",
27
+ "html-minifier-terser": "^7.2.0",
28
+ "sass": "^1.86.0",
29
+ "unplugin": "^3.0.0"
19
30
  },
20
31
  "devDependencies": {
21
- "eslint": "^6.8.0",
22
- "eslint-config-babel": "^9.0.0",
23
- "eslint-config-google": "^0.13.0",
24
- "eslint-plugin-jsdoc": "^15.8.0",
25
- "jsdoc": "^3.6.3",
26
- "jsdoc-to-markdown": "^5.0.0"
27
- },
28
- "peerDependencies": {
29
- "node-sass": "^4.13.1"
32
+ "eslint": "^9.0.0",
33
+ "eslint-config-google": "^0.14.0",
34
+ "eslint-plugin-jsdoc": "^50.0.0",
35
+ "jsdoc": "^4.0.0",
36
+ "jsdoc-to-markdown": "^9.0.0",
37
+ "jsdom": "^29.1.1"
30
38
  }
31
39
  }
package/rollup.js ADDED
@@ -0,0 +1,2 @@
1
+ import { unplugin } from './src/plugin.js';
2
+ export default unplugin.rollup;
package/rspack.js ADDED
@@ -0,0 +1,2 @@
1
+ import { unplugin } from './src/plugin.js';
2
+ export default unplugin.rspack;
@@ -1,4 +1,4 @@
1
- const {template} = require('./utils');
1
+ import {template} from './utils.js';
2
2
  /**
3
3
  * @param {HTMLElement} root The root element to find all elements from.
4
4
  * @param {Object} context the context object for finding functions to bind against. default is the root element
@@ -96,4 +96,4 @@ const bindEvents = (root, context = root) => {
96
96
  return root;
97
97
  };
98
98
 
99
- module.exports = bindEvents;
99
+ export default bindEvents;
@@ -1,5 +1,5 @@
1
1
 
2
- module.exports = (superclass) =>
2
+ export default (superclass) =>
3
3
  /**
4
4
  * @class ContextBinding
5
5
  * @param {class} superclass inheriting class
@@ -1,4 +1,4 @@
1
- const EventMap = require('./event-map');
1
+ import EventMap from './event-map.js';
2
2
 
3
3
  /**
4
4
  * @class DataStore
@@ -90,4 +90,4 @@ class DataManager {
90
90
  }
91
91
  }
92
92
 
93
- module.exports = DataManager;
93
+ export default DataManager;
package/src/event-map.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * @class EventMap
3
3
  * @description provides an event bus for when properties of the underlying Map change.
4
4
  */
5
- module.exports = class EventMap {
5
+ export default class EventMap {
6
6
  constructor() {
7
7
  this._map = new Map();
8
8
  this._subscribers = {
package/src/i18n.js CHANGED
@@ -1,9 +1,8 @@
1
- const DataManager = require('./data-manager');
2
- const {template, getFromObj, toLowerMap} = require('./utils');
1
+ import DataManager from './data-manager.js';
2
+ import {template, getFromObj, toLowerMap} from './utils.js';
3
3
 
4
- if (typeof HTMLElement === 'undefined') {
5
- // eslint-disable-next-line no-global-assign
6
- HTMLElement = class {};
4
+ if (typeof globalThis.HTMLElement === 'undefined') {
5
+ globalThis.HTMLElement = class {};
7
6
  }
8
7
 
9
8
  /**
@@ -246,6 +245,4 @@ class I18n {
246
245
  }
247
246
  }
248
247
 
249
- module.exports = {
250
- I18n,
251
- };
248
+ export { I18n };
package/src/index.js CHANGED
@@ -1,13 +1,11 @@
1
- import ContextBinding from './context-binding';
2
- import DataManager from './data-manager';
3
- import EventMap from './event-map';
4
- import HTMLLoader from './loaders/html-loader';
5
- export * from './utils';
1
+ import ContextBinding from './context-binding.js';
2
+ import DataManager from './data-manager.js';
3
+ import EventMap from './event-map.js';
4
+ export * from './utils.js';
5
+ export * from './i18n.js';
6
6
 
7
7
  export {
8
8
  ContextBinding,
9
9
  DataManager,
10
10
  EventMap,
11
11
  };
12
-
13
- export default HTMLLoader;
@@ -1,4 +1,4 @@
1
- const Parser = require('fastparse');
1
+ import Parser from 'fastparse';
2
2
 
3
3
  const attrsParser = new Parser({
4
4
  outside: {
@@ -41,7 +41,7 @@ const attrsParser = new Parser({
41
41
  },
42
42
  });
43
43
 
44
- module.exports = (html, tagAttr) => {
44
+ export default (html, tagAttr) => {
45
45
  return attrsParser.parse('outside', html, {
46
46
  currentTag: null,
47
47
  results: [],
@@ -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,22 +1,30 @@
1
- module.exports = function (nodeList, context) {
1
+ export default function setupConnect(nodeList, context) {
2
2
  nodeList.connect = function (root) {
3
- if (typeof HTMLElement !== "undefined") {
4
- if (!root && context instanceof HTMLElement) {
5
- root = context;
6
- if (
7
- root instanceof HTMLElement &&
8
- root.shadowRoot &&
9
- root.shadowRoot.mode === "open"
10
- ) {
11
- root = root.shadowRoot;
12
- } else {
13
- root = document.createElement("div");
14
- }
15
- root.innerHTML = "";
16
- nodeList.forEach((node) => root.appendChild(node));
17
- return root;
3
+ if (typeof HTMLElement === "undefined") {
4
+ return;
5
+ }
6
+
7
+ if (root) {
8
+ // Explicit root provided — clear and append nodes.
9
+ root.innerHTML = "";
10
+ nodeList.forEach((node) => root.appendChild(node));
11
+ return root;
12
+ }
13
+
14
+ // No root provided — derive from context.
15
+ if (context instanceof HTMLElement) {
16
+ if (
17
+ context.shadowRoot &&
18
+ context.shadowRoot.mode === "open"
19
+ ) {
20
+ root = context.shadowRoot;
21
+ } else {
22
+ root = document.createElement("div");
18
23
  }
24
+ root.innerHTML = "";
25
+ nodeList.forEach((node) => root.appendChild(node));
26
+ return root;
19
27
  }
20
28
  };
21
29
  return nodeList;
22
- };
30
+ }
package/src/utils.js CHANGED
@@ -28,7 +28,7 @@ const getFromObj = (path, obj = {}) => {
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
  /**
@@ -45,10 +45,10 @@ const stripES6 = function(expr, context) {
45
45
  let matchArr;
46
46
  while (matchArr = nestedES6.exec(result)) {
47
47
  const [, outerMatch, key] = matchArr;
48
- const replacement = getFromObj(key, context);
48
+ const replacement = getFromObj(key.replace(thisRegex, ''), context);
49
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
  /**
@@ -277,7 +277,7 @@ const toLowerMap = (obj = {}) => {
277
277
  }, {});
278
278
  };
279
279
 
280
- module.exports = {
280
+ export {
281
281
  getFromObj,
282
282
  template,
283
283
  stripES6,
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
- ];