@jackens/nnn 2026.9.30 → 2026.11.16

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 (4) hide show
  1. package/README.md +219 -600
  2. package/nnn.d.ts +26 -88
  3. package/nnn.js +135 -270
  4. package/package.json +14 -10
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # nnn
2
2
 
3
- A collection of Jackens’ JavaScript helper utilities (version: `2026.9.30`).
3
+ A collection of Jackens’ JavaScript helper utilities (version: `2026.11.16`).
4
4
 
5
5
  ## Installation
6
6
 
@@ -18,16 +18,11 @@ npm i @jackens/nnn
18
18
 
19
19
  ```js
20
20
  import {
21
- CNode,
22
- HArgs,
23
- HArgs1,
24
- LexerSpec,
25
- LexerSpecOptions,
21
+ type CNode,
22
+ type HArgs,
23
+ type HArgs1,
26
24
  c,
27
- createLexer,
28
25
  h,
29
- monokai,
30
- nanolightTs,
31
26
  rwd,
32
27
  s,
33
28
  svgUse,
@@ -42,20 +37,10 @@ import {
42
37
  Tuple argument type for the [`h`](#h) and [`s`](#s) helpers.
43
38
  - [`HArgs1`](#HArgs1):
44
39
  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
40
  - [`c`](#c):
50
41
  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
42
  - [`h`](#h):
54
43
  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
44
  - [`rwd`](#rwd):
60
45
  A responsive web design helper that generates CSS rules for a grid-like layout of floating elements.
61
46
  - [`s`](#s):
@@ -67,7 +52,7 @@ import {
67
52
 
68
53
  ```ts
69
54
  type CNode = {
70
- [attributeOrSelector: string]: string | number | CNode | undefined
55
+ [attributeOrSelector: string]: string | number | CNode | null | undefined
71
56
  }
72
57
  ```
73
58
 
@@ -89,38 +74,6 @@ type HArgs1 = Record<PropertyKey, unknown> | null | undefined | Node | string |
89
74
 
90
75
  Single argument type for the [`h`](#h) and [`s`](#s) helpers.
91
76
 
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
77
  ### c
125
78
 
126
79
  ```ts
@@ -134,14 +87,19 @@ A minimal CSS-in-JS helper that converts a JavaScript object hierarchy into a CS
134
87
  An object describing CSS rules.
135
88
  Keys are selectors or at-rules; values are either CSS property values or nested rule objects.
136
89
  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`).
90
+ If a nested selector contains `&`, every `&` is replaced with the parent selector instead (e.g. `'&:hover'` → `a:hover`, `'.c &'` → `.c a`).
137
91
  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}`).
92
+ Commas inside brackets or quotes (e.g. `':is(.b,.c)'`, `'[title="b,c"]'`) do not separate alternatives; whitespace after a separating comma is ignored (use `&` for a descendant combinator in a non-first alternative, e.g. `'.b,& .c'` → `a.b,a .c`).
93
+ At-rules (e.g. `'@media …'`) wrap their nested rules; an at-rule nested in a selector wraps that selector (e.g. `{ a: { '@media(…)': { color: 'red' } } }` → `@media(…){a{color:red}}`).
94
+ Inside a top-level `:root` rule nested keys are concatenated into property names (e.g. `{ ':root': { '--': { bg: 'red' } } }` → `:root{--bg:red}`).
95
+ Properties without a selector (top-level or directly in a top-level at-rule, e.g. `@font-face`) are emitted as bare declarations.
139
96
  `null`/`undefined` values are skipped.
140
97
 
141
98
  #### splitter
142
99
 
143
100
  A delimiter used to create unique keys (default: `'$$'`).
144
101
  The substring from `splitter` to the end of a key is ignored (e.g. `src$$1` → `src`).
102
+ An empty `splitter` disables this feature.
145
103
 
146
104
  #### Returns
147
105
 
