wc-compiler 0.1.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 +106 -0
- package/package.json +52 -0
- package/src/dom-shim.js +141 -0
- package/src/wcc.js +130 -0
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# wcc
|
|
2
|
+
|
|
3
|
+
[](https://app.netlify.com/sites/merry-caramel-524e61/deploys)
|
|
4
|
+
[](https://github.com/thescientist13/wcc/tags)
|
|
5
|
+

|
|
6
|
+
[](https://github.com/thescientist13/wcc/issues)
|
|
7
|
+
[](https://raw.githubusercontent.com/thescientist13/wcc/master/LICENSE.md)
|
|
8
|
+
|
|
9
|
+
Experimental native Web Components compiler. (`<w⚙️⚙️/>`)
|
|
10
|
+
|
|
11
|
+
> _It's Web Components all the way down._ 🐢
|
|
12
|
+
|
|
13
|
+
## Overview
|
|
14
|
+
|
|
15
|
+
**Web Components Compiler (WCC)** is a NodeJS package designed to make server-side rendering (SSR) of native Web Components easier. It can render (within reason 😅) your Web Component into static HTML leveraging [Declarative Shadow DOM](https://web.dev/declarative-shadow-dom/).
|
|
16
|
+
|
|
17
|
+
It is not a static site generator or framework. It is focused on producing raw HTML from Web Components with the intent of being easily _integrated_ into a site generator or framework.
|
|
18
|
+
|
|
19
|
+
> _The original motivation for this project was to create a [purpose built, lighter weight, alternative to puppeteer for SSR of `HTMLElement`](https://github.com/ProjectEvergreen/greenwood/issues/926) for the project [**Greenwood**](https://www.greenwoodjs.io/)._
|
|
20
|
+
|
|
21
|
+
In addition, WCC hopes to provide a surface area to explore patterns around [streaming](https://github.com/thescientist13/wcc/issues/5) and serverless rendering, as well as acting as a test bed for the [Web Components Community Groups](https://github.com/webcomponents-cg) discussions around community protocols, like [hydration](https://github.com/thescientist13/wcc/issues/3).
|
|
22
|
+
|
|
23
|
+
## Key Features
|
|
24
|
+
|
|
25
|
+
1. Supports the following `HTMLElement` lifecycles and methods on the server side
|
|
26
|
+
- `constructor`
|
|
27
|
+
- `connectedCallback`
|
|
28
|
+
- `attachShadow`
|
|
29
|
+
- `innerHTML`
|
|
30
|
+
- `[get|set|has]Attribute`
|
|
31
|
+
1. Recursive rendering of nested custom elements
|
|
32
|
+
1. Optional Declarative Shadow DOM (for producing purely content driven static pages)
|
|
33
|
+
1. Metadata and runtime hints to support progressive hydration and lazy loading strategies
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
**wcc** can be installed from npm.
|
|
38
|
+
|
|
39
|
+
```shell
|
|
40
|
+
$ npm install wc-compiler --save-dev
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
WCC exposes a few utilities to render your Web Components. Below is one example, with [full docs and more examples](https://wcc.greenwoodjs.io) available on the website.
|
|
46
|
+
|
|
47
|
+
1. Given a custom element like so:
|
|
48
|
+
```js
|
|
49
|
+
const template = document.createElement('template');
|
|
50
|
+
|
|
51
|
+
template.innerHTML = `
|
|
52
|
+
<style>
|
|
53
|
+
.footer {
|
|
54
|
+
color: white;
|
|
55
|
+
background-color: #192a27;
|
|
56
|
+
}
|
|
57
|
+
</style>
|
|
58
|
+
|
|
59
|
+
<footer class="footer">
|
|
60
|
+
<h4>My Blog © ${new Date().getFullYear()}</h4>
|
|
61
|
+
</footer>
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
class Footer extends HTMLElement {
|
|
65
|
+
connectedCallback() {
|
|
66
|
+
if (!this.shadowRoot) {
|
|
67
|
+
this.attachShadow({ mode: 'open' });
|
|
68
|
+
this.shadowRoot.appendChild(template.content.cloneNode(true));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export default Footer;
|
|
74
|
+
|
|
75
|
+
customElements.define('wcc-footer', Footer);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
1. Using NodeJS, create a file that imports `renderToString` and provide it the path to your web component
|
|
79
|
+
```js
|
|
80
|
+
import { renderToString } from 'wc-compiler';
|
|
81
|
+
|
|
82
|
+
const { html } = renderToString(new URL('./path/to/footer.js', import.meta.url));
|
|
83
|
+
|
|
84
|
+
console.debug({ html })
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
1. You will get the following html output that can be used in conjunction with your preferred site framework or templating solution.
|
|
88
|
+
```html
|
|
89
|
+
<wcc-footer>
|
|
90
|
+
<template shadowroot="open">
|
|
91
|
+
<style>
|
|
92
|
+
.footer {
|
|
93
|
+
color: white;
|
|
94
|
+
background-color: #192a27;
|
|
95
|
+
}
|
|
96
|
+
</style>
|
|
97
|
+
|
|
98
|
+
<footer class="footer">
|
|
99
|
+
<h4>My Blog © 2022</h4>
|
|
100
|
+
</footer>
|
|
101
|
+
</template>
|
|
102
|
+
</wcc-footer>
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
> _**Make sure to test in Chrome, or other Declarative Shadow DOM compatible browser, otherwise you will need to include the [DSD polyfill](https://web.dev/declarative-shadow-dom/#polyfill).**_
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wc-compiler",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Experimental native Web Components compiler.",
|
|
5
|
+
"main": "src/wcc.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"author": "Owen Buckley <owen@thegreenhouse.io>",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=14"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src/"
|
|
14
|
+
],
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"lint": "eslint \"*.*js\" \"./src/**/**/*.js\" \"./test/**/**/*.js\"",
|
|
20
|
+
"develop": "concurrently \"nodemon --watch src --watch docs -e js,md,css,html ./build.js\" \"http-server ./dist --open\"",
|
|
21
|
+
"build": "node ./build.js",
|
|
22
|
+
"serve": "node ./build.js && http-server ./dist --open",
|
|
23
|
+
"example:ssg": "node ./examples/ssg.js",
|
|
24
|
+
"example:ssr": "node ./examples/ssr.js",
|
|
25
|
+
"start": "npm run develop",
|
|
26
|
+
"test": "c8 mocha --parallel",
|
|
27
|
+
"test:tdd": "npm run test --watch"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"acorn": "^8.7.0",
|
|
31
|
+
"acorn-walk": "^8.2.0",
|
|
32
|
+
"parse5": "^6.0.1"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@mapbox/rehype-prism": "^0.8.0",
|
|
36
|
+
"c8": "^7.11.2",
|
|
37
|
+
"chai": "^4.3.6",
|
|
38
|
+
"concurrently": "^7.1.0",
|
|
39
|
+
"eslint": "^8.14.0",
|
|
40
|
+
"http-server": "^14.1.0",
|
|
41
|
+
"jsdom": "^19.0.0",
|
|
42
|
+
"mocha": "^9.2.2",
|
|
43
|
+
"nodemon": "^2.0.15",
|
|
44
|
+
"prismjs": "^1.28.0",
|
|
45
|
+
"simple.css": "^0.1.3",
|
|
46
|
+
"rehype-raw": "^6.1.1",
|
|
47
|
+
"rehype-stringify": "^9.0.3",
|
|
48
|
+
"remark-parse": "^10.0.1",
|
|
49
|
+
"remark-rehype": "^10.1.0",
|
|
50
|
+
"unified": "^10.1.2"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/dom-shim.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget
|
|
2
|
+
class EventTarget { }
|
|
3
|
+
|
|
4
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/Node
|
|
5
|
+
// EventTarget <- Node
|
|
6
|
+
class Node extends EventTarget {
|
|
7
|
+
constructor() {
|
|
8
|
+
super();
|
|
9
|
+
// console.debug('Node constructor');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// eslint-disable-next-line
|
|
13
|
+
cloneNode(deep) {
|
|
14
|
+
return this;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
appendChild(node) {
|
|
18
|
+
this.innerHTML = this.innerHTML ? this.innerHTML += node.textContent : node.textContent;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/Element
|
|
23
|
+
// EventTarget <- Node <- Element
|
|
24
|
+
class Element extends Node {
|
|
25
|
+
constructor() {
|
|
26
|
+
super();
|
|
27
|
+
// console.debug('Element constructor');
|
|
28
|
+
this.shadowRoot = null;
|
|
29
|
+
this.innerHTML;
|
|
30
|
+
this.attributes = {};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
setAttribute(name, value) {
|
|
34
|
+
this.attributes[name] = value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
getAttribute(name) {
|
|
38
|
+
return this.attributes[name];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
hasAttribute(name) {
|
|
42
|
+
return !!this.attributes[name];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement
|
|
47
|
+
// EventTarget <- Node <- Element <- HTMLElement
|
|
48
|
+
class HTMLElement extends Element {
|
|
49
|
+
constructor() {
|
|
50
|
+
super();
|
|
51
|
+
// console.debug('HTMLElement::constructor');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
attachShadow(options) {
|
|
55
|
+
// console.debug('HTMLElement::attachShadow');
|
|
56
|
+
this.shadowRoot = new ShadowRoot(options);
|
|
57
|
+
|
|
58
|
+
return this.shadowRoot;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
connectedCallback() {
|
|
62
|
+
// console.debug('HTMLElement::connectedCallback');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// https://github.com/mfreed7/declarative-shadow-dom/blob/master/README.md#serialization
|
|
66
|
+
getInnerHTML(options = {}) {
|
|
67
|
+
return options.includeShadowRoots
|
|
68
|
+
? `
|
|
69
|
+
<template shadowroot="${this.shadowRoot.mode}">
|
|
70
|
+
${this.shadowRoot.innerHTML}
|
|
71
|
+
</template>
|
|
72
|
+
`
|
|
73
|
+
: this.shadowRoot.innerHTML;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment
|
|
78
|
+
// EventTarget <- Node <- DocumentFragment
|
|
79
|
+
class DocumentFragment extends Node {
|
|
80
|
+
// eslint-disable-next-line
|
|
81
|
+
constructor(contents) {
|
|
82
|
+
super();
|
|
83
|
+
// console.debug('DocumentFragment constructor', contents);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot
|
|
88
|
+
// EventTarget <- Node <- DocumentFragment <- ShadowRoot
|
|
89
|
+
class ShadowRoot extends DocumentFragment {
|
|
90
|
+
constructor(options) {
|
|
91
|
+
super();
|
|
92
|
+
// console.debug('ShadowRoot constructor');
|
|
93
|
+
this.mode = options.mode || 'closed';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLTemplateElement
|
|
98
|
+
// EventTarget <- Node <- Element <- HTMLElement <- HTMLTemplateElement
|
|
99
|
+
class HTMLTemplateElement extends HTMLElement {
|
|
100
|
+
constructor() {
|
|
101
|
+
super();
|
|
102
|
+
// console.debug('HTMLTemplateElement constructor');
|
|
103
|
+
|
|
104
|
+
this.content = new DocumentFragment(this.innerHTML);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
set innerHTML(html) {
|
|
108
|
+
this.content.textContent = html;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const customElementsRegistry = {};
|
|
113
|
+
|
|
114
|
+
globalThis.document = {
|
|
115
|
+
createElement(tagName) {
|
|
116
|
+
switch (tagName) {
|
|
117
|
+
|
|
118
|
+
case 'template':
|
|
119
|
+
return new HTMLTemplateElement();
|
|
120
|
+
|
|
121
|
+
default:
|
|
122
|
+
return new HTMLElement();
|
|
123
|
+
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
createDocumentFragment(html) {
|
|
127
|
+
return new DocumentFragment(html);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
globalThis.HTMLElement = HTMLElement;
|
|
132
|
+
globalThis.customElements = {
|
|
133
|
+
define: (tagName, BaseClass) => {
|
|
134
|
+
// console.debug('customElements.define => ', tagName);
|
|
135
|
+
customElementsRegistry[tagName] = BaseClass;
|
|
136
|
+
},
|
|
137
|
+
get: (tagName) => {
|
|
138
|
+
// console.debug('customElements.get => ', tagName);
|
|
139
|
+
return customElementsRegistry[tagName];
|
|
140
|
+
}
|
|
141
|
+
};
|
package/src/wcc.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// this must come first
|
|
2
|
+
import './dom-shim.js';
|
|
3
|
+
|
|
4
|
+
import * as acorn from 'acorn';
|
|
5
|
+
import * as walk from 'acorn-walk';
|
|
6
|
+
import { parse, parseFragment, serialize } from 'parse5';
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs/promises';
|
|
9
|
+
|
|
10
|
+
let definitions;
|
|
11
|
+
|
|
12
|
+
async function renderComponentRoots(tree, includeShadowRoots = true) {
|
|
13
|
+
for (const node of tree.childNodes) {
|
|
14
|
+
if (node.tagName && node.tagName.indexOf('-') > 0) {
|
|
15
|
+
const { tagName } = node;
|
|
16
|
+
const { moduleURL } = definitions[tagName];
|
|
17
|
+
const elementInstance = await initializeCustomElement(moduleURL, tagName, node.attrs);
|
|
18
|
+
|
|
19
|
+
const shadowRootHtml = elementInstance.getInnerHTML({ includeShadowRoots });
|
|
20
|
+
const shadowRootTree = parseFragment(shadowRootHtml);
|
|
21
|
+
|
|
22
|
+
node.childNodes = node.childNodes.length === 0 ? shadowRootTree.childNodes : [...shadowRootTree.childNodes, ...node.childNodes];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (node.childNodes && node.childNodes.length > 0) {
|
|
26
|
+
await renderComponentRoots(node, includeShadowRoots);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// does this only apply to `<template>` tags?
|
|
30
|
+
if (node.content && node.content.childNodes && node.content.childNodes.length > 0) {
|
|
31
|
+
await renderComponentRoots(node.content, includeShadowRoots);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return tree;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function registerDependencies(moduleURL) {
|
|
39
|
+
const moduleContents = await fs.readFile(moduleURL, 'utf-8');
|
|
40
|
+
|
|
41
|
+
walk.simple(acorn.parse(moduleContents, {
|
|
42
|
+
ecmaVersion: 'latest',
|
|
43
|
+
sourceType: 'module'
|
|
44
|
+
}), {
|
|
45
|
+
async ImportDeclaration(node) {
|
|
46
|
+
const dependencyModuleURL = new URL(node.source.value, moduleURL);
|
|
47
|
+
|
|
48
|
+
await registerDependencies(dependencyModuleURL);
|
|
49
|
+
},
|
|
50
|
+
async ExpressionStatement(node) {
|
|
51
|
+
const { expression } = node;
|
|
52
|
+
|
|
53
|
+
if (expression.type === 'CallExpression' && expression.callee && expression.callee.object
|
|
54
|
+
&& expression.callee.property && expression.callee.object.name === 'customElements'
|
|
55
|
+
&& expression.callee.property.name === 'define') {
|
|
56
|
+
|
|
57
|
+
const tagName = node.expression.arguments[0].value;
|
|
58
|
+
|
|
59
|
+
definitions[tagName] = {
|
|
60
|
+
instanceName: node.expression.arguments[1].name,
|
|
61
|
+
moduleURL
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function initializeCustomElement(elementURL, tagName, attrs = []) {
|
|
69
|
+
await registerDependencies(elementURL);
|
|
70
|
+
|
|
71
|
+
const element = tagName
|
|
72
|
+
? customElements.get(tagName)
|
|
73
|
+
: (await import(elementURL)).default;
|
|
74
|
+
const dataLoader = (await import(elementURL)).getData;
|
|
75
|
+
const data = dataLoader ? await dataLoader() : {};
|
|
76
|
+
const elementInstance = new element(data); // eslint-disable-line new-cap
|
|
77
|
+
|
|
78
|
+
attrs.forEach((attr) => {
|
|
79
|
+
elementInstance.setAttribute(attr.name, attr.value);
|
|
80
|
+
|
|
81
|
+
if (attr.name === 'hydrate') {
|
|
82
|
+
definitions[tagName].hydrate = attr.value;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await elementInstance.connectedCallback();
|
|
87
|
+
|
|
88
|
+
return elementInstance;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function renderToString(elementURL, options = {}) {
|
|
92
|
+
definitions = [];
|
|
93
|
+
|
|
94
|
+
const { lightMode = false } = options;
|
|
95
|
+
const includeShadowRoots = !lightMode;
|
|
96
|
+
|
|
97
|
+
const elementInstance = await initializeCustomElement(elementURL);
|
|
98
|
+
const elementHtml = elementInstance.getInnerHTML({ includeShadowRoots });
|
|
99
|
+
const elementTree = parseFragment(elementHtml);
|
|
100
|
+
const finalTree = await renderComponentRoots(elementTree, includeShadowRoots);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
html: serialize(finalTree),
|
|
104
|
+
metadata: definitions
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function renderFromHTML(html, elements = [], options = {}) {
|
|
109
|
+
definitions = [];
|
|
110
|
+
|
|
111
|
+
const { lightMode = false } = options;
|
|
112
|
+
const includeShadowRoots = !lightMode;
|
|
113
|
+
|
|
114
|
+
for (const url of elements) {
|
|
115
|
+
await initializeCustomElement(url);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const elementTree = parse(html);
|
|
119
|
+
const finalTree = await renderComponentRoots(elementTree, includeShadowRoots);
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
html: serialize(finalTree),
|
|
123
|
+
metadata: definitions
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export {
|
|
128
|
+
renderToString,
|
|
129
|
+
renderFromHTML
|
|
130
|
+
};
|