@jackens/nnn 2026.10.1 → 2026.11.17

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 +304 -62
  2. package/nnn.d.ts +29 -17
  3. package/nnn.js +138 -80
  4. package/package.json +13 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # nnn
2
2
 
3
- A collection of Jackens’ JavaScript helper utilities (version: `2026.10.1`).
3
+ A collection of Jackens’ JavaScript helper utilities (version: `2026.11.17`).
4
4
 
5
5
  ## Installation
6
6
 
@@ -18,9 +18,9 @@ npm i @jackens/nnn
18
18
 
19
19
  ```js
20
20
  import {
21
- CNode,
22
- HArgs,
23
- HArgs1,
21
+ type CNode,
22
+ type HArgs,
23
+ type HArgs1,
24
24
  c,
25
25
  h,
26
26
  rwd,
@@ -52,7 +52,7 @@ import {
52
52
 
53
53
  ```ts
54
54
  type CNode = {
55
- [attributeOrSelector: string]: string | number | CNode | undefined
55
+ [attributeOrSelector: string]: string | number | CNode | null | undefined
56
56
  }
57
57
  ```
58
58
 
@@ -87,14 +87,20 @@ A minimal CSS-in-JS helper that converts a JavaScript object hierarchy into a CS
87
87
  An object describing CSS rules.
88
88
  Keys are selectors or at-rules; values are either CSS property values or nested rule objects.
89
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`).
90
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'`).
91
- 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.
93
+ Whitespace after a separating comma belongs to the next alternative, so like a leading space of the first one it makes a descendant combinator when nested (e.g. `' .b, .c'` → `a .b,a .c`, `'.b, .c'` → `a.b,a .c`); leading whitespace of a resulting selector is ignored (e.g. `'h1, h2'` → `h1,h2`, `'.b, &:hover'` → `a.b,a:hover`).
94
+ 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}}`).
95
+ Inside a top-level `:root` rule nested keys are concatenated into property names (e.g. `{ ':root': { '--': { bg: 'red' } } }` → `:root{--bg:red}`).
96
+ Properties without a selector (top-level or directly in a top-level at-rule, e.g. `@font-face`) are emitted as bare declarations.
92
97
  `null`/`undefined` values are skipped.
93
98
 
94
99
  #### splitter
95
100
 
96
101
  A delimiter used to create unique keys (default: `'$$'`).
97
102
  The substring from `splitter` to the end of a key is ignored (e.g. `src$$1` → `src`).
103
+ An empty `splitter` disables this feature.
98
104
 
99
105
  #### Returns
100
106
 
@@ -169,7 +175,7 @@ const actual = c({
169
175
  '@font-face$$1': {
170
176
  'font-family': 'Jackens',
171
177
  src$$1: 'url(otf/jackens.otf)',
172
- src$$2: "url(otf/jackens.otf) format('opentype')," + "url(svg/jackens.svg) format('svg')",
178
+ src$$2: "url(otf/jackens.otf) format('opentype'),url(svg/jackens.svg) format('svg')",
173
179
  'font-weight': 'normal',
174
180
  'font-style': 'normal',
175
181
  },
@@ -405,13 +411,156 @@ const expected = `
405
411
  expect(actual).toBe(expected)
406
412
  ```
407
413
 
414
+ `&` is replaced with the parent selector:
415
+
416
+ ```ts
417
+ const actual = c({
418
+ a: {
419
+ color: 'red',
420
+ '&:hover': { color: 'blue' },
421
+ '.dark &': { color: 'white' },
422
+ '.b,& .c': { margin: 1 },
423
+ },
424
+ })
425
+
426
+ const expected = `
427
+ a{
428
+ color:red
429
+ }
430
+ a:hover{
431
+ color:blue
432
+ }
433
+ .dark a{
434
+ color:white
435
+ }
436
+ a.b,a .c{
437
+ margin:1
438
+ }
439
+ `.replace(/\n\s*/g, '')
440
+
441
+ expect(actual).toBe(expected)
442
+ ```
443
+
444
+ Commas inside brackets or quotes do not split selectors:
445
+
446
+ ```ts
447
+ const actual = c({
448
+ ':is(.a,.b)': {
449
+ '.c,.d': { margin: 1 },
450
+ '[title="x,y"]': { margin: 2 },
451
+ },
452
+ })
453
+
454
+ const expected = `
455
+ :is(.a,.b).c,:is(.a,.b).d{
456
+ margin:1
457
+ }
458
+ :is(.a,.b)[title="x,y"]{
459
+ margin:2
460
+ }
461
+ `.replace(/\n\s*/g, '')
462
+
463
+ expect(actual).toBe(expected)
464
+ ```
465
+
466
+ Whitespace after a comma is kept, but leading whitespace of a resulting selector is ignored:
467
+
468
+ ```ts
469
+ const actual = c({
470
+ 'h1, h2': {
471
+ margin: 0,
472
+ ' .a, .b': { margin: 1 },
473
+ '.c, .d , &:hover': { margin: 2 },
474
+ },
475
+ })
476
+
477
+ const expected = `
478
+ h1,h2{
479
+ margin:0
480
+ }
481
+ h1 .a,h1 .b,h2 .a,h2 .b{
482
+ margin:1
483
+ }
484
+ h1.c,h1 .d,h1:hover,h2.c,h2 .d,h2:hover{
485
+ margin:2
486
+ }
487
+ `.replace(/\n\s*/g, '')
488
+
489
+ expect(actual).toBe(expected)
490
+ ```
491
+
492
+ At-rules nested in selectors wrap these selectors:
493
+
494
+ ```ts
495
+ const actual = c({
496
+ a: {
497
+ color: 'red',
498
+ '@media(min-width:600px)': {
499
+ color: 'blue',
500
+ '.b': { margin: 1 },
501
+ },
502
+ },
503
+ })
504
+
505
+ const expected = `
506
+ a{
507
+ color:red
508
+ }
509
+ @media(min-width:600px){
510
+ a{
511
+ color:blue
512
+ }
513
+ a.b{
514
+ margin:1
515
+ }
516
+ }
517
+ `.replace(/\n\s*/g, '')
518
+
519
+ expect(actual).toBe(expected)
520
+ ```
521
+
522
+ Declarations without a selector are separated from subsequent rules:
523
+
524
+ ```ts
525
+ expect(c({ color: 'red', margin: 0 })).toBe('color:red;margin:0')
526
+
527
+ expect(c({ color: 'red', a: { margin: 0 }, padding: 0 })).toBe('color:red;a{margin:0}padding:0')
528
+ ```
529
+
530
+ `null` and `undefined` values are skipped:
531
+
532
+ ```ts
533
+ const actual = c({
534
+ a: {
535
+ color: 'red',
536
+ margin: null,
537
+ padding: undefined,
538
+ '.b': null,
539
+ },
540
+ ':root': {
541
+ '--': { bg: null, fg: 'black' },
542
+ },
543
+ })
544
+
545
+ expect(actual).toBe('a{color:red}:root{--fg:black}')
546
+ ```
547
+
548
+ Custom splitter (an empty one disables splitting):
549
+
550
+ ```ts
551
+ expect(c({ a: { 'src#1': 'url(a.otf)', 'src#2': 'url(a.woff)' } }, '#')).toBe('a{src:url(a.otf);src:url(a.woff)}')
552
+
553
+ expect(c({ a: { b$$c: 1 } }, '')).toBe('a{b$$c:1}')
554
+ ```
555
+
408
556
  ### h
409
557
 
410
558
  ```ts