@@ -216,7 +174,7 @@ const actual = c({
216
174
  '@font-face$$1': {
217
175
  'font-family': 'Jackens',
218
176
  src$$1: 'url(otf/jackens.otf)',
219
- src$$2: "url(otf/jackens.otf) format('opentype')," + "url(svg/jackens.svg) format('svg')",
177
+ src$$2: "url(otf/jackens.otf) format('opentype'),url(svg/jackens.svg) format('svg')",
220
178
  'font-weight': 'normal',
221
179
  'font-style': 'normal',
222
180
  },
@@ -452,208 +410,120 @@ const expected = `
452
410
  expect(actual).toBe(expected)
453
411
  ```
454
412
 
455
- ### createLexer
456
-
457
- ```ts
458
- const createLexer: <M, T>(
459
- decorator: (chunk: string, metadata?: M) => T,
460
- ...specs: LexerSpec<M>[]
461
- ) => (code: string) => T[]
462
- ```
463
-
464
- A helper for building simple stateful lexers (see also [`nanolightTs`](#nanolightTs)).
465
-
466
- #### Remarks
467
-
468
- 1. Matches starting at an earlier position take precedence.
469
- 2. Among matches at the same position, the longer one wins.
470
- 3. Among matches of the same position and length, the one defined earlier wins.
471
- 4. Only specs active in the current state (the top of the state stack, initially `'root'`) are considered.
472
- 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.
473
- 6. Empty matches are ignored.
474
-
475
- #### decorator
476
-
477
- A function that wraps each chunk of the input.
478
- It receives the matched text (`chunk`) and the `metadata` of the spec whose pattern produced the match.
479
- For unmatched text between matches, `metadata` is `undefined`.
480
-
481
- #### specs
482
-
483
- An array of tuples `[metadata, pattern, options?]` where:
484
-
485
- - `metadata`: arbitrary data (e.g., a CSS class name) passed to `decorator` when the pattern matches.
486
- - `pattern`: a `string` (matched literally) or `RegExp` to match against the input.
487
- - `options`: optional [`LexerSpecOptions`](#LexerSpecOptions) (states in which the spec is active and state transitions).
488
-
489
- #### Returns
490
-
491
- A lexer function that accepts an input string and returns an array of decorated chunks covering the whole input (in order).
492
-
493
- #### Usage Examples
494
-
495
- Tokenizing keywords and strings with `RegExp` patterns:
496
-
497
- ```ts
498
- const lexer = createLexer(
499
- (chunk, metadata) => ({ chunk, metadata }),
500
- ['keyword', /\b(if|else|for)\b/],
501
- ['string', /"[^"]*"/]
502
- )
503
-
504
- const result = lexer('if "hello" else "world"')
505
-
506
- expect(result).toEqual([
507
- { chunk: 'if', metadata: 'keyword' },
508
- { chunk: ' ', metadata: undefined },
509
- { chunk: '"hello"', metadata: 'string' },
510
- { chunk: ' ', metadata: undefined },
511
- { chunk: 'else', metadata: 'keyword' },
512
- { chunk: ' ', metadata: undefined },
513
- { chunk: '"world"', metadata: 'string' },
514
- ])
515
- ```
516
-
517
- String patterns are matched literally and unmatched text gets `undefined` metadata:
518
-
519
- ```ts
520
- const lexer = createLexer((chunk, metadata) => `${metadata}:${chunk}`, ['tag', 'BEGIN'], ['end', 'END'])
521
-
522
- const result = lexer('aBEGINbENDc')
523
-
524
- expect(result).toEqual(['undefined:a', 'tag:BEGIN', 'undefined:b', 'end:END', 'undefined:c'])
525
- ```
526
-
527
- Empty input produces an empty result:
528
-
529
- ```ts
530
- const lexer = createLexer(chunk => chunk, ['test', /test/])
531
-
532
- const result = lexer('')
533
-
534
- expect(result).toEqual([])
535
- ```
536
-
537
- `^` anchors to the start of the whole input:
538
-
539
- ```ts
540
- const lexer = createLexer((chunk, metadata) => ({ chunk, metadata }), ['start', /^test/])
541
-
542
- const result = lexer('test here')
543
-
544
- expect(result).toEqual([
545
- { chunk: 'test', metadata: 'start' },
546
- { chunk: ' here', metadata: undefined },
547
- ])
548
- ```
549
-
550
- Matches starting earlier take precedence regardless of spec order:
551
-
552
- ```ts
553
- const lexer = createLexer((chunk, metadata) => metadata, ['later', /x/], ['earlier', /y/])
554
-
555
- const result = lexer('yx')
556
-
557
- expect(result).toEqual(['earlier', 'later'])
558
- ```
559
-
560
- The longer match wins at the same position:
413
+ `&` is replaced with the parent selector:
561
414
 
562
415
  ```ts
563
- const lexer = createLexer(chunk => chunk, ['short', 'a'], ['long', 'abc'])
564
-
565
- const result = lexer('abc')
566
-
567
- expect(result).toEqual(['abc'])
568
- ```
569
-
570
- Empty string patterns are ignored:
571
-
572
- ```ts
573
- const lexer = createLexer(chunk => chunk, ['empty', ''], ['word', /\w+/])
574
-
575
- const result = lexer('hello')
576
-
577
- expect(result).toEqual(['hello'])
578
- ```
579
-
580
- Input without any match is returned as a single chunk:
581
-
582
- ```ts
583
- const lexer = createLexer(chunk => chunk, ['test', /xyz/])
584
-
585
- const result = lexer('abc')
586
-
587
- expect(result).toEqual(['abc'])
588
- ```
589
-
590
- Consecutive matches are returned as separate chunks:
591
-
592
- ```ts
593
- const lexer = createLexer((_chunk, metadata) => metadata, ['a', 'a'], ['b', 'b'])
416
+ const actual = c({
417
+ a: {
418
+ color: 'red',
419
+ '&:hover': { color: 'blue' },
420
+ '.dark &': { color: 'white' },
421
+ '.b,& .c': { margin: 1 },
422
+ },
423
+ })
594
424
 
595
- const result = lexer('aabb')
425
+ const expected = `
426
+ a{
427
+ color:red
428
+ }
429
+ a:hover{
430
+ color:blue
431
+ }
432
+ .dark a{
433
+ color:white
434
+ }
435
+ a.b,a .c{
436
+ margin:1
437
+ }
438
+ `.replace(/\n\s*/g, '')
596
439
 
597
- expect(result).toEqual(['a', 'a', 'b', 'b'])
440
+ expect(actual).toBe(expected)
598
441
  ```
599
442
 
600
- Entering and leaving states with `push` and `pop`:
443
+ Commas inside brackets or quotes do not split selectors and whitespace after a comma is ignored:
601
444
 
602
445
  ```ts
603
- const lexer = createLexer(
604
- (chunk, metadata) => `${metadata}:${chunk}`,
605
- ['open', '(', { push: 'inner' }],
606
- ['word', /\w+/, { in: 'inner' }],
607
- ['close', ')', { in: 'inner', pop: true }]
608
- )
446
+ const actual = c({
447
+ ':is(.a,.b)': {
448
+ '.c, .d': { margin: 1 },
449
+ '[title="x,y"]': { margin: 2 },
450
+ },
451
+ })
609
452
 
610
- const result = lexer('a(b)c)')
453
+ const expected = `
454
+ :is(.a,.b).c,:is(.a,.b).d{
455
+ margin:1
456
+ }
457
+ :is(.a,.b)[title="x,y"]{
458
+ margin:2
459
+ }
460
+ `.replace(/\n\s*/g, '')
611
461
 
612
- expect(result).toEqual(['undefined:a', 'open:(', 'word:b', 'close:)', 'undefined:c)'])
462
+ expect(actual).toBe(expected)
613
463
  ```
614
464
 
615
- Nested states with a spec active in multiple states:
465
+ At-rules nested in selectors wrap these selectors:
616
466
 
617
467
  ```ts
618
- const lexer = createLexer(
619
- (chunk, metadata) => `${metadata}:${chunk}`,
620
- ['open', '{', { in: ['root', 'nested'], push: 'nested' }],
621
- ['close', '}', { in: 'nested', pop: true }]
622
- )
468
+ const actual = c({
469
+ a: {
470
+ color: 'red',
471
+ '@media(min-width:600px)': {
472
+ color: 'blue',
473
+ '.b': { margin: 1 },
474
+ },
475
+ },
476
+ })
623
477
 
624
- const result = lexer('{{}}}')
478
+ const expected = `
479
+ a{
480
+ color:red
481
+ }
482
+ @media(min-width:600px){
483
+ a{
484
+ color:blue
485
+ }
486
+ a.b{
487
+ margin:1
488
+ }
489
+ }
490
+ `.replace(/\n\s*/g, '')
625
491
 
626
- expect(result).toEqual(['open:{', 'open:{', 'close:}', 'close:}', 'undefined:}'])
492
+ expect(actual).toBe(expected)
627
493
  ```
628
494
 
629
- Lookbehinds see the text preceding the current position:
495
+ Declarations without a selector are separated from subsequent rules:
630
496
 
631
497
  ```ts
632
- const lexer = createLexer((chunk, metadata) => `${metadata}:${chunk}`, ['a', 'a'], ['b', /(?<=a)b/])
633
-
634
- const result = lexer('abcb')
498
+ expect(c({ color: 'red', margin: 0 })).toBe('color:red;margin:0')
635
499
 
636
- expect(result).toEqual(['a:a', 'b:b', 'undefined:cb'])
500
+ expect(c({ color: 'red', a: { margin: 0 }, padding: 0 })).toBe('color:red;a{margin:0}padding:0')
637
501
  ```
638
502
 
639
- Sticky patterns match only at the current position:
503
+ `null` and `undefined` values are skipped:
640
504
 
641
505
  ```ts
642
- const lexer = createLexer((chunk, metadata) => `${metadata}:${chunk}`, ['x', /x/y], ['y', 'y'])
643
-
644
- const result = lexer('xyx.x')
506
+ const actual = c({
507
+ a: {
508
+ color: 'red',
509
+ margin: null,
510
+ padding: undefined,
511
+ '.b': null,
512
+ },
513
+ ':root': {
514
+ '--': { bg: null, fg: 'black' },
515
+ },
516
+ })
645
517
 
646
- expect(result).toEqual(['x:x', 'y:y', 'x:x', 'undefined:.x'])
518
+ expect(actual).toBe('a{color:red}:root{--fg:black}')
647
519
  ```
648
520
 
649
- Empty `RegExp` matches are skipped:
521
+ Custom splitter (an empty one disables splitting):
650
522
 
651
523
  ```ts
652
- const lexer = createLexer((chunk, metadata) => `${metadata}:${chunk}`, ['digits', /\d*/])
524
+ expect(c({ a: { 'src#1': 'url(a.otf)', 'src#2': 'url(a.woff)' } }, '#')).toBe('a{src:url(a.otf);src:url(a.woff)}')
653
525
 
654
- const result = lexer('ab12')
655
-
656
- expect(result).toEqual(['undefined:ab', 'digits:12'])
526
+ expect(c({ a: { b$$c: 1 } }, '')).toBe('a{b$$c:1}')
657
527
  ```
658
528
 
659
529
  ### h
@@ -661,6 +531,7 @@ expect(result).toEqual(['undefined:ab', 'digits:12'])
661
531
  ```ts
662
532
  const h: {
663
533
  <T extends keyof HTMLElementTagNameMap>(tag: T, ...args: HArgs1[]): HTMLElementTagNameMap[T]
534
+ (tag: string, ...args: HArgs1[]): HTMLElement
664
535
  <N extends Node>(node: N, ...args: HArgs1[]): N
665
536
  (tagOrNode: string | Node, ...args: HArgs1[]): Node
666
537
  }
@@ -678,9 +549,9 @@ If a `Node`, that node is modified in place.
678
549
  Additional arguments processed as follows:
679
550
 
680
551
  - `Object`: maps attributes/properties.
681
- - 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`).
682
- - 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`.
683
- A value of `true` sets an empty attribute (e.g. `checked=""`), a value of `false` removes the attribute.
552
+ - Keys starting with `$` set element properties (without the `$` prefix); if the value is a plain object (created by `{…}` or `Object.create(null)`), it is shallow-merged into the existing property instead of replacing it (useful e.g. for `$style`), other values (including arrays and class instances) replace the property.
553
+ - Other keys set attributes: a key prefixed with `xlink:`, `xml:` or `xmlns:` sets the attribute via `setAttributeNS` in the corresponding namespace; any other key sets it via `setAttribute`.
554
+ A value of `true` sets an empty attribute (e.g. `checked=""`), a value of `false` removes the attribute, a value of `null`/`undefined` leaves the attribute unchanged.
684
555
  - `null`/`undefined`: ignored.
685
556
  - `Node`: appended as a child.
686
557
  - `string`/`number`: converted to a `Text` node and appended.
@@ -806,356 +677,44 @@ const elemWithNested = h('div', ['span', 'hello'], ['b', 'world'])
806
677
  expect(elemWithNested.outerHTML).toBe('<div><span>hello</span><b>world</b></div>')
807
678
  ```
808
679
 
809
- ### monokai
810
-
811
- ```ts
812
- const monokai: CNode
813
- ```
814
-
815
- A Monokai-inspired color scheme for use with the [`c`](#c) helper and [`nanolightTs`](#nanolightTs) lexer.
816
- It styles `pre` and `code>span` elements with the class names produced by [`nanolightTs`](#nanolightTs) (with light, dark and print variants).
817
-
818
- #### Usage Examples
819
-
820
- Styling `nanolightTs` output:
821
-
822
- ```ts
823
- const style = h('style', c(monokai))
824
- const pre = h('pre', ['code', ...nanolightTs('const x = 42')])
825
-
826
- expect(style.textContent).toContain('code>span.keyword1{color:var(--keyword1)}')
827
- expect(style.textContent).toContain('code>span.regexp{color:var(--regexp)}')
828
- expect(style.textContent).toContain('@media only screen and (prefers-color-scheme: dark){:root{--bg:#2d2a2e;')
829
- expect(pre.outerHTML).toBe(
830
- '<pre><code>' +
831
- '<span class="keyword1">const</span> ' +
832
- '<span class="identifier4">x</span> ' +
833
- '<span class="operator">=</span> ' +
834
- '<span class="number">42</span>' +
835
- '</code></pre>'
836
- )
837
- ```
838
-
839
- ### nanolightTs
840
-
841
- ```ts
842
- const nanolightTs: (code: string) => HArgs1[]
843
- ```
844
-
845
- A TypeScript/JavaScript syntax highlighting lexer built using [`createLexer`](#createLexer).
846
-
847
- #### code
848
-
849
- The source code string to tokenize.
850
-
851
- #### Returns
852
-
853
- 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)).
854
-
855
- #### Usage Examples
856
-
857
- Keywords, identifiers, operators, numbers, punctuation, strings and comments:
680
+ `false` removes an attribute, `null` and `undefined` leave it unchanged:
858
681
 
859
682
  ```ts
860
- const codeJs = 'const answerToLifeTheUniverseAndEverything = ' + "{ 42: 42 }['42'] /* 42 */"
861
-
862
- expect(nanolightTs(codeJs)).toEqual([
863
- ['span', { class: 'keyword1' }, 'const'],
864
- ' ',
865
- ['span', { class: 'identifier4' }, 'answerToLifeTheUniverseAndEverything'],
866
- ' ',
867
- ['span', { class: 'operator' }, '='],
868
- ' ',
869
- ['span', { class: 'punctuation' }, '{'],
870
- ' ',
871
- ['span', { class: 'number' }, '42'],
872
- ['span', { class: 'operator' }, ':'],
873
- ' ',
874
- ['span', { class: 'number' }, '42'],
875
- ' ',
876
- ['span', { class: 'punctuation' }, '}'],
877
- ['span', { class: 'punctuation' }, '['],
878
- ['span', { class: 'string' }, "'42'"],
879
- ['span', { class: 'punctuation' }, ']'],
880
- ' ',
881
- ['span', { class: 'comment' }, '/* 42 */'],
882
- ])
883
- ```
683
+ const input = h('input', { type: 'checkbox', checked: true, title: 'title' })
884
684
 
885
- Line comments:
685
+ h(input, { checked: false, title: null, type: undefined })
886
686
 
887
- ```ts
888
- const codeJs = 'const x = 42 // answer'
889
-
890
- expect(nanolightTs(codeJs)).toEqual([
891
- ['span', { class: 'keyword1' }, 'const'],
892
- ' ',
893
- ['span', { class: 'identifier4' }, 'x'],
894
- ' ',
895
- ['span', { class: 'operator' }, '='],
896
- ' ',
897
- ['span', { class: 'number' }, '42'],
898
- ' ',
899
- ['span', { class: 'comment' }, '// answer'],
900
- ])
687
+ expect(input.outerHTML).toBe('<input type="checkbox" title="title">')
901
688
  ```
902
689
 
903
- Regex literals vs division:
690
+ Only plain objects are merged into `$`-prefixed properties:
904
691
 
905
692
  ```ts
906
- expect(nanolightTs('x = /a\\/b[/]c/gi.test(y) / 2')).toEqual([
907
- ['span', { class: 'identifier4' }, 'x'],
908
- ' ',
909
- ['span', { class: 'operator' }, '='],
910
- ' ',
911
- ['span', { class: 'regexp' }, '/'],
912
- ['span', { class: 'regexp' }, 'a'],
913
- ['span', { class: 'keyword2' }, '\\/'],
914
- ['span', { class: 'regexp' }, 'b'],
915
- ['span', { class: 'punctuation' }, '['],
916
- ['span', { class: 'regexp' }, '/'],
917
- ['span', { class: 'punctuation' }, ']'],
918
- ['span', { class: 'regexp' }, 'c'],
919
- ['span', { class: 'regexp' }, '/'],
920
- ['span', { class: 'keyword1' }, 'gi'],
921
- ['span', { class: 'punctuation' }, '.'],
922
- ['span', { class: 'identifier2' }, 'test'],
923
- ['span', { class: 'punctuation' }, '('],
924
- ['span', { class: 'identifier4' }, 'y'],
925
- ['span', { class: 'punctuation' }, ')'],
926
- ' ',
927
- ['span', { class: 'operator' }, '/'],
928
- ' ',
929
- ['span', { class: 'number' }, '2'],
930
- ])
931
-
932
- expect(nanolightTs('a / b / c')).toEqual([
933
- ['span', { class: 'identifier4' }, 'a'],
934
- ' ',
935
- ['span', { class: 'operator' }, '/'],
936
- ' ',
937
- ['span', { class: 'identifier4' }, 'b'],
938
- ' ',
939
- ['span', { class: 'operator' }, '/'],
940
- ' ',
941
- ['span', { class: 'identifier4' }, 'c'],
942
- ])
943
-
944
- expect(nanolightTs('return / x /')).toEqual([
945
- ['span', { class: 'keyword1' }, 'return'],
946
- ' ',
947
- ['span', { class: 'regexp' }, '/'],
948
- ['span', { class: 'regexp' }, ' x '],
949
- ['span', { class: 'regexp' }, '/'],
950
- ])
951
-
952
- expect(nanolightTs('x = /=/g')).toEqual([
953
- ['span', { class: 'identifier4' }, 'x'],
954
- ' ',
955
- ['span', { class: 'operator' }, '='],
956
- ' ',
957
- ['span', { class: 'regexp' }, '/='],
958
- ['span', { class: 'regexp' }, '/'],
959
- ['span', { class: 'keyword1' }, 'g'],
960
- ])
961
- ```
693
+ const date = new Date(0)
694
+ const div = h('div', { $date: date, $list: [1, 2] })
962
695
 
963
- Nested template literals with substitutions:
696
+ expect(div.date).toBe(date)
964
697
 
965
- ```ts
966
- expect(nanolightTs('`a ${b + `c ${ {d}.d }`} \\${e} $f`')).toEqual([
967
- ['span', { class: 'string' }, '`a '],
968
- ['span', { class: 'punctuation' }, '${'],
969
- ['span', { class: 'identifier4' }, 'b'],
970
- ' ',
971
- ['span', { class: 'operator' }, '+'],
972
- ' ',
973
- ['span', { class: 'string' }, '`c '],
974
- ['span', { class: 'punctuation' }, '${'],
975
- ' ',
976
- ['span', { class: 'punctuation' }, '{'],
977
- ['span', { class: 'identifier4' }, 'd'],
978
- ['span', { class: 'punctuation' }, '}'],
979
- ['span', { class: 'punctuation' }, '.'],
980
- ['span', { class: 'identifier4' }, 'd'],
981
- ' ',
982
- ['span', { class: 'punctuation' }, '}'],
983
- ['span', { class: 'string' }, '`'],
984
- ['span', { class: 'punctuation' }, '}'],
985
- ['span', { class: 'string' }, ' \\${e} $f`'],
986
- ])
987
-
988
- expect(nanolightTs('`${x}` / 2')).toEqual([
989
- ['span', { class: 'string' }, '`'],
990
- ['span', { class: 'punctuation' }, '${'],
991
- ['span', { class: 'identifier4' }, 'x'],
992
- ['span', { class: 'punctuation' }, '}'],
993
- ['span', { class: 'string' }, '`'],
994
- ' ',
995
- ['span', { class: 'operator' }, '/'],
996
- ' ',
997
- ['span', { class: 'number' }, '2'],
998
- ])
999
- ```
1000
-
1001
- Comment markers inside strings and unterminated comments:
1002
-
1003
- ```ts
1004
- expect(nanolightTs('"a // b" // c')).toEqual([
1005
- ['span', { class: 'string' }, '"a // b"'],
1006
- ' ',
1007
- ['span', { class: 'comment' }, '// c'],
1008
- ])
1009
-
1010
- expect(nanolightTs("'it\\'s' /* x")).toEqual([
1011
- ['span', { class: 'string' }, "'it\\'s'"],
1012
- ' ',
1013
- ['span', { class: 'comment' }, '/* x'],
1014
- ])
1015
- ```
1016
-
1017
- Hexadecimal, BigInt, numeric separators and exponents:
1018
-
1019
- ```ts
1020
- expect(nanolightTs('0xFF + 1_000n + .5e-3')).toEqual([
1021
- ['span', { class: 'number' }, '0xFF'],
1022
- ' ',
1023
- ['span', { class: 'operator' }, '+'],
1024
- ' ',
1025
- ['span', { class: 'number' }, '1_000n'],
1026
- ' ',
1027
- ['span', { class: 'operator' }, '+'],
1028
- ' ',
1029
- ['span', { class: 'number' }, '.5e-3'],
1030
- ])
1031
- ```
698
+ h(div, { $list: [3] })
1032
699
 
1033
- Division after `++` and optional chaining vs ternary with decimals:
1034
-
1035
- ```ts
1036
- expect(nanolightTs('a++ / 2 / b')).toEqual([
1037
- ['span', { class: 'identifier4' }, 'a'],
1038
- ['span', { class: 'operator' }, '++'],
1039
- ' ',
1040
- ['span', { class: 'operator' }, '/'],
1041
- ' ',
1042
- ['span', { class: 'number' }, '2'],
1043
- ' ',
1044
- ['span', { class: 'operator' }, '/'],
1045
- ' ',
1046
- ['span', { class: 'identifier4' }, 'b'],
1047
- ])
1048
-
1049
- expect(nanolightTs('x?.5:a?.b')).toEqual([
1050
- ['span', { class: 'identifier4' }, 'x'],
1051
- ['span', { class: 'operator' }, '?'],
1052
- ['span', { class: 'number' }, '.5'],
1053
- ['span', { class: 'operator' }, ':'],
1054
- ['span', { class: 'identifier4' }, 'a'],
1055
- ['span', { class: 'punctuation' }, '?.'],
1056
- ['span', { class: 'identifier4' }, 'b'],
1057
- ])
700
+ expect(div.list).toEqual([3])
1058
701
  ```
1059
702
 
1060
- Keywords as property names and method calls:
703
+ Namespaced attributes:
1061
704
 
1062
705
  ```ts
1063
- expect(nanolightTs('p.catch(obj.in, ...this)')).toEqual([
1064
- ['span', { class: 'identifier4' }, 'p'],
1065
- ['span', { class: 'punctuation' }, '.'],
1066
- ['span', { class: 'identifier2' }, 'catch'],
1067
- ['span', { class: 'punctuation' }, '('],
1068
- ['span', { class: 'identifier4' }, 'obj'],
1069
- ['span', { class: 'punctuation' }, '.'],
1070
- ['span', { class: 'identifier4' }, 'in'],
1071
- ['span', { class: 'punctuation' }, ','],
1072
- ' ',
1073
- ['span', { class: 'operator' }, '...'],
1074
- ['span', { class: 'keyword1' }, 'this'],
1075
- ['span', { class: 'punctuation' }, ')'],
1076
- ])
1077
- ```
1078
-
1079
- Hashbang, decorators and private class members:
706
+ const div = h('div', { 'xml:lang': 'pl', 'ng:x': 1 })
1080
707
 
1081
- ```ts
1082
- expect(nanolightTs('#!/usr/bin/env bun\n@sealed class A { #x = 1 }')).toEqual([
1083
- ['span', { class: 'comment' }, '#!/usr/bin/env bun'],
1084
- '\n',
1085
- ['span', { class: 'identifier2' }, '@sealed'],
1086
- ' ',
1087
- ['span', { class: 'keyword1' }, 'class'],
1088
- ' ',
1089
- ['span', { class: 'identifier1' }, 'A'],
1090
- ' ',
1091
- ['span', { class: 'punctuation' }, '{'],
1092
- ' ',
1093
- ['span', { class: 'identifier4' }, '#x'],
1094
- ' ',
1095
- ['span', { class: 'operator' }, '='],
1096
- ' ',
1097
- ['span', { class: 'number' }, '1'],
1098
- ' ',
1099
- ['span', { class: 'punctuation' }, '}'],
1100
- ])
708
+ expect(div.getAttributeNS('http://www.w3.org/XML/1998/namespace', 'lang')).toBe('pl')
709
+ expect(div.getAttribute('ng:x')).toBe('1')
1101
710
  ```
1102
711
 
1103
- Escapes, anchors, quantifiers, groups, character sets and flags inside regex literals:
712
+ Return types:
1104
713
 
1105
714
  ```ts
1106
- expect(nanolightTs('/^(?:\\d{1,3}|[^a-z\\]])+?\\b(?<year>\\d{4})-\\k<year>\\1$/u')).toEqual([
1107
- ['span', { class: 'regexp' }, '/'],
1108
- ['span', { class: 'keyword1' }, '^'],
1109
- ['span', { class: 'punctuation' }, '(?:'],
1110
- ['span', { class: 'keyword3' }, '\\d'],
1111
- ['span', { class: 'operator' }, '{1,3}'],
1112
- ['span', { class: 'operator' }, '|'],
1113
- ['span', { class: 'punctuation' }, '['],
1114
- ['span', { class: 'operator' }, '^'],
1115
- ['span', { class: 'regexp' }, 'a'],
1116
- ['span', { class: 'operator' }, '-'],
1117
- ['span', { class: 'regexp' }, 'z'],
1118
- ['span', { class: 'keyword2' }, '\\]'],
1119
- ['span', { class: 'punctuation' }, ']'],
1120
- ['span', { class: 'punctuation' }, ')'],
1121
- ['span', { class: 'operator' }, '+?'],
1122
- ['span', { class: 'keyword1' }, '\\b'],
1123
- ['span', { class: 'punctuation' }, '(?<'],
1124
- ['span', { class: 'identifier2' }, 'year'],
1125
- ['span', { class: 'punctuation' }, '>'],
1126
- ['span', { class: 'keyword3' }, '\\d'],
1127
- ['span', { class: 'operator' }, '{4}'],
1128
- ['span', { class: 'punctuation' }, ')'],
1129
- ['span', { class: 'regexp' }, '-'],
1130
- ['span', { class: 'identifier2' }, '\\k<year>'],
1131
- ['span', { class: 'identifier2' }, '\\1'],
1132
- ['span', { class: 'keyword1' }, '$'],
1133
- ['span', { class: 'regexp' }, '/'],
1134
- ['span', { class: 'keyword1' }, 'u'],
1135
- ])
1136
-
1137
- expect(nanolightTs('/(?<=\\$)(?!0)\\p{L}+\\.\\u{1F600}|\\x41[.\\b-]/v')).toEqual([
1138
- ['span', { class: 'regexp' }, '/'],
1139
- ['span', { class: 'punctuation' }, '(?<='],
1140
- ['span', { class: 'keyword2' }, '\\$'],
1141
- ['span', { class: 'punctuation' }, ')'],
1142
- ['span', { class: 'punctuation' }, '(?!'],
1143
- ['span', { class: 'regexp' }, '0'],
1144
- ['span', { class: 'punctuation' }, ')'],
1145
- ['span', { class: 'keyword3' }, '\\p{L}'],
1146
- ['span', { class: 'operator' }, '+'],
1147
- ['span', { class: 'keyword2' }, '\\.'],
1148
- ['span', { class: 'keyword2' }, '\\u{1F600}'],
1149
- ['span', { class: 'operator' }, '|'],
1150
- ['span', { class: 'keyword2' }, '\\x41'],
1151
- ['span', { class: 'punctuation' }, '['],
1152
- ['span', { class: 'regexp' }, '.'],
1153
- ['span', { class: 'keyword2' }, '\\b'],
1154
- ['span', { class: 'regexp' }, '-'],
1155
- ['span', { class: 'punctuation' }, ']'],
1156
- ['span', { class: 'regexp' }, '/'],
1157
- ['span', { class: 'keyword1' }, 'v'],
1158
- ])
715
+ expectTypeOf(h('input')).toEqualTypeOf<HTMLInputElement>()
716
+ expectTypeOf(h('my-element')).toEqualTypeOf<HTMLElement>()
717
+ expectTypeOf(h(document.createTextNode(''))).toEqualTypeOf<Text>()
1159
718
  ```
1160
719
 
1161
720
  ### rwd
@@ -1166,7 +725,7 @@ const rwd: (
1166
725
  selector: string,
1167
726
  cellWidthPx: number,
1168
727
  cellHeightPx: number,
1169
- ...specs: [number, number?, number?][]
728
+ ...specs: [columns: number, width?: number, height?: number][]
1170
729
  ) => void
1171
730
  ```
1172
731
 
@@ -1183,20 +742,24 @@ The CSS selector for the grid item.
1183
742
 
1184
743
  #### cellWidthPx
1185
744
 
1186
- The base cell width in pixels.
745
+ The base cell width in pixels (a positive number).
1187
746
 
1188
747
  #### cellHeightPx
1189
748
 
1190
- The base cell height in pixels.
749
+ The base cell height in pixels (a positive number).
1191
750
 
1192
751
  #### specs
1193
752
 
1194
- An array of breakpoint specifications, each a tuple of:
753
+ An array of breakpoint specifications, each a tuple of positive integers:
1195
754
 
1196
- - `maxWidth`: number of cells per row; the breakpoint is `min-width: cellWidthPx * maxWidth` (`1` modifies the base rule).
1197
- - `width` (optional, default `1`): number of horizontal cells the element spans.
755
+ - `columns`: number of cells per row; the breakpoint is `min-width: cellWidthPx * columns` (`1` modifies the base rule).
756
+ - `width` (optional, default `1`, at most `columns`): number of horizontal cells the element spans.
1198
757
  - `height` (optional, default `1`): number of vertical cells the element spans.
1199
758
 
759
+ #### Throws
760
+
761
+ A `RangeError` if any of the numeric arguments is out of range.
762
+
1200
763
  #### Usage Examples
1201
764
 
1202
765
  Responsive grid cell widths and heights with media queries:
@@ -1246,11 +809,57 @@ expect(style).toEqual({
1246
809
  })
1247
810
  ```
1248
811
 
812
+ Widths that divide evenly do not use `calc`; existing at-rules are extended:
813
+
814
+ ```ts
815
+ const style: CNode = {
816
+ '@media(min-width:400px)': {
817
+ '.r2': { margin: 0 },
818
+ },
819
+ }
820
+
821
+ rwd(style, '.r2', 100, 40, [4, 2], [2, 2, 3])
822
+
823
+ expect(style).toEqual({
824
+ '@media(min-width:400px)': {
825
+ '.r2': {
826
+ margin: 0,
827
+ width: '50%',
828
+ height: '40px',
829
+ },
830
+ },
831
+ '.r2': {
832
+ 'box-sizing': 'border-box',
833
+ display: 'block',
834
+ float: 'left',
835
+ width: '100%',
836
+ height: '40px',
837
+ },
838
+ '@media(min-width:200px)': {
839
+ '.r2': {
840
+ width: '100%',
841
+ height: '120px',
842
+ },
843
+ },
844
+ })
845
+ ```
846
+
847
+ Invalid arguments throw a `RangeError`:
848
+
849
+ ```ts
850
+ expect(() => rwd({}, '.x', 0, 50)).toThrow(RangeError)
851
+ expect(() => rwd({}, '.x', 100, Infinity)).toThrow(RangeError)
852
+ expect(() => rwd({}, '.x', 100, 50, [0])).toThrow(RangeError)
853
+ expect(() => rwd({}, '.x', 100, 50, [2, 3])).toThrow(RangeError)
854
+ expect(() => rwd({}, '.x', 100, 50, [2, 1, 1.5])).toThrow(RangeError)
855
+ ```
856
+
1249
857
  ### s
1250
858
 
1251
859
  ```ts
1252
860
  const s: {
1253
861
  <T extends keyof SVGElementTagNameMap>(tag: T, ...args: HArgs1[]): SVGElementTagNameMap[T]
862
+ (tag: string, ...args: HArgs1[]): SVGElement
1254
863
  <N extends Node>(node: N, ...args: HArgs1[]): N
1255
864
  (tagOrNode: string | Node, ...args: HArgs1[]): Node
1256
865
  }
@@ -1268,9 +877,9 @@ If a `Node`, that node is modified in place.
1268
877
  Additional arguments processed as follows:
1269
878
 
1270
879
  - `Object`: maps attributes/properties.
1271
- - 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.
1272
- - 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`.
1273
- A value of `true` sets an empty attribute (e.g. `checked=""`), a value of `false` removes the attribute.
880
+ - Keys starting with `$` set element properties (without the `$` prefix); if the value is a plain object (created by `{…}` or `Object.create(null)`), it is shallow-merged into the existing property instead of replacing it, other values (including arrays and class instances) replace the property.
881
+ - Other keys set attributes: a key prefixed with `xlink:`, `xml:` or `xmlns:` sets the attribute via `setAttributeNS` in the corresponding namespace (e.g. `xml:lang`); any other key sets it via `setAttribute`.
882
+ A value of `true` sets an empty attribute, a value of `false` removes the attribute, a value of `null`/`undefined` leaves the attribute unchanged.
1274
883
  - `null`/`undefined`: ignored.
1275
884
  - `Node`: appended as a child.
1276
885
  - `string`/`number`: converted to a `Text` node and appended.
@@ -1282,6 +891,16 @@ The created `SVGElement` or the modified node.
1282
891
 
1283
892
  #### Usage Examples
1284
893
 
894
+ Elements (including nested ones) are created in the SVG namespace:
895
+
896
+ ```ts
897
+ const svg = s('svg', ['g', ['circle', { r: 1 }]])
898
+
899
+ expect(svg.namespaceURI).toBe('http://www.w3.org/2000/svg')
900
+ expect(svg.querySelector('circle')?.namespaceURI).toBe('http://www.w3.org/2000/svg')
901
+ expect(svg.outerHTML).toBe('<svg><g><circle r="1"></circle></g></svg>')
902
+ ```
903
+
1285
904
  Namespaced `xlink:` attribute set to `true` gets an empty value:
1286
905
 
1287
906
  ```ts
@@ -1290,10 +909,12 @@ const svg = s('svg', { 'xlink:href': true })
1290
909
  expect(svg.getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBe('')
1291
910
  ```
1292
911
 
1293
- Namespaced `xlink:` attribute set to `false` is not set:
912
+ Namespaced `xlink:` attribute set to `false` is removed:
1294
913
 
1295
914
  ```ts
1296
- const svg = s('svg', { 'xlink:href': false })
915
+ const svg = s('svg', { 'xlink:href': '#id' })
916
+
917
+ s(svg, { 'xlink:href': false })
1297
918
 
1298
919
  expect(svg.getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBeNull()
1299
920
  ```
@@ -1314,6 +935,22 @@ const svg = s('svg', { 'xlink:title': 42 })
1314
935
  expect(svg.getAttributeNS('http://www.w3.org/1999/xlink', 'title')).toBe('42')
1315
936
  ```
1316
937
 
938
+ Namespaced `xml:` and `xmlns:` attributes:
939
+
940
+ ```ts
941
+ const svg = s('svg', { 'xml:lang': 'pl', 'xmlns:xlink': 'http://www.w3.org/1999/xlink' })
942
+
943
+ expect(svg.getAttributeNS('http://www.w3.org/XML/1998/namespace', 'lang')).toBe('pl')
944
+ expect(svg.getAttributeNS('http://www.w3.org/2000/xmlns/', 'xlink')).toBe('http://www.w3.org/1999/xlink')
945
+ ```
946
+
947
+ Return types:
948
+
949
+ ```ts
950
+ expectTypeOf(s('circle')).toEqualTypeOf<SVGCircleElement>()
951
+ expectTypeOf(s('my-element')).toEqualTypeOf<SVGElement>()
952
+ ```
953
+
1317
954
  ### svgUse
1318
955
 
1319
956
  ```ts
@@ -1325,7 +962,7 @@ Shorthand for creating an `<svg>` element with a `<use>` child referencing a sym
1325
962
  Equivalent to:
1326
963
 
1327
964
  ```ts
1328
- s('svg', ['use', { 'xlink:href': '#' + id }], ...args)
965
+ s('svg', ['use', { href: '#' + id }], ...args)
1329
966
  ```
1330
967
 
1331
968
  #### id
@@ -1342,46 +979,28 @@ An `SVGSVGElement` containing a `<use>` element.
1342
979
 
1343
980
  #### Usage Examples
1344
981
 
1345
- SVG `use` element referencing a symbol, with extra attributes:
982
+ SVG `use` element referencing a symbol:
1346
983
 
1347
984
  ```ts
1348
985
  const svg = svgUse('icon-home')
1349
986
 
1350
- expect(svg.children.length).toBe(1)
1351
-
1352
- const useElement = svg.children[0]
1353
-
1354
- expect(useElement.getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBe('#icon-home')
1355
-
1356
- const svgWithViewBox = svgUse('icon-star', { viewBox: '0 0 24 24' })
1357
-
1358
- expect(svgWithViewBox.getAttribute('viewBox')).toBe('0 0 24 24')
1359
-
1360
- const useViewBox = svgWithViewBox.children[0]
1361
-
1362
- expect(useViewBox.getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBe('#icon-star')
1363
-
1364
- const svgWithClass = svgUse('icon-menu', { class: 'icon-btn' })
1365
-
1366
- expect(svgWithClass.getAttribute('class')).toBe('icon-btn')
1367
-
1368
- expect(svgWithClass.children.length).toBe(1)
1369
-
1370
- const useClass = svgWithClass.children[0]
1371
-
1372
- expect(useClass.getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBe('#icon-menu')
987
+ expect(svg.outerHTML).toBe('<svg><use href="#icon-home"></use></svg>')
988
+ ```
1373
989
 
1374
- const svgWithMultipleAttrs = svgUse('icon-settings', { width: 24, height: 24, class: 'icon' })
990
+ Additional attributes are set on the outer `svg` element:
1375
991
 
1376
- expect(svgWithMultipleAttrs.getAttribute('width')).toBe('24')
992
+ ```ts
993
+ const svg = svgUse('icon-settings', { width: 24, height: 24, class: 'icon' })
1377
994
 
1378
- expect(svgWithMultipleAttrs.getAttribute('height')).toBe('24')
995
+ expect(svg.outerHTML).toBe('<svg width="24" height="24" class="icon"><use href="#icon-settings"></use></svg>')
996
+ ```
1379
997
 
1380
- expect(svgWithMultipleAttrs.getAttribute('class')).toBe('icon')
998
+ Additional children are appended to the outer `svg` element:
1381
999
 
1382
- const useMultiple = svgWithMultipleAttrs.children[0]
1000
+ ```ts
1001
+ const svg = svgUse('icon-star', ['title', 'Star'])
1383
1002
 
1384
- expect(useMultiple.getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBe('#icon-settings')
1003
+ expect(svg.outerHTML).toBe('<svg><use href="#icon-star"></use><title>Star</title></svg>')
1385
1004
  ```
1386
1005
 
1387
1006
  ## License