@jackens/nnn 2026.9.9 → 2026.9.29

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.
Files changed (5) hide show
  1. package/README.md +1229 -0
  2. package/nnn.d.ts +180 -204
  3. package/nnn.js +84 -95
  4. package/package.json +3 -14
  5. package/readme.md +0 -1370
package/README.md ADDED
@@ -0,0 +1,1229 @@
1
+ # nnn
2
+
3
+ A collection of Jackens’ JavaScript helper utilities (version: `2026.9.29`).
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ bun i @jackens/nnn
9
+ ```
10
+
11
+ or
12
+
13
+ ```sh
14
+ npm i @jackens/nnn
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```js
20
+ import {
21
+ CNode,
22
+ HArgs,
23
+ HArgs1,
24
+ LexerSpec,
25
+ LexerSpecOptions,
26
+ c,
27
+ createLexer,
28
+ h,
29
+ monokai,
30
+ nanolightTs,
31
+ rwd,
32
+ s,
33
+ svgUse,
34
+ } from '@jackens/nnn' // or './node_modules/@jackens/nnn/nnn.js'
35
+ ```
36
+
37
+ ## Exports
38
+
39
+ - [`CNode`](#CNode):
40
+ Represents a CSS rule node for the [`c`](#c) helper. Keys are CSS properties or nested selectors.
41
+ - [`HArgs`](#HArgs):
42
+ Tuple argument type for the [`h`](#h) and [`s`](#s) helpers.
43
+ - [`HArgs1`](#HArgs1):
44
+ Single argument type for the [`h`](#h) and [`s`](#s) helpers.
45
+ - [`LexerSpec`](#LexerSpec):
46
+ A [`createLexer`](#createLexer) spec: a `[metadata, pattern, options?]` tuple.
47
+ - [`LexerSpecOptions`](#LexerSpecOptions):
48
+ Options of a [`createLexer`](#createLexer) spec controlling the lexer state stack.
49
+ - [`c`](#c):
50
+ A minimal CSS-in-JS helper that converts a JavaScript object hierarchy into a CSS string.
51
+ - [`createLexer`](#createLexer):
52
+ A helper for building simple stateful lexers (see also [`nanolightTs`](#nanolightTs)).
53
+ - [`h`](#h):
54
+ A lightweight _HyperScript_-style helper for creating and modifying `HTMLElement`s (see also [`s`](#s)).
55
+ - [`monokai`](#monokai):
56
+ A Monokai-inspired color scheme for use with the [`c`](#c) helper and [`nanolightTs`](#nanolightTs) lexer.
57
+ - [`nanolightTs`](#nanolightTs):
58
+ A TypeScript/JavaScript syntax highlighting lexer built using [`createLexer`](#createLexer).
59
+ - [`rwd`](#rwd):
60
+ A responsive web design helper that generates CSS rules for a grid-like layout of floating elements.
61
+ - [`s`](#s):
62
+ A lightweight _HyperScript_-style helper for creating and modifying `SVGElement`s (see also [`h`](#h)).
63
+ - [`svgUse`](#svgUse):
64
+ Shorthand for creating an `<svg>` element with a `<use>` child referencing a symbol (e.g. an icon) by ID.
65
+
66
+ ### CNode
67
+
68
+ ```ts
69
+ type CNode = {
70
+ [attributeOrSelector: string]: string | number | CNode | undefined
71
+ }
72
+ ```
73
+
74
+ Represents a CSS rule node for the [`c`](#c) helper. Keys are CSS properties or nested selectors.
75
+
76
+ ### HArgs
77
+
78
+ ```ts
79
+ type HArgs = [string | Node, ...HArgs1[]]
80
+ ```
81
+
82
+ Tuple argument type for the [`h`](#h) and [`s`](#s) helpers.
83
+
84
+ ### HArgs1
85
+
86
+ ```ts
87
+ type HArgs1 = Record<PropertyKey, unknown> | null | undefined | Node | string | number | HArgs
88
+ ```
89
+
90
+ Single argument type for the [`h`](#h) and [`s`](#s) helpers.
91
+
92
+ ### LexerSpec
93
+
94
+ ```ts
95
+ type LexerSpec<M> = [metadata: M, pattern: string | RegExp, options?: LexerSpecOptions]
96
+ ```
97
+
98
+ A [`createLexer`](#createLexer) spec: a `[metadata, pattern, options?]` tuple.
99
+
100
+ ### LexerSpecOptions
101
+
102
+ ```ts
103
+ type LexerSpecOptions = {
104
+ in?: string | string[]
105
+ pop?: boolean
106
+ push?: string
107
+ }
108
+ ```
109
+
110
+ Options of a [`createLexer`](#createLexer) spec controlling the lexer state stack.
111
+
112
+ #### in
113
+
114
+ State(s) in which the spec is active (default: `'root'`).
115
+
116
+ #### pop
117
+
118
+ Whether to pop the current state off the stack after a match (the `'root'` state at the bottom is never popped).
119
+
120
+ #### push
121
+
122
+ State to push onto the stack after a match (applied after `pop`).
123
+
124
+ ### c
125
+
126
+ ```ts
127
+ const c: (node: CNode, splitter?: string) => string
128
+ ```
129
+
130
+ A minimal CSS-in-JS helper that converts a JavaScript object hierarchy into a CSS string.
131
+
132
+ #### node
133
+
134
+ An object describing CSS rules.
135
+ Keys are selectors or at-rules; values are either CSS property values or nested rule objects.
136
+ Nested selectors are appended to the parent selector as is (e.g. `'.c'` inside `a` → `a.c`), so a leading space is needed for a descendant combinator (e.g. `' .c'` → `a .c`).
137
+ A selector key may contain comma-separated alternatives (e.g. `'.b,.c'`); when nested, each alternative is combined with each parent alternative (cartesian product), producing a comma-joined selector list (e.g. `'a.b,a.c'`).
138
+ Top-level at-rules (e.g. `'@media …'`) wrap their nested rules; inside a top-level `:root` rule nested keys are concatenated into property names (e.g. `{ ':root': { '--': { bg: 'red' } } }` → `:root{--bg:red}`).
139
+ `null`/`undefined` values are skipped.
140
+
141
+ #### splitter
142
+
143
+ A delimiter used to create unique keys (default: `'$$'`).
144
+ The substring from `splitter` to the end of a key is ignored (e.g. `src$$1` → `src`).
145
+
146
+ #### Returns
147
+
148
+ A CSS string representing the compiled rules.
149
+
150
+ #### Usage Examples
151
+
152
+ ```ts
153
+ const actual = c({
154
+ a: {
155
+ color: 'red',
156
+ margin: 1,
157
+ '.c': { margin: 2, padding: 2 },
158
+ padding: 1,
159
+ },
160
+ })
161
+
162
+ const expected = `
163
+ a{
164
+ color:red;
165
+ margin:1
166
+ }
167
+ a.c{
168
+ margin:2;
169
+ padding:2
170
+ }
171
+ a{
172
+ padding:1
173
+ }
174
+ `.replace(/\n\s*/g, '')
175
+
176
+ expect(actual).toBe(expected)
177
+ ```
178
+
179
+ ```ts
180
+ const actual = c({
181
+ a: {
182
+ '.b': {
183
+ color: 'red',
184
+ margin: 1,
185
+ '.c': { margin: 2, padding: 2 },
186
+ padding: 1,
187
+ },
188
+ },
189
+ })
190
+
191
+ const expected = `
192
+ a.b{
193
+ color:red;
194
+ margin:1
195
+ }
196
+ a.b.c{
197
+ margin:2;
198
+ padding:2
199
+ }
200
+ a.b{
201
+ padding:1
202
+ }
203
+ `.replace(/\n\s*/g, '')
204
+
205
+ expect(actual).toBe(expected)
206
+ ```
207
+
208
+ ```ts
209
+ const actual = c({
210
+ '@font-face$$1': {
211
+ 'font-family': 'Jackens',
212
+ src$$1: 'url(otf/jackens.otf)',
213
+ src$$2: "url(otf/jackens.otf) format('opentype')," + "url(svg/jackens.svg) format('svg')",
214
+ 'font-weight': 'normal',
215
+ 'font-style': 'normal',
216
+ },
217
+ '@font-face$$2': {
218
+ 'font-family': 'C64',
219
+ src: 'url(fonts/C64_Pro_Mono-STYLE.woff)',
220
+ },
221
+ '@keyframes spin': {
222
+ '0%': { transform: 'rotate(0deg)' },
223
+ '100%': { transform: 'rotate(360deg)' },
224
+ },
225
+ div: {
226
+ border: 'solid red 1px',
227
+ '.c1': { 'background-color': '#000' },
228
+ ' .c1': { 'background-color': 'black' },
229
+ '.c2': { 'background-color': 'rgb(0,0,0)' },
230
+ },
231
+ '@media(min-width:200px)': {
232
+ div: { margin: 0, padding: 0 },
233
+ span: { color: '#000' },
234
+ },
235
+ })
236
+
237
+ const expected = `
238
+ @font-face{
239
+ font-family:Jackens;
240
+ src:url(otf/jackens.otf);
241
+ src:url(otf/jackens.otf) format('opentype'),url(svg/jackens.svg) format('svg');
242
+ font-weight:normal;
243
+ font-style:normal
244
+ }
245
+ @font-face{
246
+ font-family:C64;
247
+ src:url(fonts/C64_Pro_Mono-STYLE.woff)
248
+ }
249
+ @keyframes spin{
250
+ 0%{
251
+ transform:rotate(0deg)
252
+ }
253
+ 100%{
254
+ transform:rotate(360deg)
255
+ }
256
+ }
257
+ div{
258
+ border:solid red 1px
259
+ }
260
+ div.c1{
261
+ background-color:#000
262
+ }
263
+ div .c1{
264
+ background-color:black
265
+ }
266
+ div.c2{
267
+ background-color:rgb(0,0,0)
268
+ }
269
+ @media(min-width:200px){
270
+ div{
271
+ margin:0;
272
+ padding:0
273
+ }
274
+ span{
275
+ color:#000
276
+ }
277
+ }
278
+ `.replace(/\n\s*/g, '')
279
+
280
+ expect(actual).toBe(expected)
281
+ ```
282
+
283
+ ```ts
284
+ const actual = c({
285
+ a: {
286
+ '.b,.c': {
287
+ margin: 1,
288
+ '.d': {
289
+ margin: 2,
290
+ },
291
+ },
292
+ },
293
+ })
294
+
295
+ const expected = `
296
+ a.b,a.c{
297
+ margin:1
298
+ }
299
+ a.b.d,a.c.d{
300
+ margin:2
301
+ }
302
+ `.replace(/\n\s*/g, '')
303
+
304
+ expect(actual).toBe(expected)
305
+ ```
306
+
307
+ ```ts
308
+ const actual = c({
309
+ '.b,.c': {
310
+ margin: 1,
311
+ '.d': {
312
+ margin: 2,
313
+ },
314
+ },
315
+ })
316
+
317
+ const expected = `
318
+ .b,.c{
319
+ margin:1
320
+ }
321
+ .b.d,.c.d{
322
+ margin:2
323
+ }
324
+ `.replace(/\n\s*/g, '')
325
+
326
+ expect(actual).toBe(expected)
327
+ ```
328
+
329
+ ```ts
330
+ const actual = c({
331
+ '.a,.b': {
332
+ margin: 1,
333
+ '.c,.d': {
334
+ margin: 2,
335
+ },
336
+ },
337
+ })
338
+
339
+ const expected = `
340
+ .a,.b{
341
+ margin:1
342
+ }
343
+ .a.c,.a.d,.b.c,.b.d{
344
+ margin:2
345
+ }
346
+ `.replace(/\n\s*/g, '')
347
+
348
+ expect(actual).toBe(expected)
349
+ ```
350
+
351
+ ```ts
352
+ const actual = c({
353
+ ':root$$monokai': {
354
+ '--': {
355
+ bg: '#faf4f2',
356
+ fg: '#29242a',
357
+ comment: '#918c8e',
358
+ identifier: {
359
+ 1: '#7058be',
360
+ 2: '#269d69',
361
+ 3: '#1c8ca8',
362
+ 4: '#29242a',
363
+ },
364
+ keyword: {
365
+ 1: '#e14775',
366
+ 2: '#7058be',
367
+ 3: '#1c8ca8',
368
+ },
369
+ number: '#7058be',
370
+ operator: '#e14775',
371
+ punctuation: '#918c8e',
372
+ string: '#cc7a0a',
373
+ },
374
+ },
375
+ '@media only screen and (prefers-color-scheme: dark)$$monokai': {
376
+ ':root': {
377
+ '--': {
378
+ bg: '#2d2a2e',
379
+ fg: '#fcfcfa',
380
+ comment: '#727072',
381
+ identifier: {
382
+ 1: '#ae81ff',
383
+ 2: '#a9dc76',
384
+ 3: '#66d9ef',
385
+ 4: '#fcfcfa',
386
+ },
387
+ keyword: {
388
+ 1: '#ff6188',
389
+ 2: '#ae81ff',
390
+ 3: '#66d9ef',
391
+ },
392
+ number: '#ae81ff',
393
+ operator: '#ff6188',
394
+ punctuation: '#727072',
395
+ string: '#ffd866',
396
+ },
397
+ },
398
+ },
399
+ })
400
+
401
+ const expected = `
402
+ :root{
403
+ --bg:#faf4f2;
404
+ --fg:#29242a;
405
+ --comment:#918c8e;
406
+ --identifier1:#7058be;
407
+ --identifier2:#269d69;
408
+ --identifier3:#1c8ca8;
409
+ --identifier4:#29242a;
410
+ --keyword1:#e14775;
411
+ --keyword2:#7058be;
412
+ --keyword3:#1c8ca8;
413
+ --number:#7058be;
414
+ --operator:#e14775;
415
+ --punctuation:#918c8e;
416
+ --string:#cc7a0a
417
+ }
418
+ @media only screen and (prefers-color-scheme: dark){
419
+ :root{
420
+ --bg:#2d2a2e;
421
+ --fg:#fcfcfa;
422
+ --comment:#727072;
423
+ --identifier1:#ae81ff;
424
+ --identifier2:#a9dc76;
425
+ --identifier3:#66d9ef;
426
+ --identifier4:#fcfcfa;
427
+ --keyword1:#ff6188;
428
+ --keyword2:#ae81ff;
429
+ --keyword3:#66d9ef;
430
+ --number:#ae81ff;
431
+ --operator:#ff6188;
432
+ --punctuation:#727072;
433
+ --string:#ffd866
434
+ }
435
+ }
436
+ `.replace(/\n\s*/g, '')
437
+
438
+ expect(actual).toBe(expected)
439
+ ```
440
+
441
+ ### createLexer
442
+
443
+ ```ts
444
+ const createLexer: <M, T>(
445
+ decorator: (chunk: string, metadata?: M) => T,
446
+ ...specs: LexerSpec<M>[]
447
+ ) => (code: string) => T[]
448
+ ```
449
+
450
+ A helper for building simple stateful lexers (see also [`nanolightTs`](#nanolightTs)).
451
+
452
+ #### Remarks
453
+
454
+ 1. Matches starting at an earlier position take precedence.
455
+ 2. Among matches at the same position, the longer one wins.
456
+ 3. Among matches of the same position and length, the one defined earlier wins.
457
+ 4. Only specs active in the current state (the top of the state stack, initially `'root'`) are considered.
458
+ 5. Patterns are matched against the whole input, so `^`, `$`, `\b` and lookbehinds see the text around the current position; a sticky (`y`) `RegExp` matches only at the current position.
459
+ 6. Empty matches are ignored.
460
+
461
+ #### decorator
462
+
463
+ A function that wraps each chunk of the input.
464
+ It receives the matched text (`chunk`) and the `metadata` of the spec whose pattern produced the match.
465
+ For unmatched text between matches, `metadata` is `undefined`.
466
+
467
+ #### specs
468
+
469
+ An array of tuples `[metadata, pattern, options?]` where:
470
+
471
+ - `metadata`: arbitrary data (e.g., a CSS class name) passed to `decorator` when the pattern matches.
472
+ - `pattern`: a `string` (matched literally) or `RegExp` to match against the input.
473
+ - `options`: optional [`LexerSpecOptions`](#LexerSpecOptions) (states in which the spec is active and state transitions).
474
+
475
+ #### Returns
476
+
477
+ A lexer function that accepts an input string and returns an array of decorated chunks covering the whole input (in order).
478
+
479
+ #### Usage Examples
480
+
481
+ ```ts
482
+ const lexer = createLexer(
483
+ (chunk, metadata) => ({ chunk, metadata }),
484
+ ['keyword', /\b(if|else|for)\b/],
485
+ ['string', /"[^"]*"/]
486
+ )
487
+
488
+ const result = lexer('if "hello" else "world"')
489
+
490
+ expect(result).toEqual([
491
+ { chunk: 'if', metadata: 'keyword' },
492
+ { chunk: ' ', metadata: undefined },
493
+ { chunk: '"hello"', metadata: 'string' },
494
+ { chunk: ' ', metadata: undefined },
495
+ { chunk: 'else', metadata: 'keyword' },
496
+ { chunk: ' ', metadata: undefined },
497
+ { chunk: '"world"', metadata: 'string' },
498
+ ])
499
+ ```
500
+
501
+ ```ts
502
+ const lexer = createLexer((chunk, metadata) => `${metadata}:${chunk}`, ['tag', 'BEGIN'], ['end', 'END'])
503
+
504
+ const result = lexer('aBEGINbENDc')
505
+
506
+ expect(result).toEqual(['undefined:a', 'tag:BEGIN', 'undefined:b', 'end:END', 'undefined:c'])
507
+ ```
508
+
509
+ ```ts
510
+ const lexer = createLexer(chunk => chunk, ['test', /test/])
511
+
512
+ const result = lexer('')
513
+
514
+ expect(result).toEqual([])
515
+ ```
516
+
517
+ ```ts
518
+ const lexer = createLexer((chunk, metadata) => ({ chunk, metadata }), ['start', /^test/])
519
+
520
+ const result = lexer('test here')
521
+
522
+ expect(result).toEqual([
523
+ { chunk: 'test', metadata: 'start' },
524
+ { chunk: ' here', metadata: undefined },
525
+ ])
526
+ ```
527
+
528
+ ```ts
529
+ const lexer = createLexer((chunk, metadata) => metadata, ['later', /x/], ['earlier', /y/])
530
+
531
+ const result = lexer('yx')
532
+
533
+ expect(result).toEqual(['earlier', 'later'])
534
+ ```
535
+
536
+ ```ts
537
+ const lexer = createLexer(chunk => chunk, ['short', 'a'], ['long', 'abc'])
538
+
539
+ const result = lexer('abc')
540
+
541
+ expect(result).toEqual(['abc'])
542
+ ```
543
+
544
+ ```ts
545
+ const lexer = createLexer(chunk => chunk, ['empty', ''], ['word', /\w+/])
546
+
547
+ const result = lexer('hello')
548
+
549
+ expect(result).toEqual(['hello'])
550
+ ```
551
+
552
+ ```ts
553
+ const lexer = createLexer(chunk => chunk, ['test', /xyz/])
554
+
555
+ const result = lexer('abc')
556
+
557
+ expect(result).toEqual(['abc'])
558
+ ```
559
+
560
+ ```ts
561
+ const lexer = createLexer((_chunk, metadata) => metadata, ['a', 'a'], ['b', 'b'])
562
+
563
+ const result = lexer('aabb')
564
+
565
+ expect(result).toEqual(['a', 'a', 'b', 'b'])
566
+ ```
567
+
568
+ ```ts
569
+ const lexer = createLexer(
570
+ (chunk, metadata) => `${metadata}:${chunk}`,
571
+ ['open', '(', { push: 'inner' }],
572
+ ['word', /\w+/, { in: 'inner' }],
573
+ ['close', ')', { in: 'inner', pop: true }]
574
+ )
575
+
576
+ const result = lexer('a(b)c)')
577
+
578
+ expect(result).toEqual(['undefined:a', 'open:(', 'word:b', 'close:)', 'undefined:c)'])
579
+ ```
580
+
581
+ ```ts
582
+ const lexer = createLexer(
583
+ (chunk, metadata) => `${metadata}:${chunk}`,
584
+ ['open', '{', { in: ['root', 'nested'], push: 'nested' }],
585
+ ['close', '}', { in: 'nested', pop: true }]
586
+ )
587
+
588
+ const result = lexer('{{}}}')
589
+
590
+ expect(result).toEqual(['open:{', 'open:{', 'close:}', 'close:}', 'undefined:}'])
591
+ ```
592
+
593
+ ```ts
594
+ const lexer = createLexer((chunk, metadata) => `${metadata}:${chunk}`, ['a', 'a'], ['b', /(?<=a)b/])
595
+
596
+ const result = lexer('abcb')
597
+
598
+ expect(result).toEqual(['a:a', 'b:b', 'undefined:cb'])
599
+ ```
600
+
601
+ ```ts
602
+ const lexer = createLexer((chunk, metadata) => `${metadata}:${chunk}`, ['x', /x/y], ['y', 'y'])
603
+
604
+ const result = lexer('xyx.x')
605
+
606
+ expect(result).toEqual(['x:x', 'y:y', 'x:x', 'undefined:.x'])
607
+ ```
608
+
609
+ ```ts
610
+ const lexer = createLexer((chunk, metadata) => `${metadata}:${chunk}`, ['digits', /\d*/])
611
+
612
+ const result = lexer('ab12')
613
+
614
+ expect(result).toEqual(['undefined:ab', 'digits:12'])
615
+ ```
616
+
617
+ ### h
618
+
619
+ ```ts
620
+ const h: {
621
+ <T extends keyof HTMLElementTagNameMap>(tag: T, ...args: HArgs1[]): HTMLElementTagNameMap[T]
622
+ <N extends Node>(node: N, ...args: HArgs1[]): N
623
+ (tagOrNode: string | Node, ...args: HArgs1[]): Node
624
+ }
625
+ ```
626
+
627
+ A lightweight _HyperScript_-style helper for creating and modifying `HTMLElement`s (see also [`s`](#s)).
628
+
629
+ #### tagOrNode
630
+
631
+ If a `string`, it is treated as the tag name for a new element.
632
+ If a `Node`, that node is modified in place.
633
+
634
+ #### args
635
+
636
+ Additional arguments processed as follows:
637
+
638
+ - `Object`: maps attributes/properties.
639
+ - Keys starting with `$` set element properties (without the `$` prefix); if the value is a plain object, it is shallow-merged into the existing property instead of replacing it (useful e.g. for `$style`).
640
+ - Other keys set attributes: a key prefixed with `xlink:` sets the attribute via `setAttributeNS` in the XLink namespace; any other key (without a colon) sets it via `setAttribute`.
641
+ A value of `true` sets an empty attribute (e.g. `checked=""`), a value of `false` removes the attribute.
642
+ - `null`/`undefined`: ignored.
643
+ - `Node`: appended as a child.
644
+ - `string`/`number`: converted to a `Text` node and appended.
645
+ - [`HArgs`](#HArgs) array: processed recursively (creates or modifies a child node and appends it).
646
+
647
+ #### Returns
648
+
649
+ The created `HTMLElement` or the modified node.
650
+
651
+ #### Usage Examples
652
+
653
+ ```ts
654
+ const b = h('b')
655
+
656
+ expect(b.outerHTML).toBe('<b></b>')
657
+
658
+ const i = h('i', 'text')
659
+
660
+ h(b, i)
661
+
662
+ expect(i.outerHTML).toBe('<i>text</i>')
663
+
664
+ expect(b.outerHTML).toBe('<b><i>text</i></b>')
665
+
666
+ h(i, { $className: 'some class' })
667
+
668
+ expect(i.outerHTML).toBe('<i class="some class">text</i>')
669
+
670
+ expect(b.outerHTML).toBe('<b><i class="some class">text</i></b>')
671
+ ```
672
+
673
+ ```ts
674
+ expect(h('span', 'text').outerHTML).toBe('<span>text</span>')
675
+
676
+ expect(h('span', { $innerText: 'text' }).outerHTML).toBe('<span>text</span>')
677
+
678
+ expect(h('span', '42').outerHTML).toBe('<span>42</span>')
679
+
680
+ expect(h('span', 42).outerHTML).toBe('<span>42</span>')
681
+ ```
682
+
683
+ ```ts
684
+ expect(h('div', { style: 'margin:0;padding:0' }).outerHTML).toBe('<div style="margin:0;padding:0"></div>')
685
+
686
+ expect(h('div', { $style: 'margin:0;padding:0' }).outerHTML).toBe('<div style="margin: 0px; padding: 0px;"></div>')
687
+
688
+ expect(h('div', { $style: { margin: 0, padding: 0 } }).outerHTML).toBe(
689
+ '<div style="margin: 0px; padding: 0px;"></div>'
690
+ )
691
+ ```
692
+
693
+ ```ts
694
+ const input1 = h('input', { value: 42 })
695
+ const input2 = h('input', { $value: '42' })
696
+
697
+ expect(input1.value).toBe('42')
698
+ expect(input2.value).toBe('42')
699
+
700
+ expect(input1.outerHTML).toBe('<input value="42">')
701
+ expect(input2.outerHTML).toBe('<input>')
702
+ ```
703
+
704
+ ```ts
705
+ const checkbox1 = h('input', {
706
+ type: 'checkbox',
707
+ checked: true,
708
+ })
709
+
710
+ const checkbox2 = h('input', {
711
+ type: 'checkbox',
712
+ $checked: true,
713
+ })
714
+
715
+ expect(checkbox1.checked).toBe(true)
716
+ expect(checkbox2.checked).toBe(true)
717
+
718
+ expect(checkbox1.outerHTML).toBe('<input type="checkbox" checked="">')
719
+
720
+ expect(checkbox2.outerHTML).toBe('<input type="checkbox">')
721
+ ```
722
+
723
+ ```ts
724
+ const div = h('div')
725
+
726
+ expect(div.key).toBeUndefined()
727
+
728
+ h(div, { $key: { one: 1 } })
729
+
730
+ expect(div.key).toEqual({ one: 1 })
731
+
732
+ h(div, { $key: { two: 2 } })
733
+
734
+ expect(div.key).toEqual({ one: 1, two: 2 })
735
+ ```
736
+
737
+ ```ts
738
+ const elemWithClass = h('div', { class: 'test' })
739
+
740
+ expect(elemWithClass.getAttribute('class')).toBe('test')
741
+
742
+ const elemWithText = h('div', 'initial')
743
+
744
+ h(elemWithText, ' more')
745
+
746
+ expect(elemWithText.outerHTML).toBe('<div>initial more</div>')
747
+
748
+ const elemWithNested = h('div', ['span', 'hello'], ['b', 'world'])
749
+
750
+ expect(elemWithNested.outerHTML).toBe('<div><span>hello</span><b>world</b></div>')
751
+ ```
752
+
753
+ ### monokai
754
+
755
+ ```ts
756
+ const monokai: CNode
757
+ ```
758
+
759
+ A Monokai-inspired color scheme for use with the [`c`](#c) helper and [`nanolightTs`](#nanolightTs) lexer.
760
+ It styles `pre` and `code>span` elements with the class names produced by [`nanolightTs`](#nanolightTs) (with light, dark and print variants).
761
+
762
+ #### Usage Examples
763
+
764
+ ```ts
765
+ const style = h('style', c(monokai))
766
+ const pre = h('pre', ['code', ...nanolightTs('const x = 42')])
767
+
768
+ expect(style.textContent).toContain('code>span.keyword1{color:var(--keyword1)}')
769
+ expect(style.textContent).toContain('@media only screen and (prefers-color-scheme: dark){:root{--bg:#2d2a2e;')
770
+ expect(pre.outerHTML).toBe(
771
+ '<pre><code>' +
772
+ '<span class="keyword1">const</span> ' +
773
+ '<span class="identifier4">x</span> ' +
774
+ '<span class="operator">=</span> ' +
775
+ '<span class="number">42</span>' +
776
+ '</code></pre>'
777
+ )
778
+ ```
779
+
780
+ ### nanolightTs
781
+
782
+ ```ts
783
+ const nanolightTs: (code: string) => HArgs1[]
784
+ ```
785
+
786
+ A TypeScript/JavaScript syntax highlighting lexer built using [`createLexer`](#createLexer).
787
+
788
+ #### code
789
+
790
+ The source code string to tokenize.
791
+
792
+ #### Returns
793
+
794
+ An array of [`HArgs1`](#HArgs1) elements suitable for rendering with [`h`](#h): plain `string`s for unrecognized text (e.g. whitespace) and `['span', { class }, chunk]` tuples for recognized tokens (class names are styled by [`monokai`](#monokai)).
795
+
796
+ #### Usage Examples
797
+
798
+ ```ts
799
+ const codeJs = 'const answerToLifeTheUniverseAndEverything = ' + "{ 42: 42 }['42'] /* 42 */"
800
+
801
+ expect(nanolightTs(codeJs)).toEqual([
802
+ ['span', { class: 'keyword1' }, 'const'],
803
+ ' ',
804
+ ['span', { class: 'identifier4' }, 'answerToLifeTheUniverseAndEverything'],
805
+ ' ',
806
+ ['span', { class: 'operator' }, '='],
807
+ ' ',
808
+ ['span', { class: 'punctuation' }, '{'],
809
+ ' ',
810
+ ['span', { class: 'number' }, '42'],
811
+ ['span', { class: 'operator' }, ':'],
812
+ ' ',
813
+ ['span', { class: 'number' }, '42'],
814
+ ' ',
815
+ ['span', { class: 'punctuation' }, '}'],
816
+ ['span', { class: 'punctuation' }, '['],
817
+ ['span', { class: 'string' }, "'42'"],
818
+ ['span', { class: 'punctuation' }, ']'],
819
+ ' ',
820
+ ['span', { class: 'comment' }, '/* 42 */'],
821
+ ])
822
+ ```
823
+
824
+ ```ts
825
+ const codeJs = 'const x = 42 // answer'
826
+
827
+ expect(nanolightTs(codeJs)).toEqual([
828
+ ['span', { class: 'keyword1' }, 'const'],
829
+ ' ',
830
+ ['span', { class: 'identifier4' }, 'x'],
831
+ ' ',
832
+ ['span', { class: 'operator' }, '='],
833
+ ' ',
834
+ ['span', { class: 'number' }, '42'],
835
+ ' ',
836
+ ['span', { class: 'comment' }, '// answer'],
837
+ ])
838
+ ```
839
+
840
+ ```ts
841
+ expect(nanolightTs('x = /a\\/b[/]c/gi.test(y) / 2')).toEqual([
842
+ ['span', { class: 'identifier4' }, 'x'],
843
+ ' ',
844
+ ['span', { class: 'operator' }, '='],
845
+ ' ',
846
+ ['span', { class: 'string' }, '/a\\/b[/]c/gi'],
847
+ ['span', { class: 'punctuation' }, '.'],
848
+ ['span', { class: 'identifier2' }, 'test'],
849
+ ['span', { class: 'punctuation' }, '('],
850
+ ['span', { class: 'identifier4' }, 'y'],
851
+ ['span', { class: 'punctuation' }, ')'],
852
+ ' ',
853
+ ['span', { class: 'operator' }, '/'],
854
+ ' ',
855
+ ['span', { class: 'number' }, '2'],
856
+ ])
857
+
858
+ expect(nanolightTs('a / b / c')).toEqual([
859
+ ['span', { class: 'identifier4' }, 'a'],
860
+ ' ',
861
+ ['span', { class: 'operator' }, '/'],
862
+ ' ',
863
+ ['span', { class: 'identifier4' }, 'b'],
864
+ ' ',
865
+ ['span', { class: 'operator' }, '/'],
866
+ ' ',
867
+ ['span', { class: 'identifier4' }, 'c'],
868
+ ])
869
+
870
+ expect(nanolightTs('return / x /')).toEqual([
871
+ ['span', { class: 'keyword1' }, 'return'],
872
+ ' ',
873
+ ['span', { class: 'string' }, '/ x /'],
874
+ ])
875
+ ```
876
+
877
+ ```ts
878
+ expect(nanolightTs('`a ${b + `c ${ {d}.d }`} \\${e} $f`')).toEqual([
879
+ ['span', { class: 'string' }, '`a '],
880
+ ['span', { class: 'punctuation' }, '${'],
881
+ ['span', { class: 'identifier4' }, 'b'],
882
+ ' ',
883
+ ['span', { class: 'operator' }, '+'],
884
+ ' ',
885
+ ['span', { class: 'string' }, '`c '],
886
+ ['span', { class: 'punctuation' }, '${'],
887
+ ' ',
888
+ ['span', { class: 'punctuation' }, '{'],
889
+ ['span', { class: 'identifier4' }, 'd'],
890
+ ['span', { class: 'punctuation' }, '}'],
891
+ ['span', { class: 'punctuation' }, '.'],
892
+ ['span', { class: 'identifier4' }, 'd'],
893
+ ' ',
894
+ ['span', { class: 'punctuation' }, '}'],
895
+ ['span', { class: 'string' }, '`'],
896
+ ['span', { class: 'punctuation' }, '}'],
897
+ ['span', { class: 'string' }, ' \\${e} $f`'],
898
+ ])
899
+
900
+ expect(nanolightTs('`${x}` / 2')).toEqual([
901
+ ['span', { class: 'string' }, '`'],
902
+ ['span', { class: 'punctuation' }, '${'],
903
+ ['span', { class: 'identifier4' }, 'x'],
904
+ ['span', { class: 'punctuation' }, '}'],
905
+ ['span', { class: 'string' }, '`'],
906
+ ' ',
907
+ ['span', { class: 'operator' }, '/'],
908
+ ' ',
909
+ ['span', { class: 'number' }, '2'],
910
+ ])
911
+ ```
912
+
913
+ ```ts
914
+ expect(nanolightTs('"a // b" // c')).toEqual([
915
+ ['span', { class: 'string' }, '"a // b"'],
916
+ ' ',
917
+ ['span', { class: 'comment' }, '// c'],
918
+ ])
919
+
920
+ expect(nanolightTs("'it\\'s' /* x")).toEqual([
921
+ ['span', { class: 'string' }, "'it\\'s'"],
922
+ ' ',
923
+ ['span', { class: 'comment' }, '/* x'],
924
+ ])
925
+ ```
926
+
927
+ ```ts
928
+ expect(nanolightTs('0xFF + 1_000n + .5e-3')).toEqual([
929
+ ['span', { class: 'number' }, '0xFF'],
930
+ ' ',
931
+ ['span', { class: 'operator' }, '+'],
932
+ ' ',
933
+ ['span', { class: 'number' }, '1_000n'],
934
+ ' ',
935
+ ['span', { class: 'operator' }, '+'],
936
+ ' ',
937
+ ['span', { class: 'number' }, '.5e-3'],
938
+ ])
939
+ ```
940
+
941
+ ```ts
942
+ expect(nanolightTs('a++ / 2 / b')).toEqual([
943
+ ['span', { class: 'identifier4' }, 'a'],
944
+ ['span', { class: 'operator' }, '++'],
945
+ ' ',
946
+ ['span', { class: 'operator' }, '/'],
947
+ ' ',
948
+ ['span', { class: 'number' }, '2'],
949
+ ' ',
950
+ ['span', { class: 'operator' }, '/'],
951
+ ' ',
952
+ ['span', { class: 'identifier4' }, 'b'],
953
+ ])
954
+
955
+ expect(nanolightTs('x?.5:a?.b')).toEqual([
956
+ ['span', { class: 'identifier4' }, 'x'],
957
+ ['span', { class: 'operator' }, '?'],
958
+ ['span', { class: 'number' }, '.5'],
959
+ ['span', { class: 'operator' }, ':'],
960
+ ['span', { class: 'identifier4' }, 'a'],
961
+ ['span', { class: 'punctuation' }, '?.'],
962
+ ['span', { class: 'identifier4' }, 'b'],
963
+ ])
964
+ ```
965
+
966
+ ```ts
967
+ expect(nanolightTs('p.catch(obj.in, ...this)')).toEqual([
968
+ ['span', { class: 'identifier4' }, 'p'],
969
+ ['span', { class: 'punctuation' }, '.'],
970
+ ['span', { class: 'identifier2' }, 'catch'],
971
+ ['span', { class: 'punctuation' }, '('],
972
+ ['span', { class: 'identifier4' }, 'obj'],
973
+ ['span', { class: 'punctuation' }, '.'],
974
+ ['span', { class: 'identifier4' }, 'in'],
975
+ ['span', { class: 'punctuation' }, ','],
976
+ ' ',
977
+ ['span', { class: 'operator' }, '...'],
978
+ ['span', { class: 'keyword1' }, 'this'],
979
+ ['span', { class: 'punctuation' }, ')'],
980
+ ])
981
+ ```
982
+
983
+ ```ts
984
+ expect(nanolightTs('#!/usr/bin/env bun\n@sealed class A { #x = 1 }')).toEqual([
985
+ ['span', { class: 'comment' }, '#!/usr/bin/env bun'],
986
+ '\n',
987
+ ['span', { class: 'identifier2' }, '@sealed'],
988
+ ' ',
989
+ ['span', { class: 'keyword1' }, 'class'],
990
+ ' ',
991
+ ['span', { class: 'identifier1' }, 'A'],
992
+ ' ',
993
+ ['span', { class: 'punctuation' }, '{'],
994
+ ' ',
995
+ ['span', { class: 'identifier4' }, '#x'],
996
+ ' ',
997
+ ['span', { class: 'operator' }, '='],
998
+ ' ',
999
+ ['span', { class: 'number' }, '1'],
1000
+ ' ',
1001
+ ['span', { class: 'punctuation' }, '}'],
1002
+ ])
1003
+ ```
1004
+
1005
+ ### rwd
1006
+
1007
+ ```ts
1008
+ const rwd: (
1009
+ root: CNode,
1010
+ selector: string,
1011
+ cellWidthPx: number,
1012
+ cellHeightPx: number,
1013
+ ...specs: [number, number?, number?][]
1014
+ ) => void
1015
+ ```
1016
+
1017
+ A responsive web design helper that generates CSS rules for a grid-like layout of floating elements.
1018
+ It modifies `root` in place: it sets base styles for `selector` and adds a `@media(min-width:…px)` rule for each breakpoint.
1019
+
1020
+ #### root
1021
+
1022
+ The CSS root object to populate (see [`c`](#c)).
1023
+
1024
+ #### selector
1025
+
1026
+ The CSS selector for the grid item.
1027
+
1028
+ #### cellWidthPx
1029
+
1030
+ The base cell width in pixels.
1031
+
1032
+ #### cellHeightPx
1033
+
1034
+ The base cell height in pixels.
1035
+
1036
+ #### specs
1037
+
1038
+ An array of breakpoint specifications, each a tuple of:
1039
+
1040
+ - `maxWidth`: number of cells per row; the breakpoint is `min-width: cellWidthPx * maxWidth` (`1` modifies the base rule).
1041
+ - `width` (optional, default `1`): number of horizontal cells the element spans.
1042
+ - `height` (optional, default `1`): number of vertical cells the element spans.
1043
+
1044
+ #### Usage Examples
1045
+
1046
+ ```ts
1047
+ const style: CNode = {
1048
+ body: {
1049
+ margin: 0,
1050
+ },
1051
+ '.r6': {
1052
+ border: 'solid red 1px',
1053
+ '.no-border': {
1054
+ border: 'none',
1055
+ },
1056
+ },
1057
+ }
1058
+
1059
+ rwd(style, '.r6', 200, 50, [6], [3], [1, 1, 2])
1060
+
1061
+ expect(style).toEqual({
1062
+ body: {
1063
+ margin: 0,
1064
+ },
1065
+ '.r6': {
1066
+ border: 'solid red 1px',
1067
+ '.no-border': {
1068
+ border: 'none',
1069
+ },
1070
+ 'box-sizing': 'border-box',
1071
+ display: 'block',
1072
+ float: 'left',
1073
+ width: '100%',
1074
+ height: '100px',
1075
+ },
1076
+ '@media(min-width:600px)': {
1077
+ '.r6': {
1078
+ width: 'calc(100% / 3)',
1079
+ height: '50px',
1080
+ },
1081
+ },
1082
+ '@media(min-width:1200px)': {
1083
+ '.r6': {
1084
+ width: 'calc(50% / 3)',
1085
+ height: '50px',
1086
+ },
1087
+ },
1088
+ })
1089
+ ```
1090
+
1091
+ ### s
1092
+
1093
+ ```ts
1094
+ const s: {
1095
+ <T extends keyof SVGElementTagNameMap>(tag: T, ...args: HArgs1[]): SVGElementTagNameMap[T]
1096
+ <N extends Node>(node: N, ...args: HArgs1[]): N
1097
+ (tagOrNode: string | Node, ...args: HArgs1[]): Node
1098
+ }
1099
+ ```
1100
+
1101
+ A lightweight _HyperScript_-style helper for creating and modifying `SVGElement`s (see also [`h`](#h)).
1102
+
1103
+ #### tagOrNode
1104
+
1105
+ If a `string`, it is treated as the tag name for a new element.
1106
+ If a `Node`, that node is modified in place.
1107
+
1108
+ #### args
1109
+
1110
+ Additional arguments processed as follows:
1111
+
1112
+ - `Object`: maps attributes/properties.
1113
+ - Keys starting with `$` set element properties (without the `$` prefix); if the value is a plain object, it is shallow-merged into the existing property instead of replacing it.
1114
+ - Other keys set attributes: a key prefixed with `xlink:` sets the attribute via `setAttributeNS` in the XLink namespace; any other key (without a colon) sets it via `setAttribute`.
1115
+ A value of `true` sets an empty attribute (e.g. `checked=""`), a value of `false` removes the attribute.
1116
+ - `null`/`undefined`: ignored.
1117
+ - `Node`: appended as a child.
1118
+ - `string`/`number`: converted to a `Text` node and appended.
1119
+ - [`HArgs`](#HArgs) array: processed recursively (creates or modifies a child node and appends it).
1120
+
1121
+ #### Returns
1122
+
1123
+ The created `SVGElement` or the modified node.
1124
+
1125
+ #### Usage Examples
1126
+
1127
+ ```ts
1128
+ const svg = s('svg', { 'xlink:href': true })
1129
+
1130
+ expect(svg.getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBe('')
1131
+ ```
1132
+
1133
+ ```ts
1134
+ const svg = s('svg', { 'xlink:href': false })
1135
+
1136
+ expect(svg.getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBeNull()
1137
+ ```
1138
+
1139
+ ```ts
1140
+ const svg = s('svg', { 'xlink:href': 'http://example.com' })
1141
+
1142
+ expect(svg.getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBe('http://example.com')
1143
+ ```
1144
+
1145
+ ```ts
1146
+ const svg = s('svg', { 'xlink:title': 42 })
1147
+
1148
+ expect(svg.getAttributeNS('http://www.w3.org/1999/xlink', 'title')).toBe('42')
1149
+ ```
1150
+
1151
+ ### svgUse
1152
+
1153
+ ```ts
1154
+ const svgUse: (id: string, ...args: HArgs1[]) => SVGSVGElement
1155
+ ```
1156
+
1157
+ Shorthand for creating an `<svg>` element with a `<use>` child referencing a symbol (e.g. an icon) by ID.
1158
+
1159
+ Equivalent to:
1160
+
1161
+ ```ts
1162
+ s('svg', ['use', { 'xlink:href': '#' + id }], ...args)
1163
+ ```
1164
+
1165
+ #### id
1166
+
1167
+ The ID of the symbol to reference (without the `#` prefix).
1168
+
1169
+ #### args
1170
+
1171
+ Additional arguments passed to the outer `<svg>` element.
1172
+
1173
+ #### Returns
1174
+
1175
+ An `SVGSVGElement` containing a `<use>` element.
1176
+
1177
+ #### Usage Examples
1178
+
1179
+ ```ts
1180
+ const svg = svgUse('icon-home')
1181
+
1182
+ expect(svg.children.length).toBe(1)
1183
+
1184
+ const useElement = svg.children[0]
1185
+
1186
+ expect(useElement.getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBe('#icon-home')
1187
+
1188
+ const svgWithViewBox = svgUse('icon-star', { viewBox: '0 0 24 24' })
1189
+
1190
+ expect(svgWithViewBox.getAttribute('viewBox')).toBe('0 0 24 24')
1191
+
1192
+ const useViewBox = svgWithViewBox.children[0]
1193
+
1194
+ expect(useViewBox.getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBe('#icon-star')
1195
+
1196
+ const svgWithClass = svgUse('icon-menu', { class: 'icon-btn' })
1197
+
1198
+ expect(svgWithClass.getAttribute('class')).toBe('icon-btn')
1199
+
1200
+ expect(svgWithClass.children.length).toBe(1)
1201
+
1202
+ const useClass = svgWithClass.children[0]
1203
+
1204
+ expect(useClass.getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBe('#icon-menu')
1205
+
1206
+ const svgWithMultipleAttrs = svgUse('icon-settings', { width: 24, height: 24, class: 'icon' })
1207
+
1208
+ expect(svgWithMultipleAttrs.getAttribute('width')).toBe('24')
1209
+
1210
+ expect(svgWithMultipleAttrs.getAttribute('height')).toBe('24')
1211
+
1212
+ expect(svgWithMultipleAttrs.getAttribute('class')).toBe('icon')
1213
+
1214
+ const useMultiple = svgWithMultipleAttrs.children[0]
1215
+
1216
+ expect(useMultiple.getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBe('#icon-settings')
1217
+ ```
1218
+
1219
+ ## License
1220
+
1221
+ The MIT License (MIT)
1222
+
1223
+ Copyright (c) 2016+ Jackens
1224
+
1225
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
1226
+
1227
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
1228
+
1229
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.