411
559
  const h: {
412
560
  <T extends keyof HTMLElementTagNameMap>(tag: T, ...args: HArgs1[]): HTMLElementTagNameMap[T]
413
561
  <N extends Node>(node: N, ...args: HArgs1[]): N
414
- (tagOrNode: string | Node, ...args: HArgs1[]): Node
562
+ (tag: string, ...args: HArgs1[]): HTMLElement
563
+ (node: Node, ...args: HArgs1[]): Node
415
564
  }
416
565
  ```
417
566
 
@@ -427,9 +576,9 @@ If a `Node`, that node is modified in place.
427
576
  Additional arguments processed as follows:
428
577
 
429
578
  - `Object`: maps attributes/properties.
430
- - 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`).
431
- - 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`.
432
- A value of `true` sets an empty attribute (e.g. `checked=""`), a value of `false` removes the attribute.
579
+ - 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.
580
+ - 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`.
581
+ 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.
433
582
  - `null`/`undefined`: ignored.
434
583
  - `Node`: appended as a child.
435
584
  - `string`/`number`: converted to a `Text` node and appended.
@@ -453,13 +602,11 @@ const i = h('i', 'text')
453
602
  h(b, i)
454
603
 
455
604
  expect(i.outerHTML).toBe('<i>text</i>')
456
-
457
605
  expect(b.outerHTML).toBe('<b><i>text</i></b>')
458
606
 
459
607
  h(i, { $className: 'some class' })
460
608
 
461
609
  expect(i.outerHTML).toBe('<i class="some class">text</i>')
462
-
463
610
  expect(b.outerHTML).toBe('<b><i class="some class">text</i></b>')
464
611
  ```
465
612
 
@@ -467,11 +614,9 @@ Text content from strings, numbers and `$innerText`:
467
614
 
468
615
  ```ts
469
616
  expect(h('span', 'text').outerHTML).toBe('<span>text</span>')
470
-
471
617
  expect(h('span', { $innerText: 'text' }).outerHTML).toBe('<span>text</span>')
472
618
 
473
619
  expect(h('span', '42').outerHTML).toBe('<span>42</span>')
474
-
475
620
  expect(h('span', 42).outerHTML).toBe('<span>42</span>')
476
621
  ```
477
622
 
@@ -479,9 +624,7 @@ expect(h('span', 42).outerHTML).toBe('<span>42</span>')
479
624
 
480
625
  ```ts
481
626
  expect(h('div', { style: 'margin:0;padding:0' }).outerHTML).toBe('<div style="margin:0;padding:0"></div>')
482
-
483
627
  expect(h('div', { $style: 'margin:0;padding:0' }).outerHTML).toBe('<div style="margin: 0px; padding: 0px;"></div>')
484
-
485
628
  expect(h('div', { $style: { margin: 0, padding: 0 } }).outerHTML).toBe(
486
629
  '<div style="margin: 0px; padding: 0px;"></div>'
487
630
  )
@@ -517,14 +660,13 @@ expect(checkbox1.checked).toBe(true)
517
660
  expect(checkbox2.checked).toBe(true)
518
661
 
519
662
  expect(checkbox1.outerHTML).toBe('<input type="checkbox" checked="">')
520
-
521
663
  expect(checkbox2.outerHTML).toBe('<input type="checkbox">')
522
664
  ```
523
665
 
524
666
  Object values of `$`-prefixed properties are merged:
525
667
 
526
668
  ```ts
