sygnal 6.3.0 → 6.4.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/llms.txt CHANGED
@@ -79,7 +79,7 @@ SaveButton.intent = ({ DOM }) => ({ SAVE: DOM.click('.save') })
79
79
  SaveButton.model = {
80
80
  SAVE: {
81
81
  STATE: (state) => ({ ...state, saving: true }),
82
- EVENTS: event('DOC_SAVED', (state) => ({ id: state.id })), // or event('RESET'), event('MODE', 'dark')
82
+ EVENTS: event('DOC_SAVED', (state) => ({ id: state.id })), // or event('RESET'), event('MODE', 'dark'); return ABORT to send nothing
83
83
  },
84
84
  }
85
85
  function Toast({ state }) { // anywhere in the tree: EVENTS is a global bus
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sygnal",
3
- "version": "6.3.0",
3
+ "version": "6.4.0",
4
4
  "description": "An intuitive framework for building fast and small components or applications based on Cycle.js",
5
5
  "main": "./dist/index.cjs.js",
6
6
  "types": "./dist/index.d.ts",
@@ -25,8 +25,10 @@
25
25
  * that matches in one Collection item is fine for all of them.
26
26
  *
27
27
  * SYG103 noise control (conditional rendering is normal): a selector whose
28
- * class/id/tag tokens all appear in the component's view source is assumed
29
- * to be conditionally rendered and is not reported. A selector that does not
28
+ * class/id/tag tokens all appear in the component's view source (or in the
29
+ * words sygnal/vite registered for it: markup drawn by view helpers in its
30
+ * module and its relative imports) is assumed to be conditionally rendered
31
+ * and is not reported. A selector that does not
30
32
  * appear in the view (typo, or an element rendered elsewhere) is reported as
31
33
  * info on the first sweep, and escalated per the rule above.
32
34
  *
@@ -71,12 +73,18 @@ onReset(() => {
71
73
 
72
74
  const unref = (t: any) => { if (t && typeof t.unref === 'function') t.unref(); return t }
73
75
 
76
+ /**
77
+ * The view's source, plus the words sygnal/vite registered for the component (its module's and
78
+ * its relative imports' string literals: markup a view helper draws), when it did
79
+ */
74
80
  function viewSource(component: any): string {
75
81
  const view = component && component.view
76
82
  if (typeof view !== 'function') return ''
77
83
  let src = viewTokens.get(view)
78
84
  if (src === undefined) {
79
85
  try { src = Function.prototype.toString.call(view) } catch (_) { src = '' }
86
+ const registered = (globalThis as any).__SYGNAL_VIEW_TOKENS__?.get?.(view)
87
+ if (typeof registered == 'string') src += '\n' + registered
80
88
  viewTokens.set(view, src as string)
81
89
  }
82
90
  return src as string
@@ -66,6 +66,18 @@ function removed(component: any, name: string, anchor: string, detail?: string):
66
66
  })
67
67
  }
68
68
 
69
+ /** SYG427: CHILD.select(x) with no component (an import of a name the module doesn't export) */
70
+ function notComponent(f: any, x: any): void {
71
+ if (!once(`SYG427:${f.name}`)) return
72
+ const got = x === null ? 'null' : typeof x == 'object' ? 'an object' : String(x)
73
+ devReport('SYG427', {
74
+ component: f.name,
75
+ message: `${f.name}'s intent calls CHILD.select(${got}): that is not a component, so it never emits`,
76
+ fix: "Pass the child component itself, and check its import: a name its module doesn't export is undefined (Vitest runs on; the browser fails to link the module). Export it: export function Child() { … }",
77
+ data: {got},
78
+ })
79
+ }
80
+
69
81
  /** the removed forms a definition shows (statics, view arity, model keys), once per function */
70
82
  let seenDefs = new WeakSet<object>()
71
83
  // G-332: resetChecks() forgets them (as the once() dedupe)
@@ -381,7 +393,11 @@ export function nextHooks(_api: any): any {
381
393
  get(t: any, k: any) {
382
394
  const v = t[k]
383
395
  if (k !== 'CHILD' || !v || typeof v.select != 'function') return v
384
- return {...v, select: (x: any, ...r: any[]) => { if (typeof x == 'string') removed(f, f.name, 'child-select-name', `CHILD.select('${x}')`); return v.select(x, ...r) }}
396
+ return {...v, select: (x: any, ...r: any[]) => {
397
+ if (typeof x == 'string') removed(f, f.name, 'child-select-name', `CHILD.select('${x}')`)
398
+ else if (typeof x != 'function') notComponent(f, x)
399
+ return v.select(x, ...r)
400
+ }}
385
401
  },
386
402
  })
