wc-compiler 0.5.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wc-compiler",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Experimental native Web Components compiler.",
5
5
  "main": "src/wcc.js",
6
6
  "type": "module",
@@ -18,24 +18,29 @@
18
18
  },
19
19
  "scripts": {
20
20
  "clean": "rimraf ./dist",
21
- "lint": "eslint \"*.*js\" \"./src/**/**/*.js\" \"./test/**/**/*.js\"",
22
- "develop": "concurrently \"nodemon --watch src --watch docs -e js,md,css,html ./build.js\" \"http-server ./dist --open\"",
21
+ "lint": "eslint \"*.*js\" \"./src/**/**/*.js*\" \"./test/**/**/*.js*\"",
22
+ "develop": "concurrently \"nodemon --watch src --watch docs -e js,md,css,html,jsx ./build.js\" \"http-server ./dist --open\"",
23
23
  "build": "node ./build.js",
24
24
  "serve": "node ./build.js && http-server ./dist --open",
25
25
  "start": "npm run develop",
26
- "test": "c8 mocha --parallel",
27
- "test:tdd": "npm run test --watch",
26
+ "test": "mocha --exclude \"./test/cases/jsx/**\" --exclude \"./test/cases/custom-extension/**\" \"./test/**/**/*.spec.js\"",
27
+ "test:exp": "c8 node --experimental-loader ./test-exp-loader.js ./node_modules/mocha/bin/mocha \"./test/**/**/*.spec.js\"",
28
+ "test:tdd": "npm run test -- --watch",
29
+ "test:tdd:exp": "npm run test:exp -- --watch",
28
30
  "dist": "rollup -c rollup.config.js",
29
31
  "prepublishOnly": "npm run clean && npm run dist"
30
32
  },
31
33
  "dependencies": {
32
34
  "acorn": "^8.7.0",
35
+ "acorn-jsx": "^5.3.2",
33
36
  "acorn-walk": "^8.2.0",
37
+ "escodegen": "^2.0.0",
34
38
  "parse5": "^6.0.1"
35
39
  },
