@invisibleloop/pulse 0.14.2 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/.claude/pulse-checklist.md +5 -3
- package/docs/public/.pulse-ui-version +1 -1
- package/docs/public/docs.css +236 -0
- package/docs/public/home-brut.css +998 -0
- package/docs/public/menu.js +237 -6
- package/docs/public/pulse-ui.css +44 -0
- package/docs/public/theme.css +8 -0
- package/docs/server.js +24 -0
- package/docs/src/lib/layout.js +51 -3
- package/docs/src/pages/getting-started.js +1 -1
- package/docs/src/pages/home.js +231 -312
- package/docs/src/pages/how-it-works.js +67 -12
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/scripts/build.js +127 -21
- package/src/agent/guide-examples.md +310 -32
- package/src/cli/dev.js +43 -0
|
@@ -1,54 +1,332 @@
|
|
|
1
|
-
##
|
|
1
|
+
## Pattern: action with validation and field errors
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
import { nav, section, input, textarea, button, alert, container } from '@invisibleloop/pulse/ui'
|
|
5
|
-
import { layout } from '../components/layout.js'
|
|
3
|
+
The correct wiring for a form action with per-field validation errors. `onStart` captures `FormData` before `validate` runs (uncontrolled inputs — never use `data-event` to mirror input values). `validate: true` runs the spec's `validation` rules before `run`. Field errors are surfaced via `state.errors`.
|
|
6
4
|
|
|
5
|
+
```js
|
|
7
6
|
export default {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
styles: ['/app.css', '/pulse-ui.css'],
|
|
11
|
-
},
|
|
7
|
+
route: '/signup',
|
|
8
|
+
meta: { title: 'Sign up', styles: ['/pulse-ui.css', '/app.css'] },
|
|
12
9
|
state: {
|
|
13
|
-
status: 'idle',
|
|
10
|
+
status: 'idle', // 'idle' | 'loading' | 'success' | 'error'
|
|
14
11
|
errors: [],
|
|
12
|
+
fields: { name: '', email: '' },
|
|
13
|
+
},
|
|
14
|
+
validation: {
|
|
15
|
+
'fields.name': { required: true, minLength: 2 },
|
|
16
|
+
'fields.email': { required: true, format: 'email' },
|
|
15
17
|
},
|
|
16
18
|
actions: {
|
|
17
|
-
|
|
19
|
+
submit: {
|
|
18
20
|
onStart: (state, formData) => ({
|
|
19
|
-
status:
|
|
20
|
-
|
|
21
|
-
email:
|
|
22
|
-
message: formData.get('message'),
|
|
21
|
+
status: 'loading',
|
|
22
|
+
errors: [],
|
|
23
|
+
fields: { name: formData.get('name') ?? '', email: formData.get('email') ?? '' },
|
|
23
24
|
}),
|
|
25
|
+
validate: true,
|
|
24
26
|
run: async (state, serverState, formData) => {
|
|
25
|
-
const res = await fetch('/api/
|
|
26
|
-
if (!res.ok)
|
|
27
|
+
const res = await fetch('/api/signup', { method: 'POST', body: formData })
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
let message = `Request failed: ${res.status}`
|
|
30
|
+
try { const j = await res.json(); message = j.message || j.error || message } catch {}
|
|
31
|
+
throw new Error(message)
|
|
32
|
+
}
|
|
27
33
|
return await res.json()
|
|
28
34
|
},
|
|
29
|
-
onSuccess: (state, result) => ({
|
|
30
|
-
|
|
35
|
+
onSuccess: (state, result) => ({
|
|
36
|
+
status: 'success',
|
|
37
|
+
_toast: { message: 'Account created!', variant: 'success' },
|
|
38
|
+
}),
|
|
39
|
+
onError: (state, err) => ({
|
|
31
40
|
status: 'error',
|
|
32
41
|
errors: err?.validation ?? [{ message: err.message }],
|
|
42
|
+
_toast: { message: 'Please check the form', variant: 'error' },
|
|
33
43
|
}),
|
|
34
44
|
},
|
|
35
45
|
},
|
|
36
|
-
view: (state) =>
|
|
37
|
-
|
|
38
|
-
${section({ title: 'Contact Us', subtitle: 'We will get back to you within 24 hours.' })}
|
|
46
|
+
view: (state) => `
|
|
47
|
+
<main id="main-content">
|
|
39
48
|
${state.status === 'success'
|
|
40
|
-
?
|
|
41
|
-
: `<form data-action="
|
|
42
|
-
${
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
${
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
? `<p>You're signed up.</p>`
|
|
50
|
+
: `<form data-action="submit" class="u-flex u-flex-col u-gap-4">
|
|
51
|
+
${input({ name: 'name', label: 'Name', value: state.fields.name,
|
|
52
|
+
error: state.errors.find(e => e.field === 'fields.name')?.message })}
|
|
53
|
+
${input({ name: 'email', label: 'Email', type: 'email', value: state.fields.email,
|
|
54
|
+
error: state.errors.find(e => e.field === 'fields.email')?.message })}
|
|
55
|
+
${button({ label: state.status === 'loading' ? 'Saving…' : 'Sign up',
|
|
56
|
+
type: 'submit', variant: 'primary',
|
|
57
|
+
attrs: state.status === 'loading' ? 'aria-busy="true" disabled' : '' })}
|
|
49
58
|
</form>`
|
|
50
59
|
}
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
</main>
|
|
61
|
+
`,
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Key points:**
|
|
66
|
+
- `onStart` captures `FormData` into state so field values survive the re-render
|
|
67
|
+
- `validate: true` runs before `run()` — validation errors arrive in `onError` as `err.validation`
|
|
68
|
+
- Always check `err?.validation` first; fall back to `err.message`
|
|
69
|
+
- `aria-busy="true"` on the submit button while loading — spinner alone is not sufficient for accessibility
|
|
70
|
+
- `res.ok` checked before parsing; never use `await res.text()` as an error message
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Pattern: server data with dynamic route and missing-data fallback
|
|
75
|
+
|
|
76
|
+
The correct pattern for a dynamic route that loads data from a server fetcher. Always add `onViewError` — a missing or invalid param must not produce a 500.
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
import { md } from '@invisibleloop/pulse/md'
|
|
80
|
+
import { prose } from '@invisibleloop/pulse/ui'
|
|
81
|
+
|
|
82
|
+
const post = md('content/blog/:slug.md')
|
|
83
|
+
|
|
84
|
+
export default {
|
|
85
|
+
route: '/blog/:slug',
|
|
86
|
+
meta: {
|
|
87
|
+
title: async (ctx) => (await post(ctx)).frontmatter.title ?? 'Post not found',
|
|
88
|
+
description: async (ctx) => (await post(ctx)).frontmatter.description ?? '',
|
|
89
|
+
},
|
|
90
|
+
server: { post },
|
|
91
|
+
view: (state, server) => `
|
|
92
|
+
<main id="main-content">
|
|
93
|
+
<h1>${server.post.frontmatter.title}</h1>
|
|
94
|
+
${prose({ content: server.post.html })}
|
|
95
|
+
</main>
|
|
96
|
+
`,
|
|
97
|
+
onViewError: (err) => `
|
|
98
|
+
<main id="main-content">
|
|
99
|
+
<p>Post not found. <a href="/blog">Back to blog</a></p>
|
|
100
|
+
</main>
|
|
101
|
+
`,
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Key points:**
|
|
106
|
+
- Call the same `md()` fetcher in both `meta` and `server` — it caches on `ctx._mdCache`, file is read once
|
|
107
|
+
- `onViewError` is required on any dynamic route where the view can receive null/missing data
|
|
108
|
+
- For database-backed routes: validate `ctx.params.id` before use; return a 404-equivalent via `onViewError` if not found
|
|
109
|
+
- Use `?.` and `??` throughout the view when server data can be null
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Pattern: streaming SSR with scoped fetchers
|
|
114
|
+
|
|
115
|
+
Use streaming when a page has slow data (feeds, lists, analytics) and you want the shell to paint instantly. `scope` prevents slow fetchers from blocking the shell.
|
|
116
|
+
|
|
117
|
+
```js
|
|
118
|
+
export default {
|
|
119
|
+
route: '/dashboard',
|
|
120
|
+
meta: { title: 'Dashboard', styles: ['/pulse-ui.css', '/app.css'] },
|
|
121
|
+
state: {},
|
|
122
|
+
server: {
|
|
123
|
+
user: async (ctx) => db.users.findByCookie(ctx.cookies.session), // fast
|
|
124
|
+
feed: async () => fetchFeed(), // slow
|
|
125
|
+
ads: async () => fetchAds(), // slow
|
|
126
|
+
},
|
|
127
|
+
stream: {
|
|
128
|
+
shell: ['header'],
|
|
129
|
+
deferred: ['feed'],
|
|
130
|
+
scope: {
|
|
131
|
+
header: ['user'], // shell sends as soon as 'user' resolves
|
|
132
|
+
feed: ['feed', 'ads'], // deferred sends when both resolve
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
view: {
|
|
136
|
+
header: (state, server) => `
|
|
137
|
+
<header>
|
|
138
|
+
<p>Hello, ${server.user?.name ?? 'Guest'}</p>
|
|
139
|
+
</header>
|
|
140
|
+
`,
|
|
141
|
+
feed: (state, server) => `
|
|
142
|
+
<ul>
|
|
143
|
+
${server.feed.map(item => `<li data-key="${item.id}">${item.title}</li>`).join('')}
|
|
144
|
+
</ul>
|
|
145
|
+
`,
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**Key points:**
|
|
151
|
+
- `view` must be a named object of functions — not a single function — when using `stream`
|
|
152
|
+
- `scope` is optional but important: without it the shell waits for all fetchers
|
|
153
|
+
- `data-key` on list items enables O(1) keyed reconciliation when the list updates client-side
|
|
154
|
+
- A fetcher listed in `scope.header` but slow will delay the shell — keep shell fetchers fast
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Pattern: global store subscription and `_storeUpdate`
|
|
159
|
+
|
|
160
|
+
Use the global store for data shared across pages (user session, cart, theme). Read store data via `spec.store`; push changes back with `_storeUpdate` from `onSuccess`.
|
|
161
|
+
|
|
162
|
+
```js
|
|
163
|
+
// Page that reads from store and updates it on save
|
|
164
|
+
export default {
|
|
165
|
+
route: '/settings',
|
|
166
|
+
store: ['user', 'settings'], // declares which store keys this page subscribes to
|
|
167
|
+
meta: { title: 'Settings', styles: ['/pulse-ui.css', '/app.css'] },
|
|
168
|
+
state: { status: 'idle' },
|
|
169
|
+
actions: {
|
|
170
|
+
save: {
|
|
171
|
+
onStart: (state, formData) => ({ status: 'loading' }),
|
|
172
|
+
run: async (state, serverState, formData) => {
|
|
173
|
+
const theme = formData.get('theme')
|
|
174
|
+
const res = await fetch('/api/settings', {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
body: JSON.stringify({ theme }),
|
|
177
|
+
headers: { 'Content-Type': 'application/json' },
|
|
178
|
+
})
|
|
179
|
+
if (!res.ok) throw new Error(`Save failed: ${res.status}`)
|
|
180
|
+
return { theme }
|
|
181
|
+
},
|
|
182
|
+
onSuccess: (state, { theme }) => ({
|
|
183
|
+
status: 'idle',
|
|
184
|
+
_storeUpdate: { settings: { theme } }, // merged into store; all subscribed pages re-render
|
|
185
|
+
_toast: { message: 'Saved', variant: 'success' },
|
|
186
|
+
}),
|
|
187
|
+
onError: (state, err) => ({
|
|
188
|
+
status: 'error',
|
|
189
|
+
_toast: { message: err.message, variant: 'error' },
|
|
190
|
+
}),
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
view: (state, server) => `
|
|
194
|
+
<main id="main-content">
|
|
195
|
+
<form data-action="save" class="u-flex u-flex-col u-gap-4">
|
|
196
|
+
${select({ name: 'theme', label: 'Theme',
|
|
197
|
+
options: [{ value: 'dark', label: 'Dark' }, { value: 'light', label: 'Light' }],
|
|
198
|
+
value: server.settings?.theme ?? 'dark' })}
|
|
199
|
+
${button({ label: state.status === 'loading' ? 'Saving…' : 'Save', type: 'submit', variant: 'primary' })}
|
|
200
|
+
</form>
|
|
201
|
+
</main>
|
|
202
|
+
`,
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**Key points:**
|
|
207
|
+
- `spec.store` declares subscriptions — store data appears in the view's `server` argument
|
|
208
|
+
- `_storeUpdate` is stripped from page state and merged into the store — do not store it
|
|
209
|
+
- All pages subscribed to the affected store keys re-render without a server round-trip
|
|
210
|
+
- Never use a full page reload to propagate shared state changes — use `_storeUpdate`
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Pattern: guard with auth redirect
|
|
215
|
+
|
|
216
|
+
Use `guard` to protect routes. It runs before server fetchers. Return `{ redirect }` to send unauthenticated users elsewhere.
|
|
217
|
+
|
|
218
|
+
```js
|
|
219
|
+
export default {
|
|
220
|
+
route: '/account',
|
|
221
|
+
meta: { title: 'Account', styles: ['/pulse-ui.css', '/app.css'] },
|
|
222
|
+
server: {
|
|
223
|
+
user: async (ctx) => db.users.find(ctx.cookies.session),
|
|
224
|
+
},
|
|
225
|
+
guard: async (ctx) => {
|
|
226
|
+
const user = await db.users.findByCookie(ctx.cookies.session)
|
|
227
|
+
if (!user) return { redirect: '/login?next=/account' }
|
|
228
|
+
},
|
|
229
|
+
state: {},
|
|
230
|
+
view: (state, server) => `
|
|
231
|
+
<main id="main-content">
|
|
232
|
+
<h1>Hello, ${server.user?.name}</h1>
|
|
233
|
+
</main>
|
|
234
|
+
`,
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
**Key points:**
|
|
239
|
+
- `guard` runs before server fetchers — returning `{ redirect }` short-circuits the entire request
|
|
240
|
+
- Return nothing (or `undefined`) to allow the request through
|
|
241
|
+
- For shared auth logic, extract to a helper and call it from `guard` on each protected spec
|
|
242
|
+
- `ctx.cookies` is available in `guard` — check session cookies here, not in the view
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Pattern: keyed list with client mutations
|
|
247
|
+
|
|
248
|
+
Use `data-key` on repeated elements whenever a list can be reordered, filtered, or have items added/removed client-side.
|
|
249
|
+
|
|
250
|
+
```js
|
|
251
|
+
export default {
|
|
252
|
+
route: '/todos',
|
|
253
|
+
meta: { title: 'Todos', styles: ['/pulse-ui.css', '/app.css'] },
|
|
254
|
+
state: {
|
|
255
|
+
items: [
|
|
256
|
+
{ id: 1, text: 'Buy milk', done: false },
|
|
257
|
+
{ id: 2, text: 'Write spec', done: false },
|
|
258
|
+
],
|
|
259
|
+
filter: 'all', // 'all' | 'active' | 'done'
|
|
260
|
+
},
|
|
261
|
+
mutations: {
|
|
262
|
+
toggle: (state, e) => {
|
|
263
|
+
const id = Number(e.target.closest('[data-id]').dataset.id)
|
|
264
|
+
return { items: state.items.map(i => i.id === id ? { ...i, done: !i.done } : i) }
|
|
265
|
+
},
|
|
266
|
+
setFilter: (state, e) => ({ filter: e.target.value }),
|
|
267
|
+
},
|
|
268
|
+
view: (state) => {
|
|
269
|
+
const visible = state.items.filter(i =>
|
|
270
|
+
state.filter === 'all' ? true : state.filter === 'done' ? i.done : !i.done
|
|
271
|
+
)
|
|
272
|
+
return `
|
|
273
|
+
<main id="main-content">
|
|
274
|
+
<ul>
|
|
275
|
+
${visible.map(item => `
|
|
276
|
+
<li data-key="${item.id}" data-id="${item.id}">
|
|
277
|
+
<button data-event="toggle" aria-pressed="${item.done}">${item.text}</button>
|
|
278
|
+
</li>
|
|
279
|
+
`).join('')}
|
|
280
|
+
</ul>
|
|
281
|
+
<select data-event="change:setFilter" aria-label="Filter todos">
|
|
282
|
+
<option value="all">All</option>
|
|
283
|
+
<option value="active">Active</option>
|
|
284
|
+
<option value="done">Done</option>
|
|
285
|
+
</select>
|
|
286
|
+
</main>
|
|
287
|
+
`
|
|
288
|
+
},
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**Key points:**
|
|
293
|
+
- `data-key` must be on every sibling element in the container — mixed keyed/unkeyed falls back to position matching
|
|
294
|
+
- Use `e.target.closest('[data-id]')` in mutations — never assume `e.target` is the element with the attribute
|
|
295
|
+
- `aria-pressed` on toggle buttons communicates state to screen readers
|
|
296
|
+
- Mutations are pure — no fetch, no DOM access, return partial state only
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## Pattern: persist — what belongs and what doesn't
|
|
301
|
+
|
|
302
|
+
`persist` survives page reload via `localStorage`. Only serialisable, user-owned state that should survive a refresh belongs here.
|
|
303
|
+
|
|
304
|
+
```js
|
|
305
|
+
export default {
|
|
306
|
+
route: '/builder',
|
|
307
|
+
meta: { title: 'Builder', styles: ['/pulse-ui.css', '/app.css'] },
|
|
308
|
+
state: {
|
|
309
|
+
// ✓ persist these — user-owned, meaningful across sessions
|
|
310
|
+
items: [],
|
|
311
|
+
activeStep: 0,
|
|
312
|
+
draft: { name: '', description: '' },
|
|
313
|
+
|
|
314
|
+
// ✗ do NOT persist these — ephemeral UI state
|
|
315
|
+
status: 'idle',
|
|
316
|
+
errors: [],
|
|
317
|
+
modalOpen: false,
|
|
318
|
+
},
|
|
319
|
+
persist: ['items', 'activeStep', 'draft'],
|
|
320
|
+
mutations: {
|
|
321
|
+
nextStep: (state) => ({ activeStep: Math.min(state.activeStep + 1, 3) }),
|
|
322
|
+
prevStep: (state) => ({ activeStep: Math.max(state.activeStep - 1, 0) }),
|
|
323
|
+
},
|
|
324
|
+
view: (state) => `<main id="main-content">…</main>`,
|
|
53
325
|
}
|
|
54
326
|
```
|
|
327
|
+
|
|
328
|
+
**Key points:**
|
|
329
|
+
- Never persist `status`, `errors`, loading flags, or modal open state — these are session-only
|
|
330
|
+
- Never persist large objects or arrays that could grow unbounded — `localStorage` has a ~5MB limit
|
|
331
|
+
- Restored persist values override spec defaults on mount and trigger a re-render if different
|
|
332
|
+
- `persist` keys must exist in `state` — persisting a key not in state has no effect
|
package/src/cli/dev.js
CHANGED
|
@@ -140,6 +140,48 @@ process.on('SIGINT', closeReloadClients)
|
|
|
140
140
|
let reloadTimer = null
|
|
141
141
|
let lastReload = 0
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Check rendered HTML class names against CSS files in public/ and warn about
|
|
145
|
+
* any classes that are used in templates but not defined in any stylesheet.
|
|
146
|
+
* Only checks project-owned CSS (not pulse-ui.css) to avoid noise.
|
|
147
|
+
*/
|
|
148
|
+
async function checkUndefinedClasses(specs) {
|
|
149
|
+
try {
|
|
150
|
+
// Collect all class names defined in project CSS files
|
|
151
|
+
const cssDir = path.join(ROOT, 'public')
|
|
152
|
+
const definedClasses = new Set()
|
|
153
|
+
if (fs.existsSync(cssDir)) {
|
|
154
|
+
for (const file of fs.readdirSync(cssDir).filter(f => f.endsWith('.css') && f !== 'pulse-ui.css')) {
|
|
155
|
+
const css = fs.readFileSync(path.join(cssDir, file), 'utf8')
|
|
156
|
+
for (const [, cls] of css.matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]*)\s*[{,:\[]/g)) {
|
|
157
|
+
definedClasses.add(cls)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (definedClasses.size === 0) return
|
|
162
|
+
|
|
163
|
+
// Render each spec and collect used class names from the HTML output
|
|
164
|
+
const { renderToString } = await import('../runtime/ssr.js')
|
|
165
|
+
for (const spec of specs) {
|
|
166
|
+
if (!spec.view) continue
|
|
167
|
+
try {
|
|
168
|
+
const { html } = await renderToString(spec, {})
|
|
169
|
+
const usedInSpec = new Set()
|
|
170
|
+
for (const [, classes] of html.matchAll(/class="([^"]+)"/g)) {
|
|
171
|
+
for (const cls of classes.trim().split(/\s+/)) {
|
|
172
|
+
if (cls && !cls.startsWith('ui-')) usedInSpec.add(cls)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const undefined_ = [...usedInSpec].filter(cls => !definedClasses.has(cls))
|
|
176
|
+
if (undefined_.length > 0) {
|
|
177
|
+
const route = spec.route ?? spec.filePath ?? '(unknown)'
|
|
178
|
+
log.warn(`${route}: class${undefined_.length === 1 ? '' : 'es'} used but not defined in any CSS: ${undefined_.slice(0, 5).map(c => `.${c}`).join(', ')}${undefined_.length > 5 ? ` … +${undefined_.length - 5} more` : ''}`)
|
|
179
|
+
}
|
|
180
|
+
} catch { /* render failures are not our concern here */ }
|
|
181
|
+
}
|
|
182
|
+
} catch { /* never crash the dev server over a lint warning */ }
|
|
183
|
+
}
|
|
184
|
+
|
|
143
185
|
async function triggerReload(label = 'File changed') {
|
|
144
186
|
clearTimeout(reloadTimer)
|
|
145
187
|
reloadTimer = setTimeout(async () => {
|
|
@@ -151,6 +193,7 @@ async function triggerReload(label = 'File changed') {
|
|
|
151
193
|
const fresh = await loadPages(ROOT, now)
|
|
152
194
|
updateSpecs(fresh)
|
|
153
195
|
log.info(`${label} — specs reloaded`)
|
|
196
|
+
checkUndefinedClasses(fresh)
|
|
154
197
|
} catch (err) {
|
|
155
198
|
log.error(`Spec reload failed: ${err.message}`)
|
|
156
199
|
}
|