527
- const div = h('div')
669
+ const div = h('div') as any
528
670
 
529
671
  expect(div.key).toBeUndefined()
530
672
 
@@ -555,6 +697,46 @@ const elemWithNested = h('div', ['span', 'hello'], ['b', 'world'])
555
697
  expect(elemWithNested.outerHTML).toBe('<div><span>hello</span><b>world</b></div>')
556
698
  ```
557
699
 
700
+ `false` removes an attribute, `null` and `undefined` leave it unchanged:
701
+
702
+ ```ts
703
+ const input = h('input', { type: 'checkbox', checked: true, title: 'title' })
704
+
705
+ h(input, { checked: false, title: null, type: undefined })
706
+
707
+ expect(input.outerHTML).toBe('<input type="checkbox" title="title">')
708
+ ```
709
+
710
+ Only plain objects are merged into `$`-prefixed properties:
711
+
712
+ ```ts
713
+ const date = new Date(0)
714
+ const div = h('div', { $date: date, $list: [1, 2] }) as any
715
+
716
+ expect(div.date).toBe(date)
717
+
718
+ h(div, { $list: [3] })
719
+
720
+ expect(div.list).toEqual([3])
721
+ ```
722
+
723
+ Namespaced attributes:
724
+
725
+ ```ts
726
+ const div = h('div', { 'xml:lang': 'pl', 'ng:x': 1 })
727
+
728
+ expect(div.getAttributeNS('http://www.w3.org/XML/1998/namespace', 'lang')).toBe('pl')
729
+ expect(div.getAttribute('ng:x')).toBe('1')
730
+ ```
731
+
732
+ Return types:
733
+
734
+ ```ts
735
+ expectTypeOf(h('input')).toEqualTypeOf<HTMLInputElement>()
736
+ expectTypeOf(h('my-element')).toEqualTypeOf<HTMLElement>()
737
+ expectTypeOf(h(document.createTextNode(''))).toEqualTypeOf<Text>()
738
+ ```
739
+
558
740
  ### rwd
559
741
 
560
742
  ```ts
@@ -563,7 +745,7 @@ const rwd: (
563
745
  selector: string,
564
746
  cellWidthPx: number,
565
747
  cellHeightPx: number,
566
- ...specs: [number, number?, number?][]
748
+ ...specs: [columns: number, width?: number, height?: number][]
567
749
  ) => void
568
750
  ```
569
751
 
@@ -580,20 +762,24 @@ The CSS selector for the grid item.
580
762
 
581
763
  #### cellWidthPx
582
764
 
583
- The base cell width in pixels.
765
+ The base cell width in pixels (a positive number).
584
766
 
585
767
  #### cellHeightPx
586
768
 
587
- The base cell height in pixels.
769
+ The base cell height in pixels (a positive number).
588
770
 
589
771
  #### specs
590
772
 
591
- An array of breakpoint specifications, each a tuple of:
773
+ An array of breakpoint specifications, each a tuple of positive integers:
592
774
 
593
- - `maxWidth`: number of cells per row; the breakpoint is `min-width: cellWidthPx * maxWidth` (`1` modifies the base rule).
594
- - `width` (optional, default `1`): number of horizontal cells the element spans.
775
+ - `columns`: number of cells per row; the breakpoint is `min-width: cellWidthPx * columns` (`1` modifies the base rule).
776
+ - `width` (optional, default `1`, at most `columns`): number of horizontal cells the element spans.
595
777
  - `height` (optional, default `1`): number of vertical cells the element spans.
596
778
 
779
+ #### Throws
780
+
781
+ A `RangeError` if any of the numeric arguments is out of range.
782
+
597
783
  #### Usage Examples
598
784
 
599
785
  Responsive grid cell widths and heights with media queries:
@@ -643,13 +829,59 @@ expect(style).toEqual({
643
829
  })
644
830
  ```
645
831
 
832
+ Widths that divide evenly do not use `calc`; existing at-rules are extended:
833
+
834
+ ```ts
835
+ const style: CNode = {
836
+ '@media(min-width:400px)': {
837
+ '.r2': { margin: 0 },
838
+ },
839
+ }
840
+
841
+ rwd(style, '.r2', 100, 40, [4, 2], [2, 2, 3])
842
+
843
+ expect(style).toEqual({
844
+ '@media(min-width:400px)': {
845
+ '.r2': {
846
+ margin: 0,
847
+ width: '50%',
848
+ height: '40px',
849
+ },
850
+ },
851
+ '.r2': {
852
+ 'box-sizing': 'border-box',
853
+ display: 'block',
854
+ float: 'left',
855
+ width: '100%',
856
+ height: '40px',
857
+ },
858
+ '@media(min-width:200px)': {
859
+ '.r2': {
860
+ width: '100%',
861
+ height: '120px',
862
+ },
863
+ },
864
+ })
865
+ ```
866
+
867
+ Invalid arguments throw a `RangeError`:
868
+
869
+ ```ts
870
+ expect(() => rwd({}, '.x', 0, 50)).toThrow(RangeError)
871
+ expect(() => rwd({}, '.x', 100, Infinity)).toThrow(RangeError)
872
+ expect(() => rwd({}, '.x', 100, 50, [0])).toThrow(RangeError)
873
+ expect(() => rwd({}, '.x', 100, 50, [2, 3])).toThrow(RangeError)
874
+ expect(() => rwd({}, '.x', 100, 50, [2, 1, 1.5])).toThrow(RangeError)
875
+ ```
876
+
646
877
  ### s