387
403
  },
@@ -6,7 +6,8 @@
6
6
  * or restored. Under Vike's Layout/Wrapper shell (the `__vike` source) the message says
7
7
  * persist isn't supported on Vike pages, layouts or wrappers yet.
8
8
  * SYG223 (warn) — a `pick` / `omit` key of persist() that isn't a key of the component's
9
- * `initialState` static (a typo: that key is never saved, or never left out).
9
+ * `initialState` static or of its `uses` (a behavior's slice, such as a chat's), so it is
10
+ * a typo: that key is never saved, or never left out.
10
11
  * Once per component name. Mechanism: onModel reads the static `view.persist` (the persist()
11
12
  * value carries its options) and `view.initialState`.
12
13
  */
@@ -38,7 +39,8 @@ export const persistCheck: DiagnosticCheck = {
38
39
  }
39
40
  const o = p.options, init = view.initialState
40
41
  if (!o || !init || typeof init != 'object') return
41
- const keys = Object.keys(init)
42
+ // a behavior's slice (`uses = { chat: chat() }`) is a state key too
43
+ const keys = [...Object.keys(init), ...Object.keys(view.uses || {})]
42
44
  for (const opt of ['pick', 'omit']) {
43
45
  for (const k of Array.isArray(o[opt]) ? o[opt] : []) {
44
46
  if (keys.includes(k) || !once(`SYG223:${name}:${opt}:${k}`)) continue
@@ -36,12 +36,20 @@ const underTest = (c: any) => {
36
36
  const dom = c?.sources?.[c.DOMSourceName || 'DOM']
37
37
  return !!dom && typeof dom == 'object' && '_hub' in dom
38
38
  }
39
- // G-620: an LLM / chat sink gets the chat driver's fix, not makeFetchDriver's
40
- const driverFix = (sink: string) =>
41
- `Pass a driver named ${sink} to run(): ` + (/^(LLM|CHAT|AI|ASSISTANT)$/.test(sink)
42
- ? `run(App, { ${sink}: makeChatDriver({ transport: openResponses({ baseURL: 'http://localhost:11434/v1', model: 'llama3.2' }) }) }) (from 'sygnal/ai'; uiMessageStream('/api/chat') for an AI SDK server)`
43
- : `run(App, { ${sink}: makeFetchDriver() }) for HTTP, or driverFromAsync(fn) / your own driver`) +
44
- `. Check the spelling against the drivers you pass`
39
+ // G-620: the usual driver for a known sink name (an LLM / chat sink gets the chat driver's fix,
40
+ // SW the service worker's), the generic text for any other name
41
+ const DRIVER_FOR: Array<[RegExp, (sink: string) => string]> = [
42
+ [/^(LLM|CHAT|AI|ASSISTANT)$/, (s) => `run(App, { ${s}: makeChatDriver({ transport: openResponses({ baseURL: 'http://localhost:11434/v1', model: 'llama3.2' }) }) }) (from 'sygnal/ai'; uiMessageStream('/api/chat') for an AI SDK server)`],
43
+ [/^(SW|SERVICE_WORKER)$/, (s) => `run(App, { ${s}: makeServiceWorkerDriver('/sw.js', { enabled: import.meta.env.PROD }) }) (enabled: false keeps the driver, with no service worker, on a dev server)`],
44
+ [/^(HTTP|API|FETCH)$/, (s) => `run(App, { ${s}: makeFetchDriver() })`],
45
+ [/^(WS|SOCKET)$/, (s) => `run(App, { ${s}: makeSocketDriver() })`],
46
+ ]
47
+ const driverFix = (sink: string) => {
48
+ const known = DRIVER_FOR.find(([re]) => re.test(sink))
49
+ return `Pass a driver named ${sink} to run(): ` +
50
+ (known ? known[1](sink) : `run(App, { ${sink}: makeFetchDriver() }) for HTTP, or driverFromAsync(fn) / your own driver`) +
51
+ `. Check the spelling against the drivers you pass`
52
+ }
45
53
 
46
54
  export const wiringCheck: DiagnosticCheck = {
47
55
  id: 'wiring',
@@ -278,6 +278,9 @@ export const CODE_TITLES: Record<string, string> = {
278
278
  SYG425: 'isolatedState slice lacks initialState keys',
279
279
  // PLAN-9 (D471): checks/next.ts; dev entry and sygnal-check
280
280
  SYG426: 'Deprecated lowercase marker tag',
281
+ // checks/next.ts and sygnal-check: a component reference that is not a function (an import of
282
+ // a name the module doesn't export is undefined under Vitest)
283
+ SYG427: 'CHILD.select() of something that is not a component',
281
284
  // PLAN-9 (D470): checks/next.ts; dev entry
282
285
  SYG453: 'Two children with the same identity share one instance',
283
286
  SYG601: "Retired in 6.0: Missing or invalid sources",
@@ -486,6 +489,8 @@ export const DEV_CODE_SEVERITY: Record<string, DiagnosticSeverity> = {
486
489
  SYG425: 'warn',
487
490
  // PLAN-9 (checks/next.ts): D471 a lowercase <collection> / <switchable> tag (deprecated in 6.3.0)
488
491
  SYG426: 'warn',
492
+ // checks/next.ts: CHILD.select(undefined), usually a missing export
493
+ SYG427: 'error',
489
494
  // PLAN-9 (checks/next.ts): D470 key identity, two children with one identity
490
495
  SYG453: 'warn',
491
496
  SYG612: 'error',
package/src/extra/pwa.ts CHANGED
@@ -18,6 +18,8 @@ export interface ServiceWorkerCommand {
18
18
 
19
19
  export interface ServiceWorkerOptions {
20
20
  scope?: string;
21
+ /** false: register nothing, ignore commands, emit nothing (a dev server: `enabled: import.meta.env.PROD`) */
22
+ enabled?: boolean;
21
23
  }
22
24
 
23
25
  export interface InstallPrompt {
@@ -47,7 +49,7 @@ export function makeServiceWorkerDriver(
47
49
  return function serviceWorkerDriver(sink$: Stream<ServiceWorkerCommand>): ServiceWorkerSource {
48
50
  const events = new EventTarget();
49
51
 
50
- if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
52
+ if (options.enabled !== false && typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
51
53
  navigator.serviceWorker
52
54
  .register(scriptUrl, {scope: options.scope})
53
55
  .then((reg) => {
@@ -5,6 +5,8 @@
5
5
  * shorthand factories for the most frequent reducer shapes.
6
6
  */
7
7
 
8
+ import {ABORT, isAbort} from '../shared'
9
+
8
10
  // ── set() ──────────────────────────────────────────────────────────
9
11
  /**
10
12
  * Create a reducer that merges a partial update into state.
@@ -40,13 +42,17 @@ export function toggle<S = any>(field: keyof S & string): (state: S) => S {
40
42
  }
41
43
 
42
44
  // event(type, payload?) → EVENTS sink fn for the object form:
43
- // `ACTION: { EVENTS: event('TYPE', (state, data) => payload) }` (static payloads allowed)
45
+ // `ACTION: { EVENTS: event('TYPE', (state, data) => payload) }` (static payloads allowed).
46
+ // A payload function that returns ABORT sends nothing, as ABORT does from any other sink.
44
47
  export function event(
45
48
  type: string,
46
49
  payload?: any | ((state: any, data: any, next: Function, props: any) => any)
47
- ): (state: any, data: any, next: Function, props: any) => { type: string; data: any } {
50
+ ): (state: any, data: any, next: Function, props: any) => { type: string; data: any } | typeof ABORT {
48
51
  return typeof payload === 'function'
49
- ? (state, data, next, props) => ({ type, data: payload(state, data, next, props) })
52
+ ? (state, data, next, props) => {
53
+ const d = payload(state, data, next, props)
54
+ return isAbort(d) ? ABORT : { type, data: d }
55
+ }
50
56
  : () => ({ type, data: payload })
51
57
  }
52
58
 
@@ -66,6 +72,6 @@ export function event(
66
72
  export function emit(
67
73
  type: string,
68
74
  data?: any | ((state: any, actionData: any, next: Function, props: any) => any)
69
- ): { EVENTS: (state: any, actionData: any, next: Function, props: any) => { type: string; data: any } } {
75
+ ): { EVENTS: (state: any, actionData: any, next: Function, props: any) => { type: string; data: any } | typeof ABORT } {
70
76
  return { EVENTS: event(type, data) }
71
77
  }
package/src/index.d.ts CHANGED
@@ -156,7 +156,7 @@ type EventNextFunction = (action: string, data?: any, delay?: number) => void
156
156
 
157
157
  /** Payload function for `event()` / `emit()`: receives the reducer arguments, returns the event data. */
158
158
  export type EventPayloadFunction<TYPE extends string = string, STATE = any, DATA = any> =
159
- (state: STATE, data: DATA, next: EventNextFunction, props: any) => EventPayload<TYPE>
159
+ (state: STATE, data: DATA, next: EventNextFunction, props: any) => EventPayload<TYPE> | ABORT
160
160
 
161
161
  /**
162
162
  * Remaining arguments of `event()` / `emit()` when the payload is a static value.
@@ -1977,7 +1977,8 @@ export function emit<TYPE extends EventName>(
1977
1977
  * }
1978
1978
  *
1979
1979
  * The payload is either a function `(state, data, next, props) => payload` or a static value:
1980
- * `event('RESET')`, `event('SET_MODE', 'dark')`.
1980
+ * `event('RESET')`, `event('SET_MODE', 'dark')`. A payload function that returns `ABORT` sends
1981
+ * nothing: `event('SAVED', (state) => state.dirty ? { id: state.id } : ABORT)`.
1981
1982
  *
1982
1983
  * With a `SygnalEvents` registry, `type` must be a registered name and the payload must match
1983
1984
  * its type. When used inside a model, the payload function's `state` and `data` parameters are
@@ -3005,6 +3006,12 @@ export interface ServiceWorkerCommand {
3005
3006
 
3006
3007
  export interface ServiceWorkerOptions {
3007
3008
  scope?: string;
3009
+ /**
3010
+ * `false`: the driver registers no service worker, ignores commands and emits nothing, so the
3011
+ * app keeps its `SW` wiring where a worker must not run: `enabled: import.meta.env.PROD`.
3012
+ * Default `true`.
3013
+ */
3014
+ enabled?: boolean;
3008
3015
  }
3009
3016
 
3010
3017
  export function makeServiceWorkerDriver(
@@ -117,6 +117,12 @@
117
117
  * /__sygnal/mcp (./mcp.ts: streamable HTTP, loopback / local Host / local Origin only)
118
118
  * and the dev entries also import 'virtual:sygnal/mcp', which connects the page over the
119
119
  * HMR channel (installMcpBridge from 'sygnal/devtools'). Never in `vite build` or Vitest.
120
+ * 11. SYG103 view words (dev server with diagnostics on; never `vite build`, SSR or Vitest): a
121
+ * module that sets `X.intent` or `X.uses` gets a tail registering, for each such X, the
122
+ * words of its string literals and of the relative modules it imports, in
123
+ * `globalThis.__SYGNAL_VIEW_TOKENS__`. The runtime SYG103 check then takes a selector drawn
124
+ * by a view helper (`{state.open && panel()}`) as conditionally rendered, as it does for
125
+ * markup in the view function itself (see viewTokensTail).
120
126
  *
121
127
  * Why not Vite's `define`? Vite's dependency optimizer does not apply user
122
128
  * `define` replacements to pre-bundled dependencies (only process.env.NODE_ENV),
@@ -483,8 +489,17 @@ export default function sygnal(options: SygnalPluginOptions = {}) {
483
489
  transform(this: any, code: string, id: string, opts?: { ssr?: boolean }) {
484
490
  // SYG666 (PLAN-5 W-2): an adapter entry imported without its optional peers; every mode
485
491
  const adapters = adapterImports(this, code, id)
486
- if (adapters) return checkPeers(this, adapters, id).then(() => devTransform.call(this, code, id, opts))
487
- return devTransform.call(this, code, id, opts)
492
+ const result = adapters
493
+ ? checkPeers(this, adapters, id).then(() => devTransform.call(this, code, id, opts))
494
+ : devTransform.call(this, code, id, opts)
495
+ // 11. the words SYG103 may take as conditionally rendered markup (dev server, diagnostics on)
496
+ const tokens = isServe && !isVitest && devOn && !opts?.ssr ? viewTokensTail(this, code, id) : null
497
+ if (!tokens) return result
498
+ return Promise.all([result, tokens]).then(([r, tail]: [any, string]) => {
499
+ if (!tail) return r
500
+ if (r == null) return withSourcemap(code, [], tail, id)
501
+ return typeof r == 'string' ? r + tail : { ...r, code: r.code + tail }
502
+ })
488
503
  },
489
504
  }
490
505
 
@@ -572,6 +587,52 @@ export default function sygnal(options: SygnalPluginOptions = {}) {
572
587
  }
573
588
  }
574
589
 
590
+ // 11. SYG103 (the dev runtime check of an intent selector that never matched) lets a selector
591
+ // through as conditionally rendered when its words appear in the component's view; the runtime
592
+ // can read only the view function's own source. A component module (one that sets `X.intent` or
593
+ // `X.uses`) gets, in dev, a tail registering for each such component the words of its string
594
+ // literals and those of the relative modules it imports, where view helpers live
595
+ // (globalThis.__SYGNAL_VIEW_TOKENS__, a WeakMap read by checks/dom.ts).
596
+ const COMPONENT_WITH_SELECTORS_RE = /(?:^|[^\w$.])([A-Z][\w$]*)\.(?:intent|uses)\s*=(?!=)/g
597
+ const STRING_RE = /(['"])((?:\\.|(?!\1)[^\\\n])*)\1|`((?:\\[\s\S]|[^\\`])*)`/g
598
+ const RELATIVE_IMPORT_RE = /(?:\bfrom|\bimport)\s*\(?\s*(['"])(\.{1,2}\/[^'"]+)\1/g
599
+
600
+ // A string that is itself a selector ('.save', '#id', 'form.ask', '[data-x]') or a path / URL: its
601
+ // words aren't markup (the intent's own DOM.click('.svae') would otherwise vouch for its typo)
602
+ const NOT_MARKUP_RE = /(?:^|[\s>+~,(])[\w-]*[.#[][\w-]|^\s*(?:\/|[\w+-]+:\/\/)/
603
+
604
+ /** the class / id / tag-like words in a module's string and template literals (comments skipped) */
605
+ function stringWords(code: string, into: Set<string>): void {
606
+ for (const m of blankOut(code, false).matchAll(STRING_RE)) {
607
+ const text = m[2] ?? m[3] ?? ''
608
+ if (NOT_MARKUP_RE.test(text)) continue
609
+ for (const w of text.match(/[A-Za-z_][\w-]*/g) || []) into.add(w)
610
+ }
611
+ }
612
+
613
+ function viewTokensTail(ctx: any, code: string, id: string): Promise<string> | null {
614
+ const file = cleanId(id)
615
+ if (!/\.[jt]sx?$/.test(file) || id.includes('node_modules') || id.startsWith('\0')) return null
616
+ const names = new Set<string>()
617
+ for (const m of blankOut(code, true).matchAll(COMPONENT_WITH_SELECTORS_RE)) names.add(m[1])
618
+ if (!names.size) return null
619
+ const words = new Set<string>()
620
+ stringWords(code, words)
621
+ const specs = new Set(Array.from(blankOut(code, false).matchAll(RELATIVE_IMPORT_RE), m => m[2]))
622
+ return Promise.all([...specs].map(async (spec) => {
623
+ try {
624
+ const r = await ctx.resolve(spec, id)
625
+ const dep = r && !r.external && cleanId(r.id)
626
+ if (!dep || !/\.[cm]?[jt]sx?$/.test(dep) || dep.includes('node_modules')) return
627
+ ctx.addWatchFile?.(dep)
628
+ stringWords(await fs.promises.readFile(dep, 'utf8'), words)
629
+ } catch (_) { /* unreadable: its words are just not added */ }
630
+ })).then(() => {
631
+ const comps = [...names].map(n => `typeof ${n} == 'function' && ${n}`).join(', ')
632
+ return `\n;(() => { try { const m = globalThis.__SYGNAL_VIEW_TOKENS__ ||= new WeakMap(), t = ${JSON.stringify([...words].join(' '))}; for (const c of [${comps}]) if (c) m.set(c, t) } catch (_) {} })();\n`
633
+ })
634
+ }
635
+
575
636
  // PLAN-5 W-2 (D209): the adapter entries and the optional peer dependencies each one imports
576
637
  // (the Zag packages pinned together: they depend on each other's exact versions)
577
638
  const ZAG = '~1.45.0'