halfcab 14.0.5 → 15.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/example/app.js +148 -0
- package/example/index.html +50 -0
- package/example/ssr-browser-stub.js +9 -0
- package/halfcab.mjs +131 -127
- package/package.json +7 -6
- package/test.js +27 -28
- package/test_ssr_switch.mjs +33 -0
package/example/app.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import halfcab, {
|
|
2
|
+
html,
|
|
3
|
+
css,
|
|
4
|
+
injectMarkdown,
|
|
5
|
+
defineRoute,
|
|
6
|
+
gotoRoute,
|
|
7
|
+
updateState,
|
|
8
|
+
formField,
|
|
9
|
+
formIsValid,
|
|
10
|
+
getRouteComponent,
|
|
11
|
+
state,
|
|
12
|
+
nextTick
|
|
13
|
+
} from '../halfcab.mjs'
|
|
14
|
+
|
|
15
|
+
// Some demo styles using the css tag helper
|
|
16
|
+
const styles = css`
|
|
17
|
+
.container {
|
|
18
|
+
max-width: 880px;
|
|
19
|
+
margin: 0 auto;
|
|
20
|
+
}
|
|
21
|
+
.counter {
|
|
22
|
+
display: inline-flex;
|
|
23
|
+
align-items: center;
|
|
24
|
+
gap: 8px;
|
|
25
|
+
padding: 8px 12px;
|
|
26
|
+
border-radius: 8px;
|
|
27
|
+
border: 1px solid #8884;
|
|
28
|
+
}
|
|
29
|
+
.navLink.active {
|
|
30
|
+
font-weight: 600;
|
|
31
|
+
text-decoration: underline;
|
|
32
|
+
}
|
|
33
|
+
.card {
|
|
34
|
+
padding: 12px;
|
|
35
|
+
border: 1px solid #8884;
|
|
36
|
+
border-radius: 8px;
|
|
37
|
+
background: #00000008;
|
|
38
|
+
margin: 12px 0;
|
|
39
|
+
}
|
|
40
|
+
`
|
|
41
|
+
|
|
42
|
+
// Demo components
|
|
43
|
+
const Home = (args) => html`
|
|
44
|
+
<section class="${styles.container}">
|
|
45
|
+
<h2>Welcome to halfcab + lit demo</h2>
|
|
46
|
+
<div class="${styles.card}">
|
|
47
|
+
${injectMarkdown(`
|
|
48
|
+
### Features shown
|
|
49
|
+
- Client-side routing
|
|
50
|
+
- Global state + rerender
|
|
51
|
+
- CSS via css helper
|
|
52
|
+
- Form helpers and validation
|
|
53
|
+
- Markdown injection
|
|
54
|
+
`)}
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div class="${styles.card}">
|
|
58
|
+
<h3>Counter</h3>
|
|
59
|
+
<div class="${styles.counter}">
|
|
60
|
+
<button onclick=${() => updateState({ count: (state.count || 0) - 1 })} aria-label="decrement">–</button>
|
|
61
|
+
<strong>${state.count || 0}</strong>
|
|
62
|
+
<button onclick=${() => updateState({ count: (state.count || 0) + 1 })} aria-label="increment">+</button>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</section>
|
|
66
|
+
`
|
|
67
|
+
|
|
68
|
+
const About = () => html`
|
|
69
|
+
<section class="${styles.container}">
|
|
70
|
+
<h2>About</h2>
|
|
71
|
+
<p>This demo page is here to help you quickly test halfcab components and interactions.</p>
|
|
72
|
+
<p>Source: <code>example/</code> directory.</p>
|
|
73
|
+
</section>
|
|
74
|
+
`
|
|
75
|
+
|
|
76
|
+
const Form = () => {
|
|
77
|
+
// A simple form using formField + validation
|
|
78
|
+
state.form = state.form || { name: '', email: '' }
|
|
79
|
+
const bindName = formField(state.form, 'name')
|
|
80
|
+
const bindEmail = formField(state.form, 'email')
|
|
81
|
+
const submit = (e) => {
|
|
82
|
+
e.preventDefault()
|
|
83
|
+
if (formIsValid(state.form)) {
|
|
84
|
+
alert(`Submitted: ${state.form.name} <${state.form.email}>`)
|
|
85
|
+
} else {
|
|
86
|
+
alert('Form is invalid. Please fix errors.')
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return html`
|
|
90
|
+
<section class="${styles.container}">
|
|
91
|
+
<h2>Form</h2>
|
|
92
|
+
<form onsubmit=${submit} novalidate>
|
|
93
|
+
<div class="${styles.card}">
|
|
94
|
+
<label>
|
|
95
|
+
Name
|
|
96
|
+
<input type="text" required placeholder="Ada Lovelace" oninput=${bindName} value="${state.form.name}" />
|
|
97
|
+
</label>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="${styles.card}">
|
|
100
|
+
<label>
|
|
101
|
+
Email
|
|
102
|
+
<input type="email" required placeholder="ada@example.com" oninput=${bindEmail} value="${state.form.email}" />
|
|
103
|
+
</label>
|
|
104
|
+
</div>
|
|
105
|
+
<button type="submit">Submit</button>
|
|
106
|
+
</form>
|
|
107
|
+
</section>
|
|
108
|
+
`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Define routes
|
|
112
|
+
defineRoute({ path: '/', title: 'Home', component: Home })
|
|
113
|
+
defineRoute({ path: '/about', title: 'About', component: About })
|
|
114
|
+
defineRoute({ path: '/form', title: 'Form', component: Form })
|
|
115
|
+
|
|
116
|
+
// Application shell
|
|
117
|
+
const Shell = () => {
|
|
118
|
+
const pathname = (state.router && state.router.pathname) || '/'
|
|
119
|
+
const NavLink = (to, label) => html`
|
|
120
|
+
<a class="navLink ${pathname === to ? 'active' : ''}"
|
|
121
|
+
href="${to}"
|
|
122
|
+
onclick=${(e) => { e.preventDefault(); gotoRoute(to) }}>${label}</a>
|
|
123
|
+
`
|
|
124
|
+
|
|
125
|
+
const Current = getRouteComponent(pathname) || Home
|
|
126
|
+
|
|
127
|
+
return html`
|
|
128
|
+
<header>
|
|
129
|
+
${NavLink('/', 'Home')}
|
|
130
|
+
${NavLink('/about', 'About')}
|
|
131
|
+
${NavLink('/form', 'Form')}
|
|
132
|
+
</header>
|
|
133
|
+
<main>
|
|
134
|
+
${Current({})}
|
|
135
|
+
</main>
|
|
136
|
+
`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Boot the app
|
|
140
|
+
halfcab({
|
|
141
|
+
el: '#app',
|
|
142
|
+
components: Shell
|
|
143
|
+
}).then(() => {
|
|
144
|
+
// Ensure initial render after possible async state init
|
|
145
|
+
nextTick(() => {})
|
|
146
|
+
}).catch((err) => {
|
|
147
|
+
console.error('Failed to start halfcab demo:', err)
|
|
148
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>halfcab demo</title>
|
|
7
|
+
<base href="/example/">
|
|
8
|
+
<script type="importmap">
|
|
9
|
+
{
|
|
10
|
+
"imports": {
|
|
11
|
+
"shifty-router": "https://esm.sh/shifty-router@0.1.1",
|
|
12
|
+
"shifty-router/href.js": "https://esm.sh/shifty-router@0.1.1/href.js",
|
|
13
|
+
"shifty-router/history.js": "https://esm.sh/shifty-router@0.1.1/history.js",
|
|
14
|
+
"shifty-router/create-location.js": "https://esm.sh/shifty-router@0.1.1/create-location.js",
|
|
15
|
+
|
|
16
|
+
"lit": "https://esm.sh/lit@3.3.2",
|
|
17
|
+
"@lit-labs/ssr-client": "https://esm.sh/@lit-labs/ssr-client@1.1.8",
|
|
18
|
+
"@lit-labs/ssr": "/example/ssr-browser-stub.js",
|
|
19
|
+
|
|
20
|
+
"axios": "https://esm.sh/axios@0.26.1",
|
|
21
|
+
"csjs-inject": "https://esm.sh/csjs-inject@1.0.1",
|
|
22
|
+
"deepmerge": "https://esm.sh/deepmerge@4.0.0",
|
|
23
|
+
"marked": "https://esm.sh/marked@0.7.0",
|
|
24
|
+
"html-entities": "https://esm.sh/html-entities@2.3.2",
|
|
25
|
+
"qs": "https://esm.sh/qs@6.5.2",
|
|
26
|
+
"nanolru": "https://esm.sh/nanolru@1.0.0",
|
|
27
|
+
"nanocomponent": "https://esm.sh/nanocomponent@6.5.2",
|
|
28
|
+
"deep-object-diff": "https://esm.sh/deep-object-diff@1.1.0",
|
|
29
|
+
"fast-clone": "https://esm.sh/fast-clone@1.5.13",
|
|
30
|
+
"event-emitter": "https://esm.sh/event-emitter@0.3.5"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
</script>
|
|
34
|
+
<style>
|
|
35
|
+
:root { color-scheme: light dark; }
|
|
36
|
+
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; margin: 0; padding: 0; }
|
|
37
|
+
header { padding: 12px 16px; border-bottom: 1px solid #ccc; display: flex; gap: 12px; align-items: center; }
|
|
38
|
+
header a { text-decoration: none; color: inherit; padding: 6px 10px; border-radius: 6px; }
|
|
39
|
+
header a.active { background: rgba(0,0,0,0.08); }
|
|
40
|
+
main { padding: 16px; }
|
|
41
|
+
code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
|
42
|
+
</style>
|
|
43
|
+
</head>
|
|
44
|
+
<body>
|
|
45
|
+
<!-- Optional initial state for router bootstrapping -->
|
|
46
|
+
<div data-initial="eyJyb3V0ZXIiOnsicGF0aG5hbWUiOiIvIn19"></div>
|
|
47
|
+
<div id="app"></div>
|
|
48
|
+
<script type="module" src="./app.js"></script>
|
|
49
|
+
</body>
|
|
50
|
+
</html>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Browser stub for @lit-labs/ssr used by halfcab.mjs during client-side demo.
|
|
2
|
+
// The real SSR renderer is only needed on the server. We export a minimal
|
|
3
|
+
// compatible surface so ESM import resolution succeeds in the browser.
|
|
4
|
+
|
|
5
|
+
export function render() {
|
|
6
|
+
throw new Error("@lit-labs/ssr 'render' is a server-only API and should not be called in the browser.");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default { render };
|
package/halfcab.mjs
CHANGED
|
@@ -2,11 +2,9 @@ import shiftyRouterModule from 'shifty-router'
|
|
|
2
2
|
import hrefModule from 'shifty-router/href.js'
|
|
3
3
|
import historyModule from 'shifty-router/history.js'
|
|
4
4
|
import createLocation from 'shifty-router/create-location.js'
|
|
5
|
-
import { html as litHtml, render
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { classMap } from 'lit-html/directives/class-map.js'
|
|
9
|
-
import { styleMap } from 'lit-html/directives/style-map.js'
|
|
5
|
+
import { html as litHtml, render } from 'lit'
|
|
6
|
+
import { render as renderSSR } from '@lit-labs/ssr'
|
|
7
|
+
import { hydrate } from '@lit-labs/ssr-client'
|
|
10
8
|
import axios from 'axios'
|
|
11
9
|
import cssInject from 'csjs-inject'
|
|
12
10
|
import merge from 'deepmerge'
|
|
@@ -14,9 +12,12 @@ import marked from 'marked'
|
|
|
14
12
|
import { decode } from 'html-entities'
|
|
15
13
|
import eventEmitter from './eventEmitter/index.mjs'
|
|
16
14
|
import qs from 'qs'
|
|
15
|
+
import LRU from 'nanolru'
|
|
16
|
+
import Component from 'nanocomponent'
|
|
17
17
|
import * as deepDiff from 'deep-object-diff'
|
|
18
18
|
import clone from 'fast-clone'
|
|
19
|
-
|
|
19
|
+
|
|
20
|
+
const cache = LRU(5000)
|
|
20
21
|
|
|
21
22
|
let cssTag = cssInject
|
|
22
23
|
let componentCSSString = ''
|
|
@@ -28,7 +29,6 @@ let rootEl
|
|
|
28
29
|
let components
|
|
29
30
|
let dataInitial
|
|
30
31
|
let el
|
|
31
|
-
let componentIndex = 0
|
|
32
32
|
|
|
33
33
|
marked.setOptions({
|
|
34
34
|
breaks: true
|
|
@@ -69,97 +69,88 @@ if (typeof window !== 'undefined') {
|
|
|
69
69
|
|
|
70
70
|
let geb = new eventEmitter({state})
|
|
71
71
|
|
|
72
|
+
const stringsCache = new WeakMap()
|
|
73
|
+
|
|
72
74
|
let html = (strings, ...values) => {
|
|
73
75
|
// fix for allowing csjs to coexist with lit-html
|
|
74
76
|
values = values.map(value => {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
if (value && value.hasOwnProperty('toString') && !value.hasOwnProperty('_$litType$')) {
|
|
78
|
+
// Check if it's a template result (lit-html object). If not, and has toString (like CSJS object), stringify it.
|
|
79
|
+
if (Array.isArray(value)) return value;
|
|
80
|
+
if (typeof value === 'object' && value !== null) {
|
|
81
|
+
if (value['_$litType$'] !== undefined) return value; // It's a TemplateResult
|
|
82
|
+
// CSJS object:
|
|
83
|
+
if (value.toString && value.toString !== Object.prototype.toString) {
|
|
84
|
+
return value.toString()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
80
87
|
}
|
|
81
88
|
return value
|
|
82
89
|
})
|
|
83
90
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return value.map(resolveTemplate).join('')
|
|
99
|
-
}
|
|
100
|
-
if (value && typeof value === 'object') {
|
|
101
|
-
if (value.strings) {
|
|
102
|
-
let result = ''
|
|
103
|
-
const { strings, values } = value
|
|
104
|
-
for (let i = 0; i < strings.length; i++) {
|
|
105
|
-
result += strings[i]
|
|
106
|
-
if (i < values.length) {
|
|
107
|
-
result += resolveTemplate(values[i])
|
|
91
|
+
// Conversion for onEvent=${fn} to @event=${fn}
|
|
92
|
+
let newStrings = stringsCache.get(strings)
|
|
93
|
+
if (!newStrings) {
|
|
94
|
+
const newRaw = strings.raw ? [...strings.raw] : [...strings]
|
|
95
|
+
const newVals = [...strings]
|
|
96
|
+
const onEventRegex = /on([a-zA-Z]+)=$/
|
|
97
|
+
|
|
98
|
+
for (let i = 0; i < newVals.length; i++) {
|
|
99
|
+
let match = newVals[i].match(onEventRegex)
|
|
100
|
+
if (match) {
|
|
101
|
+
const eventName = match[1]
|
|
102
|
+
const replacement = `@${eventName}=`
|
|
103
|
+
newVals[i] = newVals[i].replace(onEventRegex, replacement)
|
|
104
|
+
newRaw[i] = newRaw[i].replace(onEventRegex, replacement)
|
|
108
105
|
}
|
|
109
|
-
}
|
|
110
|
-
return result
|
|
111
106
|
}
|
|
107
|
+
|
|
108
|
+
newStrings = newVals
|
|
109
|
+
newStrings.raw = newRaw
|
|
110
|
+
stringsCache.set(strings, newStrings)
|
|
111
|
+
}
|
|
112
112
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (directiveClass.directiveName === 'unsafeHTML' && value.values && value.values.length > 0) {
|
|
116
|
-
return String(value.values[0])
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (directiveClass === RepeatDirective) {
|
|
120
|
-
const items = value.values[0]
|
|
121
|
-
const templateFn = value.values[value.values.length - 1]
|
|
122
|
-
if (items && typeof templateFn === 'function') {
|
|
123
|
-
return Array.from(items).map((item, index) => resolveTemplate(templateFn(item, index))).join('')
|
|
124
|
-
}
|
|
125
|
-
return ''
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (directiveClass === ClassMapDirective) {
|
|
129
|
-
const classObj = value.values[0]
|
|
130
|
-
if (typeof classObj === 'object') {
|
|
131
|
-
return Object.keys(classObj)
|
|
132
|
-
.filter(key => classObj[key])
|
|
133
|
-
.join(' ')
|
|
134
|
-
}
|
|
135
|
-
return ''
|
|
136
|
-
}
|
|
113
|
+
return litHtml(newStrings, ...values)
|
|
114
|
+
}
|
|
137
115
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
116
|
+
// Detect if a container likely contains Lit SSR markers so hydration is safe
|
|
117
|
+
function canHydrateContainer (container) {
|
|
118
|
+
try {
|
|
119
|
+
if (!container || !container.hasChildNodes()) return false
|
|
120
|
+
// Walk comment nodes looking for lit markers inserted by @lit-labs/ssr
|
|
121
|
+
const walker = document.createTreeWalker(
|
|
122
|
+
container,
|
|
123
|
+
NodeFilter.SHOW_COMMENT,
|
|
124
|
+
null,
|
|
125
|
+
false
|
|
126
|
+
)
|
|
127
|
+
let n = walker.nextNode()
|
|
128
|
+
while (n) {
|
|
129
|
+
const data = (n.data || '').toLowerCase()
|
|
130
|
+
if (
|
|
131
|
+
data.includes('lit-part') ||
|
|
132
|
+
data.includes('lit$') ||
|
|
133
|
+
data.includes('lit-ssr')
|
|
134
|
+
) {
|
|
135
|
+
return true
|
|
146
136
|
}
|
|
137
|
+
n = walker.nextNode()
|
|
147
138
|
}
|
|
139
|
+
} catch (e) {
|
|
140
|
+
// If anything goes wrong, err on the safe side and do not hydrate
|
|
141
|
+
return false
|
|
148
142
|
}
|
|
149
|
-
|
|
150
|
-
if (typeof value === 'function') {
|
|
151
|
-
return ''
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
return value === undefined || value === null ? '' : String(value)
|
|
143
|
+
return false
|
|
155
144
|
}
|
|
156
145
|
|
|
157
146
|
function ssr (rootComponent) {
|
|
158
|
-
//
|
|
147
|
+
// Use @lit-labs/ssr render
|
|
148
|
+
// It returns an iterable
|
|
149
|
+
const resultIterator = renderSSR(rootComponent)
|
|
159
150
|
let componentsString = ''
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
151
|
+
for (const chunk of resultIterator) {
|
|
152
|
+
componentsString += chunk
|
|
153
|
+
}
|
|
163
154
|
return {componentsString, stylesString: componentCSSString}
|
|
164
155
|
}
|
|
165
156
|
|
|
@@ -320,13 +311,13 @@ function nextTick (func) {
|
|
|
320
311
|
|
|
321
312
|
function stateUpdated () {
|
|
322
313
|
if (rootEl) {
|
|
323
|
-
componentIndex = 0
|
|
324
314
|
let startTime = Date.now()
|
|
325
|
-
let
|
|
315
|
+
let newTemplate = components(state)
|
|
326
316
|
console.log(`Component render: ${Date.now() - startTime}`)
|
|
327
317
|
startTime = Date.now()
|
|
328
|
-
|
|
329
|
-
|
|
318
|
+
// Render into the container (rootEl)
|
|
319
|
+
render(newTemplate, rootEl)
|
|
320
|
+
console.log(`DOM update: ${Date.now() - startTime}`)
|
|
330
321
|
}
|
|
331
322
|
}
|
|
332
323
|
|
|
@@ -352,7 +343,8 @@ function updateState (updateObject, options) {
|
|
|
352
343
|
|
|
353
344
|
debounce(stateUpdated)
|
|
354
345
|
|
|
355
|
-
|
|
346
|
+
// Avoid referencing process in browsers without a bundler (process is undefined)
|
|
347
|
+
if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production') {
|
|
356
348
|
console.log('------STATE UPDATE------')
|
|
357
349
|
console.log(updateObject)
|
|
358
350
|
console.log(' ')
|
|
@@ -365,32 +357,20 @@ function updateState (updateObject, options) {
|
|
|
365
357
|
}
|
|
366
358
|
|
|
367
359
|
function emptySSRVideos (c) {
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
let autoplayTrue = c.querySelectorAll('video[autoplay="true"]')
|
|
372
|
-
let autoplayAutoplay = c.querySelectorAll('video[autoplay="autoplay"]')
|
|
373
|
-
let autoplayOn = c.querySelectorAll('video[autoplay="on"]')
|
|
374
|
-
let selectors = [autoplayTrue, autoplayAutoplay, autoplayOn]
|
|
375
|
-
selectors.forEach(selector => {
|
|
376
|
-
Array.from(selector).forEach(video => {
|
|
377
|
-
video.pause()
|
|
378
|
-
Array.from(video.childNodes).forEach(source => {
|
|
379
|
-
source.src && (source.src = '')
|
|
380
|
-
})
|
|
381
|
-
})
|
|
382
|
-
})
|
|
360
|
+
// This was for nanomorph. Lit handles updates differently.
|
|
361
|
+
// If we need to manipulate DOM before render, it's harder with Templates.
|
|
362
|
+
// Leaving empty or deprecated.
|
|
383
363
|
}
|
|
384
364
|
|
|
385
365
|
function injectHTML (htmlString, options) {
|
|
386
366
|
if (options && options.wrapper === false) {
|
|
387
|
-
return
|
|
367
|
+
return html([htmlString])
|
|
388
368
|
}
|
|
389
|
-
return html`<div>${
|
|
369
|
+
return html([`<div>${htmlString}</div>`])
|
|
390
370
|
}
|
|
391
371
|
|
|
392
372
|
function injectMarkdown (mdString, options) {
|
|
393
|
-
return injectHTML(decode(marked(mdString)), options)
|
|
373
|
+
return injectHTML(decode(marked(mdString)), options)
|
|
394
374
|
}
|
|
395
375
|
|
|
396
376
|
function gotoRoute (route) {
|
|
@@ -502,26 +482,30 @@ export default (config, {shiftyRouter = shiftyRouterModule, href = hrefModule, h
|
|
|
502
482
|
gotoRoute(location.href)
|
|
503
483
|
})
|
|
504
484
|
|
|
505
|
-
|
|
506
|
-
let c = components(state)//root element generated by components
|
|
485
|
+
let c = components(state)// component template
|
|
507
486
|
if (el) {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
if (
|
|
513
|
-
|
|
514
|
-
|
|
487
|
+
// rootEl is the container
|
|
488
|
+
rootEl = document.querySelector(el)
|
|
489
|
+
|
|
490
|
+
// Initial render. Only hydrate when container has Lit SSR markers.
|
|
491
|
+
if (canHydrateContainer(rootEl)) {
|
|
492
|
+
try {
|
|
493
|
+
hydrate(c, rootEl)
|
|
494
|
+
} catch (e) {
|
|
495
|
+
// Fallback to render if hydration fails (or if not SSR'd by Lit)
|
|
496
|
+
console.warn('Hydration failed or not applicable, falling back to render', e)
|
|
497
|
+
render(c, rootEl)
|
|
498
|
+
}
|
|
515
499
|
} else {
|
|
516
|
-
rootEl
|
|
500
|
+
render(c, rootEl)
|
|
517
501
|
}
|
|
518
|
-
|
|
502
|
+
|
|
519
503
|
return resolve({rootEl, state})
|
|
520
504
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
505
|
+
// If no root element provided?
|
|
506
|
+
rootEl = null
|
|
507
|
+
// We return 'c' which is now a TemplateResult.
|
|
508
|
+
resolve({rootEl: c, state})
|
|
525
509
|
})
|
|
526
510
|
}
|
|
527
511
|
|
|
@@ -529,20 +513,39 @@ function rerender () {
|
|
|
529
513
|
debounce(stateUpdated)
|
|
530
514
|
}
|
|
531
515
|
|
|
532
|
-
class Component {
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
return html``
|
|
516
|
+
class PureComponent extends Component {
|
|
517
|
+
createElement (args) {
|
|
518
|
+
this.args = clone(args)
|
|
536
519
|
}
|
|
537
|
-
}
|
|
538
520
|
|
|
539
|
-
|
|
521
|
+
update (args) {
|
|
522
|
+
let diff = deepDiff.diff(this.args, args)
|
|
523
|
+
Object.keys(diff).forEach(key => {
|
|
524
|
+
if (typeof diff[key] === 'function') {
|
|
525
|
+
this[key] = args[key]
|
|
526
|
+
}
|
|
527
|
+
})
|
|
528
|
+
return !!Object.keys(diff).find(key => typeof diff[key] !== 'function')
|
|
529
|
+
}
|
|
530
|
+
}
|
|
540
531
|
|
|
541
532
|
function cachedComponent (Class, args, id) {
|
|
542
|
-
|
|
533
|
+
let instance
|
|
534
|
+
if (id) {
|
|
535
|
+
let found = cache.get(id)
|
|
536
|
+
if (found) {
|
|
537
|
+
instance = found
|
|
538
|
+
} else {
|
|
539
|
+
instance = new Class()
|
|
540
|
+
cache.set(id, instance)
|
|
541
|
+
}
|
|
542
|
+
return instance.render(args)
|
|
543
|
+
} else {
|
|
544
|
+
instance = new Class()
|
|
545
|
+
return instance.createElement(args)
|
|
546
|
+
}
|
|
543
547
|
}
|
|
544
548
|
|
|
545
|
-
|
|
546
549
|
export {
|
|
547
550
|
getRouteComponent,
|
|
548
551
|
rerender,
|
|
@@ -555,6 +558,7 @@ export {
|
|
|
555
558
|
html,
|
|
556
559
|
defineRoute,
|
|
557
560
|
updateState,
|
|
561
|
+
state,
|
|
558
562
|
formField,
|
|
559
563
|
gotoRoute,
|
|
560
564
|
cssTag as css,
|
|
@@ -568,4 +572,4 @@ export {
|
|
|
568
572
|
LRU,
|
|
569
573
|
cachedComponent,
|
|
570
574
|
PureComponent
|
|
571
|
-
}
|
|
575
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "halfcab",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "15.0.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A simple universal JavaScript framework focused on making use of es2015 template strings to build components.",
|
|
6
6
|
"main": "halfcab.mjs",
|
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
"test:coverage": "c8 --reporter=html --check-coverage --lines 75 --functions 75 --branches 75 npm test",
|
|
12
12
|
"test:coveralls": "c8 npm test && c8 report --reporter=text-lcov | coveralls",
|
|
13
13
|
"versionbump:fix": "npm version patch --no-git-tag-version",
|
|
14
|
-
"versionbump:feature": "npm version
|
|
14
|
+
"versionbump:feature": "npm version major --no-git-tag-version",
|
|
15
15
|
"versionbump:breakingchanges": "npm version major --no-git-tag-version",
|
|
16
|
-
"npm-publish": "npm publish"
|
|
16
|
+
"npm-publish": "npm publish --tag beta",
|
|
17
|
+
"start:example": "npx --yes serve . -l 5173"
|
|
17
18
|
},
|
|
18
19
|
"repository": {
|
|
19
20
|
"type": "git",
|
|
@@ -47,6 +48,8 @@
|
|
|
47
48
|
"sinon-chai": "^3.0.0"
|
|
48
49
|
},
|
|
49
50
|
"dependencies": {
|
|
51
|
+
"@lit-labs/ssr": "^4.0.0",
|
|
52
|
+
"@lit-labs/ssr-client": "^1.1.8",
|
|
50
53
|
"axios": "^0.26.1",
|
|
51
54
|
"csjs-inject": "^1.0.1",
|
|
52
55
|
"deep-object-diff": "^1.1.0",
|
|
@@ -54,12 +57,10 @@
|
|
|
54
57
|
"event-emitter": "^0.3.5",
|
|
55
58
|
"fast-clone": "^1.5.13",
|
|
56
59
|
"html-entities": "^2.3.2",
|
|
57
|
-
"lit
|
|
60
|
+
"lit": "^3.3.2",
|
|
58
61
|
"marked": "^0.7.0",
|
|
59
62
|
"nanocomponent": "^6.5.2",
|
|
60
|
-
"nanohtml": "^1.6.3",
|
|
61
63
|
"nanolru": "^1.0.0",
|
|
62
|
-
"nanomorph": "^5.4.0",
|
|
63
64
|
"qs": "^6.5.2",
|
|
64
65
|
"shifty-router": "^0.1.1"
|
|
65
66
|
},
|
package/test.js
CHANGED
|
@@ -3,25 +3,12 @@ import dirtyChai from 'dirty-chai'
|
|
|
3
3
|
import sinon from 'sinon'
|
|
4
4
|
import sinonChai from 'sinon-chai'
|
|
5
5
|
import jsdomGlobal from 'jsdom-global'
|
|
6
|
-
import server from 'nanohtml/lib/server.js'
|
|
7
6
|
|
|
8
7
|
const {expect} = chai
|
|
9
8
|
chai.use(dirtyChai)
|
|
10
9
|
chai.use(sinonChai)
|
|
11
10
|
chai.use(dirtyChai)
|
|
12
11
|
|
|
13
|
-
let serverHtml = (strings, ...values) => {
|
|
14
|
-
// this duplicates the halfcab html function but uses pelo instead of nanohtml
|
|
15
|
-
values = values.map(value => {
|
|
16
|
-
if (value && value.hasOwnProperty('toString')) {
|
|
17
|
-
return value.toString()
|
|
18
|
-
}
|
|
19
|
-
return value
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
return server(strings, ...values)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
12
|
let halfcab, ssr, html, defineRoute, gotoRoute, formField, cache, updateState, injectMarkdown, formIsValid,
|
|
26
13
|
css, state, getRouteComponent, nextTick
|
|
27
14
|
|
|
@@ -29,6 +16,11 @@ function intialData (dataInitial) {
|
|
|
29
16
|
let el = document.createElement('div')
|
|
30
17
|
el.setAttribute('data-initial', dataInitial)
|
|
31
18
|
document.body.appendChild(el)
|
|
19
|
+
|
|
20
|
+
// Also ensure a root element exists for tests that target #root
|
|
21
|
+
let root = document.createElement('div')
|
|
22
|
+
root.id = 'root'
|
|
23
|
+
document.body.appendChild(root)
|
|
32
24
|
}
|
|
33
25
|
|
|
34
26
|
describe('halfcab', () => {
|
|
@@ -36,7 +28,7 @@ describe('halfcab', () => {
|
|
|
36
28
|
describe('Server', () => {
|
|
37
29
|
before(async () => {
|
|
38
30
|
jsdomGlobal()
|
|
39
|
-
intialData('
|
|
31
|
+
intialData('eyJjb250YWN0Ijp7InRpdGxlIjoiQ29udGFjdCBVcyJ9LCJyb3V0ZXIiOnsicGF0aG5hbWUiOiIvIn19')
|
|
40
32
|
let halfcabModule = await import('./halfcab.mjs')
|
|
41
33
|
;({
|
|
42
34
|
ssr,
|
|
@@ -60,8 +52,10 @@ describe('halfcab', () => {
|
|
|
60
52
|
width: 100px;
|
|
61
53
|
}
|
|
62
54
|
`
|
|
63
|
-
|
|
64
|
-
|
|
55
|
+
// Use html instead of serverHtml (which depended on nanohtml)
|
|
56
|
+
// lit-html on server via ssr() should return string
|
|
57
|
+
let {componentsString, stylesString} = ssr(html`
|
|
58
|
+
<div class="${style.myStyle}" @input=${() => {
|
|
65
59
|
}}></div>
|
|
66
60
|
`)
|
|
67
61
|
expect(typeof componentsString === 'string').to.be.true()
|
|
@@ -72,7 +66,7 @@ describe('halfcab', () => {
|
|
|
72
66
|
|
|
73
67
|
before(async () => {
|
|
74
68
|
jsdomGlobal()
|
|
75
|
-
intialData('
|
|
69
|
+
intialData('eyJjb250YWN0Ijp7InRpdGxlIjoiQ29udGFjdCBVcyJ9LCJyb3V0ZXIiOnsicGF0aG5hbWUiOiIvIn19')
|
|
76
70
|
let halfcabModule = await import('./halfcab.mjs')
|
|
77
71
|
;({
|
|
78
72
|
ssr,
|
|
@@ -93,20 +87,19 @@ describe('halfcab', () => {
|
|
|
93
87
|
|
|
94
88
|
it('Produces a TemplateResult when rendering', () => {
|
|
95
89
|
let el = html`
|
|
96
|
-
<div
|
|
90
|
+
<div @input=${() => {
|
|
97
91
|
}}></div>
|
|
98
92
|
`
|
|
99
|
-
|
|
100
|
-
expect(el).to.
|
|
93
|
+
// Check for Lit TemplateResult marker
|
|
94
|
+
expect(el['_$litType$']).to.exist()
|
|
101
95
|
})
|
|
102
96
|
|
|
103
97
|
it('Produces a TemplateResult wrapping as a reusable component', () => {
|
|
104
98
|
let el = args => html`
|
|
105
|
-
<div
|
|
99
|
+
<div @input=${() => {
|
|
106
100
|
}}></div>
|
|
107
101
|
`
|
|
108
|
-
expect(
|
|
109
|
-
expect(el({})).to.have.property('strings')
|
|
102
|
+
expect(el({})['_$litType$']).to.exist()
|
|
110
103
|
})
|
|
111
104
|
|
|
112
105
|
it('Runs halfcab function without error', () => {
|
|
@@ -116,13 +109,14 @@ describe('halfcab', () => {
|
|
|
116
109
|
return html `<div></div>`
|
|
117
110
|
}
|
|
118
111
|
})
|
|
119
|
-
.then(rootEl => {
|
|
112
|
+
.then(({rootEl}) => {
|
|
120
113
|
expect(typeof rootEl === 'object').to.be.true()
|
|
121
114
|
})
|
|
122
115
|
})
|
|
123
116
|
|
|
124
117
|
it('updating state causes a rerender with state', (done) => {
|
|
125
118
|
halfcab({
|
|
119
|
+
el: '#root', // Ensure el is passed so rootEl is the container
|
|
126
120
|
components (args) {
|
|
127
121
|
return html`<div>${args.testing || ''}</div>`
|
|
128
122
|
}
|
|
@@ -139,6 +133,7 @@ describe('halfcab', () => {
|
|
|
139
133
|
|
|
140
134
|
it('updates state without merging arrays when told to', () => {
|
|
141
135
|
return halfcab({
|
|
136
|
+
el: '#root',
|
|
142
137
|
components () {
|
|
143
138
|
return html `<div></div>`
|
|
144
139
|
}
|
|
@@ -165,6 +160,7 @@ describe('halfcab', () => {
|
|
|
165
160
|
}
|
|
166
161
|
`
|
|
167
162
|
return halfcab({
|
|
163
|
+
el: '#root',
|
|
168
164
|
components (args) {
|
|
169
165
|
return html `<div class="${style.myStyle}">${args.testing.inner || ''}</div>`
|
|
170
166
|
}
|
|
@@ -180,6 +176,7 @@ describe('halfcab', () => {
|
|
|
180
176
|
|
|
181
177
|
it('injects external content without error', () => {
|
|
182
178
|
return halfcab({
|
|
179
|
+
el: '#root',
|
|
183
180
|
components (args) {
|
|
184
181
|
return html `<div>${injectMarkdown('### Heading')}</div>`
|
|
185
182
|
}
|
|
@@ -192,6 +189,7 @@ describe('halfcab', () => {
|
|
|
192
189
|
|
|
193
190
|
it('injects markdown without wrapper without error', () => {
|
|
194
191
|
return halfcab({
|
|
192
|
+
el: '#root',
|
|
195
193
|
components (args) {
|
|
196
194
|
return html `<div>${injectMarkdown('### Heading', {wrapper: false})}</div>`
|
|
197
195
|
}
|
|
@@ -364,6 +362,7 @@ describe('halfcab', () => {
|
|
|
364
362
|
})
|
|
365
363
|
|
|
366
364
|
return halfcab({
|
|
365
|
+
el: '#root',
|
|
367
366
|
components () {
|
|
368
367
|
return html `<div></div>`
|
|
369
368
|
}
|
|
@@ -373,8 +372,7 @@ describe('halfcab', () => {
|
|
|
373
372
|
let routing = () => {
|
|
374
373
|
gotoRoute('/testFakeRoute')
|
|
375
374
|
}
|
|
376
|
-
expect(routing).to.not.throw()
|
|
377
|
-
// be fine
|
|
375
|
+
expect(routing).to.not.throw()
|
|
378
376
|
})
|
|
379
377
|
|
|
380
378
|
})
|
|
@@ -382,6 +380,7 @@ describe('halfcab', () => {
|
|
|
382
380
|
it(`Throws an error when a route doesn't exist`, () => {
|
|
383
381
|
|
|
384
382
|
return halfcab({
|
|
383
|
+
el: '#root',
|
|
385
384
|
components () {
|
|
386
385
|
return html `<div></div>`
|
|
387
386
|
}
|
|
@@ -390,8 +389,7 @@ describe('halfcab', () => {
|
|
|
390
389
|
let routing = () => {
|
|
391
390
|
gotoRoute('/thisIsAFakeRoute')
|
|
392
391
|
}
|
|
393
|
-
expect(routing).to.throw()
|
|
394
|
-
// fine
|
|
392
|
+
expect(routing).to.throw()
|
|
395
393
|
})
|
|
396
394
|
|
|
397
395
|
})
|
|
@@ -407,6 +405,7 @@ describe('halfcab', () => {
|
|
|
407
405
|
|
|
408
406
|
it(`Doesn't clone when merging`, (done) => {
|
|
409
407
|
halfcab({
|
|
408
|
+
el: '#root',
|
|
410
409
|
components () {
|
|
411
410
|
return html `<div></div>`
|
|
412
411
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ssr, html } from './halfcab.mjs';
|
|
2
|
+
import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
|
|
3
|
+
import { repeat } from 'lit-html/directives/repeat.js';
|
|
4
|
+
import { classMap } from 'lit-html/directives/class-map.js';
|
|
5
|
+
import { styleMap } from 'lit-html/directives/style-map.js';
|
|
6
|
+
|
|
7
|
+
console.log('--- Testing SSR ---');
|
|
8
|
+
|
|
9
|
+
const items = ['A', 'B', 'C'];
|
|
10
|
+
const classes = { active: true, disabled: false };
|
|
11
|
+
const styles = { color: 'red', 'font-size': '20px' };
|
|
12
|
+
const fn = () => console.log('clicked');
|
|
13
|
+
|
|
14
|
+
const template = html`
|
|
15
|
+
<div class="container">
|
|
16
|
+
<h1>Hello SSR</h1>
|
|
17
|
+
<div class=${classMap(classes)} style=${styleMap(styles)}>Styled</div>
|
|
18
|
+
<ul>
|
|
19
|
+
${repeat(items, (item) => html`<li>${item}</li>`)}
|
|
20
|
+
</ul>
|
|
21
|
+
<div>${unsafeHTML('<span>Unsafe</span>')}</div>
|
|
22
|
+
<button onclick=${fn}>Click</button>
|
|
23
|
+
<button disabled=${null}>Disabled Null</button>
|
|
24
|
+
</div>
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const result = ssr(template);
|
|
29
|
+
console.log('Result componentsString:');
|
|
30
|
+
console.log(result.componentsString);
|
|
31
|
+
} catch (e) {
|
|
32
|
+
console.error('SSR Error:', e);
|
|
33
|
+
}
|