647
878
 
648
879
  ```ts
649
880
  const s: {
650
881
  <T extends keyof SVGElementTagNameMap>(tag: T, ...args: HArgs1[]): SVGElementTagNameMap[T]
651
882
  <N extends Node>(node: N, ...args: HArgs1[]): N
652
- (tagOrNode: string | Node, ...args: HArgs1[]): Node
883
+ (tag: string, ...args: HArgs1[]): SVGElement
884
+ (node: Node, ...args: HArgs1[]): Node
653
885
  }
654
886
  ```
655
887
 
@@ -665,9 +897,9 @@ If a `Node`, that node is modified in place.
665
897
  Additional arguments processed as follows:
666
898
 
667
899
  - `Object`: maps attributes/properties.
668
- - 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.
669
- - 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`.
670
- A value of `true` sets an empty attribute (e.g. `checked=""`), a value of `false` removes the attribute.
900
+ - 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.
901
+ - 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`.
902
+ A value of `true` sets an empty attribute, a value of `false` removes the attribute, a value of `null`/`undefined` leaves the attribute unchanged.
671
903
  - `null`/`undefined`: ignored.
672
904
  - `Node`: appended as a child.
673
905
  - `string`/`number`: converted to a `Text` node and appended.
@@ -679,6 +911,16 @@ The created `SVGElement` or the modified node.
679
911
 
680
912
  #### Usage Examples
681
913
 
914
+ Elements (including nested ones) are created in the SVG namespace:
915
+
916
+ ```ts
917
+ const svg = s('svg', ['g', ['circle', { r: 1 }]])
918
+
919
+ expect(svg.namespaceURI).toBe('http://www.w3.org/2000/svg')
920
+ expect(svg.querySelector('circle')?.namespaceURI).toBe('http://www.w3.org/2000/svg')
921
+ expect(svg.outerHTML).toBe('<svg><g><circle r="1"></circle></g></svg>')
922
+ ```
923
+
682
924
  Namespaced `xlink:` attribute set to `true` gets an empty value:
683
925
 
684
926
  ```ts
@@ -687,10 +929,12 @@ const svg = s('svg', { 'xlink:href': true })
687
929
  expect(svg.getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBe('')
688
930
  ```
689
931
 
690
- Namespaced `xlink:` attribute set to `false` is not set:
932
+ Namespaced `xlink:` attribute set to `false` is removed:
691
933
 
692
934
  ```ts
693
- const svg = s('svg', { 'xlink:href': false })
935
+ const svg = s('svg', { 'xlink:href': '#id' })
936
+
937
+ s(svg, { 'xlink:href': false })
694
938
 
695
939
  expect(svg.getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBeNull()
696
940
  ```
@@ -711,6 +955,22 @@ const svg = s('svg', { 'xlink:title': 42 })
711
955
  expect(svg.getAttributeNS('http://www.w3.org/1999/xlink', 'title')).toBe('42')
712
956
  ```
713
957
 
958
+ Namespaced `xml:` and `xmlns:` attributes:
959
+
960
+ ```ts
961
+ const svg = s('svg', { 'xml:lang': 'pl', 'xmlns:xlink': 'http://www.w3.org/1999/xlink' })
962
+
963
+ expect(svg.getAttributeNS('http://www.w3.org/XML/1998/namespace', 'lang')).toBe('pl')
964
+ expect(svg.getAttributeNS('http://www.w3.org/2000/xmlns/', 'xlink')).toBe('http://www.w3.org/1999/xlink')
965
+ ```
966
+
967
+ Return types:
968
+
969
+ ```ts
970
+ expectTypeOf(s('circle')).toEqualTypeOf<SVGCircleElement>()
971
+ expectTypeOf(s('my-element')).toEqualTypeOf<SVGElement>()
972
+ ```
973
+
714
974
  ### svgUse
715
975
 
716
976
  ```ts
@@ -722,7 +982,7 @@ Shorthand for creating an `<svg>` element with a `<use>` child referencing a sym
722
982
  Equivalent to:
723
983
 
724
984
  ```ts
725
- s('svg', ['use', { 'xlink:href': '#' + id }], ...args)
985
+ s('svg', ['use', { href: '#' + id }], ...args)
726
986
  ```
727
987
 
728
988
  #### id
@@ -739,46 +999,28 @@ An `SVGSVGElement` containing a `<use>` element.
739
999
 
740
1000
  #### Usage Examples
741
1001
 
742
- SVG `use` element referencing a symbol, with extra attributes:
1002
+ SVG `use` element referencing a symbol:
743
1003
 
744
1004
  ```ts
745
1005
  const svg = svgUse('icon-home')
746
1006
 
747
- expect(svg.children.length).toBe(1)
748
-
749
- const useElement = svg.children[0]
750
-
751
- expect(useElement.getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBe('#icon-home')
752
-
753
- const svgWithViewBox = svgUse('icon-star', { viewBox: '0 0 24 24' })
754
-
755
- expect(svgWithViewBox.getAttribute('viewBox')).toBe('0 0 24 24')
756
-
757
- const useViewBox = svgWithViewBox.children[0]
758
-
759
- expect(useViewBox.getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBe('#icon-star')
760
-
761
- const svgWithClass = svgUse('icon-menu', { class: 'icon-btn' })
762
-
763
- expect(svgWithClass.getAttribute('class')).toBe('icon-btn')
764
-
765
- expect(svgWithClass.children.length).toBe(1)
766
-
767
- const useClass = svgWithClass.children[0]
768
-
769
- expect(useClass.getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBe('#icon-menu')
1007
+ expect(svg.outerHTML).toBe('<svg><use href="#icon-home"></use></svg>')
1008
+ ```
770
1009
 
