@jackens/nnn 2026.10.1 → 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 +274 -52
  2. package/nnn.d.ts +26 -15
  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.16`).
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,19 @@ 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; 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.
92
96
  `null`/`undefined` values are skipped.
93
97
 
94
98
  #### splitter
95
99
 
96
100
  A delimiter used to create unique keys (default: `'$$'`).
97
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.
98
103
 
99
104
  #### Returns
100
105
 
@@ -169,7 +174,7 @@ const actual = c({
169
174
  '@font-face$$1': {
170
175
  'font-family': 'Jackens',
171
176
  src$$1: 'url(otf/jackens.otf)',
172
- 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')",
173
178
  'font-weight': 'normal',
174
179
  'font-style': 'normal',
175
180
  },
@@ -405,11 +410,128 @@ const expected = `
405
410
  expect(actual).toBe(expected)
406
411
  ```
407
412
 
413
+ `&` is replaced with the parent selector:
414
+
415
+ ```ts
416
+ const actual = c({
417
+ a: {
418
+ color: 'red',
419
+ '&:hover': { color: 'blue' },
420
+ '.dark &': { color: 'white' },
421
+ '.b,& .c': { margin: 1 },
422
+ },
423
+ })
424
+
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, '')
439
+
440
+ expect(actual).toBe(expected)
441
+ ```
442
+
443
+ Commas inside brackets or quotes do not split selectors and whitespace after a comma is ignored:
444
+
445
+ ```ts
446
+ const actual = c({
447
+ ':is(.a,.b)': {
448
+ '.c, .d': { margin: 1 },
449
+ '[title="x,y"]': { margin: 2 },
450
+ },
451
+ })
452
+
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, '')
461
+
462
+ expect(actual).toBe(expected)
463
+ ```
464
+
465
+ At-rules nested in selectors wrap these selectors:
466
+
467
+ ```ts
468
+ const actual = c({
469
+ a: {
470
+ color: 'red',
471
+ '@media(min-width:600px)': {
472
+ color: 'blue',
473
+ '.b': { margin: 1 },
474
+ },
475
+ },
476
+ })
477
+
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, '')
491
+
492
+ expect(actual).toBe(expected)
493
+ ```
494
+
495
+ Declarations without a selector are separated from subsequent rules:
496
+
497
+ ```ts
498
+ expect(c({ color: 'red', margin: 0 })).toBe('color:red;margin:0')
499
+
500
+ expect(c({ color: 'red', a: { margin: 0 }, padding: 0 })).toBe('color:red;a{margin:0}padding:0')
501
+ ```
502
+
503
+ `null` and `undefined` values are skipped:
504
+
505
+ ```ts
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
+ })
517
+
518
+ expect(actual).toBe('a{color:red}:root{--fg:black}')
519
+ ```
520
+
521
+ Custom splitter (an empty one disables splitting):
522
+
523
+ ```ts
524
+ expect(c({ a: { 'src#1': 'url(a.otf)', 'src#2': 'url(a.woff)' } }, '#')).toBe('a{src:url(a.otf);src:url(a.woff)}')
525
+
526
+ expect(c({ a: { b$$c: 1 } }, '')).toBe('a{b$$c:1}')
527
+ ```
528
+
408
529
  ### h
409
530
 
410
531
  ```ts
411
532
  const h: {
412
533
  <T extends keyof HTMLElementTagNameMap>(tag: T, ...args: HArgs1[]): HTMLElementTagNameMap[T]
534
+ (tag: string, ...args: HArgs1[]): HTMLElement
413
535
  <N extends Node>(node: N, ...args: HArgs1[]): N
414
536
  (tagOrNode: string | Node, ...args: HArgs1[]): Node
415
537
  }
@@ -427,9 +549,9 @@ If a `Node`, that node is modified in place.
427
549
  Additional arguments processed as follows:
428
550
 
