~repos /atoms-element
git clone https://pyrossh.dev/repos/atoms-element.git
A simple web component library for defining your custom elements. It works on both client and server.
1d4524c3
—
Peter John 4 years ago
improve structure
- src/index.d.ts → element.d.ts +0 -0
- src/index.js → element.js +62 -22
- test/index.test.js → element.test.js +8 -3
- example/index.js +40 -0
- example/main.js +0 -30
- src/lit-html.js → lit-html.js +0 -0
- package.json +0 -6
- page.js +70 -0
- page.test.js +85 -0
- src/ssr.js +0 -24
src/index.d.ts → element.d.ts
RENAMED
|
File without changes
|
src/index.js → element.js
RENAMED
|
@@ -3,6 +3,56 @@ import { html, render as litRender, directive, NodePart, AttributePart, Property
|
|
|
3
3
|
const isBrowser = typeof window !== 'undefined';
|
|
4
4
|
export { html, isBrowser };
|
|
5
5
|
|
|
6
|
+
const constructionToken = Symbol();
|
|
7
|
+
|
|
8
|
+
export class CSSResult {
|
|
9
|
+
constructor(cssText, safeToken) {
|
|
10
|
+
if (safeToken !== constructionToken) {
|
|
11
|
+
throw new Error('CSSResult is not constructable. Use `unsafeCSS` or `css` instead.');
|
|
12
|
+
}
|
|
13
|
+
this.cssText = cssText;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
toString() {
|
|
17
|
+
return this.cssText;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Wrap a value for interpolation in a [[`css`]] tagged template literal.
|
|
23
|
+
*
|
|
24
|
+
* This is unsafe because untrusted CSS text can be used to phone home
|
|
25
|
+
* or exfiltrate data to an attacker controlled site. Take care to only use
|
|
26
|
+
* this with trusted input.
|
|
27
|
+
*/
|
|
28
|
+
export const unsafeCSS = (value) => {
|
|
29
|
+
return new CSSResult(String(value), constructionToken);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const textFromCSSResult = (value) => {
|
|
33
|
+
if (value instanceof CSSResult) {
|
|
34
|
+
return value.cssText;
|
|
35
|
+
} else if (typeof value === 'number') {
|
|
36
|
+
return value;
|
|
37
|
+
} else {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Value passed to 'css' function must be a 'css' function result: ${value}. Use 'unsafeCSS' to pass non-literal values, but
|
|
40
|
+
take care to ensure page security.`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Template tag which which can be used with LitElement's [[LitElement.styles |
|
|
47
|
+
* `styles`]] property to set element styles. For security reasons, only literal
|
|
48
|
+
* string values may be used. To incorporate non-literal values [[`unsafeCSS`]]
|
|
49
|
+
* may be used inside a template string part.
|
|
50
|
+
*/
|
|
51
|
+
export const css = (strings, ...values) => {
|
|
52
|
+
const cssText = values.reduce((acc, v, idx) => acc + textFromCSSResult(v) + strings[idx + 1], strings[0]);
|
|
53
|
+
return new CSSResult(cssText, constructionToken);
|
|
54
|
+
};
|
|
55
|
+
|
|
6
56
|
const lastAttributeNameRegex =
|
|
7
57
|
/([ \x09\x0a\x0c\x0d])([^\0-\x1F\x7F-\x9F "'>=/]+)([ \x09\x0a\x0c\x0d]*=[ \x09\x0a\x0c\x0d]*(?:[^ \x09\x0a\x0c\x0d"'`<>=]*|"[^"]*|'[^']*))$/;
|
|
8
58
|
|
|
@@ -310,10 +360,10 @@ const enqueueLayoutEffects = batch(microtask, filo, (c) => c._flushEffects('layo
|
|
|
310
360
|
const enqueueEffects = batch(task, filo, (c) => c._flushEffects('effects'));
|
|
311
361
|
const enqueueUpdate = batch(microtask, fifo, (c) => c._performUpdate());
|
|
312
362
|
|
|
313
|
-
const BaseElement = isBrowser ? window.HTMLElement : class {};
|
|
314
|
-
const count = [0];
|
|
315
363
|
const registry = {};
|
|
364
|
+
const BaseElement = isBrowser ? window.HTMLElement : class {};
|
|
365
|
+
|
|
316
|
-
export class AtomsElement extends BaseElement {
|
|
366
|
+
export default class AtomsElement extends BaseElement {
|
|
317
367
|
static register() {
|
|
318
368
|
registry[this.name] = this;
|
|
319
369
|
if (isBrowser) {
|
|
@@ -329,23 +379,15 @@ export class AtomsElement extends BaseElement {
|
|
|
329
379
|
return registry[name];
|
|
330
380
|
}
|
|
331
381
|
|
|
332
|
-
static getNextIndex() {
|
|
333
|
-
count[0] = count[0] + 1;
|
|
334
|
-
return count[0];
|
|
335
|
-
}
|
|
336
|
-
|
|
337
382
|
static get observedAttributes() {
|
|
338
383
|
if (!this.attrTypes) {
|
|
339
384
|
return [];
|
|
340
385
|
}
|
|
341
|
-
return Object.keys(this.attrTypes)
|
|
386
|
+
return Object.keys(this.attrTypes).map((k) => k.toLowerCase());
|
|
342
|
-
.filter((key) => this.attrTypes[key].type !== 'function')
|
|
343
|
-
.map((k) => k.toLowerCase());
|
|
344
387
|
}
|
|
345
388
|
|
|
346
389
|
constructor(attrs) {
|
|
347
390
|
super();
|
|
348
|
-
this._id = `atoms:${this.constructor.getNextIndex()}`;
|
|
349
391
|
this._dirty = false;
|
|
350
392
|
this._connected = false;
|
|
351
393
|
this.hooks = {
|
|
@@ -444,15 +486,13 @@ export class AtomsElement extends BaseElement {
|
|
|
444
486
|
}
|
|
445
487
|
|
|
446
488
|
getAttrs() {
|
|
447
|
-
return Object.keys(this.constructor.attrTypes)
|
|
489
|
+
return Object.keys(this.constructor.attrTypes).reduceRight((acc, key) => {
|
|
448
|
-
.filter((key) => this.constructor.attrTypes[key].type !== 'function')
|
|
449
|
-
.reduceRight((acc, key) => {
|
|
450
|
-
|
|
490
|
+
const attrType = this.constructor.attrTypes[key];
|
|
451
|
-
|
|
491
|
+
const newValue = isBrowser ? this.getAttribute(key.toLowerCase()) : this.attrs.find((item) => item.name === key.toLowerCase())?.value;
|
|
452
|
-
|
|
492
|
+
const data = attrType.parse(newValue);
|
|
453
|
-
|
|
493
|
+
attrType.validate(`<${this.constructor.name}> ${key}`, data);
|
|
454
|
-
|
|
494
|
+
acc[key] = data;
|
|
455
|
-
|
|
495
|
+
return acc;
|
|
456
|
-
|
|
496
|
+
}, {});
|
|
457
497
|
}
|
|
458
498
|
}
|
test/index.test.js → element.test.js
RENAMED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { expect, test, jest } from '@jest/globals';
|
|
2
|
-
import
|
|
2
|
+
import AtomsElement, { html, render, number, boolean, string, array, object, unsafeHTML, css, classMap } from './element.js';
|
|
3
3
|
|
|
4
4
|
global.__DEV = true;
|
|
5
5
|
|
|
@@ -239,10 +239,15 @@ test('AtomsElement', async () => {
|
|
|
239
239
|
address: object({
|
|
240
240
|
street: string.isRequired,
|
|
241
241
|
}).isRequired,
|
|
242
|
-
renderItem: func(),
|
|
243
242
|
};
|
|
244
243
|
|
|
244
|
+
styles() {
|
|
245
|
-
|
|
245
|
+
return css`
|
|
246
|
+
div {
|
|
247
|
+
color: red;
|
|
248
|
+
}
|
|
249
|
+
`;
|
|
250
|
+
}
|
|
246
251
|
|
|
247
252
|
render() {
|
|
248
253
|
const {
|
example/index.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import AtomsElement, { html, object, number, string } from '../element.js';
|
|
2
|
+
import Page from '../page.js';
|
|
3
|
+
|
|
4
|
+
class Counter extends AtomsElement {
|
|
5
|
+
static name = 'app-counter';
|
|
6
|
+
|
|
7
|
+
static attrTypes = {
|
|
8
|
+
name: string.isRequired,
|
|
9
|
+
meta: object({
|
|
10
|
+
start: number,
|
|
11
|
+
}),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
static styles = `
|
|
15
|
+
.container {
|
|
16
|
+
}
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
render() {
|
|
20
|
+
const { name, meta } = this.getAttrs();
|
|
21
|
+
const [count, setCount] = this.useState(meta?.start || 0);
|
|
22
|
+
|
|
23
|
+
return html`
|
|
24
|
+
<div>
|
|
25
|
+
<div class="font-bold mb-2">Counter: ${name}</div>
|
|
26
|
+
<div class="flex flex-1 flex-row text-3xl text-gray-700">
|
|
27
|
+
<button @click=${() => setCount((v) => v - 1)}>-</button>
|
|
28
|
+
<div class="mx-20">
|
|
29
|
+
<h1 class="text-1xl">${count}</h1>
|
|
30
|
+
</div>
|
|
31
|
+
<button @click=${() => setCount((v) => v + 1)}>+</button>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
Counter.register();
|
|
39
|
+
|
|
40
|
+
console.log(ssr(html`<app-counter name="1"></app-counter>`));
|
example/main.js
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { defineElement, html, object, number, string, useState } from '../src/index.js';
|
|
2
|
-
import { ssr } from '../src/ssr.js';
|
|
3
|
-
|
|
4
|
-
const attrTypes = {
|
|
5
|
-
name: string.isRequired,
|
|
6
|
-
meta: object({
|
|
7
|
-
start: number,
|
|
8
|
-
}),
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
const Counter = ({ name, meta }) => {
|
|
12
|
-
const [count, setCount] = useState(meta?.start || 0);
|
|
13
|
-
|
|
14
|
-
return html`
|
|
15
|
-
<div>
|
|
16
|
-
<div class="font-bold mb-2">Counter: ${name}</div>
|
|
17
|
-
<div class="flex flex-1 flex-row text-3xl text-gray-700">
|
|
18
|
-
<button @click=${() => setCount((v) => v - 1)}>-</button>
|
|
19
|
-
<div class="mx-20">
|
|
20
|
-
<h1 class="text-1xl">${count}</h1>
|
|
21
|
-
</div>
|
|
22
|
-
<button @click=${() => setCount((v) => v + 1)}>+</button>
|
|
23
|
-
</div>
|
|
24
|
-
</div>
|
|
25
|
-
`;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
defineElement('app-counter', Counter, attrTypes);
|
|
29
|
-
|
|
30
|
-
console.log(ssr(html`<app-counter name="1"></app-counter>`));
|
src/lit-html.js → lit-html.js
RENAMED
|
File without changes
|
package.json
CHANGED
|
@@ -20,12 +20,6 @@
|
|
|
20
20
|
"fuco"
|
|
21
21
|
],
|
|
22
22
|
"license": "MIT",
|
|
23
|
-
"main": "src/index.js",
|
|
24
|
-
"files": [
|
|
25
|
-
"src",
|
|
26
|
-
"example"
|
|
27
|
-
],
|
|
28
|
-
"typings": "src/index.d.ts",
|
|
29
23
|
"author": "pyros.sh",
|
|
30
24
|
"type": "module",
|
|
31
25
|
"engines": {
|
page.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import parse5 from 'parse5';
|
|
2
|
+
import AtomsElement, { render } from './element.js';
|
|
3
|
+
|
|
4
|
+
export default class Page {
|
|
5
|
+
constructor({ config, data, item, headScript, bodyScript }) {
|
|
6
|
+
this.config = config;
|
|
7
|
+
this.data = data;
|
|
8
|
+
this.item = item;
|
|
9
|
+
this.headScript = headScript;
|
|
10
|
+
this.bodyScript = bodyScript;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
find(node) {
|
|
14
|
+
for (const child of node.childNodes) {
|
|
15
|
+
if (AtomsElement.getElement(child.tagName)) {
|
|
16
|
+
const Clazz = AtomsElement.getElement(child.tagName);
|
|
17
|
+
const instance = new Clazz(child.attrs);
|
|
18
|
+
const res = render(instance.render());
|
|
19
|
+
const frag = parse5.parseFragment(res);
|
|
20
|
+
child.childNodes.push(...frag.childNodes);
|
|
21
|
+
}
|
|
22
|
+
if (child.childNodes) {
|
|
23
|
+
this.find(child);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
ssr(template) {
|
|
29
|
+
const text = render(template);
|
|
30
|
+
const h = parse5.parseFragment(text);
|
|
31
|
+
this.find(h);
|
|
32
|
+
return parse5.serialize(h);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
render() {
|
|
36
|
+
const isProd = process.env.NODE_ENV === 'production';
|
|
37
|
+
const props = { config: this.config, data: this.data, item: this.item };
|
|
38
|
+
const headHtml = this.ssr(this.head(props));
|
|
39
|
+
const stylesCss = this.styles(props).cssText;
|
|
40
|
+
const bodyHtml = this.ssr(this.body(props));
|
|
41
|
+
return `
|
|
42
|
+
<!DOCTYPE html>
|
|
43
|
+
<html lang="${this.config.lang}">
|
|
44
|
+
<head>
|
|
45
|
+
<meta charset="utf-8" />
|
|
46
|
+
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
|
47
|
+
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
|
48
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=5.0, shrink-to-fit=no">
|
|
49
|
+
<link rel="sitemap" type="application/xml" href="/sitemap.xml" />
|
|
50
|
+
<link rel="icon" type="image/png" href="/assets/icon.png" />
|
|
51
|
+
${headHtml}
|
|
52
|
+
<style>
|
|
53
|
+
${stylesCss}
|
|
54
|
+
</style>
|
|
55
|
+
${this.headScript}
|
|
56
|
+
</head>
|
|
57
|
+
<body>
|
|
58
|
+
${bodyHtml}
|
|
59
|
+
<script>
|
|
60
|
+
window.__DEV__ = ${!isProd};
|
|
61
|
+
window.config = ${JSON.stringify(this.config)};
|
|
62
|
+
window.data = ${JSON.stringify(this.data)};
|
|
63
|
+
window.item = ${JSON.stringify(this.item)};
|
|
64
|
+
</script>
|
|
65
|
+
${this.bodyScript}
|
|
66
|
+
</body>
|
|
67
|
+
</html>
|
|
68
|
+
`;
|
|
69
|
+
}
|
|
70
|
+
}
|
page.test.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { expect, test } from '@jest/globals';
|
|
2
|
+
import { html, css } from './element.js';
|
|
3
|
+
import Page from './page.js';
|
|
4
|
+
|
|
5
|
+
test('Page', () => {
|
|
6
|
+
class MainPage extends Page {
|
|
7
|
+
route({ config }) {
|
|
8
|
+
const langPart = config.lang === 'en' ? '' : `/${config.lang}`;
|
|
9
|
+
return `${langPart}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
styles({ config }) {
|
|
13
|
+
return css`
|
|
14
|
+
div {
|
|
15
|
+
color: red;
|
|
16
|
+
}
|
|
17
|
+
`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
head({ config }) {
|
|
21
|
+
return html`
|
|
22
|
+
<title>${config.title}</title>
|
|
23
|
+
<meta name="title" content=${config.title} />
|
|
24
|
+
<meta name="description" content=${config.title} />
|
|
25
|
+
`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
body({ config }) {
|
|
29
|
+
return html`
|
|
30
|
+
<div>
|
|
31
|
+
<app-header></app-header>
|
|
32
|
+
<main class="flex flex-1 flex-col mt-20 items-center">
|
|
33
|
+
<h1 class="text-5xl">${config.title}</h1>
|
|
34
|
+
</main>
|
|
35
|
+
</div>
|
|
36
|
+
`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const scripts = '<script type="module"><script>';
|
|
40
|
+
const mainPage = new MainPage({ config: { lang: 'en', title: '123' }, headScript: scripts, bodyScript: scripts });
|
|
41
|
+
const res = mainPage.render();
|
|
42
|
+
expect(res).toEqual(`
|
|
43
|
+
<!DOCTYPE html>
|
|
44
|
+
<html lang="en">
|
|
45
|
+
<head>
|
|
46
|
+
<meta charset="utf-8" />
|
|
47
|
+
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
|
48
|
+
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
|
49
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=5.0, shrink-to-fit=no">
|
|
50
|
+
<link rel="sitemap" type="application/xml" href="/sitemap.xml" />
|
|
51
|
+
<link rel="icon" type="image/png" href="/assets/icon.png" />
|
|
52
|
+
|
|
53
|
+
<title>123</title>
|
|
54
|
+
<meta name="title" content="123">
|
|
55
|
+
<meta name="description" content="123">
|
|
56
|
+
|
|
57
|
+
<style>
|
|
58
|
+
|
|
59
|
+
div {
|
|
60
|
+
color: red;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
</style>
|
|
64
|
+
<script type="module"><script>
|
|
65
|
+
</head>
|
|
66
|
+
<body>
|
|
67
|
+
|
|
68
|
+
<div>
|
|
69
|
+
<app-header></app-header>
|
|
70
|
+
<main class="flex flex-1 flex-col mt-20 items-center">
|
|
71
|
+
<h1 class="text-5xl">123</h1>
|
|
72
|
+
</main>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<script>
|
|
76
|
+
window.__DEV__ = true;
|
|
77
|
+
window.config = {"lang":"en","title":"123"};
|
|
78
|
+
window.data = undefined;
|
|
79
|
+
window.item = undefined;
|
|
80
|
+
</script>
|
|
81
|
+
<script type="module"><script>
|
|
82
|
+
</body>
|
|
83
|
+
</html>
|
|
84
|
+
`);
|
|
85
|
+
});
|
src/ssr.js
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import parse5 from 'parse5';
|
|
2
|
-
import { getElement, render } from '../src/index.js';
|
|
3
|
-
|
|
4
|
-
export const find = async (node) => {
|
|
5
|
-
for (const child of node.childNodes) {
|
|
6
|
-
if (getElement(child.tagName)) {
|
|
7
|
-
const element = getElement(child.tagName);
|
|
8
|
-
const elementInstance = new element.Clazz(child.attrs);
|
|
9
|
-
const res = elementInstance.render();
|
|
10
|
-
const frag = parse5.parseFragment(res);
|
|
11
|
-
child.childNodes.push(...frag.childNodes);
|
|
12
|
-
}
|
|
13
|
-
if (child.childNodes) {
|
|
14
|
-
find(child);
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export const ssr = (template) => {
|
|
20
|
-
const text = render(template);
|
|
21
|
-
const h = parse5.parseFragment(text);
|
|
22
|
-
find(h);
|
|
23
|
-
return parse5.serialize(h);
|
|
24
|
-
};
|