771
- const svgWithMultipleAttrs = svgUse('icon-settings', { width: 24, height: 24, class: 'icon' })
1010
+ Additional attributes are set on the outer `svg` element:
772
1011
 
773
- expect(svgWithMultipleAttrs.getAttribute('width')).toBe('24')
1012
+ ```ts
1013
+ const svg = svgUse('icon-settings', { width: 24, height: 24, class: 'icon' })
774
1014
 
775
- expect(svgWithMultipleAttrs.getAttribute('height')).toBe('24')
1015
+ expect(svg.outerHTML).toBe('<svg width="24" height="24" class="icon"><use href="#icon-settings"></use></svg>')
1016
+ ```
776
1017
 
777
- expect(svgWithMultipleAttrs.getAttribute('class')).toBe('icon')
1018
+ Additional children are appended to the outer `svg` element:
778
1019
 
779
- const useMultiple = svgWithMultipleAttrs.children[0]
1020
+ ```ts
1021
+ const svg = svgUse('icon-star', ['title', 'Star'])
780
1022
 
781
- expect(useMultiple.getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBe('#icon-settings')
1023
+ expect(svg.outerHTML).toBe('<svg><use href="#icon-star"></use><title>Star</title></svg>')
782
1024
  ```
783
1025
 
784
1026
  ## License
package/nnn.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Represents a CSS rule node for the {@link c} helper. Keys are CSS properties or nested selectors. */
2
2
  export type CNode = {
3
- [attributeOrSelector: string]: string | number | CNode | undefined
3
+ [attributeOrSelector: string]: string | number | CNode | null | undefined
4
4
  }
5
5
  /**
6
6
  * A minimal CSS-in-JS helper that converts a JavaScript object hierarchy into a CSS string.
@@ -10,14 +10,20 @@ export type CNode = {
10
10
  * An object describing CSS rules.
11
11
  * Keys are selectors or at-rules; values are either CSS property values or nested rule objects.
12
12
  * 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`).
13
+ * If a nested selector contains `&`, every `&` is replaced with the parent selector instead (e.g. `'&:hover'` → `a:hover`, `'.c &'` → `.c a`).
13
14
  * 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'`).
14
- * 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}`).
15
+ * Commas inside brackets or quotes (e.g. `':is(.b,.c)'`, `'[title="b,c"]'`) do not separate alternatives.
16
+ * Whitespace after a separating comma belongs to the next alternative, so like a leading space of the first one it makes a descendant combinator when nested (e.g. `' .b, .c'` → `a .b,a .c`, `'.b, .c'` → `a.b,a .c`); leading whitespace of a resulting selector is ignored (e.g. `'h1, h2'` → `h1,h2`, `'.b, &:hover'` → `a.b,a:hover`).
17
+ * 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}}`).
18
+ * Inside a top-level `:root` rule nested keys are concatenated into property names (e.g. `{ ':root': { '--': { bg: 'red' } } }` → `:root{--bg:red}`).
19
+ * Properties without a selector (top-level or directly in a top-level at-rule, e.g. `@font-face`) are emitted as bare declarations.
15
20
  * `null`/`undefined` values are skipped.
16
21
  *
17
22
  * @param splitter
18
23
  *
19
24
  * A delimiter used to create unique keys (default: `'$$'`).
20
25
  * The substring from `splitter` to the end of a key is ignored (e.g. `src$$1` → `src`).
26
+ * An empty `splitter` disables this feature.
21
27
  *
22
28
  * @returns
23
29
  *
@@ -41,9 +47,9 @@ export type HArgs = [string | Node, ...HArgs1[]]
41
47
  * Additional arguments processed as follows:
42
48
  *
43
49
  * - `Object`: maps attributes/properties.
44
- * - 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`).
45
- * - 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`.
46
- * A value of `true` sets an empty attribute (e.g. `checked=""`), a value of `false` removes the attribute.
50
+ * - 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.
51
+ * - 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`.
52
+ * 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.
47
53
  * - `null`/`undefined`: ignored.
48
54
  * - `Node`: appended as a child.
49
55
  * - `string`/`number`: converted to a `Text` node and appended.
@@ -56,7 +62,8 @@ export type HArgs = [string | Node, ...HArgs1[]]
56
62
  export declare const h: {
57
63
  <T extends keyof HTMLElementTagNameMap>(tag: T, ...args: HArgs1[]): HTMLElementTagNameMap[T]
58
64
  <N extends Node>(node: N, ...args: HArgs1[]): N
59
- (tagOrNode: string | Node, ...args: HArgs1[]): Node
65
+ (tag: string, ...args: HArgs1[]): HTMLElement
66
+ (node: Node, ...args: HArgs1[]): Node
60
67
  }
61
68
  /**
62
69
  * A lightweight _HyperScript_-style helper for creating and modifying `SVGElement`s (see also {@link h}).
@@ -71,9 +78,9 @@ export declare const h: {
71
78
  * Additional arguments processed as follows:
72
79
  *
73
80
  * - `Object`: maps attributes/properties.
74
- * - 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.
75
- * - 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`.
76
- * A value of `true` sets an empty attribute (e.g. `checked=""`), a value of `false` removes the attribute.
81
+ * - 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.
82
+ * - 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`.
83
+ * A value of `true` sets an empty attribute, a value of `false` removes the attribute, a value of `null`/`undefined` leaves the attribute unchanged.
77
84
  * - `null`/`undefined`: ignored.
78
85
  * - `Node`: appended as a child.
79
86
  * - `string`/`number`: converted to a `Text` node and appended.
@@ -86,7 +93,8 @@ export declare const h: {
86
93
  export declare const s: {
87
94
  <T extends keyof SVGElementTagNameMap>(tag: T, ...args: HArgs1[]): SVGElementTagNameMap[T]
88
95
  <N extends Node>(node: N, ...args: HArgs1[]): N
89
- (tagOrNode: string | Node, ...args: HArgs1[]): Node
96
+ (tag: string, ...args: HArgs1[]): SVGElement
97
+ (node: Node, ...args: HArgs1[]): Node
90
98
  }
91
99
  /**
92
100
  * Shorthand for creating an `<svg>` element with a `<use>` child referencing a symbol (e.g. an icon) by ID.
@@ -94,7 +102,7 @@ export declare const s: {
94
102
  * Equivalent to:
95
103
  *
96
104
  * ```ts
97
- * s('svg', ['use', { 'xlink:href': '#' + id }], ...args)
105
+ * s('svg', ['use', { href: '#' + id }], ...args)
98
106
  * ```
99
107
  *
100
108
  * @param id
@@ -124,24 +132,28 @@ export declare const svgUse: (id: string, ...args: HArgs1[]) => SVGSVGElement
124
132
  *
125
133
  * @param cellWidthPx
126
134
  *
127
- * The base cell width in pixels.
135
+ * The base cell width in pixels (a positive number).
128
136
  *
129
137
  * @param cellHeightPx
130
138
  *
131
- * The base cell height in pixels.
139
+ * The base cell height in pixels (a positive number).
132
140
  *
133
141
  * @param specs
134
142
  *
135
- * An array of breakpoint specifications, each a tuple of:
143
+ * An array of breakpoint specifications, each a tuple of positive integers:
136
144
  *
137
- * - `maxWidth`: number of cells per row; the breakpoint is `min-width: cellWidthPx * maxWidth` (`1` modifies the base rule).
138
- * - `width` (optional, default `1`): number of horizontal cells the element spans.
145
+ * - `columns`: number of cells per row; the breakpoint is `min-width: cellWidthPx * columns` (`1` modifies the base rule).
146
+ * - `width` (optional, default `1`, at most `columns`): number of horizontal cells the element spans.
139
147
  * - `height` (optional, default `1`): number of vertical cells the element spans.
148
+ *
149
+ * @throws
150
+ *
151
+ * A `RangeError` if any of the numeric arguments is out of range.
140
152
  */