429
551
  - `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.
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.
433
555
  - `null`/`undefined`: ignored.
434
556
  - `Node`: appended as a child.
435
557
  - `string`/`number`: converted to a `Text` node and appended.
@@ -555,6 +677,46 @@ const elemWithNested = h('div', ['span', 'hello'], ['b', 'world'])
555
677
  expect(elemWithNested.outerHTML).toBe('<div><span>hello</span><b>world</b></div>')
556
678
  ```
557
679
 
680
+ `false` removes an attribute, `null` and `undefined` leave it unchanged:
681
+
682
+ ```ts
683
+ const input = h('input', { type: 'checkbox', checked: true, title: 'title' })
684
+
685
+ h(input, { checked: false, title: null, type: undefined })
686
+
687
+ expect(input.outerHTML).toBe('<input type="checkbox" title="title">')
688
+ ```
689
+
690
+ Only plain objects are merged into `$`-prefixed properties:
691
+
692
+ ```ts
693
+ const date = new Date(0)
694
+ const div = h('div', { $date: date, $list: [1, 2] })
695
+
696
+ expect(div.date).toBe(date)
697
+
698
+ h(div, { $list: [3] })
699
+
700
+ expect(div.list).toEqual([3])
701
+ ```
702
+
703
+ Namespaced attributes:
704
+
705
+ ```ts
706
+ const div = h('div', { 'xml:lang': 'pl', 'ng:x': 1 })
707
+
708
+ expect(div.getAttributeNS('http://www.w3.org/XML/1998/namespace', 'lang')).toBe('pl')
709
+ expect(div.getAttribute('ng:x')).toBe('1')
710
+ ```
711
+
712
+ Return types:
713
+
714
+ ```ts
715
+ expectTypeOf(h('input')).toEqualTypeOf<HTMLInputElement>()
716
+ expectTypeOf(h('my-element')).toEqualTypeOf<HTMLElement>()
717
+ expectTypeOf(h(document.createTextNode(''))).toEqualTypeOf<Text>()
718
+ ```
719
+
558
720
  ### rwd
559
721
 
560
722
  ```ts
@@ -563,7 +725,7 @@ const rwd: (
563
725
  selector: string,
564
726
  cellWidthPx: number,
565
727
  cellHeightPx: number,
566
- ...specs: [number, number?, number?][]
728
+ ...specs: [columns: number, width?: number, height?: number][]
567
729
  ) => void
568
730
  ```
569
731
 
@@ -580,20 +742,24 @@ The CSS selector for the grid item.
580
742
 
581
743
  #### cellWidthPx
582
744
 
583
- The base cell width in pixels.
745
+ The base cell width in pixels (a positive number).
584
746
 
585
747
  #### cellHeightPx
586
748
 
587
- The base cell height in pixels.
749
+ The base cell height in pixels (a positive number).
588
750
 
589
751
  #### specs
590
752
 
591
- An array of breakpoint specifications, each a tuple of:
753
+ An array of breakpoint specifications, each a tuple of positive integers:
592
754
 
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.
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.
595
757
  - `height` (optional, default `1`): number of vertical cells the element spans.
596
758
 
759
+ #### Throws
760
+
761
+ A `RangeError` if any of the numeric arguments is out of range.
762
+
597
763
  #### Usage Examples
598
764
 
599
765
  Responsive grid cell widths and heights with media queries:
@@ -643,11 +809,57 @@ expect(style).toEqual({
643
809
  })
644
810
  ```
645
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
+
646
857
  ### s
647
858
 
648
859
  ```ts
649
860
  const s: {
650
861
  <T extends keyof SVGElementTagNameMap>(tag: T, ...args: HArgs1[]): SVGElementTagNameMap[T]
862
+ (tag: string, ...args: HArgs1[]): SVGElement
651
863
  <N extends Node>(node: N, ...args: HArgs1[]): N
652
864
  (tagOrNode: string | Node, ...args: HArgs1[]): Node
653
865
  }
@@ -665,9 +877,9 @@ If a `Node`, that node is modified in place.
665
877
  Additional arguments processed as follows:
666
878
 
667
879
  - `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.
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.
671
883
  - `null`/`undefined`: ignored.
672
884
  - `Node`: appended as a child.
673
885
  - `string`/`number`: converted to a `Text` node and appended.
@@ -679,6 +891,16 @@ The created `SVGElement` or the modified node.
679
891
 
680
892
  #### Usage Examples
681
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
+
682
904
  Namespaced `xlink:` attribute set to `true` gets an empty value:
683
905
 
684
906
  ```ts
@@ -687,10 +909,12 @@ const svg = s('svg', { 'xlink:href': true })
687
909
  expect(svg.getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBe('')
688
910
  ```
689
911
 
690
- Namespaced `xlink:` attribute set to `false` is not set:
912
+ Namespaced `xlink:` attribute set to `false` is removed:
691
913
 
692
914
  ```ts
693
- const svg = s('svg', { 'xlink:href': false })
915
+ const svg = s('svg', { 'xlink:href': '#id' })
916
+
917
+ s(svg, { 'xlink:href': false })
694
918
 
695
919
  expect(svg.getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBeNull()
696
920
  ```
@@ -711,6 +935,22 @@ const svg = s('svg', { 'xlink:title': 42 })
711
935
  expect(svg.getAttributeNS('http://www.w3.org/1999/xlink', 'title')).toBe('42')
712
936
  ```
713
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
+
714
954
  ### svgUse
715
955
 
716
956
  ```ts
@@ -722,7 +962,7 @@ Shorthand for creating an `<svg>` element with a `<use>` child referencing a sym
722
962
  Equivalent to:
723
963
 
724
964
  ```ts
725
- s('svg', ['use', { 'xlink:href': '#' + id }], ...args)
965
+ s('svg', ['use', { href: '#' + id }], ...args)
726
966
  ```
727
967
 
728
968
  #### id
@@ -739,46 +979,28 @@ An `SVGSVGElement` containing a `<use>` element.
739
979
 
740
980
  #### Usage Examples
741
981
 
742
- SVG `use` element referencing a symbol, with extra attributes:
982
+ SVG `use` element referencing a symbol:
743
983
 
744
984
  ```ts
745
985
  const svg = svgUse('icon-home')
746
986
 
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')
987
+ expect(svg.outerHTML).toBe('<svg><use href="#icon-home"></use></svg>')
988
+ ```
770
989
 
771
- const svgWithMultipleAttrs = svgUse('icon-settings', { width: 24, height: 24, class: 'icon' })
990
+ Additional attributes are set on the outer `svg` element:
772
991
 
773
- expect(svgWithMultipleAttrs.getAttribute('width')).toBe('24')
992
+ ```ts
993
+ const svg = svgUse('icon-settings', { width: 24, height: 24, class: 'icon' })
774
994
 
775
- 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
+ ```
776
997
 
777
- expect(svgWithMultipleAttrs.getAttribute('class')).toBe('icon')
998
+ Additional children are appended to the outer `svg` element:
778
999
 
779
- const useMultiple = svgWithMultipleAttrs.children[0]
1000
+ ```ts
1001
+ const svg = svgUse('icon-star', ['title', 'Star'])
780
1002
 
781
- 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>')
782
1004
  ```
783
1005
 
784
1006
  ## 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,19 @@ 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; 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`).
16
+ * 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}}`).
17
+ * Inside a top-level `:root` rule nested keys are concatenated into property names (e.g. `{ ':root': { '--': { bg: 'red' } } }` → `:root{--bg:red}`).
18
+ * Properties without a selector (top-level or directly in a top-level at-rule, e.g. `@font-face`) are emitted as bare declarations.
15
19
  * `null`/`undefined` values are skipped.
16
20
  *
17
21
  * @param splitter
18
22
  *
19
23
  * A delimiter used to create unique keys (default: `'$$'`).
20
24
  * The substring from `splitter` to the end of a key is ignored (e.g. `src$$1` → `src`).
25
+ * An empty `splitter` disables this feature.
21
26
  *
22
27
  * @returns
23
28
  *
@@ -41,9 +46,9 @@ export type HArgs = [string | Node, ...HArgs1[]]
41
46
  * Additional arguments processed as follows:
42
47
  *
43
48
  * - `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.
