atoms-element v5.0.0
git clone https://git.pyrossh.dev/atoms-element
A simple web component library for defining your custom elements. It works on both client and server.
ba74771
— Peter John
2026-08-07T16:03:34+05:30
improve components
Generated files not shown: package-lock.json
- examples/e2e.spec.js +32 -4
- examples/elements/app-counter.js +13 -58
- examples/elements/app-total.js +6 -3
- examples/pages/index.js +2 -2
- examples/server.js +0 -1
- examples/store.js +0 -15
- index.d.ts +13 -0
- index.js +117 -6
- package.json +1 -0
examples/e2e.spec.js
CHANGED
|
@@ -49,7 +49,18 @@ test('renders each counter exactly once, with no leftover duplicate SSR markup',
|
|
|
49
49
|
}
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
test('seeds each counter from its count attribute, and app-total sums them with no manual sync', async () => {
|
|
53
|
+
const first = page.locator('app-counter').nth(0);
|
|
54
|
+
const second = page.locator('app-counter').nth(1);
|
|
55
|
+
assert.strictEqual((await first.locator('h1').textContent()).trim(), '5', 'first app-counter has count="5"');
|
|
56
|
+
assert.strictEqual((await second.locator('h1').textContent()).trim(), '7', 'second app-counter has count="7"');
|
|
57
|
+
// computed server-side by cheerio-parsing the already-rendered preceding
|
|
58
|
+
// markup (both app-counter tags), via useAttr — not a separately-tracked
|
|
59
|
+
// total, and no shared store between the two components.
|
|
60
|
+
assert.strictEqual((await page.locator('app-total h1').textContent()).trim(), 'Total of 2 Counters: 12');
|
|
61
|
+
});
|
|
62
|
+
|
|
52
|
-
test('clicking +/- updates the counter and the
|
|
63
|
+
test('clicking +/- updates the counter and the derived total', async () => {
|
|
53
64
|
const first = page.locator('app-counter').nth(0);
|
|
54
65
|
const second = page.locator('app-counter').nth(1);
|
|
55
66
|
|
|
@@ -59,9 +70,26 @@ test('clicking +/- updates the counter and the shared total reducer', async () =
|
|
|
59
70
|
await second.getByRole('button', { name: '+' }).click();
|
|
60
71
|
await first.getByRole('button', { name: '-' }).click();
|
|
61
72
|
|
|
73
|
+
// first: 5 + 3 - 1 = 7, second: 7 + 1 = 8, total: sum of both, live. Reflection
|
|
74
|
+
// and the MutationObserver it feeds are both async, so wait for the total to
|
|
75
|
+
// actually settle rather than asserting immediately after the last click.
|
|
76
|
+
await page.waitForFunction(() => document.querySelector('app-total h1').textContent.trim() === 'Total of 2 Counters: 15');
|
|
62
|
-
assert.strictEqual((await first.locator('h1').textContent()).trim(), '
|
|
77
|
+
assert.strictEqual((await first.locator('h1').textContent()).trim(), '7');
|
|
63
|
-
assert.strictEqual((await second.locator('h1').textContent()).trim(), '
|
|
78
|
+
assert.strictEqual((await second.locator('h1').textContent()).trim(), '8');
|
|
79
|
+
assert.strictEqual((await page.locator('app-total h1').textContent()).trim(), 'Total of 2 Counters: 15');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('external attribute writes are adopted directly, with no separate internal state to conflict with', async () => {
|
|
83
|
+
// Continuing from the previous test, the first counter currently shows 7.
|
|
84
|
+
// count now lives only in the attribute (via useProp) — there's no reducer
|
|
85
|
+
// value to reconcile it against, so an external write just is the new
|
|
86
|
+
// state, and app-total (via useAttr) picks it up automatically.
|
|
87
|
+
const first = page.locator('app-counter').nth(0);
|
|
88
|
+
await first.evaluate((el) => el.setAttribute('count', '30'));
|
|
89
|
+
await page.waitForFunction(() => document.querySelector('app-counter').querySelector('h1').textContent.trim() === '30');
|
|
90
|
+
assert.strictEqual((await first.locator('h1').textContent()).trim(), '30');
|
|
91
|
+
await page.waitForFunction(() => document.querySelector('app-total h1').textContent.trim() === 'Total of 2 Counters: 38');
|
|
64
|
-
assert.strictEqual((await page.locator('app-total h1').textContent()).trim(), 'Total of 2 Counters:
|
|
92
|
+
assert.strictEqual((await page.locator('app-total h1').textContent()).trim(), 'Total of 2 Counters: 38');
|
|
65
93
|
});
|
|
66
94
|
|
|
67
95
|
test('no console/page errors were thrown', () => {
|
examples/elements/app-counter.js
CHANGED
|
@@ -1,68 +1,23 @@
|
|
|
1
|
-
import { useEffect } from '../../index.js';
|
|
2
|
-
import { createElement, css, html,
|
|
1
|
+
import { createElement, css, html, useProp } from '../../index.js';
|
|
3
|
-
import { totalReducer } from '../store.js';
|
|
4
2
|
|
|
3
|
+
// State lives only in the count attribute — useProp reads it (type-coerced to
|
|
4
|
+
// a number, since the default 0 is a number) and writes it straight back via
|
|
5
|
+
// setAttribute, with no separate internal copy to keep in sync. count still
|
|
6
|
+
// has to appear as a destructured param below for it to end up in
|
|
7
|
+
// observedAttributes; useProp doesn't add it there itself.
|
|
5
|
-
const Counter = ({ name,
|
|
8
|
+
const Counter = ({ name, count }) => {
|
|
6
|
-
const { count, actions, effects } = useReducer({
|
|
7
|
-
initial: {
|
|
8
|
-
count: 0,
|
|
9
|
-
data: undefined,
|
|
10
|
-
err: null,
|
|
11
|
-
},
|
|
12
|
-
reducer: {
|
|
13
|
-
increment: (state) => {
|
|
14
|
-
state.count += 1;
|
|
15
|
-
},
|
|
16
|
-
decrement: (state) => {
|
|
17
|
-
state.count -= 1;
|
|
18
|
-
},
|
|
19
|
-
setLoading: (state, v) => {
|
|
20
|
-
state.loading = v;
|
|
21
|
-
},
|
|
22
|
-
setData: (state, data) => {
|
|
23
|
-
state.data = data;
|
|
24
|
-
},
|
|
25
|
-
setErr: (state, err) => {
|
|
26
|
-
state.err = err;
|
|
27
|
-
},
|
|
28
|
-
},
|
|
29
|
-
effects: {
|
|
30
|
-
loadData: async (actions, id) => {
|
|
31
|
-
try {
|
|
32
|
-
actions.setLoading(true);
|
|
33
|
-
|
|
9
|
+
const [currentCount, setCount] = useProp('count', 0);
|
|
34
|
-
actions.setData(await res.json());
|
|
35
|
-
} catch (err) {
|
|
36
|
-
actions.setErr(err);
|
|
37
|
-
} finally {
|
|
38
|
-
actions.setLoading(false);
|
|
39
|
-
}
|
|
40
|
-
},
|
|
41
|
-
},
|
|
42
|
-
});
|
|
43
|
-
useEffect(() => {
|
|
44
|
-
effects.loadData(name);
|
|
45
|
-
}, []);
|
|
46
|
-
const increment = () =>
|
|
10
|
+
const increment = () => setCount((c) => c + 1);
|
|
47
|
-
actions.increment();
|
|
48
|
-
totalReducer.actions.increment(count + 1);
|
|
49
|
-
};
|
|
50
|
-
const decrement = () =>
|
|
11
|
+
const decrement = () => setCount((c) => c - 1);
|
|
51
|
-
actions.decrement();
|
|
52
|
-
totalReducer.actions.decrement(count - 1);
|
|
53
|
-
};
|
|
54
|
-
const warningClass =
|
|
12
|
+
const warningClass = currentCount > 10 ? 'warning' : '';
|
|
55
13
|
|
|
56
14
|
return html`
|
|
57
15
|
<div class="heading">
|
|
58
|
-
<div class="label">
|
|
16
|
+
<div class="label">Counter: ${name}</div>
|
|
59
|
-
Counter: ${name}
|
|
60
|
-
<span>starts at ${meta?.start}</span>
|
|
61
|
-
</div>
|
|
62
17
|
<div class="controls">
|
|
63
18
|
<button @click=${decrement}>-</button>
|
|
64
19
|
<div class="count">
|
|
65
|
-
<h1 class="${warningClass}">${
|
|
20
|
+
<h1 class="${warningClass}">${currentCount}</h1>
|
|
66
21
|
</div>
|
|
67
22
|
<button @click=${increment}>+</button>
|
|
68
23
|
</div>
|
examples/elements/app-total.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { createElement, css, html,
|
|
1
|
+
import { createElement, css, html, useAttr } from '../../index.js';
|
|
2
|
-
import { totalReducer } from '../store.js';
|
|
3
2
|
|
|
4
3
|
const Total = () => {
|
|
4
|
+
// No shared store: reads the count attribute directly off every app-counter
|
|
5
|
+
// on the page. Server-side this parses the already-rendered preceding
|
|
6
|
+
// markup; client-side it's backed by a MutationObserver, set up once.
|
|
7
|
+
const counts = useAttr('app-counter', 'count');
|
|
5
|
-
const
|
|
8
|
+
const total = counts.reduce((sum, v) => sum + (Number(v) || 0), 0);
|
|
6
9
|
return html`
|
|
7
10
|
<div>
|
|
8
11
|
<h1>Total of 2 Counters: ${total}</h1>
|
examples/pages/index.js
CHANGED
|
@@ -21,8 +21,8 @@ const head = ({ config }) => {
|
|
|
21
21
|
const body = () => {
|
|
22
22
|
return staticHtml`
|
|
23
23
|
<div class="page">
|
|
24
|
-
<app-counter name="1"
|
|
24
|
+
<app-counter name="1" count="5"></app-counter>
|
|
25
|
-
<app-counter name="2"
|
|
25
|
+
<app-counter name="2" count="7"></app-counter>
|
|
26
26
|
<app-total></app-total>
|
|
27
27
|
</div>
|
|
28
28
|
`;
|
examples/server.js
CHANGED
|
@@ -12,7 +12,6 @@ const port = process.argv[2] || 3000;
|
|
|
12
12
|
const elements = ['app-counter.js', 'app-total.js'];
|
|
13
13
|
const srcMap = {
|
|
14
14
|
'/index.js': `${rootDir}/index.js`,
|
|
15
|
-
'/store.js': `${__dirname}/store.js`,
|
|
16
15
|
};
|
|
17
16
|
elements.forEach((el) => {
|
|
18
17
|
srcMap['/elements/' + el] = `${__dirname}/elements/${el}`;
|
examples/store.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { createReducer } from '../index.js';
|
|
2
|
-
|
|
3
|
-
export const totalReducer = createReducer({
|
|
4
|
-
initial: {
|
|
5
|
-
total: 0,
|
|
6
|
-
},
|
|
7
|
-
reducer: {
|
|
8
|
-
increment: (state) => {
|
|
9
|
-
state.total += 1;
|
|
10
|
-
},
|
|
11
|
-
decrement: (state) => {
|
|
12
|
-
state.total -= 1;
|
|
13
|
-
},
|
|
14
|
-
},
|
|
15
|
-
});
|
index.d.ts
CHANGED
|
@@ -69,3 +69,16 @@ export function staticHtml(strings: TemplateStringsArray, ...values: any[]): any
|
|
|
69
69
|
// (e.g. meta=${jsonAttr(data)}) so it round-trips through this library's
|
|
70
70
|
// attribute-parsing convention instead of being stringified as [object Object].
|
|
71
71
|
export function jsonAttr(value: any): unknown;
|
|
72
|
+
// Reads `attribute` off every element matching `selector` — server-side via
|
|
73
|
+
// cheerio over the already-rendered preceding markup, client-side via a
|
|
74
|
+
// MutationObserver. Returns the raw attribute strings; parse them yourself.
|
|
75
|
+
export function useAttr(selector: string, attribute: string): string[];
|
|
76
|
+
// The element instance itself (e.g. to call host.setAttribute(...)). Only
|
|
77
|
+
// call this during render — capture the result via closure for later use.
|
|
78
|
+
export function useHost(): any;
|
|
79
|
+
// State lives only in the named attribute — no separate internal copy. `name`
|
|
80
|
+
// must also appear as a destructured render-function parameter for it to end
|
|
81
|
+
// up in observedAttributes. defaultValue's type controls how the attribute
|
|
82
|
+
// string is parsed (number/boolean/string); the setter accepts a value or an
|
|
83
|
+
// updater function, always resolved against the live attribute at call time.
|
|
84
|
+
export function useProp<T>(name: string, defaultValue: T): [T, (next: T | ((prev: T) => T)) => void];
|
index.js
CHANGED
|
@@ -13,11 +13,20 @@ import { create } from 'mutative';
|
|
|
13
13
|
const isBrowser = typeof window !== 'undefined';
|
|
14
14
|
export { html, isBrowser, unsafeHTML, staticHtml };
|
|
15
15
|
|
|
16
|
-
// @lit-labs/ssr
|
|
16
|
+
// @lit-labs/ssr and cheerio both pull in Node-only dependencies (module
|
|
17
|
-
// polyfills), so
|
|
17
|
+
// resolution, fetch polyfills), so they can only ever be imported on the
|
|
18
|
-
// index.js is loaded directly in the browser too (no
|
|
18
|
+
// server. This project's index.js is loaded directly in the browser too (no
|
|
19
|
-
//
|
|
19
|
+
// bundler), so these imports are conditional and dynamic — in the browser
|
|
20
|
+
// these lines never execute.
|
|
20
21
|
const ssr = isBrowser ? null : await import('@lit-labs/ssr');
|
|
22
|
+
const cheerio = isBrowser ? null : await import('cheerio');
|
|
23
|
+
|
|
24
|
+
// Set by expandCustomElements right before rendering each matched component,
|
|
25
|
+
// to whatever HTML has already been generated before it in the current page
|
|
26
|
+
// (i.e. its preceding siblings, already expanded) — this is what lets useAttr
|
|
27
|
+
// answer "what does this other, already-rendered element's attribute say"
|
|
28
|
+
// during SSR, where there's no live DOM to query.
|
|
29
|
+
const ssrContext = { precedingHtml: '' };
|
|
21
30
|
|
|
22
31
|
const attrRE = /\s([^'"/\s><]+?)[\s/>]|([^\s=]+)=\s?(".*?"|'.*?')/g;
|
|
23
32
|
const voidElements = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
|
|
@@ -88,6 +97,28 @@ const parseTag = (tag) => {
|
|
|
88
97
|
};
|
|
89
98
|
const parseAttrValue = (value) => (value && value.startsWith('{') ? JSON.parse(value.replace(/'/g, '"')) : value);
|
|
90
99
|
|
|
100
|
+
// Type is inferred from defaultValue, the way useProp's own default argument
|
|
101
|
+
// works: a number default means parse as a number, a boolean default means
|
|
102
|
+
// parse as a boolean, otherwise pass the string through as-is. Anything
|
|
103
|
+
// already parsed upstream (an object/array, via parseAttrValue) is returned
|
|
104
|
+
// unchanged.
|
|
105
|
+
const coerceAttr = (raw, defaultValue) => {
|
|
106
|
+
if (raw === undefined || raw === null) {
|
|
107
|
+
return defaultValue;
|
|
108
|
+
}
|
|
109
|
+
if (typeof raw !== 'string') {
|
|
110
|
+
return raw;
|
|
111
|
+
}
|
|
112
|
+
if (typeof defaultValue === 'number') {
|
|
113
|
+
return Number(raw);
|
|
114
|
+
}
|
|
115
|
+
if (typeof defaultValue === 'boolean') {
|
|
116
|
+
return raw !== 'false' && raw !== '0';
|
|
117
|
+
}
|
|
118
|
+
return raw;
|
|
119
|
+
};
|
|
120
|
+
const serializeAttr = (value) => (typeof value === 'object' && value !== null ? JSON.stringify(value).replace(/"/g, `'`) : String(value));
|
|
121
|
+
|
|
91
122
|
class JsonAttrDirective extends Directive {
|
|
92
123
|
render(value) {
|
|
93
124
|
return JSON.stringify(value).replace(/"/g, `'`);
|
|
@@ -122,7 +153,8 @@ const expandCustomElements = (htmlString) => {
|
|
|
122
153
|
return htmlString;
|
|
123
154
|
}
|
|
124
155
|
const re = new RegExp(`<(${tagNames.join('|')})(?=[\\s>])([^>]*)>\\s*</\\1>`, 'g');
|
|
125
|
-
return htmlString.replace(re, (match, tagName, attrsPortion) => {
|
|
156
|
+
return htmlString.replace(re, (match, tagName, attrsPortion, offset) => {
|
|
157
|
+
ssrContext.precedingHtml = htmlString.slice(0, offset);
|
|
126
158
|
const { attrs } = parseTag(`<${tagName}${attrsPortion}>`);
|
|
127
159
|
Object.keys(attrs).forEach((key) => {
|
|
128
160
|
attrs[key] = parseAttrValue(attrs[key]);
|
|
@@ -290,6 +322,7 @@ const task = (flush) => {
|
|
|
290
322
|
}
|
|
291
323
|
};
|
|
292
324
|
const depsChanged = (prev, next) => prev == null || next.some((f, i) => !Object.is(f, prev[i]));
|
|
325
|
+
const shallowArrayEqual = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
|
|
293
326
|
|
|
294
327
|
export const createAttrs = (attrs) => attrs;
|
|
295
328
|
|
|
@@ -354,6 +387,12 @@ export const useReducer = (reducer, selector = identity) => {
|
|
|
354
387
|
const state = comp.hooks.data[index].getValue();
|
|
355
388
|
return { ...state, actions: comp.hooks.data[index].actions, effects: comp.hooks.data[index].effects };
|
|
356
389
|
};
|
|
390
|
+
// The element instance itself, e.g. to reflect state onto its own attributes
|
|
391
|
+
// (host.setAttribute(...)) so other components can read it via useAttr. Safe to
|
|
392
|
+
// call only during render — capture the return value via closure for later use
|
|
393
|
+
// in effects/handlers, don't call this hook itself from inside one.
|
|
394
|
+
export const useHost = () => currentComponent.get();
|
|
395
|
+
|
|
357
396
|
export const useEffect = (fn, deps) => {
|
|
358
397
|
const comp = currentComponent.get();
|
|
359
398
|
const index = comp.hooks.index++;
|
|
@@ -363,6 +402,64 @@ export const useEffect = (fn, deps) => {
|
|
|
363
402
|
}
|
|
364
403
|
};
|
|
365
404
|
|
|
405
|
+
// Reads `attribute` off every element matching `selector`, without either side
|
|
406
|
+
// needing a shared store: on the server there's no live DOM to query, so it
|
|
407
|
+
// reads from ssrContext.precedingHtml (everything already rendered before this
|
|
408
|
+
// component in the current page) via cheerio; in the browser it sets up a
|
|
409
|
+
// MutationObserver once and re-renders this component whenever a match changes.
|
|
410
|
+
export const useAttr = (selector, attribute) => {
|
|
411
|
+
const comp = currentComponent.get();
|
|
412
|
+
const index = comp.hooks.index++;
|
|
413
|
+
if (!isBrowser) {
|
|
414
|
+
const $ = cheerio.load(ssrContext.precedingHtml);
|
|
415
|
+
return $(selector)
|
|
416
|
+
.map((i, el) => $(el).attr(attribute))
|
|
417
|
+
.get();
|
|
418
|
+
}
|
|
419
|
+
if (!comp.hooks.data[index]) {
|
|
420
|
+
const read = () => Array.from(document.querySelectorAll(selector)).map((el) => el.getAttribute(attribute));
|
|
421
|
+
comp.hooks.data[index] = read();
|
|
422
|
+
const observer = new window.MutationObserver(() => {
|
|
423
|
+
const next = read();
|
|
424
|
+
if (!shallowArrayEqual(next, comp.hooks.data[index])) {
|
|
425
|
+
comp.hooks.data[index] = next;
|
|
426
|
+
comp.update();
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
observer.observe(document.body, { attributes: true, attributeFilter: [attribute], subtree: true });
|
|
430
|
+
comp.hooks.cleanups[index] = () => observer.disconnect();
|
|
431
|
+
}
|
|
432
|
+
return comp.hooks.data[index];
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
// The attribute is the only copy of this state — there's no separate internal
|
|
436
|
+
// value to keep in sync with it (that bidirectional sync, tried in an earlier
|
|
437
|
+
// version of this, was a genuine, unresolvable race: whichever side changed
|
|
438
|
+
// last could always be undone by a stale update from the other side still in
|
|
439
|
+
// flight). Reading here is just "what does the attribute say right now";
|
|
440
|
+
// writing is just "set the attribute" (which synchronously updates comp.attrs
|
|
441
|
+
// via attributeChangedCallback and schedules a re-render if connected).
|
|
442
|
+
//
|
|
443
|
+
// The setter accepts a plain value or an updater function (setCount(c => c+1))
|
|
444
|
+
// — always resolved against the live attribute value at call time, not a
|
|
445
|
+
// value captured in a render closure, so rapid successive calls (e.g. quick
|
|
446
|
+
// clicks, each queued before a re-render lands) don't read stale state and
|
|
447
|
+
// silently lose updates.
|
|
448
|
+
//
|
|
449
|
+
// name must still appear as a destructured render-function parameter (even if
|
|
450
|
+
// unused otherwise) for it to end up in observedAttributes — that list is read
|
|
451
|
+
// by the browser once, synchronously, when the class is defined, so it can't
|
|
452
|
+
// depend on anything this hook (called later, during render) discovers.
|
|
453
|
+
export const useProp = (name, defaultValue) => {
|
|
454
|
+
const comp = currentComponent.get();
|
|
455
|
+
const value = coerceAttr(comp.attrs[name], defaultValue);
|
|
456
|
+
const setValue = (next) => {
|
|
457
|
+
const resolved = typeof next === 'function' ? next(coerceAttr(comp.attrs[name], defaultValue)) : next;
|
|
458
|
+
comp.setAttribute(name, serializeAttr(resolved));
|
|
459
|
+
};
|
|
460
|
+
return [value, setValue];
|
|
461
|
+
};
|
|
462
|
+
|
|
366
463
|
const registry = {};
|
|
367
464
|
const injectedStyleTags = new Set();
|
|
368
465
|
export const getElement = (name) => registry[name];
|
|
@@ -381,6 +478,7 @@ export const createElement = (meta, renderFn) => {
|
|
|
381
478
|
constructor(ssrAttrs) {
|
|
382
479
|
super();
|
|
383
480
|
this._dirty = false;
|
|
481
|
+
this._effectsPending = false;
|
|
384
482
|
this._connected = false;
|
|
385
483
|
this._hydrated = false;
|
|
386
484
|
this.attrs = ssrAttrs || {};
|
|
@@ -465,7 +563,20 @@ export const createElement = (meta, renderFn) => {
|
|
|
465
563
|
}
|
|
466
564
|
|
|
467
565
|
enqueueEffects() {
|
|
566
|
+
// Without this guard, several render cycles completing in quick succession
|
|
567
|
+
// (e.g. rapid clicks) each schedule their own separate effects-flush task;
|
|
568
|
+
// those can interleave with later renders' own effect scheduling in ways
|
|
569
|
+
// that break invariants effects rely on (observed this firsthand: a
|
|
570
|
+
// still-pending flush from an earlier render raced a newer one and
|
|
571
|
+
// clobbered state that had already moved on).
|
|
572
|
+
if (this._effectsPending) {
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
this._effectsPending = true;
|
|
468
|
-
this.batch(task, filo, () =>
|
|
576
|
+
this.batch(task, filo, () => {
|
|
577
|
+
this._effectsPending = false;
|
|
578
|
+
this._flushEffects();
|
|
579
|
+
});
|
|
469
580
|
}
|
|
470
581
|
|
|
471
582
|
render() {
|
package.json
CHANGED
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@lit-labs/ssr": "^4.1.0",
|
|
43
43
|
"@lit-labs/ssr-client": "^1.1.8",
|
|
44
|
+
"cheerio": "^1.2.0",
|
|
44
45
|
"lit-html": "^3.3.3",
|
|
45
46
|
"mutative": "^1.3.0"
|
|
46
47
|
}
|