141
153
  export declare const rwd: (
142
154
  root: CNode,
143
155
  selector: string,
144
156
  cellWidthPx: number,
145
157
  cellHeightPx: number,
146
- ...specs: [number, number?, number?][]
158
+ ...specs: [columns: number, width?: number, height?: number][]
147
159
  ) => void
package/nnn.js CHANGED
@@ -1,114 +1,174 @@
1
- // src/nnn/c.ts
1
+ // src/c.ts
2
2
  var DEFAULT_SPLITTER = "$$";
3
- var combinePrefixAndName = (prefix, name) => {
4
- const prefixes = prefix === "" ? [""] : prefix.split(",");
5
- const names = name.split(",");
6
- const combined = [];
7
- for (const p of prefixes) {
8
- for (const n of names) {
9
- combined.push(p + n);
3
+ var OPENING_BRACKETS = "([";
4
+ var CLOSING_BRACKETS = ")]";
5
+ var scan = (selector, onChar) => {
6
+ let depth = 0;
7
+ let quote = "";
8
+ for (const char of selector) {
9
+ if (quote !== "") {
10
+ if (char === quote) {
11
+ quote = "";
12
+ }
13
+ } else if (char === '"' || char === "'") {
14
+ quote = char;
15
+ } else if (OPENING_BRACKETS.includes(char)) {
16
+ ++depth;
17
+ } else if (CLOSING_BRACKETS.includes(char)) {
18
+ --depth;
19
+ }
20
+ onChar(char, quote === "" && depth === 0);
21
+ }
22
+ };
23
+ var splitSelectorList = (selectorList) => {
24
+ const selectors = [""];
25
+ scan(selectorList, (char, topLevel) => {
26
+ if (topLevel && char === ",") {
27
+ selectors.push("");
28
+ } else {
29
+ selectors[selectors.length - 1] += char;
30
+ }
31
+ });
32
+ return selectors.map((selector) => selector.trimEnd());
33
+ };
34
+ var nestSelector = (parent, selector) => {
35
+ let nested = "";
36
+ let ampersandFound = false;
37
+ scan(selector, (char, topLevel) => {
38
+ if (char === "&" && topLevel) {
39
+ nested += parent;
40
+ ampersandFound = true;
41
+ } else {
42
+ nested += char;
43
+ }
44
+ });
45
+ return (ampersandFound ? nested : parent + selector).trimStart();
46
+ };
47
+ var stripSplitter = (key, splitter) => {
48
+ const splitterIndex = splitter === "" ? -1 : key.indexOf(splitter);
49
+ return splitterIndex < 0 ? key : key.slice(0, splitterIndex);
50
+ };
51
+ var cProperties = (node, splitter, prefix) => {
52
+ const out = [];
53
+ for (const [key, value] of Object.entries(node)) {
54
+ if (value != null) {
55
+ const name = prefix + stripSplitter(key, splitter);
56
+ if (typeof value === "object") {
57
+ out.push(...cProperties(value, splitter, name));
58
+ } else {
59
+ out.push(`${name}:${value}`);
60
+ }
10
61
  }
11
62
  }
12
- return combined.join(",");
63
+ return out;
13
64
  };
14
- var _c = (node, splitter = DEFAULT_SPLITTER, prefix = "", flatten = false) => {
65
+ var cRules = (node, splitter, selectors) => {
15
66
  const out = [];
16
- let buffer = [];
67
+ let declarations = [];
17
68
  const flush = () => {
18
- if (buffer.length === 0) {
19
- return;
69
+ if (declarations.length > 0) {
70
+ out.push(selectors.length === 0 ? `${declarations.join(";")};` : `${selectors.join(",")}{${declarations.join(";")}}`);
71
+ declarations = [];
20
72
  }
21
- out.push(prefix === "" ? buffer.join(";") : `${prefix}{${buffer.join(";")}}`);
22
- buffer = [];
23
73
  };
24
- for (const [rawKey, value] of Object.entries(node)) {
74
+ for (const [key, value] of Object.entries(node)) {
25
75
  if (value == null) {
26
76
  continue;
27
77
  }
28
- const splitterIndex = rawKey.indexOf(splitter);
29
- const name = splitterIndex < 0 ? rawKey : rawKey.slice(0, splitterIndex);
30
- if (typeof value === "number" || typeof value === "string") {
31
- if (flatten) {
32
- out.push(`${prefix + name}:${value}`);
33
- } else {
34
- buffer.push(`${name}:${value}`);
35
- }
36
- continue;
37
- }
38
- if (flatten) {
39
- out.push(_c(value, splitter, prefix + name, true));
78
+ const name = stripSplitter(key, splitter);
79
+ if (typeof value !== "object") {
80
+ declarations.push(`${name}:${value}`);
40
81
  continue;
41
82
  }
42
83
  flush();
43
- if (prefix === "" && (name === ":root" || name[0] === "@")) {
44
- out.push(`${name}{${_c(value, splitter, "", name === ":root")}}`);
84
+ if (name[0] === "@") {
85
+ out.push(`${name}{${cRules(value, splitter, selectors)}}`);
86
+ } else if (selectors.length === 0 && name === ":root") {
87
+ out.push(`:root{${cProperties(value, splitter, "").join(";")}}`);
45
88
  } else {
46
- out.push(_c(value, splitter, combinePrefixAndName(prefix, name), false));
89
+ const alternatives = splitSelectorList(name);
90
+ const parents = selectors.length === 0 ? [""] : selectors;
91
+ out.push(cRules(value, splitter, parents.flatMap((parent) => alternatives.map((selector) => nestSelector(parent, selector)))));
47
92
  }
48
93
  }
49
94
  flush();
50
- return out.join(flatten ? ";" : "");
95
+ const css = out.join("");
96
+ return css.endsWith(";") ? css.slice(0, -1) : css;
97
+ };
98
+ var c = (node, splitter = DEFAULT_SPLITTER) => cRules(node, splitter, []);
99
+ // src/h.ts
100
+ var SVG_NS = "http://www.w3.org/2000/svg";
101
+ var NAMESPACES = {
102
+ xlink: "http://www.w3.org/1999/xlink",
103
+ xml: "http://www.w3.org/XML/1998/namespace",
104
+ xmlns: "http://www.w3.org/2000/xmlns/"
105
+ };
106
+ var isPlainObject = (value) => {
107
+ if (typeof value !== "object" || value == null) {
108
+ return false;
109
+ }
110
+ const prototype = Object.getPrototypeOf(value);
111
+ return prototype === Object.prototype || prototype == null;
112
+ };
113
+ var setAttribute = (element, name, value) => {
114
+ if (value == null) {
115
+ return;
116
+ }
117
+ const indexOfColon = name.indexOf(":");
118
+ const namespace = indexOfColon < 0 ? undefined : NAMESPACES[name.slice(0, indexOfColon)];
119
+ if (namespace == null) {
120
+ if (value === false) {
121
+ element.removeAttribute(name);
122
+ } else {
123
+ element.setAttribute(name, value === true ? "" : String(value));
124
+ }
125
+ } else if (value === false) {
126
+ element.removeAttributeNS(namespace, name.slice(indexOfColon + 1));
127
+ } else {
128
+ element.setAttributeNS(namespace, name, value === true ? "" : String(value));
129
+ }
51
130
  };
52
- var c = (node, splitter = DEFAULT_SPLITTER) => _c(node, splitter);
53
- // src/nnn/h.ts
54
- var XLINK_NS = "http://www.w3.org/1999/xlink";
55
- var _h = (createElement, tagOrNode, ...args) => {
131
+ var hyperscript = (createElement, tagOrNode, ...args) => {
56
132
  const node = typeof tagOrNode === "string" ? createElement(tagOrNode) : tagOrNode;
57
- args.forEach((arg) => {
58
- let child = null;
133
+ for (const arg of args) {
59
134
  if (arg instanceof Node) {
60
- child = arg;
135
+ node.appendChild(arg);
61
136
  } else if (Array.isArray(arg)) {
62
- child = _h(createElement, ...arg);
137
+ node.appendChild(hyperscript(createElement, ...arg));
63
138
  } else if (typeof arg === "string" || typeof arg === "number") {
64
- child = document.createTextNode(arg);
65
- } else if (typeof arg === "object" && arg != null) {
139
+ node.appendChild(document.createTextNode(String(arg)));
140
+ } else if (arg != null) {
66
141
  for (const name in arg) {
67
142
  const value = arg[name];
68
143
  if (name[0] === "$") {
69
144
  const name1 = name.slice(1);
70
- if (typeof value === "object" && value != null && !Array.isArray(value)) {
145
+ if (isPlainObject(value)) {
71
146
  Object.assign(node[name1] ??= {}, value);
72
147
  } else {
73
148
  node[name1] = value;
74
149
  }
75
150
  } else if (node instanceof Element) {
76
- const indexOfColon = name.indexOf(":");
77
- if (indexOfColon >= 0) {
78
- const nsKey = name.slice(0, indexOfColon);
79
- if (nsKey === "xlink") {
80
- const basename = name.slice(indexOfColon + 1);
81
- if (value === true) {
82
- node.setAttributeNS(XLINK_NS, basename, "");
83
- } else if (value === false) {
84
- node.removeAttributeNS(XLINK_NS, basename);
85
- } else {
86
- node.setAttributeNS(XLINK_NS, basename, "" + value);
87
- }
88
- }
89
- } else {
90
- if (value === true) {
91
- node.setAttribute(name, "");
92
- } else if (value === false) {
93
- node.removeAttribute(name);
94
- } else {
95
- node.setAttribute(name, "" + value);
96
- }
97
- }
151
+ setAttribute(node, name, value);
98
152
  }
99
153
  }
100
154
  }
101
- if (child != null) {
102
- node.appendChild(child);
103
- }
104
- });
155
+ }
105
156
  return node;
106
157
  };
107
- var h = _h.bind(null, (tag) => document.createElement(tag));
108
- var s = _h.bind(null, (tag) => document.createElementNS("http://www.w3.org/2000/svg", tag));
109
- var svgUse = (id, ...args) => s("svg", ["use", { "xlink:href": "#" + id }], ...args);
110
- // src/nnn/rwd.ts
158
+ var h = hyperscript.bind(null, (tag) => document.createElement(tag));
159
+ var s = hyperscript.bind(null, (tag) => document.createElementNS(SVG_NS, tag));
160
+ var svgUse = (id, ...args) => s("svg", ["use", { href: "#" + id }], ...args);
161
+ // src/rwd.ts
162
+ var isPositiveInteger = (value) => Number.isInteger(value) && value > 0;
111
163
  var rwd = (root, selector, cellWidthPx, cellHeightPx, ...specs) => {
164
+ if (!(cellWidthPx > 0 && cellHeightPx > 0 && Number.isFinite(cellWidthPx) && Number.isFinite(cellHeightPx))) {
165
+ throw new RangeError(`rwd: cell size must be positive: ${cellWidthPx}×${cellHeightPx}`);
166
+ }
167
+ for (const [columns, width = 1, height = 1] of specs) {
168
+ if (!isPositiveInteger(columns) || !isPositiveInteger(width) || !isPositiveInteger(height) || width > columns) {
169
+ throw new RangeError(`rwd: invalid spec: [${columns}, ${width}, ${height}]`);
170
+ }
171
+ }
112
172
  const main = root[selector] ??= {};
113
173
  main["box-sizing"] = "border-box";
114
174
  main.display = "block";
@@ -116,18 +176,16 @@ var rwd = (root, selector, cellWidthPx, cellHeightPx, ...specs) => {
116
176
  main.width = "100%";
117
177
  main.height = `${cellHeightPx}px`;
118
178
  specs.sort(([a], [b]) => a - b);
119
- for (let [maxWidth, width, height] of specs) {
120
- const atRule = `@media(min-width:${cellWidthPx * maxWidth}px)`;
121
- const node = maxWidth === 1 ? main : (root[atRule] ??= {})[selector] ??= {};
122
- width ??= 1;
123
- height ??= 1;
179
+ for (const [columns, width = 1, height = 1] of specs) {
180
+ const atRule = `@media(min-width:${cellWidthPx * columns}px)`;
181
+ const node = columns === 1 ? main : (root[atRule] ??= {})[selector] ??= {};
124
182
  let gcd = 100 * width;
125
- let tmp = maxWidth;
183
+ let tmp = columns;
126
184
  while (tmp > 0) {
127
185
  [gcd, tmp] = [tmp, gcd % tmp];
128
186
  }
129
187
  const widthOverGcd = 100 * width / gcd;
130
- node.width = maxWidth === gcd ? `${widthOverGcd}%` : `calc(${widthOverGcd}% / ${maxWidth / gcd})`;
188
+ node.width = columns === gcd ? `${widthOverGcd}%` : `calc(${widthOverGcd}% / ${columns / gcd})`;
131
189
  node.height = `${cellHeightPx * height}px`;
132
190
  }
133
191
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackens/nnn",
3
- "version": "2026.10.1",
3
+ "version": "2026.11.17",
4
4
  "description": "Jackens’ JavaScript helpers.",
5
5
  "keywords": [
6
6
  "CSS-in-JS",
@@ -14,5 +14,16 @@
14
14
  "author": "Jackens",
15
15
  "type": "module",
16
16
  "main": "nnn.js",
17
- "types": "nnn.d.ts"
17
+ "types": "nnn.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./nnn.d.ts",
21
+ "default": "./nnn.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "nnn.d.ts",
26
+ "nnn.js"
27
+ ],
28
+ "sideEffects": false
18
29
  }