49
+ * - 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.
50
+ * - 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`.
51
+ * 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
52
  * - `null`/`undefined`: ignored.
48
53
  * - `Node`: appended as a child.
49
54
  * - `string`/`number`: converted to a `Text` node and appended.
@@ -55,6 +60,7 @@ export type HArgs = [string | Node, ...HArgs1[]]
55
60
  */
56
61
  export declare const h: {
57
62
  <T extends keyof HTMLElementTagNameMap>(tag: T, ...args: HArgs1[]): HTMLElementTagNameMap[T]
63
+ (tag: string, ...args: HArgs1[]): HTMLElement
58
64
  <N extends Node>(node: N, ...args: HArgs1[]): N
59
65
  (tagOrNode: string | Node, ...args: HArgs1[]): Node
60
66
  }
@@ -71,9 +77,9 @@ export declare const h: {
71
77
  * Additional arguments processed as follows:
72
78
  *
73
79
  * - `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.
80
+ * - 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.
81
+ * - 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`.
82
+ * 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
83
  * - `null`/`undefined`: ignored.
78
84
  * - `Node`: appended as a child.
79
85
  * - `string`/`number`: converted to a `Text` node and appended.
@@ -85,6 +91,7 @@ export declare const h: {
85
91
  */
86
92
  export declare const s: {
87
93
  <T extends keyof SVGElementTagNameMap>(tag: T, ...args: HArgs1[]): SVGElementTagNameMap[T]
94
+ (tag: string, ...args: HArgs1[]): SVGElement
88
95
  <N extends Node>(node: N, ...args: HArgs1[]): N
89
96
  (tagOrNode: string | Node, ...args: HArgs1[]): Node
90
97
  }
@@ -94,7 +101,7 @@ export declare const s: {
94
101
  * Equivalent to:
95
102
  *
96
103
  * ```ts
97
- * s('svg', ['use', { 'xlink:href': '#' + id }], ...args)
104
+ * s('svg', ['use', { href: '#' + id }], ...args)
98
105
  * ```
99
106
  *
100
107
  * @param id
@@ -124,24 +131,28 @@ export declare const svgUse: (id: string, ...args: HArgs1[]) => SVGSVGElement
124
131
  *
125
132
  * @param cellWidthPx
126
133
  *
127
- * The base cell width in pixels.
134
+ * The base cell width in pixels (a positive number).
128
135
  *
129
136
  * @param cellHeightPx
130
137
  *
131
- * The base cell height in pixels.
138
+ * The base cell height in pixels (a positive number).
132
139
  *
133
140
  * @param specs
134
141
  *
135
- * An array of breakpoint specifications, each a tuple of:
142
+ * An array of breakpoint specifications, each a tuple of positive integers:
136
143
  *
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.
144
+ * - `columns`: number of cells per row; the breakpoint is `min-width: cellWidthPx * columns` (`1` modifies the base rule).
145
+ * - `width` (optional, default `1`, at most `columns`): number of horizontal cells the element spans.
139
146
  * - `height` (optional, default `1`): number of vertical cells the element spans.
147
+ *
148
+ * @throws
149
+ *
150
+ * A `RangeError` if any of the numeric arguments is out of range.
140
151
  */
141
152
  export declare const rwd: (
142
153
  root: CNode,
143
154
  selector: string,
144
155
  cellWidthPx: number,
145
156
  cellHeightPx: number,
146
- ...specs: [number, number?, number?][]
157
+ ...specs: [columns: number, width?: number, height?: number][]
147
158
  ) => 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, index) => (index === 0 ? selector : selector.trimStart()).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;
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.16",
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
  }