~repos /atoms-element

#js

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.


1bdc5ec4 pyros2097

4 years ago
Merge pull request #1 from pyros2097/class_based_atom
element.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { Config } from './page';
2
+
3
+ export declare type Location = {
4
+ readonly ancestorOrigins: DOMStringList;
5
+ hash: string;
6
+ host: string;
7
+ hostname: string;
8
+ href: string;
9
+ readonly origin: string;
10
+ pathname: string;
11
+ port: string;
12
+ protocol: string;
13
+ search: string;
14
+ assign: (url: string | URL) => void;
15
+ reload: () => void;
16
+ replace: (url: string | URL) => void;
17
+ toString: () => string;
18
+ }
19
+
20
+ export default class AtomsElement {
21
+ static register(): () => void;
22
+ static observedAttributes: Array<string>;
23
+ static getElement: (name: string) => AtomsElement | undefined;
24
+ config: Config;
25
+ location: Location;
26
+ attrs: {[key: string]: any};
27
+ state: {[key: string]: any};
28
+ computed: {[key: string]: any};
29
+ styles: () => string;
30
+ }
31
+
32
+ // declare type Colors = 'red' | 'purple' | 'blue' | 'green';
33
+ // declare type Luminance = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
34
+ // declare type BgColor = `bg-${Colors}-${Luminance}`;
35
+ // declare type Distance = 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
36
+ // declare type Breakpoints = 'xs:' | 'sm:' | 'md:' | 'lg:' | 'xl:' | '';
37
+ // declare type Space = `${Breakpoints}space-${'x' | 'y'}-${Distance}`;
38
+ // declare type ValidClass = Space | BgColor;
39
+ // declare type Tailwind<S> = S extends `${infer Class} ${infer Rest}` ? Class extends ValidClass ? `${Class} ${Tailwind<Rest>}` : never : S extends `${infer Class}` ? Class extends ValidClass ? S : never : never;
40
+ // declare function doSomethingWithTwClass<S>(cls: Tailwind<S>): Tailwind<S>;
41
+ // declare const bad: never;
42
+ // declare const bad2: never;
43
+ // declare const bad3: never;
44
+ // declare const bad4: never;
45
+ // declare const good: "bg-red-400 space-x-4 md:space-x-8";
46
+ // declare const good2: "bg-red-400 space-x-4";
47
+ // declare const good3: "space-x-1.5 bg-blue-200";
element.js ADDED
@@ -0,0 +1,451 @@
1
+ import { html, render as litRender, directive, NodePart, AttributePart, PropertyPart, isPrimitive } from './lit-html.js';
2
+
3
+ const isBrowser = typeof window !== 'undefined';
4
+ export { html, isBrowser };
5
+
6
+ // Taken from emotion
7
+ const murmur2 = (str) => {
8
+ var h = 0;
9
+ var k,
10
+ i = 0,
11
+ len = str.length;
12
+ for (; len >= 4; ++i, len -= 4) {
13
+ k = (str.charCodeAt(i) & 0xff) | ((str.charCodeAt(++i) & 0xff) << 8) | ((str.charCodeAt(++i) & 0xff) << 16) | ((str.charCodeAt(++i) & 0xff) << 24);
14
+ k = (k & 0xffff) * 0x5bd1e995 + (((k >>> 16) * 0xe995) << 16);
15
+ k ^= k >>> 24;
16
+ h = ((k & 0xffff) * 0x5bd1e995 + (((k >>> 16) * 0xe995) << 16)) ^ ((h & 0xffff) * 0x5bd1e995 + (((h >>> 16) * 0xe995) << 16));
17
+ }
18
+ switch (len) {
19
+ case 3:
20
+ h ^= (str.charCodeAt(i + 2) & 0xff) << 16;
21
+ case 2:
22
+ h ^= (str.charCodeAt(i + 1) & 0xff) << 8;
23
+ case 1:
24
+ h ^= str.charCodeAt(i) & 0xff;
25
+ h = (h & 0xffff) * 0x5bd1e995 + (((h >>> 16) * 0xe995) << 16);
26
+ }
27
+ h ^= h >>> 13;
28
+ h = (h & 0xffff) * 0x5bd1e995 + (((h >>> 16) * 0xe995) << 16);
29
+ return ((h ^ (h >>> 15)) >>> 0).toString(36);
30
+ };
31
+
32
+ const hyphenate = (s) => s.replace(/[A-Z]|^ms/g, '-$&').toLowerCase();
33
+
34
+ export const convertStyles = (prefix, obj, parentClassName, indent = '') => {
35
+ const className = parentClassName || prefix + '-' + murmur2(JSON.stringify(obj)).toString(36);
36
+ const cssText = Object.keys(obj).reduce((acc, key) => {
37
+ const value = obj[key];
38
+ if (typeof value === 'object') {
39
+ acc += '\n ' + indent + convertStyles(prefix, value, key, indent + ' ').cssText;
40
+ } else {
41
+ acc += ' ' + indent + hyphenate(key) + ': ' + value + ';\n';
42
+ }
43
+ return acc;
44
+ }, `${parentClassName ? '' : '.'}${className} {\n`);
45
+ return { className, cssText: cssText + `\n${indent}}` };
46
+ };
47
+
48
+ export const css = (obj) => {
49
+ Object.keys(obj).forEach((key) => {
50
+ const value = obj[key];
51
+ const { className, cssText } = convertStyles(key, value);
52
+ obj[key] = className;
53
+ obj[className] = cssText;
54
+ });
55
+ obj.toString = () => {
56
+ return Object.keys(obj).reduce((acc, key) => {
57
+ acc += key.includes('-') ? obj[key] + '\n\n' : '';
58
+ return acc;
59
+ }, '');
60
+ };
61
+ return obj;
62
+ };
63
+
64
+ const lastAttributeNameRegex =
65
+ /([ \x09\x0a\x0c\x0d])([^\0-\x1F\x7F-\x9F "'>=/]+)([ \x09\x0a\x0c\x0d]*=[ \x09\x0a\x0c\x0d]*(?:[^ \x09\x0a\x0c\x0d"'`<>=]*|"[^"]*|'[^']*))$/;
66
+
67
+ const wrapAttribute = (attrName, suffix, text, v) => {
68
+ let buffer = text;
69
+ const hasQuote = suffix && suffix.includes(`="`);
70
+ if (attrName && !hasQuote) {
71
+ buffer += `"`;
72
+ }
73
+ buffer += v;
74
+ if (attrName && !hasQuote) {
75
+ buffer += `"`;
76
+ }
77
+ return buffer;
78
+ };
79
+
80
+ export const render = isBrowser
81
+ ? litRender
82
+ : (template) => {
83
+ let js = '';
84
+ template.strings.forEach((text, i) => {
85
+ const value = template.values[i];
86
+ const type = typeof value;
87
+ let attrName, suffix;
88
+ const matchName = lastAttributeNameRegex.exec(text);
89
+ if (matchName) {
90
+ attrName = matchName[2];
91
+ suffix = matchName[3];
92
+ }
93
+ if (value === null || !(type === 'object' || type === 'function' || type === 'undefined')) {
94
+ js += wrapAttribute(attrName, suffix, text, type !== 'string' ? String(value) : value);
95
+ } else if (Array.isArray(value) && value.find((item) => item && item.strings && item.type === 'html')) {
96
+ js += text;
97
+ value.forEach((v) => {
98
+ js += render(v);
99
+ });
100
+ } else if (type === 'object') {
101
+ // TemplateResult
102
+ if (value.strings && value.type === 'html') {
103
+ js += text;
104
+ js += render(value);
105
+ } else {
106
+ js += wrapAttribute(attrName, suffix, text, JSON.stringify(value).replace(/"/g, `'`));
107
+ }
108
+ } else if (type == 'function') {
109
+ if (attrName) {
110
+ js += text.replace(' ' + attrName + '=', '');
111
+ } else {
112
+ // js += text;
113
+ // js += value();
114
+ }
115
+ } else if (type !== 'undefined') {
116
+ js += text;
117
+ js += value.toString();
118
+ } else {
119
+ js += text;
120
+ // console.log('value', value);
121
+ }
122
+ });
123
+ return js;
124
+ };
125
+
126
+ const previousValues = new WeakMap();
127
+ export const unsafeHTML = isBrowser
128
+ ? directive((value) => (part) => {
129
+ if (!(part instanceof NodePart)) {
130
+ throw new Error('unsafeHTML can only be used in text bindings');
131
+ }
132
+ const previousValue = previousValues.get(part);
133
+ if (previousValue !== undefined && isPrimitive(value) && value === previousValue.value && part.value === previousValue.fragment) {
134
+ return;
135
+ }
136
+ const template = document.createElement('template');
137
+ template.innerHTML = value; // innerHTML casts to string internally
138
+ const fragment = document.importNode(template.content, true);
139
+ part.setValue(fragment);
140
+ previousValues.set(part, { value, fragment });
141
+ })
142
+ : (value) => value;
143
+
144
+ const previousClassesCache = new WeakMap();
145
+ export const classMap = isBrowser
146
+ ? directive((classInfo) => (part) => {
147
+ if (!(part instanceof AttributePart) || part instanceof PropertyPart || part.committer.name !== 'class' || part.committer.parts.length > 1) {
148
+ throw new Error('The `classMap` directive must be used in the `class` attribute ' + 'and must be the only part in the attribute.');
149
+ }
150
+ const { committer } = part;
151
+ const { element } = committer;
152
+ let previousClasses = previousClassesCache.get(part);
153
+ if (previousClasses === undefined) {
154
+ // Write static classes once
155
+ // Use setAttribute() because className isn't a string on SVG elements
156
+ element.setAttribute('class', committer.strings.join(' '));
157
+ previousClassesCache.set(part, (previousClasses = new Set()));
158
+ }
159
+ const classList = element.classList;
160
+ // Remove old classes that no longer apply
161
+ // We use forEach() instead of for-of so that re don't require down-level
162
+ // iteration.
163
+ previousClasses.forEach((name) => {
164
+ if (!(name in classInfo)) {
165
+ classList.remove(name);
166
+ previousClasses.delete(name);
167
+ }
168
+ });
169
+ // Add or remove classes based on their classMap value
170
+ for (const name in classInfo) {
171
+ const value = classInfo[name];
172
+ if (value != previousClasses.has(name)) {
173
+ // We explicitly want a loose truthy check of `value` because it seems
174
+ // more convenient that '' and 0 are skipped.
175
+ if (value) {
176
+ classList.add(name);
177
+ previousClasses.add(name);
178
+ } else {
179
+ classList.remove(name);
180
+ previousClasses.delete(name);
181
+ }
182
+ }
183
+ }
184
+ if (typeof classList.commit === 'function') {
185
+ classList.commit();
186
+ }
187
+ })
188
+ : (classes) => {
189
+ let value = '';
190
+ for (const key in classes) {
191
+ if (classes[key]) {
192
+ value += `${value.length ? ' ' : ''}${key}`;
193
+ }
194
+ }
195
+ return value;
196
+ };
197
+
198
+ const logError = (msg) => {
199
+ if (isBrowser ? window.__DEV__ : global.__DEV) {
200
+ console.warn(msg);
201
+ }
202
+ };
203
+
204
+ const validator = (type, validate) => (innerType) => {
205
+ const isPrimitiveType = ['number', 'string', 'boolean'].includes(type);
206
+ const common = {
207
+ type: type,
208
+ parse: isPrimitiveType ? (attr) => attr : (attr) => (attr ? JSON.parse(attr.replace(/'/g, `"`)) : null),
209
+ validate: (context, data) => {
210
+ if (data === null || typeof data === 'undefined') {
211
+ if (common.__required) {
212
+ logError(`'${context}' Field is required`);
213
+ }
214
+ return;
215
+ }
216
+ if (!isPrimitiveType) {
217
+ validate(innerType, context, data);
218
+ } else {
219
+ const dataType = typeof data;
220
+ if (dataType !== type) {
221
+ logError(`'${context}' Expected type '${type}' got type '${dataType}'`);
222
+ }
223
+ }
224
+ },
225
+ };
226
+ common.required = () => {
227
+ common.__required = true;
228
+ return common;
229
+ };
230
+ common.default = (fnOrValue) => {
231
+ common.__default = fnOrValue;
232
+ return common;
233
+ };
234
+ common.compute = (...args) => {
235
+ const fn = args[args.length - 1];
236
+ const deps = args.slice(0, args.length - 1);
237
+ common.__compute = {
238
+ fn,
239
+ deps,
240
+ };
241
+ return common;
242
+ };
243
+ return common;
244
+ };
245
+
246
+ export const number = validator('number');
247
+ export const string = validator('string');
248
+ export const boolean = validator('boolean');
249
+ export const object = validator('object', (innerType, context, data) => {
250
+ if (data.constructor !== Object) {
251
+ logError(`'${context}' Expected object literal '{}' got '${typeof data}'`);
252
+ }
253
+ for (const key of Object.keys(innerType)) {
254
+ const fieldValidator = innerType[key];
255
+ const item = data[key];
256
+ fieldValidator.validate(`${context}.${key}`, item);
257
+ }
258
+ });
259
+ export const array = validator('array', (innerType, context, data) => {
260
+ if (!Array.isArray(data)) {
261
+ logError(`Expected Array got ${data}`);
262
+ }
263
+ for (let i = 0; i < data.length; i++) {
264
+ const item = data[i];
265
+ innerType.validate(`${context}[${i}]`, item);
266
+ }
267
+ });
268
+
269
+ const fifo = (q) => q.shift();
270
+ const filo = (q) => q.pop();
271
+ const microtask = (flush) => () => queueMicrotask(flush);
272
+ const task = (flush) => {
273
+ if (isBrowser) {
274
+ const ch = new window.MessageChannel();
275
+ ch.port1.onmessage = flush;
276
+ return () => ch.port2.postMessage(null);
277
+ } else {
278
+ return () => setImmediate(flush);
279
+ }
280
+ };
281
+
282
+ const registry = {};
283
+ const BaseElement = isBrowser ? window.HTMLElement : class {};
284
+
285
+ export class AtomsElement extends BaseElement {
286
+ static register() {
287
+ registry[this.name] = this;
288
+ if (isBrowser) {
289
+ if (window.customElements.get(this.name)) {
290
+ return;
291
+ } else {
292
+ window.customElements.define(this.name, registry[this.name]);
293
+ }
294
+ }
295
+ }
296
+
297
+ static getElement(name) {
298
+ return registry[name];
299
+ }
300
+
301
+ static get observedAttributes() {
302
+ if (!this.attrTypes) {
303
+ return [];
304
+ }
305
+ return Object.keys(this.attrTypes).map((k) => k.toLowerCase());
306
+ }
307
+
308
+ constructor(ssrAttributes) {
309
+ super();
310
+ this._dirty = false;
311
+ this._connected = false;
312
+ this._state = {};
313
+ this.ssrAttributes = ssrAttributes;
314
+ this.config = isBrowser ? window.config : global.config;
315
+ this.location = isBrowser ? window.location : global.location;
316
+ this.stylesMounted = false;
317
+ }
318
+
319
+ connectedCallback() {
320
+ this._connected = true;
321
+ if (isBrowser) {
322
+ this.update();
323
+ }
324
+ }
325
+ disconnectedCallback() {
326
+ this._connected = false;
327
+ }
328
+
329
+ attributeChangedCallback(key, oldValue, newValue) {
330
+ if (this._connected) {
331
+ this.update();
332
+ }
333
+ }
334
+
335
+ update() {
336
+ if (this._dirty) {
337
+ return;
338
+ }
339
+ this._dirty = true;
340
+ this.enqueueUpdate();
341
+ }
342
+
343
+ _performUpdate() {
344
+ if (!this._connected) {
345
+ return;
346
+ }
347
+ this.renderTemplate();
348
+ this._dirty = false;
349
+ }
350
+
351
+ batch(runner, pick, callback) {
352
+ const q = [];
353
+ const flush = () => {
354
+ let p;
355
+ while ((p = pick(q))) callback(p);
356
+ };
357
+ const run = runner(flush);
358
+ q.push(this) === 1 && run();
359
+ }
360
+
361
+ enqueueUpdate() {
362
+ this.batch(microtask, fifo, () => this._performUpdate());
363
+ }
364
+
365
+ get attrs() {
366
+ return Object.keys(this.constructor.attrTypes).reduceRight((acc, key) => {
367
+ const attrType = this.constructor.attrTypes[key];
368
+ const newValue = isBrowser ? this.getAttribute(key.toLowerCase()) : this.ssrAttributes.find((item) => item.name === key.toLowerCase())?.value;
369
+ const data = attrType.parse(newValue);
370
+ attrType.validate(`<${this.constructor.name}> ${key}`, data);
371
+ acc[key] = data;
372
+ return acc;
373
+ }, {});
374
+ }
375
+
376
+ get state() {
377
+ return Object.keys(this.constructor.stateTypes).reduceRight((acc, key) => {
378
+ const stateType = this.constructor.stateTypes[key];
379
+ if (!this._state[key] && typeof stateType.__default !== 'undefined') {
380
+ this._state[key] = typeof stateType.__default === 'function' ? stateType.__default(this.attrs, this._state) : stateType.__default;
381
+ }
382
+ acc[key] = this._state[key];
383
+ acc[`set${key[0].toUpperCase()}${key.slice(1)}`] = (v) => {
384
+ // TODO: check type on set
385
+ this._state[key] = typeof v === 'function' ? v(this._state[key]) : v;
386
+ this.update();
387
+ };
388
+ return acc;
389
+ }, {});
390
+ }
391
+
392
+ get computed() {
393
+ return Object.keys(this.constructor.computedTypes).reduceRight((acc, key) => {
394
+ const type = this.constructor.computedTypes[key];
395
+ const state = this.state;
396
+ const values = type.__compute.deps.reduce((acc, key) => {
397
+ if (typeof state[key] !== undefined) {
398
+ acc.push(state[key]);
399
+ }
400
+ return acc;
401
+ }, []);
402
+ acc[key] = type.__compute.fn(...values);
403
+ return acc;
404
+ }, {});
405
+ }
406
+
407
+ renderTemplate() {
408
+ const template = this.render();
409
+ const result = render(template, this);
410
+ if (isBrowser) {
411
+ if (!this.stylesMounted) {
412
+ this.appendChild(document.createElement('style')).textContent = this.constructor.styles.toString();
413
+ this.stylesMounted = true;
414
+ }
415
+ } else {
416
+ return `
417
+ ${result}
418
+ <style>
419
+ ${this.constructor.styles.toString()}
420
+ </style>
421
+ `;
422
+ }
423
+ }
424
+ }
425
+
426
+ export const createElement = ({ name, attrTypes, stateTypes, computedTypes, styles, render }) => {
427
+ const Element = class extends AtomsElement {
428
+ static name = name();
429
+
430
+ static attrTypes = attrTypes();
431
+
432
+ static stateTypes = stateTypes();
433
+
434
+ static computedTypes = computedTypes();
435
+
436
+ static styles = styles;
437
+
438
+ constructor(ssrAttributes) {
439
+ super(ssrAttributes);
440
+ }
441
+ render() {
442
+ return render({
443
+ attrs: this.attrs,
444
+ state: this.state,
445
+ computed: this.computed,
446
+ });
447
+ }
448
+ };
449
+ Element.register();
450
+ return { name, attrTypes, stateTypes, computedTypes, styles, render };
451
+ };
element.test.js ADDED
@@ -0,0 +1,366 @@
1
+ import { expect, test, jest } from '@jest/globals';
2
+ import { AtomsElement, html, render, number, boolean, string, array, object, unsafeHTML, css, classMap } from './element.js';
3
+
4
+ global.__DEV = true;
5
+
6
+ const primitives = [
7
+ {
8
+ type: 'number',
9
+ valid: [123, 40.5, 6410],
10
+ invalid: ['123', false, {}, [], new Date()],
11
+ },
12
+ {
13
+ type: 'boolean',
14
+ valid: [false, true],
15
+ invalid: ['123', 123, {}, [], new Date()],
16
+ },
17
+ {
18
+ type: 'string',
19
+ valid: ['', '123'],
20
+ invalid: [123, false, {}, [], new Date()],
21
+ },
22
+ ];
23
+ const primitiveTypes = {
24
+ number: number,
25
+ boolean: boolean,
26
+ string: string,
27
+ };
28
+
29
+ primitives.forEach((value) =>
30
+ it(`${value.type}`, () => {
31
+ const spy = jest.spyOn(global.console, 'warn').mockImplementation();
32
+ const context = 'key';
33
+ const validator = primitiveTypes[value.type]();
34
+ const validatorReq = primitiveTypes[value.type]().required();
35
+ expect(validator.type).toEqual(value.type);
36
+ expect(validator.__required).toEqual(undefined);
37
+ expect(validatorReq.__required).toEqual(true);
38
+ validator.validate(context);
39
+ for (const v of value.valid) {
40
+ validator.validate(context, v);
41
+ validatorReq.validate(context, v);
42
+ }
43
+ validatorReq.validate(context);
44
+ expect(console.warn).toHaveBeenCalledWith(`'key' Field is required`);
45
+ for (const v of value.invalid) {
46
+ validator.validate(context, v);
47
+ expect(console.warn).toHaveBeenCalledWith(`'key' Expected type '${value.type}' got type '${typeof v}'`);
48
+ }
49
+ spy.mockRestore();
50
+ }),
51
+ );
52
+
53
+ test('object', () => {
54
+ const spy = jest.spyOn(global.console, 'warn').mockImplementation();
55
+ const context = 'data';
56
+ object({}).validate(context, { name: '123' });
57
+ object({ name: string() }).validate(context, { name: '123' });
58
+ object({ name: string().required() }).validate(context, { name: '' });
59
+ object({ name: string().required() }).validate(context, {});
60
+ expect(console.warn).toHaveBeenCalledWith(`'data.name' Field is required`);
61
+
62
+ const schema = object({
63
+ address: object({
64
+ street: string(),
65
+ }),
66
+ });
67
+ schema.validate(context, {});
68
+ schema.validate(context, '123');
69
+ expect(console.warn).toHaveBeenCalledWith(`'data' Expected object literal '{}' got 'string'`);
70
+ schema.validate(context, {
71
+ address: {},
72
+ });
73
+ schema.validate(context, {
74
+ address: '123',
75
+ });
76
+ expect(console.warn).toHaveBeenCalledWith(`'data.address' Expected object literal '{}' got 'string'`);
77
+ schema.validate(context, {
78
+ address: {
79
+ street: 'avenue 1',
80
+ },
81
+ });
82
+ schema.validate(context, {
83
+ address: {
84
+ street: false,
85
+ },
86
+ });
87
+ expect(console.warn).toHaveBeenCalledWith(`'data.address.street' Expected type 'string' got type 'boolean'`);
88
+
89
+ const schema2 = object({
90
+ address: object({
91
+ street: string().required(),
92
+ }),
93
+ });
94
+ schema2.validate(context, {});
95
+ schema2.validate(context, {
96
+ address: {
97
+ street: '11th avenue',
98
+ },
99
+ });
100
+ schema2.validate(context, {
101
+ address: {},
102
+ });
103
+ expect(console.warn).toHaveBeenCalledWith(`'data.address.street' Field is required`);
104
+ spy.mockRestore();
105
+ });
106
+
107
+ test('array', () => {
108
+ const spy = jest.spyOn(global.console, 'warn').mockImplementation();
109
+ const context = 'items';
110
+ array(string()).validate(context, ['123']);
111
+ array(string()).validate(context, [123]);
112
+ expect(console.warn).toHaveBeenCalledWith(`'items[0]' Expected type 'string' got type 'number'`);
113
+ array(array(string())).validate(context, [['123']]);
114
+ array(array(string())).validate(context, [[123]]);
115
+ expect(console.warn).toHaveBeenCalledWith(`'items[0][0]' Expected type 'string' got type 'number'`);
116
+
117
+ const schema = object({
118
+ street: string().required(),
119
+ });
120
+ array(schema).validate(context, []);
121
+ array(schema).validate(context, [{ street: '123' }, { street: '456' }, { street: '789' }]);
122
+ array(schema).validate(context, [{}]);
123
+ expect(console.warn).toHaveBeenCalledWith(`'items[0].street' Field is required`);
124
+ array(schema).validate(context, [{ street: false }]);
125
+ expect(console.warn).toHaveBeenCalledWith(`'items[0].street' Expected type 'string' got type 'boolean'`);
126
+ array(schema).validate(context, [{ street: '123' }, {}]);
127
+ expect(console.warn).toHaveBeenCalledWith(`'items[1].street' Field is required`);
128
+ array(schema).validate(context, [{ street: '123' }, { street: false }]);
129
+ expect(console.warn).toHaveBeenCalledWith(`'items[1].street' Expected type 'string' got type 'boolean'`);
130
+ spy.mockRestore();
131
+ });
132
+
133
+ test('css', () => {
134
+ const styles = css({
135
+ button: {
136
+ color: 'magenta',
137
+ fontSize: '10px',
138
+ '@media screen and (min-width:40em)': {
139
+ fontSize: '64px',
140
+ },
141
+ ':hover': {
142
+ color: 'black',
143
+ },
144
+ '@media screen and (min-width:56em)': {
145
+ ':hover': {
146
+ color: 'navy',
147
+ },
148
+ },
149
+ },
150
+ container: {
151
+ flex: 1,
152
+ alignItems: 'center',
153
+ justifyContent: 'center',
154
+ },
155
+ });
156
+ expect(styles.toString()).toEqual(`.button-1t9ijgh {
157
+ color: magenta;
158
+ font-size: 10px;
159
+
160
+ @media screen and (min-width:40em) {
161
+ font-size: 64px;
162
+
163
+ }
164
+ :hover {
165
+ color: black;
166
+
167
+ }
168
+ @media screen and (min-width:56em) {
169
+
170
+ :hover {
171
+ color: navy;
172
+
173
+ }
174
+ }
175
+ }
176
+
177
+ .container-1dvem0h {
178
+ flex: 1;
179
+ align-items: center;
180
+ justify-content: center;
181
+
182
+ }
183
+
184
+ `);
185
+ });
186
+
187
+ test('render', async () => {
188
+ const age = 1;
189
+ const data = { name: '123', address: { street: '1' } };
190
+ const items = [1, 2, 3];
191
+ const highlight = 'high';
192
+ const template = html`
193
+ <div>
194
+ <app-counter name="123" class="abc ${highlight}" age=${age} details1=${data} items=${items}></app-counter>
195
+ </div>
196
+ `;
197
+ const res = await render(template);
198
+ expect(res).toEqual(`
199
+ <div>
200
+ <app-counter name="123" class="abc high" age="1" details1="{'name':'123','address':{'street':'1'}}" items="[1,2,3]"></app-counter>
201
+ </div>
202
+ `);
203
+ });
204
+
205
+ test('render attribute keys', async () => {
206
+ const template = html`
207
+ <div>
208
+ <app-counter name="123" perPage="1"></app-counter>
209
+ </div>
210
+ `;
211
+ const res = await render(template);
212
+ expect(res).toEqual(`
213
+ <div>
214
+ <app-counter name="123" perPage="1"></app-counter>
215
+ </div>
216
+ `);
217
+ });
218
+
219
+ test('render attributes within quotes', async () => {
220
+ const age = 1;
221
+ const data = { name: '123', address: { street: '1' } };
222
+ const items = [1, 2, 3];
223
+ const classes = 'high';
224
+ const template = html`
225
+ <div>
226
+ <app-counter name="123" class=${classes} age="${age}" details1="${data}" items="${items}"></app-counter>
227
+ </div>
228
+ `;
229
+ const res = await render(template);
230
+ expect(res).toEqual(`
231
+ <div>
232
+ <app-counter name="123" class="high" age="1" details1="{'name':'123','address':{'street':'1'}}" items="[1,2,3]"></app-counter>
233
+ </div>
234
+ `);
235
+ });
236
+
237
+ test('render unsafeHTML', async () => {
238
+ const textContent = `<div><p class="123">this is unsafe</p></div>`;
239
+ const template = html` <div>${unsafeHTML(textContent)}</div> `;
240
+ const res = await render(template);
241
+ expect(res).toEqual(` <div><div><p class="123">this is unsafe</p></div></div> `);
242
+ });
243
+
244
+ test('render classMap show', async () => {
245
+ const hide = false;
246
+ const template = html` <div class="abc ${classMap({ show: !hide })}"></div> `;
247
+ const res = await render(template);
248
+ expect(res).toEqual(` <div class="abc show"></div> `);
249
+ });
250
+
251
+ test('render classMap hide', async () => {
252
+ const hide = true;
253
+ const template = html` <div class="abc ${classMap({ show: !hide })}"></div> `;
254
+ const res = await render(template);
255
+ expect(res).toEqual(` <div class="abc "></div> `);
256
+ });
257
+
258
+ test('render single template', async () => {
259
+ const template = html` <div>${html`NoCountry ${false}`}</div> `;
260
+ const res = await render(template);
261
+ expect(res).toEqual(` <div>NoCountry false</div> `);
262
+ });
263
+
264
+ test('render multi template', async () => {
265
+ const template = html`
266
+ <div>
267
+ ${[1, 2].map(
268
+ (v) => html`
269
+ <app-item meta="${{ index: v }}" @click=${() => {}} .handleClick=${() => {}}>
270
+ <button @click=${() => {}}>+</button>
271
+ </app-item>
272
+ `,
273
+ )}
274
+ </div>
275
+ `;
276
+ const res = await render(template);
277
+ expect(res).toEqual(`
278
+ <div>
279
+
280
+ <app-item meta="{'index':1}">
281
+ <button>+</button>
282
+ </app-item>
283
+
284
+ <app-item meta="{'index':2}">
285
+ <button>+</button>
286
+ </app-item>
287
+
288
+ </div>
289
+ `);
290
+ });
291
+
292
+ test('AtomsElement', async () => {
293
+ class AppItem extends AtomsElement {
294
+ static name = 'app-item';
295
+
296
+ static attrTypes = {
297
+ perPage: string().required(),
298
+ address: object({
299
+ street: string().required(),
300
+ }).required(),
301
+ };
302
+
303
+ static stateTypes = {
304
+ count: number().required().default(0),
305
+ };
306
+
307
+ static computedTypes = {
308
+ sum: number()
309
+ .required()
310
+ .compute('count', (count) => {
311
+ return count + 10;
312
+ }),
313
+ };
314
+
315
+ static styles = css({
316
+ div: {
317
+ color: 'red',
318
+ },
319
+ });
320
+
321
+ render() {
322
+ const {
323
+ perPage,
324
+ address: { street },
325
+ } = this.attrs;
326
+ const { count, setCount } = this.state;
327
+ const { sum } = this.computed;
328
+ return html`
329
+ <div perPage=${perPage}>
330
+ <p>street: ${street}</p>
331
+ <p>count: ${count}</p>
332
+ <p>sum: ${sum}</p>
333
+ ${this.renderItem()}
334
+ </div>
335
+ `;
336
+ }
337
+ }
338
+ AppItem.register();
339
+ const Clazz = AtomsElement.getElement('app-item');
340
+ expect(Clazz.name).toEqual(AppItem.name);
341
+ const instance = new AppItem([
342
+ { name: 'address', value: JSON.stringify({ street: '123' }).replace(/"/g, `'`) },
343
+ { name: 'perpage', value: '1' },
344
+ ]);
345
+ instance.renderItem = () => html`<div><p>render item 1</p></div>`;
346
+ expect(AppItem.observedAttributes).toEqual(['perpage', 'address']);
347
+ const res = instance.renderTemplate();
348
+ expect(res).toEqual(`
349
+
350
+ <div perPage="1">
351
+ <p>street: 123</p>
352
+ <p>count: 0</p>
353
+ <p>sum: 10</p>
354
+ <div><p>render item 1</p></div>
355
+ </div>
356
+
357
+ <style>
358
+ .div-1gao8uk {
359
+ color: red;
360
+
361
+ }
362
+
363
+
364
+ </style>
365
+ `);
366
+ });
example/app-counter.js ADDED
@@ -0,0 +1,91 @@
1
+ import { createElement, html, css, object, number, string } from '../element.js';
2
+
3
+ const name = () => 'app-counter';
4
+
5
+ const attrTypes = () => ({
6
+ name: string().required(),
7
+ meta: object({
8
+ start: number(),
9
+ }),
10
+ });
11
+
12
+ const stateTypes = () => ({
13
+ count: number()
14
+ .required()
15
+ .default((attrs) => attrs.meta?.start || 0),
16
+ });
17
+
18
+ const computedTypes = () => ({
19
+ sum: number()
20
+ .required()
21
+ .compute('count', (count) => {
22
+ return count + 10;
23
+ }),
24
+ });
25
+
26
+ const styles = css({
27
+ title: {
28
+ fontSize: '20px',
29
+ marginBottom: '0.5rem',
30
+ textAlign: 'center',
31
+ },
32
+ span: {
33
+ fontSize: '16px',
34
+ },
35
+ container: {
36
+ display: 'flex',
37
+ flex: 1,
38
+ flexDirection: 'row',
39
+ fontSize: '32px',
40
+ color: 'rgba(55, 65, 81, 1)',
41
+ },
42
+ mx: {
43
+ marginLeft: '5rem',
44
+ marginRight: '5rem',
45
+ fontSize: '30px',
46
+ fontFamily: `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace`,
47
+ },
48
+ button: {
49
+ paddingTop: '0.5rem',
50
+ paddingBottom: '0.5rem',
51
+ paddingLeft: '1rem',
52
+ paddingRight: '1rem',
53
+ color: 'rgba(55, 65, 81, 1)',
54
+ borderRadius: '0.25rem',
55
+ backgroundColor: 'rgba(209, 213, 219, 1)',
56
+ },
57
+ });
58
+
59
+ const render = ({ attrs, state, computed }) => {
60
+ const { name, meta } = attrs;
61
+ const { count, setCount } = state;
62
+ const { sum } = computed;
63
+
64
+ return html`
65
+ <div>
66
+ <div class=${styles.title}>
67
+ Counter: ${name}
68
+ <span class=${styles.span}>starts at ${meta?.start}</span>
69
+ </div>
70
+ <div class=${styles.container}>
71
+ <button class=${styles.button} @click=${() => setCount((v) => v - 1)}>-</button>
72
+ <div class=${styles.mx}>
73
+ <h1>${count}</h1>
74
+ </div>
75
+ <button class=${styles.button} @click=${() => setCount((v) => v + 1)}>+</button>
76
+ </div>
77
+ <div class=${styles.mx}>
78
+ <h1>Sum: ${sum}</h1>
79
+ </div>
80
+ </div>
81
+ `;
82
+ };
83
+
84
+ export default createElement({
85
+ name,
86
+ attrTypes,
87
+ stateTypes,
88
+ computedTypes,
89
+ styles,
90
+ render,
91
+ });
example/index.js ADDED
@@ -0,0 +1,38 @@
1
+ import { html, css } from '../element.js';
2
+ import { createPage } from '../page.js';
3
+ import './app-counter.js';
4
+
5
+ const route = () => {
6
+ return '/counter';
7
+ };
8
+
9
+ const styles = css({
10
+ center: {
11
+ display: 'flex',
12
+ flex: 1,
13
+ alignItems: 'center',
14
+ justifyContent: 'center',
15
+ },
16
+ });
17
+
18
+ const head = ({ config }) => {
19
+ return html`
20
+ <title>${config.title}</title>
21
+ <link href="/styles.css" rel="stylesheet" as="style" />
22
+ `;
23
+ };
24
+
25
+ const body = () => {
26
+ return html`
27
+ <div class=${styles.center}>
28
+ <app-counter name="1" meta="{'start': 5}"></app-counter>
29
+ </div>
30
+ `;
31
+ };
32
+
33
+ export default createPage({
34
+ route,
35
+ styles,
36
+ head,
37
+ body,
38
+ });
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>`));
example/server.js ADDED
@@ -0,0 +1,45 @@
1
+ import http from 'http';
2
+ import fs from 'fs';
3
+ import { fileURLToPath } from 'url';
4
+ import path, { dirname } from 'path';
5
+ import renderIndex from './index.js';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = dirname(__filename);
9
+
10
+ const port = process.argv[2] || 3000;
11
+ const map = {
12
+ '/element.js': `${__dirname}/../element.js`,
13
+ '/lit-html.js': `${__dirname}/../lit-html.js`,
14
+ '/styles.css': `${__dirname}/styles.css`,
15
+ '/app-counter.js': `${__dirname}/app-counter.js`,
16
+ '.js': 'text/javascript',
17
+ '.css': 'text/css',
18
+ };
19
+
20
+ http
21
+ .createServer(function (req, res) {
22
+ if (req.url === '/') {
23
+ res.statusCode = 200;
24
+ res.setHeader('Content-type', 'text/html');
25
+ const html = renderIndex({
26
+ config: { lang: 'en', title: 'Counter App' },
27
+ headScript: '',
28
+ bodyScript: `
29
+ <script type="module">
30
+ import './app-counter.js';
31
+ </script>
32
+ `,
33
+ });
34
+ res.end(html);
35
+ return;
36
+ }
37
+ const filename = map[req.url];
38
+ const data = fs.readFileSync(filename);
39
+ const ext = path.parse(filename).ext;
40
+ res.setHeader('Content-type', map[ext] || 'text/plain');
41
+ res.end(data);
42
+ })
43
+ .listen(parseInt(port));
44
+
45
+ console.log(`Server listening on port ${port}`);
example/styles.css ADDED
@@ -0,0 +1,567 @@
1
+ /*! tailwindcss v2.1.2 | MIT License | https://tailwindcss.com */
2
+
3
+ /*! modern-normalize v1.1.0 | MIT License | https://github.com/sindresorhus/modern-normalize */
4
+
5
+ /*
6
+ Document
7
+ ========
8
+ */
9
+
10
+ /**
11
+ Use a better box model (opinionated).
12
+ */
13
+
14
+ *,
15
+ ::before,
16
+ ::after {
17
+ box-sizing: border-box;
18
+ }
19
+
20
+ /**
21
+ Use a more readable tab size (opinionated).
22
+ */
23
+
24
+ html {
25
+ -moz-tab-size: 4;
26
+ -o-tab-size: 4;
27
+ tab-size: 4;
28
+ }
29
+
30
+ /**
31
+ 1. Correct the line height in all browsers.
32
+ 2. Prevent adjustments of font size after orientation changes in iOS.
33
+ */
34
+
35
+ html {
36
+ line-height: 1.15; /* 1 */
37
+ -webkit-text-size-adjust: 100%; /* 2 */
38
+ }
39
+
40
+ /*
41
+ Sections
42
+ ========
43
+ */
44
+
45
+ /**
46
+ Remove the margin in all browsers.
47
+ */
48
+
49
+ body {
50
+ margin: 0;
51
+ }
52
+
53
+ /**
54
+ Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3)
55
+ */
56
+
57
+ body {
58
+ font-family: system-ui, -apple-system, /* Firefox supports this but not yet `system-ui` */ 'Segoe UI', Roboto, Helvetica, Arial, sans-serif,
59
+ 'Apple Color Emoji', 'Segoe UI Emoji';
60
+ }
61
+
62
+ /*
63
+ Grouping content
64
+ ================
65
+ */
66
+
67
+ /**
68
+ 1. Add the correct height in Firefox.
69
+ 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
70
+ */
71
+
72
+ hr {
73
+ height: 0; /* 1 */
74
+ color: inherit; /* 2 */
75
+ }
76
+
77
+ /*
78
+ Text-level semantics
79
+ ====================
80
+ */
81
+
82
+ /**
83
+ Add the correct text decoration in Chrome, Edge, and Safari.
84
+ */
85
+
86
+ abbr[title] {
87
+ -webkit-text-decoration: underline dotted;
88
+ text-decoration: underline dotted;
89
+ }
90
+
91
+ /**
92
+ Add the correct font weight in Edge and Safari.
93
+ */
94
+
95
+ b,
96
+ strong {
97
+ font-weight: bolder;
98
+ }
99
+
100
+ /**
101
+ 1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3)
102
+ 2. Correct the odd 'em' font sizing in all browsers.
103
+ */
104
+
105
+ code,
106
+ kbd,
107
+ samp,
108
+ pre {
109
+ font-family: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; /* 1 */
110
+ font-size: 1em; /* 2 */
111
+ }
112
+
113
+ /**
114
+ Add the correct font size in all browsers.
115
+ */
116
+
117
+ small {
118
+ font-size: 80%;
119
+ }
120
+
121
+ /**
122
+ Prevent 'sub' and 'sup' elements from affecting the line height in all browsers.
123
+ */
124
+
125
+ sub,
126
+ sup {
127
+ font-size: 75%;
128
+ line-height: 0;
129
+ position: relative;
130
+ vertical-align: baseline;
131
+ }
132
+
133
+ sub {
134
+ bottom: -0.25em;
135
+ }
136
+
137
+ sup {
138
+ top: -0.5em;
139
+ }
140
+
141
+ /*
142
+ Tabular data
143
+ ============
144
+ */
145
+
146
+ /**
147
+ 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
148
+ 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
149
+ */
150
+
151
+ table {
152
+ text-indent: 0; /* 1 */
153
+ border-color: inherit; /* 2 */
154
+ }
155
+
156
+ /*
157
+ Forms
158
+ =====
159
+ */
160
+
161
+ /**
162
+ 1. Change the font styles in all browsers.
163
+ 2. Remove the margin in Firefox and Safari.
164
+ */
165
+
166
+ button,
167
+ input,
168
+ optgroup,
169
+ select,
170
+ textarea {
171
+ /* font-family: inherit; */ /* 1 */
172
+ font-size: 100%; /* 1 */
173
+ /* line-height: 1.15; */ /* 1 */
174
+ margin: 0; /* 2 */
175
+ }
176
+
177
+ /**
178
+ Remove the inheritance of text transform in Edge and Firefox.
179
+ 1. Remove the inheritance of text transform in Firefox.
180
+ */
181
+
182
+ button,
183
+ select {
184
+ /* 1 */
185
+ /* text-transform: none; */
186
+ }
187
+
188
+ /**
189
+ Correct the inability to style clickable types in iOS and Safari.
190
+ */
191
+
192
+ button,
193
+ [type='button'],
194
+ [type='reset'],
195
+ [type='submit'] {
196
+ /* -webkit-appearance: button; */
197
+ }
198
+
199
+ /**
200
+ Remove the inner border and padding in Firefox.
201
+ */
202
+
203
+ ::-moz-focus-inner {
204
+ border-style: none;
205
+ padding: 0;
206
+ }
207
+
208
+ /**
209
+ Restore the focus styles unset by the previous rule.
210
+ */
211
+
212
+ :-moz-focusring {
213
+ outline: 1px dotted ButtonText;
214
+ }
215
+
216
+ /**
217
+ Remove the additional ':invalid' styles in Firefox.
218
+ See: https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737
219
+ */
220
+
221
+ :-moz-ui-invalid {
222
+ box-shadow: none;
223
+ }
224
+
225
+ /**
226
+ Remove the padding so developers are not caught out when they zero out 'fieldset' elements in all browsers.
227
+ */
228
+
229
+ legend {
230
+ padding: 0;
231
+ }
232
+
233
+ /**
234
+ Add the correct vertical alignment in Chrome and Firefox.
235
+ */
236
+
237
+ progress {
238
+ vertical-align: baseline;
239
+ }
240
+
241
+ /**
242
+ Correct the cursor style of increment and decrement buttons in Safari.
243
+ */
244
+
245
+ ::-webkit-inner-spin-button,
246
+ ::-webkit-outer-spin-button {
247
+ height: auto;
248
+ }
249
+
250
+ /**
251
+ 1. Correct the odd appearance in Chrome and Safari.
252
+ 2. Correct the outline style in Safari.
253
+ */
254
+
255
+ [type='search'] {
256
+ -webkit-appearance: textfield; /* 1 */
257
+ outline-offset: -2px; /* 2 */
258
+ }
259
+
260
+ /**
261
+ Remove the inner padding in Chrome and Safari on macOS.
262
+ */
263
+
264
+ ::-webkit-search-decoration {
265
+ -webkit-appearance: none;
266
+ }
267
+
268
+ /**
269
+ 1. Correct the inability to style clickable types in iOS and Safari.
270
+ 2. Change font properties to 'inherit' in Safari.
271
+ */
272
+
273
+ ::-webkit-file-upload-button {
274
+ -webkit-appearance: button; /* 1 */
275
+ font: inherit; /* 2 */
276
+ }
277
+
278
+ /*
279
+ Interactive
280
+ ===========
281
+ */
282
+
283
+ /*
284
+ Add the correct display in Chrome and Safari.
285
+ */
286
+
287
+ summary {
288
+ display: list-item;
289
+ }
290
+
291
+ /**
292
+ * Manually forked from SUIT CSS Base: https://github.com/suitcss/base
293
+ * A thin layer on top of normalize.css that provides a starting point more
294
+ * suitable for web applications.
295
+ */
296
+
297
+ /**
298
+ * Removes the default spacing and border for appropriate elements.
299
+ */
300
+
301
+ blockquote,
302
+ dl,
303
+ dd,
304
+ h1,
305
+ h2,
306
+ h3,
307
+ h4,
308
+ h5,
309
+ h6,
310
+ hr,
311
+ figure,
312
+ p,
313
+ pre {
314
+ margin: 0;
315
+ }
316
+
317
+ button {
318
+ /* background-color: transparent; */
319
+ kground-image: none;
320
+ /* background-image: none; */
321
+ }
322
+
323
+ /**
324
+ * Work around a Firefox/IE bug where the transparent `button` background
325
+ * results in a loss of the default `button` focus styles.
326
+ */
327
+
328
+ button:focus {
329
+ outline: 1px dotted;
330
+ outline: 5px auto -webkit-focus-ring-color;
331
+ }
332
+
333
+ fieldset {
334
+ margin: 0;
335
+ padding: 0;
336
+ }
337
+
338
+ ol,
339
+ ul {
340
+ list-style: none;
341
+ margin: 0;
342
+ padding: 0;
343
+ }
344
+
345
+ /**
346
+ * Tailwind custom reset styles
347
+ */
348
+
349
+ /**
350
+ * 1. Use the user's configured `sans` font-family (with Tailwind's default
351
+ * sans-serif font stack as a fallback) as a sane default.
352
+ * 2. Use Tailwind's default "normal" line-height so the user isn't forced
353
+ * to override it to ensure consistency even when using the default theme.
354
+ */
355
+
356
+ html {
357
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
358
+ 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; /* 1 */
359
+ line-height: 1.5; /* 2 */
360
+ }
361
+
362
+ /**
363
+ * Inherit font-family and line-height from `html` so users can set them as
364
+ * a class directly on the `html` element.
365
+ */
366
+
367
+ body {
368
+ font-family: inherit;
369
+ line-height: inherit;
370
+ }
371
+
372
+ /**
373
+ * 1. Prevent padding and border from affecting element width.
374
+ *
375
+ * We used to set this in the html element and inherit from
376
+ * the parent element for everything else. This caused issues
377
+ * in shadow-dom-enhanced elements like <details> where the content
378
+ * is wrapped by a div with box-sizing set to `content-box`.
379
+ *
380
+ * https://github.com/mozdevs/cssremedy/issues/4
381
+ *
382
+ *
383
+ * 2. Allow adding a border to an element by just adding a border-width.
384
+ *
385
+ * By default, the way the browser specifies that an element should have no
386
+ * border is by setting it's border-style to `none` in the user-agent
387
+ * stylesheet.
388
+ *
389
+ * In order to easily add borders to elements by just setting the `border-width`
390
+ * property, we change the default border-style for all elements to `solid`, and
391
+ * use border-width to hide them instead. This way our `border` utilities only
392
+ * need to set the `border-width` property instead of the entire `border`
393
+ * shorthand, making our border utilities much more straightforward to compose.
394
+ *
395
+ * https://github.com/tailwindcss/tailwindcss/pull/116
396
+ */
397
+
398
+ *,
399
+ ::before,
400
+ ::after {
401
+ box-sizing: border-box; /* 1 */
402
+ border-width: 0; /* 2 */
403
+ border-style: solid; /* 2 */
404
+ border-color: #e5e7eb; /* 2 */
405
+ }
406
+
407
+ /*
408
+ * Ensure horizontal rules are visible by default
409
+ */
410
+
411
+ hr {
412
+ border-top-width: 1px;
413
+ }
414
+
415
+ /**
416
+ * Undo the `border-style: none` reset that Normalize applies to images so that
417
+ * our `border-{width}` utilities have the expected effect.
418
+ *
419
+ * The Normalize reset is unnecessary for us since we default the border-width
420
+ * to 0 on all elements.
421
+ *
422
+ * https://github.com/tailwindcss/tailwindcss/issues/362
423
+ */
424
+
425
+ img {
426
+ border-style: solid;
427
+ }
428
+
429
+ textarea {
430
+ resize: vertical;
431
+ }
432
+
433
+ input::-moz-placeholder,
434
+ textarea::-moz-placeholder {
435
+ opacity: 1;
436
+ color: #9ca3af;
437
+ }
438
+
439
+ input:-ms-input-placeholder,
440
+ textarea:-ms-input-placeholder {
441
+ opacity: 1;
442
+ color: #9ca3af;
443
+ }
444
+
445
+ input::placeholder,
446
+ textarea::placeholder {
447
+ opacity: 1;
448
+ color: #9ca3af;
449
+ }
450
+
451
+ button,
452
+ [role='button'] {
453
+ cursor: pointer;
454
+ }
455
+
456
+ table {
457
+ border-collapse: collapse;
458
+ }
459
+
460
+ h1,
461
+ h2,
462
+ h3,
463
+ h4,
464
+ h5,
465
+ h6 {
466
+ font-size: inherit;
467
+ font-weight: inherit;
468
+ }
469
+
470
+ /**
471
+ * Reset links to optimize for opt-in styling instead of
472
+ * opt-out.
473
+ */
474
+
475
+ a {
476
+ color: inherit;
477
+ text-decoration: inherit;
478
+ }
479
+
480
+ /**
481
+ * Reset form element properties that are easy to forget to
482
+ * style explicitly so you don't inadvertently introduce
483
+ * styles that deviate from your design system. These styles
484
+ * supplement a partial reset that is already applied by
485
+ * normalize.css.
486
+ */
487
+
488
+ button,
489
+ input,
490
+ optgroup,
491
+ select,
492
+ textarea {
493
+ padding: 0;
494
+ line-height: inherit;
495
+ color: inherit;
496
+ }
497
+
498
+ /**
499
+ * Use the configured 'mono' font family for elements that
500
+ * are expected to be rendered with a monospace font, falling
501
+ * back to the system monospace stack if there is no configured
502
+ * 'mono' font family.
503
+ */
504
+
505
+ pre,
506
+ code,
507
+ kbd,
508
+ samp {
509
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
510
+ }
511
+
512
+ /**
513
+ * Make replaced elements `display: block` by default as that's
514
+ * the behavior you want almost all of the time. Inspired by
515
+ * CSS Remedy, with `svg` added as well.
516
+ *
517
+ * https://github.com/mozdevs/cssremedy/issues/14
518
+ */
519
+
520
+ img,
521
+ svg,
522
+ video,
523
+ canvas,
524
+ audio,
525
+ iframe,
526
+ embed,
527
+ object {
528
+ display: block;
529
+ vertical-align: middle;
530
+ }
531
+
532
+ /**
533
+ * Constrain images and videos to the parent width and preserve
534
+ * their intrinsic aspect ratio.
535
+ *
536
+ * https://github.com/mozdevs/cssremedy/issues/14
537
+ */
538
+
539
+ img,
540
+ video {
541
+ max-width: 100%;
542
+ height: auto;
543
+ }
544
+
545
+ html {
546
+ width: 100%;
547
+ height: 100%;
548
+ }
549
+
550
+ body {
551
+ background-color: white;
552
+ width: 100%;
553
+ height: 100%;
554
+ display: flex;
555
+ flex-direction: column;
556
+ flex: 1 1 0%;
557
+ min-width: 320px;
558
+ margin: 0px;
559
+ min-height: 100vh;
560
+ line-height: 1.4;
561
+ font-weight: 400;
562
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
563
+ color: rgba(44, 62, 80, 1);
564
+ direction: ltr;
565
+ font-synthesis: none;
566
+ text-rendering: optimizeLegibility;
567
+ }
src/lit-html.js → lit-html.js RENAMED
File without changes
package-lock.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "atoms-element",
3
- "version": "1.2.1",
3
+ "version": "2.1.0",
4
4
  "lockfileVersion": 2,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
- "version": "1.2.1",
8
+ "version": "2.1.0",
9
9
  "license": "MIT",
10
10
  "dependencies": {
11
11
  "parse5": "^6.0.1"
package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atoms-element",
3
- "version": "1.2.1",
3
+ "version": "2.1.0",
4
4
  "description": "A simple web component library for defining your custom elements. It works on both client and server. It supports hooks and follows the same principles of react.",
5
5
  "keywords": [
6
6
  "pyros.sh",
@@ -10,28 +10,24 @@
10
10
  "server side rendering",
11
11
  "client",
12
12
  "server",
13
- "hooks",
14
13
  "react",
15
- "lit-html",
14
+ "lit-html"
16
- "lit-html-server",
17
- "haunted",
18
- "tonic",
19
- "atomico",
20
- "fuco"
21
15
  ],
22
- "license": "MIT",
23
- "main": "src/index.js",
24
16
  "files": [
17
+ "lit-html.js",
18
+ "element.js",
19
+ "element.d.ts",
25
- "src",
20
+ "page.js",
26
- "example"
21
+ "page.d.ts"
27
22
  ],
28
- "typings": "src/index.d.ts",
23
+ "license": "MIT",
29
24
  "author": "pyros.sh",
30
25
  "type": "module",
31
26
  "engines": {
32
27
  "node": ">=14.0.0"
33
28
  },
34
29
  "scripts": {
30
+ "example": "node example/server.js",
35
31
  "test": "NODE_OPTIONS=--experimental-vm-modules jest"
36
32
  },
37
33
  "devDependencies": {
page.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ export declare type Config = {
2
+ version: string
3
+ url: string
4
+ image: string
5
+ author: string
6
+ languages: Array<string>
7
+ title: string
8
+ description: string
9
+ keywords: string
10
+ categories: Array<any>
11
+ tags: Array<any>
12
+ strings: {[key: string]: any}
13
+ themes: {[key: string]: any}
14
+ }
15
+ export declare type Data = any;
16
+ export declare type Item = any;
17
+
18
+ export class Page {
19
+ config: Config;
20
+ data: Data;
21
+ item: Item;
22
+ route: () => string;
23
+ styles: () => string;
24
+ head: () => string;
25
+ body: () => string;
26
+ }
page.js ADDED
@@ -0,0 +1,61 @@
1
+ import parse5 from 'parse5';
2
+ import { AtomsElement, render } from './element.js';
3
+
4
+ export const find = (node) => {
5
+ for (const child of node.childNodes) {
6
+ if (AtomsElement.getElement(child.tagName)) {
7
+ const Clazz = AtomsElement.getElement(child.tagName);
8
+ const instance = new Clazz(child.attrs);
9
+ const res = instance.renderTemplate();
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
+ };
25
+
26
+ export const createPage = ({ route, datapaths, head, body, styles }) => {
27
+ return ({ config, data, item, headScript, bodyScript }) => {
28
+ const isProd = process.env.NODE_ENV === 'production';
29
+ const props = { config, data, item };
30
+ const headHtml = ssr(head(props));
31
+ const bodyHtml = ssr(body(props));
32
+ return `
33
+ <!DOCTYPE html>
34
+ <html lang="${config.lang}">
35
+ <head>
36
+ <meta charset="utf-8" />
37
+ <meta http-equiv="x-ua-compatible" content="ie=edge" />
38
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
39
+ <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=5.0, shrink-to-fit=no">
40
+ <link rel="sitemap" type="application/xml" href="/sitemap.xml" />
41
+ <link rel="icon" type="image/png" href="/assets/icon.png" />
42
+ ${headHtml}
43
+ <style>
44
+ ${styles.toString()}
45
+ </style>
46
+ ${headScript}
47
+ </head>
48
+ <body>
49
+ ${bodyHtml}
50
+ <script>
51
+ window.__DEV__ = ${!isProd};
52
+ window.config = ${JSON.stringify(config)};
53
+ window.data = ${JSON.stringify(data)};
54
+ window.item = ${JSON.stringify(item)};
55
+ </script>
56
+ ${bodyScript}
57
+ </body>
58
+ </html>
59
+ `;
60
+ };
61
+ };
page.test.js ADDED
@@ -0,0 +1,85 @@
1
+ import { expect, test } from '@jest/globals';
2
+ import { html, css } from './element.js';
3
+ import { createPage } from './page.js';
4
+
5
+ test('Page', () => {
6
+ const route = () => {
7
+ const langPart = this.config.lang === 'en' ? '' : `/${this.config.lang}`;
8
+ return `${langPart}`;
9
+ };
10
+ const styles = css({
11
+ div: {
12
+ color: 'red',
13
+ },
14
+ });
15
+ const head = ({ config }) => {
16
+ return html`
17
+ <title>${config.title}</title>
18
+ <meta name="title" content=${config.title} />
19
+ <meta name="description" content=${config.title} />
20
+ `;
21
+ };
22
+
23
+ const body = ({ config }) => {
24
+ return html`
25
+ <div>
26
+ <app-header></app-header>
27
+ <main class="flex flex-1 flex-col mt-20 items-center">
28
+ <h1 class="text-5xl">${config.title}</h1>
29
+ </main>
30
+ </div>
31
+ `;
32
+ };
33
+ const renderPage = createPage({
34
+ route,
35
+ head,
36
+ body,
37
+ styles,
38
+ });
39
+ const scripts = '<script type="module"><script>';
40
+ const res = renderPage({ config: { lang: 'en', title: '123' }, headScript: scripts, bodyScript: scripts });
41
+ expect(res).toEqual(`
42
+ <!DOCTYPE html>
43
+ <html lang="en">
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
+
52
+ <title>123</title>
53
+ <meta name="title" content="123">
54
+ <meta name="description" content="123">
55
+
56
+ <style>
57
+ .div-1gao8uk {
58
+ color: red;
59
+
60
+ }
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
+ });
readme.md CHANGED
@@ -3,12 +3,10 @@
3
3
  [![Version](https://img.shields.io/npm/v/atoms-element?style=flat-square&color=blue)](https://www.npmjs.com/package/atoms-element)
4
4
  ![Test](https://github.com/pyros2097/atoms-element/actions/workflows/main.yml/badge.svg)
5
5
 
6
- A simple web component library for defining your custom elements. It works on both client and server. It supports hooks and follows the same
7
- principles of react. Data props are attributes on the custom element by default so its easier to debug and functions/handlers are
8
- methods attached to the element.
6
+ A simple web component library for defining your custom elements. It works on both client and server. It follows the same principles of react. Data props are attributes on the custom element by default so its easier to debug and functions/handlers are attached to the element.
9
7
 
10
8
  I initially started researching if it was possible to server render web components but found out not one framework supported it. I liked using
11
- [haunted](https://github.com/matthewp/haunted) as it was react like with hooks but was lost on how to implement server rendering. Libraries like
9
+ [haunted](https://github.com/matthewp/haunted) as it was react-like with hooks but was lost on how to implement server rendering. Libraries like
12
10
  JSDOM couldn't be of use since it didn't support web components and I didn't want to use puppeteer for something like this.
13
11
 
14
12
  After a year of thinking about it and researching it I found out this awesome framework [Tonic](https://github.com/optoolco/tonic).
@@ -23,7 +21,7 @@ After going through all these libraries,
23
21
  5. [Atomico](https://github.com/atomicojs/atomico)
24
22
  6. [fuco](https://github.com/wtnbass/fuco)
25
23
 
26
- And figuring out how each one implemented their on custom elements I came up with atoms-element. It still doesn't have proper rehydration lit-html just replaces the DOM under the web component for now. Atomico has implemented proper SSR with hydration so I might need to look into that in the future or
24
+ And figuring out how each one implemented their on custom elements I came up with atoms-element. It still doesn't have proper rehydration lit-html just replaces the DOM under the server rendered web component for now. Atomico has implemented proper SSR with hydration so I might need to look into that in the future or
27
25
  use it instead of lit-html. Since I made a few modifications like json attributes and attrTypes I don't know if it will easy.
28
26
 
29
27
  ## Installation
@@ -35,38 +33,102 @@ npm i atoms-element
35
33
  ## Example
36
34
 
37
35
  ```js
38
- import { defineElement, html, object, number, string, useState } from 'atoms-element';
36
+ import { createElement, html, css, object, number, string } from 'atoms-element/element.js';
39
- import { ssr } from 'atoms-element/src/ssr.js';
37
+ import { ssr } from 'atoms-element/page.js';
40
38
 
39
+ const name = () => 'app-counter';
40
+
41
- const attrTypes = {
41
+ const attrTypes = () => ({
42
- name: string.isRequired,
42
+ name: string().required(),
43
43
  meta: object({
44
- start: number,
44
+ start: number(),
45
45
  }),
46
- };
46
+ });
47
+
47
-
48
+ const stateTypes = () => ({
49
+ count: number()
50
+ .required()
51
+ .default((attrs) => attrs.meta?.start || 0),
52
+ });
53
+
54
+ const computedTypes = () => ({
55
+ sum: number()
56
+ .required()
57
+ .compute('count', (count) => {
58
+ return count + 10;
59
+ }),
60
+ });
61
+
62
+ const styles = css({
63
+ title: {
64
+ fontSize: '20px',
65
+ marginBottom: '0.5rem',
66
+ textAlign: 'center',
67
+ },
68
+ span: {
69
+ fontSize: '16px',
70
+ },
71
+ container: {
72
+ display: 'flex',
73
+ flex: 1,
74
+ flexDirection: 'row',
75
+ fontSize: '32px',
76
+ color: 'rgba(55, 65, 81, 1)',
77
+ },
78
+ mx: {
79
+ marginLeft: '5rem',
80
+ marginRight: '5rem',
81
+ fontSize: '30px',
82
+ fontFamily: `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace`,
83
+ },
84
+ button: {
85
+ paddingTop: '0.5rem',
86
+ paddingBottom: '0.5rem',
87
+ paddingLeft: '1rem',
88
+ paddingRight: '1rem',
89
+ color: 'rgba(55, 65, 81, 1)',
90
+ borderRadius: '0.25rem',
91
+ backgroundColor: 'rgba(209, 213, 219, 1)',
92
+ },
93
+ });
94
+
48
- const Counter = ({ name, meta }) => {
95
+ const render = ({ attrs, state, computed }) => {
96
+ const { name, meta } = attrs;
49
- const [count, setCount] = useState(meta?.start || 0);
97
+ const { count, setCount } = state;
98
+ const { sum } = computed;
50
99
 
51
100
  return html`
52
101
  <div>
102
+ <div class=${styles.title}>
103
+ Counter: ${name}
53
- <div class="font-bold mb-2">Counter: ${name}</div>
104
+ <span class=${styles.span}>starts at ${meta?.start}</span>
105
+ </div>
54
- <div class="flex flex-1 flex-row text-3xl text-gray-700">
106
+ <div class=${styles.container}>
55
- <button @click=${() => setCount((v) => v - 1)}>-</button>
107
+ <button class=${styles.button} @click=${() => setCount((v) => v - 1)}>-</button>
56
- <div class="mx-20">
108
+ <div class=${styles.mx}>
57
- <h1 class="text-1xl">${count}</h1>
109
+ <h1>${count}</h1>
58
110
  </div>
59
- <button @click=${() => setCount((v) => v + 1)}>+</button>
111
+ <button class=${styles.button} @click=${() => setCount((v) => v + 1)}>+</button>
112
+ </div>
113
+ <div class=${styles.mx}>
114
+ <h1>Sum: ${sum}</h1>
60
115
  </div>
61
116
  </div>
62
117
  `;
63
118
  };
64
119
 
65
- defineElement('app-counter', Counter, attrTypes);
120
+ export default createElement({
121
+ name,
122
+ attrTypes,
123
+ stateTypes,
124
+ computedTypes,
125
+ styles,
126
+ render,
127
+ });
66
128
 
67
129
  console.log(ssr(html`<app-counter name="1"></app-counter>`));
68
130
  ```
69
131
 
70
132
  This works in node, to make it work in the client side you need to either compile atoms-element using esintall or esbuild to an esm module and then
71
133
  rewrite the path from 'atoms-element' to 'web_modules/atoms-element/index.js' or if you can host the node_modules in a web server and change the path to
72
- the import 'node_modules/atoms-element/index.js'
134
+ the import 'atoms-element/element.js'
src/index.d.ts DELETED
@@ -1,60 +0,0 @@
1
- export declare type Destructor = () => void | undefined;
2
- export declare type EffectCallback = () => (void | Destructor);
3
- export declare type SetStateAction<S> = S | ((prevState: S) => S);
4
- export declare type Dispatch<A> = (value: A) => void;
5
- export declare type DispatchWithoutAction = () => void;
6
- export declare type DependencyList = ReadonlyArray<any>;
7
- export declare type Reducer<S, A> = (prevState: S, action: A) => S;
8
- export declare type ReducerWithoutAction<S> = (prevState: S) => S;
9
- export declare type ReducerState<R extends Reducer<any, any>> = R extends Reducer<infer S, any> ? S : never;
10
- export declare type ReducerAction<R extends Reducer<any, any>> = R extends Reducer<any, infer A> ? A : never;
11
- export declare type ReducerStateWithoutAction<R extends ReducerWithoutAction<any>> = R extends ReducerWithoutAction<infer S> ? S : never;
12
- export declare interface MutableRefObject<T> {
13
- current: T | null | undefined;
14
- }
15
- export declare type Config = {
16
- version: string
17
- url: string
18
- image: string
19
- author: string
20
- languages: Array<string>
21
- title: string
22
- description: string
23
- keywords: string
24
- categories: Array<string>
25
- tags: Array<string>
26
- strings: {[key: string]: string}
27
- }
28
- export declare type Location = {
29
- readonly ancestorOrigins: DOMStringList;
30
- hash: string;
31
- host: string;
32
- hostname: string;
33
- href: string;
34
- readonly origin: string;
35
- pathname: string;
36
- port: string;
37
- protocol: string;
38
- search: string;
39
- assign: (url: string | URL) => void;
40
- reload: () => void;
41
- replace: (url: string | URL) => void;
42
- toString: () => string;
43
- }
44
-
45
- export declare const useState: <S>(initialState: S | (() => S)) => [S, Dispatch<SetStateAction<S>>];
46
- export declare const useEffect: (effect: EffectCallback, deps?: DependencyList) => void;
47
- export declare const useLayoutEffect: (effect: EffectCallback, deps?: DependencyList) => void;
48
- export declare const useReducer: <R extends ReducerWithoutAction<any>, I>(
49
- reducer: R,
50
- initializerArg: I,
51
- initializer: (arg: I) => ReducerStateWithoutAction<R>
52
- ) => [ReducerStateWithoutAction<R>, DispatchWithoutAction];
53
-
54
- export declare const useCallback: <T extends (...args: any[]) => any>(callback: T, deps: DependencyList) => T;
55
- export declare const useMemo: <T>(factory: () => T, deps: DependencyList | undefined) => T;
56
- // function useImperativeHandle<T, R extends T>(ref: Ref<T>|undefined, init: () => R, deps?: DependencyList): void;
57
- export declare const useRef: <T>(initialValue: T | null | undefined) => MutableRefObject<T>;
58
-
59
- export declare const useConfig: () => Config;
60
- export declare const useLocation: () => Location;
src/index.js DELETED
@@ -1,497 +0,0 @@
1
- import { html, render as litRender, directive, NodePart, AttributePart, PropertyPart, isPrimitive } from './lit-html.js';
2
-
3
- const registry = {};
4
- const isBrowser = typeof window !== 'undefined';
5
- export { html, isBrowser };
6
-
7
- const lastAttributeNameRegex =
8
- /([ \x09\x0a\x0c\x0d])([^\0-\x1F\x7F-\x9F "'>=/]+)([ \x09\x0a\x0c\x0d]*=[ \x09\x0a\x0c\x0d]*(?:[^ \x09\x0a\x0c\x0d"'`<>=]*|"[^"]*|'[^']*))$/;
9
-
10
- const wrapAttribute = (attrName, suffix, text, v) => {
11
- let buffer = text;
12
- const hasQuote = suffix && suffix.includes(`="`);
13
- if (attrName && !hasQuote) {
14
- buffer += `"`;
15
- }
16
- buffer += v;
17
- if (attrName && !hasQuote) {
18
- buffer += `"`;
19
- }
20
- return buffer;
21
- };
22
-
23
- export const render = isBrowser
24
- ? litRender
25
- : (template) => {
26
- let js = '';
27
- template.strings.forEach((text, i) => {
28
- const value = template.values[i];
29
- const type = typeof value;
30
- let attrName, suffix;
31
- const matchName = lastAttributeNameRegex.exec(text);
32
- if (matchName) {
33
- attrName = matchName[2];
34
- suffix = matchName[3];
35
- }
36
- if (value === null || !(type === 'object' || type === 'function' || type === 'undefined')) {
37
- js += wrapAttribute(attrName, suffix, text, type !== 'string' ? String(value) : value);
38
- } else if (Array.isArray(value) && value.find((item) => item && item.strings && item.type === 'html')) {
39
- js += text;
40
- value.forEach((v) => {
41
- js += render(v);
42
- });
43
- } else if (type === 'object') {
44
- // TemplateResult
45
- if (value.strings && value.type === 'html') {
46
- js += text;
47
- js += render(value);
48
- } else {
49
- js += wrapAttribute(attrName, suffix, text, JSON.stringify(value).replace(/"/g, `'`));
50
- }
51
- } else if (type == 'function') {
52
- if (attrName) {
53
- js += text.replace(' ' + attrName + '=', '');
54
- } else {
55
- // js += text;
56
- // js += value();
57
- }
58
- } else if (type !== 'undefined') {
59
- js += text;
60
- js += value.toString();
61
- } else {
62
- js += text;
63
- // console.log('value', value);
64
- }
65
- });
66
- return js;
67
- };
68
-
69
- const previousValues = new WeakMap();
70
- export const unsafeHTML = isBrowser
71
- ? directive((value) => (part) => {
72
- if (!(part instanceof NodePart)) {
73
- throw new Error('unsafeHTML can only be used in text bindings');
74
- }
75
- const previousValue = previousValues.get(part);
76
- if (previousValue !== undefined && isPrimitive(value) && value === previousValue.value && part.value === previousValue.fragment) {
77
- return;
78
- }
79
- const template = document.createElement('template');
80
- template.innerHTML = value; // innerHTML casts to string internally
81
- const fragment = document.importNode(template.content, true);
82
- part.setValue(fragment);
83
- previousValues.set(part, { value, fragment });
84
- })
85
- : (value) => value;
86
-
87
- const previousClassesCache = new WeakMap();
88
- export const classMap = isBrowser
89
- ? directive((classInfo) => (part) => {
90
- if (!(part instanceof AttributePart) || part instanceof PropertyPart || part.committer.name !== 'class' || part.committer.parts.length > 1) {
91
- throw new Error('The `classMap` directive must be used in the `class` attribute ' + 'and must be the only part in the attribute.');
92
- }
93
- const { committer } = part;
94
- const { element } = committer;
95
- let previousClasses = previousClassesCache.get(part);
96
- if (previousClasses === undefined) {
97
- // Write static classes once
98
- // Use setAttribute() because className isn't a string on SVG elements
99
- element.setAttribute('class', committer.strings.join(' '));
100
- previousClassesCache.set(part, (previousClasses = new Set()));
101
- }
102
- const classList = element.classList;
103
- // Remove old classes that no longer apply
104
- // We use forEach() instead of for-of so that re don't require down-level
105
- // iteration.
106
- previousClasses.forEach((name) => {
107
- if (!(name in classInfo)) {
108
- classList.remove(name);
109
- previousClasses.delete(name);
110
- }
111
- });
112
- // Add or remove classes based on their classMap value
113
- for (const name in classInfo) {
114
- const value = classInfo[name];
115
- if (value != previousClasses.has(name)) {
116
- // We explicitly want a loose truthy check of `value` because it seems
117
- // more convenient that '' and 0 are skipped.
118
- if (value) {
119
- classList.add(name);
120
- previousClasses.add(name);
121
- } else {
122
- classList.remove(name);
123
- previousClasses.delete(name);
124
- }
125
- }
126
- }
127
- if (typeof classList.commit === 'function') {
128
- classList.commit();
129
- }
130
- })
131
- : (classes) => {
132
- let value = '';
133
- for (const key in classes) {
134
- if (classes[key]) {
135
- value += `${value.length ? ' ' : ''}${key}`;
136
- }
137
- }
138
- return value;
139
- };
140
-
141
- let currentCursor;
142
- let currentComponent;
143
- let logError = (msg) => {
144
- console.warn(msg);
145
- };
146
-
147
- export const setLogError = (fn) => {
148
- logError = fn;
149
- };
150
-
151
- const checkRequired = (context, data) => {
152
- if (data === null || typeof data === 'undefined') {
153
- logError(`'${context}' Field is required`);
154
- }
155
- };
156
-
157
- const checkPrimitive = (primitiveType) => {
158
- const common = {
159
- type: primitiveType,
160
- parse: (attr) => attr,
161
- };
162
- const validate = (context, data) => {
163
- if (data === null || typeof data === 'undefined') {
164
- return;
165
- }
166
- const dataType = typeof data;
167
- if (dataType !== primitiveType) {
168
- logError(`'${context}' Expected type '${primitiveType}' got type '${dataType}'`);
169
- }
170
- };
171
- return {
172
- validate,
173
- ...common,
174
- isRequired: {
175
- ...common,
176
- validate: (context, data) => {
177
- checkRequired(context, data);
178
- validate(context, data);
179
- },
180
- },
181
- };
182
- };
183
-
184
- const checkComplex = (complexType, validate) => {
185
- const common = {
186
- type: complexType,
187
- parse: (attr) => (attr ? JSON.parse(attr.replace(/'/g, `"`)) : null),
188
- };
189
- return (innerType) => {
190
- return {
191
- ...common,
192
- validate: (context, data) => {
193
- if (!data) {
194
- return;
195
- }
196
- validate(innerType, context, data);
197
- },
198
- isRequired: {
199
- ...common,
200
- validate: (context, data) => {
201
- checkRequired(context, data);
202
- validate(innerType, context, data);
203
- },
204
- },
205
- };
206
- };
207
- };
208
-
209
- export const number = checkPrimitive('number');
210
- export const string = checkPrimitive('string');
211
- export const boolean = checkPrimitive('boolean');
212
- export const object = checkComplex('object', (innerType, context, data) => {
213
- if (data.constructor !== Object) {
214
- logError(`'${context}' Expected object literal '{}' got '${typeof data}'`);
215
- }
216
- for (const key of Object.keys(innerType)) {
217
- const fieldValidator = innerType[key];
218
- const item = data[key];
219
- fieldValidator.validate(`${context}.${key}`, item);
220
- }
221
- });
222
- export const array = checkComplex('array', (innerType, context, data) => {
223
- if (!Array.isArray(data)) {
224
- logError(`Expected Array got ${data}`);
225
- }
226
- for (let i = 0; i < data.length; i++) {
227
- const item = data[i];
228
- innerType.validate(`${context}[${i}]`, item);
229
- }
230
- });
231
- export const func = checkComplex('function', (innerType, context, data) => {});
232
-
233
- export const hooks = (config) => {
234
- const h = currentComponent.hooks;
235
- const c = currentComponent;
236
- const index = currentCursor++;
237
- if (h.values.length <= index && config.oncreate) {
238
- h.values[index] = config.oncreate(h, c, index);
239
- }
240
- if (config.onupdate) {
241
- h.values[index] = config.onupdate(h, c, index);
242
- }
243
- return h.values[index];
244
- };
245
-
246
- export const __setCurrent__ = (c) => {
247
- currentComponent = c;
248
- currentCursor = 0;
249
- };
250
-
251
- export const useDispatchEvent = (name) =>
252
- hooks({
253
- oncreate: (_, c) => (data) => c.dispatchEvent(new CustomEvent(name, data)),
254
- });
255
- export const useRef = (initialValue) =>
256
- hooks({
257
- oncreate: (_h, _c) => ({ current: initialValue }),
258
- });
259
- export const useState = (initialState) =>
260
- hooks({
261
- oncreate: (h, c, i) => [
262
- typeof initialState === 'function' ? initialState() : initialState,
263
- function setState(nextState) {
264
- const state = h.values[i][0];
265
- if (typeof nextState === 'function') {
266
- nextState = nextState(state);
267
- }
268
- if (!Object.is(state, nextState)) {
269
- h.values[i][0] = nextState;
270
- c.update();
271
- }
272
- },
273
- ],
274
- });
275
- export const useReducer = (reducer, initialState) =>
276
- hooks({
277
- oncreate: (h, c, i) => [
278
- initialState,
279
- function dispatch(action) {
280
- const state = h.values[i][0];
281
- const nextState = reducer(state, action);
282
- if (!Object.is(state, nextState)) {
283
- h.values[i][0] = nextState;
284
- c.update();
285
- }
286
- },
287
- ],
288
- });
289
- const depsChanged = (prev, next) => prev == null || next.some((f, i) => !Object.is(f, prev[i]));
290
- export const useEffect = (handler, deps) =>
291
- hooks({
292
- onupdate(h, _, i) {
293
- if (!deps || depsChanged(h.deps[i], deps)) {
294
- h.deps[i] = deps || [];
295
- h.effects[i] = handler;
296
- }
297
- },
298
- });
299
- export const useLayoutEffect = (handler, deps) =>
300
- hooks({
301
- onupdate(h, _, i) {
302
- if (!deps || depsChanged(h.deps[i], deps)) {
303
- h.deps[i] = deps || [];
304
- h.layoutEffects[i] = handler;
305
- }
306
- },
307
- });
308
- export const useMemo = (fn, deps) =>
309
- hooks({
310
- onupdate(h, _, i) {
311
- let value = h.values[i];
312
- if (!deps || depsChanged(h.deps[i], deps)) {
313
- h.deps[i] = deps || [];
314
- value = fn();
315
- }
316
- return value;
317
- },
318
- });
319
- export const useCallback = (callback, deps) => useMemo(() => callback, deps);
320
- export const useConfig = () => {
321
- if (isBrowser) {
322
- return window.config;
323
- }
324
- return global.config;
325
- };
326
- export const useLocation = () => {
327
- if (isBrowser) {
328
- return window.location;
329
- }
330
- return global.location;
331
- };
332
-
333
- const batch = (runner, pick, callback) => {
334
- const q = [];
335
- const flush = () => {
336
- let p;
337
- while ((p = pick(q))) callback(p);
338
- };
339
- const run = runner(flush);
340
- return (c) => q.push(c) === 1 && run();
341
- };
342
- const fifo = (q) => q.shift();
343
- const filo = (q) => q.pop();
344
- const microtask = (flush) => {
345
- return () => queueMicrotask(flush);
346
- };
347
- const task = (flush) => {
348
- if (isBrowser) {
349
- const ch = new window.MessageChannel();
350
- ch.port1.onmessage = flush;
351
- return () => ch.port2.postMessage(null);
352
- } else {
353
- return () => setImmediate(flush);
354
- }
355
- };
356
- const enqueueLayoutEffects = batch(microtask, filo, (c) => c._flushEffects('layoutEffects'));
357
- const enqueueEffects = batch(task, filo, (c) => c._flushEffects('effects'));
358
- const enqueueUpdate = batch(microtask, fifo, (c) => c._performUpdate());
359
-
360
- const BaseElement = isBrowser ? window.HTMLElement : class {};
361
-
362
- export class AtomsElement extends BaseElement {
363
- constructor() {
364
- super();
365
- this._dirty = false;
366
- this._connected = false;
367
- this.hooks = {
368
- values: [],
369
- deps: [],
370
- effects: [],
371
- layoutEffects: [],
372
- cleanup: [],
373
- };
374
- this.props = {};
375
- this.attrTypes = {};
376
- this.name = '';
377
- this.renderer = () => {};
378
- this.attrTypes = {};
379
- this.funcKeys = [];
380
- this.attrTypesMap = {};
381
- }
382
- connectedCallback() {
383
- this._connected = true;
384
- if (isBrowser) {
385
- this.update();
386
- } else {
387
- __setCurrent__(this);
388
- }
389
- }
390
- disconnectedCallback() {
391
- this._connected = false;
392
- let cleanup;
393
- while ((cleanup = this.hooks.cleanup.shift())) {
394
- cleanup();
395
- }
396
- }
397
-
398
- attributeChangedCallback(key, oldValue, newValue) {
399
- const attr = this.attrTypesMap[key];
400
- if (!attr) {
401
- return;
402
- }
403
- const data = attr.propType.parse(newValue);
404
- attr.propType.validate(`<${this.name}> ${key}`, data);
405
- this.props[attr.propName] = data;
406
- if (this._connected) {
407
- this.update();
408
- }
409
- }
410
-
411
- update() {
412
- if (this._dirty) {
413
- return;
414
- }
415
- this._dirty = true;
416
- enqueueUpdate(this);
417
- }
418
- _performUpdate() {
419
- if (!this._connected) {
420
- return;
421
- }
422
- __setCurrent__(this);
423
- this.render();
424
- enqueueLayoutEffects(this);
425
- enqueueEffects(this);
426
- this._dirty = false;
427
- }
428
- _flushEffects(effectKey) {
429
- const effects = this.hooks[effectKey];
430
- const cleanups = this.hooks.cleanup;
431
- for (let i = 0, len = effects.length; i < len; i++) {
432
- if (effects[i]) {
433
- cleanups[i] && cleanups[i]();
434
- const cleanup = effects[i]();
435
- if (cleanup) {
436
- cleanups[i] = cleanup;
437
- }
438
- delete effects[i];
439
- }
440
- }
441
- }
442
-
443
- render() {
444
- if (isBrowser) {
445
- this.funcKeys.forEach((key) => {
446
- this.props[key] = this[key];
447
- });
448
- render(this.renderer(this.props), this);
449
- } else {
450
- __setCurrent__(this);
451
- return render(this.renderer(this.props), this);
452
- }
453
- }
454
- }
455
- export const getElement = (name) => registry[name];
456
-
457
- export function defineElement(name, fn, attrTypes = {}) {
458
- const keys = Object.keys(attrTypes);
459
- registry[name] = {
460
- fn,
461
- attrTypes,
462
- Clazz: class extends AtomsElement {
463
- constructor(attrs) {
464
- super();
465
- this.name = name;
466
- this.renderer = fn;
467
- this.attrTypes = attrTypes;
468
- this.funcKeys = keys.filter((key) => attrTypes[key].type === 'function');
469
- this.attrTypesMap = keys
470
- .filter((key) => attrTypes[key].type !== 'function')
471
- .reduce((acc, key) => {
472
- acc[key.toLowerCase()] = {
473
- propName: key,
474
- propType: attrTypes[key],
475
- };
476
- return acc;
477
- }, {});
478
- if (attrs) {
479
- attrs.forEach((item) => {
480
- this.attributeChangedCallback(item.name, null, item.value);
481
- });
482
- }
483
- }
484
-
485
- static get observedAttributes() {
486
- return keys.map((k) => k.toLowerCase());
487
- }
488
- },
489
- };
490
- if (isBrowser) {
491
- if (window.customElements.get(name)) {
492
- return;
493
- } else {
494
- window.customElements.define(name, registry[name].Clazz);
495
- }
496
- }
497
- }
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
- };
test/index.test.js DELETED
@@ -1,266 +0,0 @@
1
- import { expect, test, jest } from '@jest/globals';
2
- import {
3
- html,
4
- render,
5
- number,
6
- boolean,
7
- string,
8
- array,
9
- object,
10
- setLogError,
11
- defineElement,
12
- getElement,
13
- useConfig,
14
- useLocation,
15
- useState,
16
- unsafeHTML,
17
- classMap,
18
- } from '../src/index.js';
19
-
20
- const logMock = jest.fn();
21
- setLogError(logMock);
22
-
23
- const expectError = (msg) => expect(logMock).toHaveBeenCalledWith(msg);
24
-
25
- const primitives = [
26
- {
27
- type: 'number',
28
- validator: number,
29
- valid: [123, 40.5, 6410],
30
- invalid: ['123', false, {}, [], new Date()],
31
- },
32
- {
33
- type: 'boolean',
34
- validator: boolean,
35
- valid: [false, true],
36
- invalid: ['123', 123, {}, [], new Date()],
37
- },
38
- {
39
- type: 'string',
40
- validator: string,
41
- valid: ['', '123'],
42
- invalid: [123, false, {}, [], new Date()],
43
- },
44
- ];
45
-
46
- primitives.forEach((value) =>
47
- it(`${value.type}`, () => {
48
- const context = 'key';
49
- expect(value.validator.type).toEqual(value.type);
50
- expect(value.validator.isRequired.type).toEqual(value.type);
51
- value.validator.validate(context);
52
- for (const v of value.valid) {
53
- value.validator.validate(context, v);
54
- value.validator.isRequired.validate(context, v);
55
- }
56
- value.validator.isRequired.validate(context);
57
- expectError(`'key' Field is required`);
58
- for (const v of value.invalid) {
59
- value.validator.validate(context, v);
60
- expectError(`'key' Expected type '${value.type}' got type '${typeof v}'`);
61
- }
62
- }),
63
- );
64
-
65
- test('object', () => {
66
- const context = 'data';
67
- object({}).validate(context, { name: '123' });
68
- object({ name: string }).validate(context, { name: '123' });
69
- object({ name: string.isRequired }).validate(context, { name: '' });
70
- object({ name: string.isRequired }).validate(context, {});
71
- expectError(`'data.name' Field is required`);
72
-
73
- const schema = object({
74
- address: object({
75
- street: string,
76
- }),
77
- });
78
- schema.validate(context, {});
79
- schema.validate(context, '123');
80
- expectError(`'data' Expected object literal '{}' got 'string'`);
81
- schema.validate(context, {
82
- address: {},
83
- });
84
- schema.validate(context, {
85
- address: '123',
86
- });
87
- expectError(`'data.address' Expected object literal '{}' got 'string'`);
88
- schema.validate(context, {
89
- address: {
90
- street: 'avenue 1',
91
- },
92
- });
93
- schema.validate(context, {
94
- address: {
95
- street: false,
96
- },
97
- });
98
- expectError(`'data.address.street' Expected type 'string' got type 'boolean'`);
99
-
100
- const schema2 = object({
101
- address: object({
102
- street: string.isRequired,
103
- }),
104
- });
105
- schema2.validate(context, {});
106
- schema2.validate(context, {
107
- address: {
108
- street: '11th avenue',
109
- },
110
- });
111
- schema2.validate(context, {
112
- address: {},
113
- });
114
- expectError(`'data.address.street' Field is required`);
115
- });
116
-
117
- test('array', () => {
118
- const context = 'items';
119
- array(string).validate(context, ['123']);
120
- array(string).validate(context, [123]);
121
- expectError(`'items[0]' Expected type 'string' got type 'number'`);
122
- array(array(string)).validate(context, [['123']]);
123
- array(array(string)).validate(context, [[123]]);
124
- expectError(`'items[0][0]' Expected type 'string' got type 'number'`);
125
-
126
- const schema = object({
127
- street: string.isRequired,
128
- });
129
- array(schema).validate(context, []);
130
- array(schema).validate(context, [{ street: '123' }, { street: '456' }, { street: '789' }]);
131
- array(schema).validate(context, [{}]);
132
- expectError(`'items[0].street' Field is required`);
133
- array(schema).validate(context, [{ street: false }]);
134
- expectError(`'items[0].street' Expected type 'string' got type 'boolean'`);
135
- array(schema).validate(context, [{ street: '123' }, {}]);
136
- expectError(`'items[1].street' Field is required`);
137
- array(schema).validate(context, [{ street: '123' }, { street: false }]);
138
- expectError(`'items[1].street' Expected type 'string' got type 'boolean'`);
139
- });
140
-
141
- test('useConfig', async () => {
142
- expect(useConfig()).toBe(undefined);
143
- global.config = {};
144
- expect(useConfig()).toMatchObject({});
145
- });
146
-
147
- test('useLocation', async () => {
148
- expect(useLocation()).toBe(undefined);
149
- global.location = {};
150
- expect(useLocation()).toMatchObject({});
151
- });
152
-
153
- test('render', async () => {
154
- const age = 1;
155
- const data = { name: '123', address: { street: '1' } };
156
- const items = [1, 2, 3];
157
- const highlight = 'high';
158
- const template = html`
159
- <div>
160
- <app-counter name="123" class="abc ${highlight}" age=${age} details1=${data} items=${items}></app-counter>
161
- </div>
162
- `;
163
- const res = await render(template);
164
- expect(res).toEqual(`
165
- <div>
166
- <app-counter name="123" class="abc high" age="1" details1="{'name':'123','address':{'street':'1'}}" items="[1,2,3]"></app-counter>
167
- </div>
168
- `);
169
- });
170
-
171
- test('render attribute keys', async () => {
172
- const template = html`
173
- <div>
174
- <app-counter name="123" perPage="1"></app-counter>
175
- </div>
176
- `;
177
- const res = await render(template);
178
- expect(res).toEqual(`
179
- <div>
180
- <app-counter name="123" perPage="1"></app-counter>
181
- </div>
182
- `);
183
- });
184
-
185
- test('render attributes within quotes', async () => {
186
- const age = 1;
187
- const data = { name: '123', address: { street: '1' } };
188
- const items = [1, 2, 3];
189
- const classes = 'high';
190
- const template = html`
191
- <div>
192
- <app-counter name="123" class=${classes} age="${age}" details1="${data}" items="${items}"></app-counter>
193
- </div>
194
- `;
195
- const res = await render(template);
196
- expect(res).toEqual(`
197
- <div>
198
- <app-counter name="123" class="high" age="1" details1="{'name':'123','address':{'street':'1'}}" items="[1,2,3]"></app-counter>
199
- </div>
200
- `);
201
- });
202
-
203
- test('render unsafeHTML', async () => {
204
- const textContent = `<div><p class="123">this is unsafe</p></div>`;
205
- const template = html` <div>${unsafeHTML(textContent)}</div> `;
206
- const res = await render(template);
207
- expect(res).toEqual(` <div><div><p class="123">this is unsafe</p></div></div> `);
208
- });
209
-
210
- test('render classMap show', async () => {
211
- const hide = false;
212
- const template = html` <div class="abc ${classMap({ show: !hide })}"></div> `;
213
- const res = await render(template);
214
- expect(res).toEqual(` <div class="abc show"></div> `);
215
- });
216
-
217
- test('render classMap hide', async () => {
218
- const hide = true;
219
- const template = html` <div class="abc ${classMap({ show: !hide })}"></div> `;
220
- const res = await render(template);
221
- expect(res).toEqual(` <div class="abc "></div> `);
222
- });
223
-
224
- test('render single template', async () => {
225
- const template = html` <div>${html`NoCountry ${false}`}</div> `;
226
- const res = await render(template);
227
- expect(res).toEqual(` <div>NoCountry false</div> `);
228
- });
229
-
230
- test('render multi template', async () => {
231
- const template = html` <div>${[1, 2].map((v) => html` <app-item meta="${{ index: v }}" @click=${() => {}} .handleClick=${() => {}}></app-item>`)}</div> `;
232
- const res = await render(template);
233
- expect(res).toEqual(` <div> <app-item meta="{'index':1}"></app-item> <app-item meta="{'index':2}"></app-item></div> `);
234
- });
235
-
236
- test('defineElement', async () => {
237
- const attrTypes = {
238
- perPage: string.isRequired,
239
- address: object({
240
- street: string.isRequired,
241
- }).isRequired,
242
- };
243
- const AppItem = ({ perPage, address: { street } }) => {
244
- const [count] = useState(0);
245
- return html`
246
- <div perPage=${perPage}>
247
- <p>street: ${street}</p>
248
- <p>count: ${count}</p>
249
- </div>
250
- `;
251
- };
252
- defineElement('app-item', AppItem, attrTypes);
253
- const { Clazz } = getElement('app-item');
254
- const instance = new Clazz([
255
- { name: 'address', value: JSON.stringify({ street: '123' }).replace(/"/g, `'`) },
256
- { name: 'perpage', value: '1' },
257
- ]);
258
- expect(Clazz.observedAttributes).toEqual(['perpage', 'address']);
259
- const res = await instance.render();
260
- expect(res).toEqual(`
261
- <div perPage="1">
262
- <p>street: 123</p>
263
- <p>count: 0</p>
264
- </div>
265
- `);
266
- });