36
40
  "devDependencies": {
37
41
  "@mapbox/rehype-prism": "^0.8.0",
38
42
  "@rollup/plugin-commonjs": "^22.0.0",
43
+ "@rollup/plugin-json": "^4.1.0",
39
44
  "@rollup/plugin-node-resolve": "^13.3.0",
40
45
  "c8": "^7.11.2",
41
46
  "chai": "^4.3.6",
package/src/dom-shim.js CHANGED
@@ -1,15 +1,16 @@
1
+ function noop() { }
2
+
1
3
  // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget
2
- class EventTarget { }
4
+ class EventTarget {
5
+ constructor() {
6
+ this.addEventListener = noop;
7
+ }
8
+ }
3
9
 
4
10
  // https://developer.mozilla.org/en-US/docs/Web/API/Node
5
11
  // EventTarget <- Node
6
12
  // TODO should be an interface?
7
13
  class Node extends EventTarget {
8
- constructor() {
9
- super();
10
- // console.debug('Node constructor');
11
- }
12
-
13
14
  // eslint-disable-next-line
14
15
  cloneNode(deep) {
15
16
  return this;
@@ -25,9 +26,8 @@ class Node extends EventTarget {
25
26
  class Element extends Node {
26
27
  constructor() {
27
28
  super();
28
- // console.debug('Element constructor');
29
29
  this.shadowRoot = null;
30
- this.innerHTML;
30
+ this.innerHTML = '';
31
31
  this.attributes = {};
32
32
  }
33
33
 
@@ -44,24 +44,37 @@ class Element extends Node {
44
44
  }
45
45
  }
46
46
 
47
+ // https://developer.mozilla.org/en-US/docs/Web/API/Document
48
+ // EventTarget <- Node <- Document
49
+ class Document extends Node {
50
+
51
+ createElement(tagName) {
52
+ switch (tagName) {
53
+
54
+ case 'template':
55
+ return new HTMLTemplateElement();
56
+
57
+ default:
58
+ return new HTMLElement();
59
+
60
+ }
61
+ }
62
+
63
+ createDocumentFragment(html) {
64
+ return new DocumentFragment(html);
65
+ }
66
+ }
67
+
47
68
  // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement
48
69
  // EventTarget <- Node <- Element <- HTMLElement
49
70
  class HTMLElement extends Element {
50
- constructor() {
51
- super();
52
- // console.debug('HTMLElement::constructor');
53
- }
54
-
55
71
  attachShadow(options) {
56
- // console.debug('HTMLElement::attachShadow');
57
72
  this.shadowRoot = new ShadowRoot(options);
58
73
 
59
74
  return this.shadowRoot;
60
75
  }
61
76
 
62
- connectedCallback() {
63
- // console.debug('HTMLElement::connectedCallback');
64
- }
77
+ connectedCallback() { }
65
78
 
66
79
  // https://github.com/mfreed7/declarative-shadow-dom/blob/master/README.md#serialization
67
80
  // eslint-disable-next-line
@@ -72,20 +85,13 @@ class HTMLElement extends Element {
72
85
 
73
86
  // https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment
74
87
  // EventTarget <- Node <- DocumentFragment
75
- class DocumentFragment extends Node {
76
- // eslint-disable-next-line
77
- constructor(contents) {
78
- super();
79
- // console.debug('DocumentFragment constructor', contents);
80
- }
81
- }
88
+ class DocumentFragment extends Node { }
82
89
 
83
90
  // https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot
84
91
  // EventTarget <- Node <- DocumentFragment <- ShadowRoot
85
92
  class ShadowRoot extends DocumentFragment {
86
93
  constructor(options) {
87
94
  super();
88
- // console.debug('ShadowRoot constructor');
89
95
  this.mode = options.mode || 'closed';
90
96
  }
91
97
  }
@@ -95,18 +101,18 @@ class ShadowRoot extends DocumentFragment {
95
101
  class HTMLTemplateElement extends HTMLElement {
96
102
  constructor() {
97
103
  super();
98
- // console.debug('HTMLTemplateElement constructor');
99
-
100
104
  this.content = new DocumentFragment();
101
105
  }
102
106
 
103
107
  // TODO open vs closed shadow root
104
108
  set innerHTML(html) {
105
- this.content.innerHTML = `
106
- <template shadowroot="open">
107
- ${html}
108
- </template>
109
- `;
109
+ if (this.content) {
110
+ this.content.innerHTML = `
111
+ <template shadowroot="open">
112
+ ${html}
113
+ </template>
114
+ `;
115
+ }
110
116
  }
111
117
 
112
118
  get innerHTML() {
@@ -114,33 +120,24 @@ class HTMLTemplateElement extends HTMLElement {
114
120
  }
115
121
  }
116
122
 
117
- const customElementsRegistry = {};
118
-
119
- globalThis.document = {
120
- createElement(tagName) {
121
- switch (tagName) {
122
-
123
- case 'template':
124
- return new HTMLTemplateElement();
125
-
126
- default:
127
- return new HTMLElement();
123
+ // https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry
124
+ class CustomElementsRegistry {
125
+ constructor() {
126
+ this.customElementsRegistry = {};
127
+ }
128
128
 
129
- }
130
- },
131
- createDocumentFragment(html) {
132
- return new DocumentFragment(html);
129
+ define(tagName, BaseClass) {
130
+ this.customElementsRegistry[tagName] = BaseClass;
133
131
  }
134
- };
135
132
 
136
- globalThis.HTMLElement = HTMLElement;
137
- globalThis.customElements = {
138
- define: (tagName, BaseClass) => {
139
- // console.debug('customElements.define => ', tagName);
140
- customElementsRegistry[tagName] = BaseClass;
141
- },
142
- get: (tagName) => {
143
- // console.debug('customElements.get => ', tagName);
144
- return customElementsRegistry[tagName];
133
+ get(tagName) {
134
+ return this.customElementsRegistry[tagName];
145
135
  }
146
- };
136
+ }
137
+
138
+ // mock top level aliases (globalThis === window)
139
+ // https://developer.mozilla.org/en-US/docs/Web/API/Window
140
+ globalThis.addEventListener = noop;
141
+ globalThis.document = new Document();
142
+ globalThis.customElements = new CustomElementsRegistry();
143
+ globalThis.HTMLElement = HTMLElement;
@@ -0,0 +1,273 @@
1
+ /* eslint-disable max-depth, complexity */
2
+ // https://nodejs.org/api/esm.html#esm_loaders
3
+ import * as acorn from 'acorn';
4
+ import * as walk from 'acorn-walk';
5
+ import escodegen from 'escodegen';
6
+ import fs from 'fs';
7
+ import jsx from 'acorn-jsx';
8
+ import { parse, parseFragment, serialize } from 'parse5';
9
+ import path from 'path';
10
+ import { URL, pathToFileURL } from 'url';
11
+
12
+ const baseURL = pathToFileURL(`${process.cwd()}/`).href;
13
+ const jsxRegex = /\.(jsx)$/;
14
+
15
+ // TODO same hack as definitions
16
+ // https://github.com/ProjectEvergreen/wcc/discussions/74
17
+ let string;
18
+
19
+ // TODO move to a util
20
+ // https://github.com/ProjectEvergreen/wcc/discussions/74
21
+ function getParse(html) {
22
+ return html.indexOf('<html>') >= 0 || html.indexOf('<body>') >= 0 || html.indexOf('<head>') >= 0
23
+ ? parse
24
+ : parseFragment;
25
+ }
26
+
27
+ export function getParser(moduleURL) {
28
+ const isJSX = path.extname(moduleURL.pathname) === '.jsx';
29
+
30
+ if (!isJSX) {
31
+ return;
32
+ }
33
+
34
+ return {
35
+ parser: acorn.Parser.extend(jsx()),
36
+ config: {
37
+ // https://github.com/acornjs/acorn/issues/829#issuecomment-1172586171
38
+ ...walk.base,
39
+ JSXElement: () => {}
40
+ }
41
+ };
42
+ }
43
+
44
+ function applyDomDepthSubstitutions(tree, currentDepth = 1, hasShadowRoot = false) {
45
+ try {
46
+ for (const node of tree.childNodes) {
47
+ const attrs = node.attrs;
48
+
49
+ // check for attributes
50
+ // and swap out __this__ with depthful parentElement chain
51
+ if (attrs && attrs.length > 0) {
52
+ for (const attr in attrs) {
53
+ const { value } = attrs[attr];
54
+
55
+ if (value.indexOf('__this__.') >= 0) {
56
+ const root = hasShadowRoot ? 'parentNode.host' : 'parentElement';
57
+
58
+ node.attrs[attr].value = value.replace(/__this__/g, `this${'.parentElement'.repeat(currentDepth - 1)}.${root}`);
59
+ }
60
+ }
61
+ }
62
+
63
+ if (node.childNodes && node.childNodes.length > 0) {
64
+ applyDomDepthSubstitutions(node, currentDepth + 1, hasShadowRoot);
65
+ }
66
+ }
67
+ } catch (e) {
68
+ console.error(e);
69
+ }
70
+
71
+ return tree;
72
+ }
73
+
74
+ function parseJsxElement(element, moduleContents = '') {
75
+ try {
76
+ const { type } = element;
77
+
78
+ if (type === 'JSXElement') {
79
+ const { openingElement } = element;
80
+ const { attributes } = openingElement;
81
+ const tagName = openingElement.name.name;
82
+
83
+ string += `<${tagName}`;
84
+
85
+ for (const attribute of attributes) {
86
+ const { name } = attribute.name;
87
+
88
+ // handle events
89
+ if (name.startsWith('on')) {
90
+ const { value } = attribute;
91
+ const { expression } = value;
92
+
93
+ // onclick={this.increment}
94
+ if (value.type === 'JSXExpressionContainer') {
95
+ if (expression.type === 'MemberExpression') {
96
+ if (expression.object.type === 'ThisExpression') {
97
+ if (expression.property.type === 'Identifier') {
98
+ // we leave markers for `this` so we can replace it later while also NOT accidentally replacing
99
+ // legitimate uses of this that might be actual content / markup of the custom element
100
+ string += ` ${name}="__this__.${expression.property.name}()"`;
101
+ }
102
+ }
103
+ }
104
+
105
+ // onclick={() => this.deleteUser(user.id)}
106
+ // TODO onclick={(e) => { this.deleteUser(user.id) }}
107
+ // TODO onclick={(e) => { this.deleteUser(user.id) && this.logAction(user.id) }}
108
+ // https://github.com/ProjectEvergreen/wcc/issues/88
109
+ if (expression.type === 'ArrowFunctionExpression') {
110
+ if (expression.body && expression.body.type === 'CallExpression') {
111
+ const { start, end } = expression;
112
+ string += ` ${name}="${moduleContents.slice(start, end).replace(/this./g, '__this__.').replace('() => ', '')}"`;
113
+ }
114
+ }
115
+
116
+ if (expression.type === 'AssignmentExpression') {
117
+ const { left, right } = expression;
118
+
119
+ if (left.object.type === 'ThisExpression') {
120
+ if (left.property.type === 'Identifier') {
121
+ // very naive (fine grained?) reactivity
122
+ string += ` ${name}="__this__.${left.property.name}${expression.operator}${right.raw}; __this__.render();"`;
123
+ }
124
+ }
125
+ }
126
+ }
127
+ } else if (attribute.name.type === 'JSXIdentifier') {
128
+ // TODO is there any difference between an attribute for an event handler vs a normal attribute?
129
+ // Can all these be parsed using one function>
130
+ if (attribute.value) {
131
+ if (attribute.value.type === 'Literal') {
132
+ // xxx="yyy" >
133
+ string += ` ${name}="${attribute.value.value}"`;
134
+ } else if (attribute.value.type === 'JSXExpressionContainer') {
135
+ // xxx={allTodos.length} >
136
+ const { value } = attribute;
137
+ const { expression } = value;
138
+
139
+ if (expression.type === 'Identifier') {
140
+ string += ` ${name}=\$\{${expression.name}\}`;
141
+ }
142
+
143
+ if (expression.type === 'MemberExpression') {
144
+ if (expression.object.type === 'Identifier') {
145
+ if (expression.property.type === 'Identifier') {
146
+ string += ` ${name}=\$\{${expression.object.name}.${expression.property.name}\}`;
147
+ }
148
+ }
149
+ }
150
+ }
151
+ } else {
152
+ // xxx >
153
+ string += ` ${name}`;
154
+ }
155
+ }
156
+ }
157
+
158
+ string += openingElement.selfClosing ? '/>' : '>';
159
+
160
+ if (element.children.length > 0) {
161
+ element.children.forEach(child => parseJsxElement(child, moduleContents));
162
+ }
163
+
164
+ string += `</${tagName}>`;
165
+ }
166
+
167
+ if (type === 'JSXText') {
168
+ string += element.raw;
169
+ }
170
+
171
+ if (type === 'JSXExpressionContainer') {
172
+ const { type } = element.expression;
173
+
174
+ if (type === 'Identifier') {
175
+ // You have {count} TODOs left to complete
176
+ string += `\$\{${element.expression.name}\}`;
177
+ } else if (type === 'MemberExpression') {
178
+ const { object } = element.expression.object;
179
+
180
+ // You have {this.todos.length} Todos left to complete
181
+ // https://github.com/ProjectEvergreen/wcc/issues/88
182
+ if (object && object.type === 'ThisExpression') {
183
+ // TODO ReferenceError: __this__ is not defined
184
+ // string += `\$\{__this__.${element.expression.object.property.name}.${element.expression.property.name}\}`;
185
+ } else {
186
+ // const { todos } = this;
187
+ // ....
188
+ // You have {todos.length} Todos left to complete
189
+ string += `\$\{${element.expression.object.name}.${element.expression.property.name}\}`;
190
+ }
191
+ }
192
+ }
193
+ } catch (e) {
194
+ console.error(e);
195
+ }
196
+
197
+ return string;
198
+ }
199
+
200
+ export function parseJsx(moduleURL) {
201
+ const moduleContents = fs.readFileSync(moduleURL, 'utf-8');
202
+ string = '';
203
+
204
+ const tree = acorn.Parser.extend(jsx()).parse(moduleContents, {
205
+ ecmaVersion: 'latest',
206
+ sourceType: 'module'
207
+ });
208
+
209
+ walk.simple(tree, {
210
+ ClassDeclaration(node) {
211
+ if (node.superClass.name === 'HTMLElement') {
212
+ const hasShadowRoot = moduleContents.slice(node.body.start, node.body.end).indexOf('this.attachShadow(') > 0;
213
+
214
+ for (const n1 of node.body.body) {
215
+ if (n1.type === 'MethodDefinition' && n1.key.name === 'render') {
216
+ for (const n2 in n1.value.body.body) {
217
+ const n = n1.value.body.body[n2];
218
+
219
+ if (n.type === 'ReturnStatement' && n.argument.type === 'JSXElement') {
220
+ const html = parseJsxElement(n.argument, moduleContents);
221
+ const elementTree = getParse(html)(html);
222
+ const elementRoot = hasShadowRoot ? 'this.shadowRoot' : 'this';
223
+
224
+ applyDomDepthSubstitutions(elementTree, undefined, hasShadowRoot);
225
+
226
+ const finalHtml = serialize(elementTree);
227
+ const transformed = acorn.parse(`${elementRoot}.innerHTML = \`${finalHtml}\`;`, {
228
+ ecmaVersion: 'latest',
229
+ sourceType: 'module'
230
+ });
231
+
232
+ n1.value.body.body[n2] = transformed;
233
+ }
234
+ }
235
+ }
236
+ }
237
+ }
238
+ }
239
+ }, {
240
+ // https://github.com/acornjs/acorn/issues/829#issuecomment-1172586171
241
+ ...walk.base,
242
+ JSXElement: () => {}
243
+ });
244
+
245
+ return tree;
246
+ }
247
+
248
+ // --------------
249
+
250
+ export function resolve(specifier, context, defaultResolve) {
251
+ const { parentURL = baseURL } = context;
252
+
253
+ if (jsxRegex.test(specifier)) {
254
+ return {
255
+ url: new URL(specifier, parentURL).href
256
+ };
257
+ }
258
+
259
+ return defaultResolve(specifier, context, defaultResolve);
260
+ }
261
+
262
+ export async function load(url, context, defaultLoad) {
263
+ if (jsxRegex.test(url)) {
264
+ const jsFromJsx = parseJsx(new URL(url));
265
+
266
+ return {
267
+ format: 'module',
268
+ source: escodegen.generate(jsFromJsx)
269
+ };
270
+ }
271
+
272
+ return defaultLoad(url, context, defaultLoad);
273
+ }
package/src/wcc.js CHANGED
@@ -1,10 +1,14 @@
1
+ /* eslint-disable max-depth */
1
2
  // this must come first
2
3
  import './dom-shim.js';
3
4
 
4
5
  import * as acorn from 'acorn';
5
6
  import * as walk from 'acorn-walk';
7
+ import escodegen from 'escodegen';
8
+ import { getParser, parseJsx } from './jsx-loader.js';
6
9
  import { parse, parseFragment, serialize } from 'parse5';
7
10
  import fs from 'fs';
11
+ import path from 'path';
8
12
 
9
13
  function getParse(html) {
10
14
  return html.indexOf('<html>') >= 0 || html.indexOf('<body>') >= 0 || html.indexOf('<head>') >= 0
@@ -54,10 +58,16 @@ async function renderComponentRoots(tree, definitions) {
54
58
  return tree;
55
59
  }
56
60
 
57
- function registerDependencies(moduleURL, definitions) {
61
+ function registerDependencies(moduleURL, definitions, depth = 0) {
58
62
  const moduleContents = fs.readFileSync(moduleURL, 'utf-8');
63
+ const nextDepth = depth += 1;
64
+ const customParser = getParser(moduleURL);
65
+ const parser = customParser ? customParser.parser : acorn;
66
+ const config = customParser ? customParser.config : {
67
+ ...walk.base
68
+ };
59
69
 
60
- walk.simple(acorn.parse(moduleContents, {
70
+ walk.simple(parser.parse(moduleContents, {
61
71
  ecmaVersion: 'latest',
62
72
  sourceType: 'module'
63
73
  }), {
@@ -65,47 +75,62 @@ function registerDependencies(moduleURL, definitions) {
65
75
  const specifier = node.source.value;
66
76
  const isBareSpecifier = specifier.indexOf('.') !== 0 && specifier.indexOf('/') !== 0;
67
77
 
68
- if (!isBareSpecifier) {
78
+ // TODO would like to decouple .jsx from the core, ideally
79
+ if (!isBareSpecifier && ['.js', '.jsx'].includes(path.extname(specifier))) {
69
80
  const dependencyModuleURL = new URL(node.source.value, moduleURL);
70
81
 
71
- registerDependencies(dependencyModuleURL, definitions);
82
+ registerDependencies(dependencyModuleURL, definitions, nextDepth);
72
83
  }
73
84
  },
74
85
  ExpressionStatement(node) {
75
86
  if (isCustomElementDefinitionNode(node)) {
76
87
  const { arguments: args } = node.expression;
77
- const tagName = args[0].value;
88
+ const tagName = args[0].type === 'Literal'
89
+ ? args[0].value // single and double quotes
90
+ : args[0].quasis[0].value.raw; // template literal
91
+ const tree = parseJsx(moduleURL);
92
+ const isEntry = nextDepth - 1 === 1;
78
93
 
79
94
  definitions[tagName] = {
80
95
  instanceName: args[1].name,
81
- moduleURL
96
+ moduleURL,
97
+ source: escodegen.generate(tree),
98
+ url: moduleURL,
99
+ isEntry
82
100
  };
83
101
  }
84
102
  }
85
- });
103
+ }, config);
86
104
  }
87
105
 
88
106
  async function getTagName(moduleURL) {
89
107
  const moduleContents = await fs.promises.readFile(moduleURL, 'utf-8');
108
+ const customParser = getParser(moduleURL);
109
+ const parser = customParser ? customParser.parser : acorn;
110
+ const config = customParser ? customParser.config : {
111
+ ...walk.base
112
+ };
90
113
  let tagName;
91
114
 
92
- walk.simple(acorn.parse(moduleContents, {
115
+ walk.simple(parser.parse(moduleContents, {
93
116
  ecmaVersion: 'latest',
94
117
  sourceType: 'module'
95
118
  }), {
96
119
  ExpressionStatement(node) {
97
120
  if (isCustomElementDefinitionNode(node)) {
98
-
99
121
  tagName = node.expression.arguments[0].value;
100
122
  }
101
123
  }
102
- });
124
+ }, config);
103
125
 
104
126
  return tagName;
105
127
  }
106
128
 
107
- async function initializeCustomElement(elementURL, tagName, attrs = [], definitions = []) {
108
- registerDependencies(elementURL, definitions);
129
+ async function initializeCustomElement(elementURL, tagName, attrs = [], definitions = [], isEntry) {
130
+ if (!tagName) {
131
+ const depth = isEntry ? 1 : 0;
132
+ registerDependencies(elementURL, definitions, depth);
133
+ }
109
134
 
110
135
  // https://github.com/ProjectEvergreen/wcc/pull/67/files#r902061804
111
136
  const { pathname } = elementURL;
@@ -132,7 +157,8 @@ async function initializeCustomElement(elementURL, tagName, attrs = [], definiti
132
157
  async function renderToString(elementURL) {
133
158
  const definitions = [];
134
159
  const elementTagName = await getTagName(elementURL);
135
- const elementInstance = await initializeCustomElement(elementURL, undefined, undefined, definitions);
160
+ const isEntry = !!elementTagName;
161
+ const elementInstance = await initializeCustomElement(elementURL, undefined, undefined, definitions, isEntry);
136
162
 
137
163
  const elementHtml = elementInstance.shadowRoot
138
164
  ? elementInstance.getInnerHTML({ includeShadowRoots: true })
@@ -156,7 +182,7 @@ async function renderFromHTML(html, elements = []) {
156
182
  const definitions = [];
157
183
 
158
184
  for (const url of elements) {
159
- await initializeCustomElement(url, undefined, undefined, definitions);
185
+ await initializeCustomElement(url, undefined, undefined, definitions, true);
160
186
  }
161
187
 
162
188
  const elementTree = getParse(html)(html);