@karimsa/mdxserve 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +60 -10
  2. package/client/CodeBlock.tsx +24 -0
  3. package/client/DocView.tsx +9 -2
  4. package/client/ListingView.tsx +14 -10
  5. package/client/MarkdownTable.tsx +190 -0
  6. package/client/MdSection.tsx +56 -21
  7. package/client/MdSectionEditor.tsx +149 -2
  8. package/client/Mermaid.tsx +33 -7
  9. package/client/Settings.css +17 -0
  10. package/client/Settings.tsx +132 -0
  11. package/client/Table.tsx +2 -14
  12. package/client/app.css +339 -1
  13. package/client/block-state/README.md +39 -0
  14. package/client/block-state/storage.ts +73 -0
  15. package/client/block-state/useBlockState.ts +120 -0
  16. package/client/builtins/DataTable.tsx +269 -0
  17. package/client/builtins/chart-data.ts +22 -10
  18. package/client/builtins/index.ts +10 -0
  19. package/client/data-table/ColumnMenu.tsx +168 -0
  20. package/client/data-table/ColumnOptions.tsx +141 -0
  21. package/client/data-table/TableCell.tsx +76 -0
  22. package/client/data-table/TableHeaderCell.tsx +94 -0
  23. package/client/data-table/model.ts +194 -0
  24. package/client/data-table/navigation.ts +34 -0
  25. package/client/data-table/table-state.ts +196 -0
  26. package/client/data-table/useColumnMenu.ts +84 -0
  27. package/client/data-table/useTableState.ts +36 -0
  28. package/client/design/base/prose.css +4 -0
  29. package/client/diagrams/Dialog.tsx +577 -0
  30. package/client/diagrams/EditDialog.tsx +67 -0
  31. package/client/diagrams/Preferences.tsx +213 -0
  32. package/client/diagrams/edit-block.ts +93 -0
  33. package/client/diagrams/edit-context.ts +4 -0
  34. package/client/diagrams/session-id.ts +8 -0
  35. package/client/diagrams/slash.ts +94 -0
  36. package/client/diagrams/source.ts +73 -0
  37. package/client/diagrams/style.css +599 -0
  38. package/client/entry.tsx +2 -0
  39. package/client/mdx-components-base.ts +2 -2
  40. package/client/router.ts +8 -1
  41. package/client/shell/AppShell.tsx +38 -15
  42. package/client/shell/Sidebar.tsx +20 -2
  43. package/client/shell/StandaloneShell.tsx +19 -6
  44. package/client/shell/TocRail.tsx +4 -3
  45. package/client/shell/TopBar.tsx +17 -5
  46. package/client/state.ts +14 -0
  47. package/client/ui/ConfirmDeleteDialog.tsx +48 -91
  48. package/client/ui/ExpandModal.tsx +63 -104
  49. package/client/ui/Modal.tsx +123 -0
  50. package/client/ui/ResizeHandle.tsx +1 -1
  51. package/client/ui/SearchDialog.tsx +81 -88
  52. package/client/ui/modal.css +42 -0
  53. package/dist/cli.js +2191 -778
  54. package/dist/diagram-worker.js +17 -0
  55. package/dist/registry.json +297 -0
  56. package/package.json +42 -2
  57. package/skills/mdxserve/SKILL.md +56 -1
package/client/app.css CHANGED
@@ -33,6 +33,9 @@
33
33
  @import "./design/base/reset.css";
34
34
  @import "./design/base/prose.css";
35
35
  @import "./design/base/editor.css";
36
+ @import "./ui/modal.css";
37
+ @import "./Settings.css";
38
+ @import "./diagrams/style.css";
36
39
 
37
40
  /* `:root[...]` (not bare `[data-theme]`) because rehype-pretty-code stamps
38
41
  data-theme="<shiki theme name>" on every <pre>/<code> it renders. */
@@ -302,7 +305,8 @@ code[data-line-numbers-max-digits="4"] [data-line]::before {
302
305
 
303
306
  /* Folder listing titles are rendered from the doc's first h1 as inline HTML. */
304
307
  /* Keep this in lockstep with the `.mdx-prose code` chip in design/base/prose.css. */
305
- .listing-title code {
308
+ .listing-title code,
309
+ .data-table-heading code {
306
310
  font: var(--type-code-inline);
307
311
  background: var(--code-bg-inline);
308
312
  color: var(--text-accent);
@@ -370,3 +374,337 @@ code[data-line-numbers-max-digits="4"] [data-line]::before {
370
374
  display: none;
371
375
  }
372
376
  }
377
+
378
+ /* Document-style tables. Menus are portaled, but anchored to their header. */
379
+ .data-table {
380
+ margin: 1.5rem 0;
381
+ border: 1px solid var(--border-default);
382
+ border-radius: var(--radius-lg);
383
+ background: var(--surface-card);
384
+ overflow: hidden;
385
+ color: var(--text-body);
386
+ font-size: var(--size-sm);
387
+ }
388
+ .data-table-toolbar {
389
+ display: flex;
390
+ align-items: center;
391
+ gap: 0.75rem;
392
+ padding: 1rem;
393
+ flex-wrap: wrap;
394
+ }
395
+ .data-table-toolbar > span:first-child {
396
+ font-weight: 600;
397
+ color: var(--text-heading);
398
+ }
399
+ .data-table-count {
400
+ color: var(--text-muted);
401
+ font-size: var(--size-xs);
402
+ }
403
+ .data-table-scroll {
404
+ overflow-x: auto;
405
+ }
406
+ .data-table table {
407
+ width: 100%;
408
+ border-collapse: collapse;
409
+ font-variant-numeric: tabular-nums;
410
+ }
411
+ .data-table th,
412
+ .data-table td {
413
+ padding: 0 1rem;
414
+ border-bottom: 1px solid var(--border-subtle);
415
+ text-align: left;
416
+ position: relative;
417
+ }
418
+ .data-table tbody tr:last-child td {
419
+ border-bottom: 0;
420
+ }
421
+ .data-table th {
422
+ padding: 0;
423
+ font-size: var(--size-xs);
424
+ font-weight: 400;
425
+ color: var(--text-muted);
426
+ height: 36px;
427
+ vertical-align: bottom;
428
+ }
429
+ .data-table td {
430
+ height: 49px;
431
+ padding-top: 0.6rem;
432
+ padding-bottom: 0.6rem;
433
+ }
434
+ .data-table td[data-numeric="true"] {
435
+ white-space: nowrap;
436
+ }
437
+ .data-table th[data-align="right"] .data-table-heading {
438
+ justify-content: flex-end;
439
+ }
440
+ .data-table [data-align="left"] {
441
+ text-align: left;
442
+ }
443
+ .data-table [data-align="center"] {
444
+ text-align: center;
445
+ }
446
+ .data-table [data-align="right"] {
447
+ text-align: right;
448
+ }
449
+ .data-table th[data-align="left"] .data-table-heading {
450
+ justify-content: flex-start;
451
+ }
452
+ .data-table th[data-align="center"] .data-table-heading {
453
+ justify-content: center;
454
+ }
455
+ .data-table td[data-bars="true"] {
456
+ padding-bottom: 0.9rem;
457
+ }
458
+ .data-table tbody tr:hover {
459
+ background: var(--surface-hover);
460
+ }
461
+ .data-table td[data-active="true"],
462
+ .data-table td:focus-visible {
463
+ outline: 1.5px solid var(--border-accent);
464
+ outline-offset: -1.5px;
465
+ background: var(--surface-accent-soft);
466
+ }
467
+ .data-table-heading {
468
+ padding: 8px 0 8px 16px;
469
+ box-sizing: border-box;
470
+ flex: 1;
471
+ min-width: 0;
472
+ min-height: 36px;
473
+ display: flex;
474
+ align-items: center;
475
+ gap: 0.3rem;
476
+ white-space: nowrap;
477
+ cursor: pointer;
478
+ border-radius: 0;
479
+ }
480
+ .data-table-heading:hover,
481
+ .data-table-heading[aria-expanded="true"] {
482
+ color: var(--text-heading);
483
+ }
484
+ .data-table-heading svg {
485
+ flex-shrink: 0;
486
+ opacity: 0.6;
487
+ }
488
+ .data-table-heading a {
489
+ color: var(--text-accent);
490
+ text-decoration: underline;
491
+ text-underline-offset: 2px;
492
+ }
493
+ .data-table-heading strong {
494
+ font-weight: 700;
495
+ }
496
+ .data-table-unit {
497
+ color: var(--text-subtle);
498
+ }
499
+ .data-table-indicator {
500
+ color: var(--text-accent);
501
+ }
502
+ .data-table-groups th[scope="colgroup"] {
503
+ text-align: center;
504
+ height: 24px;
505
+ border-bottom: 1px solid var(--border-subtle);
506
+ font-size: var(--size-xs);
507
+ }
508
+ .data-table-bar {
509
+ position: absolute;
510
+ height: 2px;
511
+ bottom: 8px;
512
+ border-radius: 2px;
513
+ background: var(--border-accent);
514
+ opacity: 0.65;
515
+ pointer-events: none;
516
+ }
517
+ .data-table-value {
518
+ position: relative;
519
+ z-index: 1;
520
+ }
521
+ .data-table-note {
522
+ display: flex;
523
+ flex-wrap: wrap;
524
+ justify-content: space-between;
525
+ gap: 0.5rem;
526
+ padding: 0.6rem 1rem;
527
+ color: var(--text-muted);
528
+ border-top: 1px solid var(--border-subtle);
529
+ font-size: var(--size-xs);
530
+ }
531
+ .data-table-chips {
532
+ display: flex;
533
+ flex-wrap: wrap;
534
+ gap: 0.5rem;
535
+ padding: 0 1rem 0.75rem;
536
+ }
537
+ .data-table-chips button {
538
+ display: inline-flex;
539
+ align-items: center;
540
+ gap: 0.5rem;
541
+ padding: 0.2rem 0.5rem;
542
+ font-size: var(--size-xs);
543
+ color: var(--text-muted);
544
+ border: 1px solid var(--border-subtle);
545
+ border-radius: 5px;
546
+ cursor: pointer;
547
+ max-width: 100%;
548
+ overflow-wrap: anywhere;
549
+ }
550
+ .data-table-chips button:hover {
551
+ background: var(--surface-hover);
552
+ color: var(--text-heading);
553
+ }
554
+ .data-table-menu {
555
+ position: fixed;
556
+ z-index: 1000;
557
+ width: 260px;
558
+ max-width: calc(100vw - 16px);
559
+ overflow-y: auto;
560
+ background: var(--surface-card);
561
+ color: var(--text-body);
562
+ border: 1px solid var(--border-default);
563
+ border-radius: var(--radius-md);
564
+ box-shadow: var(--shadow-lg);
565
+ padding: 0.75rem;
566
+ font: var(--size-sm)/1.5 var(--font-core);
567
+ }
568
+ .data-table-menu-title {
569
+ font-weight: 600;
570
+ color: var(--text-heading);
571
+ padding: 0 4rem 0 0;
572
+ display: flex;
573
+ align-items: center;
574
+ min-height: 30px;
575
+ }
576
+ .data-table-menu button {
577
+ cursor: pointer;
578
+ border-radius: 5px;
579
+ padding: 0.4rem 0.5rem;
580
+ }
581
+ .data-table-menu button:hover,
582
+ .data-table-menu button[aria-pressed="true"] {
583
+ background: var(--surface-hover);
584
+ }
585
+ .data-table-menu-sorting {
586
+ position: absolute;
587
+ top: 10px;
588
+ right: 10px;
589
+ display: flex;
590
+ gap: 2px;
591
+ }
592
+ .data-table-menu-sorting button {
593
+ width: 26px;
594
+ height: 26px;
595
+ padding: 0;
596
+ display: grid;
597
+ place-items: center;
598
+ font-size: 16px;
599
+ }
600
+ .data-table-menu label {
601
+ display: block;
602
+ color: var(--text-muted);
603
+ font-size: var(--size-xs);
604
+ }
605
+ .data-table-menu-filter {
606
+ padding: 0.4rem 0;
607
+ }
608
+ .data-table-menu input:not([type="checkbox"]),
609
+ .data-table-menu select {
610
+ width: 100%;
611
+ min-width: 0;
612
+ display: block;
613
+ border: 1px solid var(--border-default);
614
+ background: var(--surface-card);
615
+ color: var(--text-body);
616
+ border-radius: 5px;
617
+ padding: 0.4rem 0.5rem;
618
+ margin-top: 0.35rem;
619
+ font-size: var(--size-sm);
620
+ }
621
+ .data-table-menu input:focus-visible,
622
+ .data-table-menu select:focus-visible {
623
+ outline: none;
624
+ border-color: var(--border-accent);
625
+ box-shadow: 0 0 0 1px var(--border-accent);
626
+ }
627
+ .data-table-range {
628
+ display: flex;
629
+ gap: 0.5rem;
630
+ }
631
+ .data-table-range label {
632
+ flex: 1;
633
+ min-width: 0;
634
+ }
635
+ .data-table-menu-unit {
636
+ border-top: 1px solid var(--border-subtle);
637
+ padding-top: 0.65rem;
638
+ }
639
+ .data-table-clear {
640
+ color: var(--text-muted);
641
+ width: 100%;
642
+ text-align: left;
643
+ margin-top: 0.65rem;
644
+ font-size: var(--size-xs);
645
+ }
646
+ .data-table-error {
647
+ color: var(--status-danger-fg);
648
+ padding: 0.5rem;
649
+ font-size: var(--size-xs);
650
+ }
651
+ @media (pointer: coarse) {
652
+ .data-table-heading,
653
+ .data-table-menu button {
654
+ min-height: 44px;
655
+ }
656
+ .data-table-menu input:not([type="checkbox"]),
657
+ .data-table-menu select {
658
+ font-size: 16px;
659
+ }
660
+ }
661
+
662
+ .data-table-header-control {
663
+ display: flex;
664
+ align-items: stretch;
665
+ min-height: 36px;
666
+ position: relative;
667
+ }
668
+ .data-table-header-chevron {
669
+ flex: 0 0 28px;
670
+ display: grid;
671
+ place-items: center;
672
+ cursor: pointer;
673
+ color: var(--text-subtle);
674
+ padding-right: 8px;
675
+ }
676
+ .data-table-header-control:hover {
677
+ background: var(--surface-hover);
678
+ }
679
+ .data-table-header-chevron:hover {
680
+ color: var(--text-heading);
681
+ }
682
+ .data-table th[data-active="true"] .data-table-header-control {
683
+ background: var(--surface-accent-soft);
684
+ }
685
+ .data-table th[data-active="true"] .data-table-header-control::after {
686
+ content: "";
687
+ position: absolute;
688
+ inset: 0;
689
+ border: 1px solid var(--border-accent);
690
+ pointer-events: none;
691
+ z-index: 1;
692
+ }
693
+ .data-table-heading:focus-visible,
694
+ .data-table-header-chevron:focus-visible {
695
+ outline: 0;
696
+ box-shadow: none;
697
+ }
698
+ @media (pointer: coarse) {
699
+ .data-table-header-chevron {
700
+ flex-basis: 40px;
701
+ min-height: 44px;
702
+ }
703
+ .data-table-menu-sorting button {
704
+ width: 40px;
705
+ }
706
+ .data-table-menu-title {
707
+ min-height: 44px;
708
+ padding-right: 5rem;
709
+ }
710
+ }
@@ -0,0 +1,39 @@
1
+ # Document block state
2
+
3
+ Use this area for local preferences belonging to a block on a document page. App-wide
4
+ preferences (theme, sidebar, listing order) have different scopes and stay in their own modules.
5
+
6
+ ```tsx
7
+ const scope = useDocumentBlockScope({ kind: "chart", version: 1, blockId: id });
8
+ const [state, setState] = useBlockState(scope, {
9
+ schema: chartStateSchema,
10
+ initialState: initialChartState,
11
+ normalize: (saved) => reconcileChartState(saved, series),
12
+ });
13
+ ```
14
+
15
+ Import the hooks from `./useBlockState` and storage helpers/types from `./storage`.
16
+
17
+ - Keep `blockId` stable across edits and unique within the document. `kind` separates component
18
+ families; `version` identifies the persisted format. The scope uses `DocContext.path`, with the
19
+ browser pathname as a fallback for standalone pages.
20
+ - Provide a schema with `safeParse` (Zod schemas work), a fresh default-state factory, and optional
21
+ pure normalization against current block props. State must be JSON-compatible. Validation
22
+ checks saved data; normalization handles changes to the block's current content or schema.
23
+ - Server rendering and initial hydration use defaults. Storage is restored in an effect before
24
+ any state is saved. Changing scope loads that scope's preferences without exposing or saving
25
+ the old scope's state under the new key. The setter supports React-style functional updates.
26
+ - Invalid saved data, blocked storage, and quota errors fall back to usable in-memory state.
27
+ Normalization runs before rendering and saving and is committed to memory, so removed
28
+ preferences do not reappear on later content edits. It may return a fresh object.
29
+ - Set `persist: false` for blocks without an unambiguous identity. They retain in-memory
30
+ interaction but never read or write saved preferences.
31
+ - For migrations, provide `legacyKeys(scope)`. Only a missing current key permits legacy reads;
32
+ a corrupt current value does not resurrect older preferences. Successful restoration is saved
33
+ under the new key. Legacy keys are left intact for compatibility with older app versions.
34
+ - Use `blockStateKey(scope)` as a React key if transient UI state (open menus, focused cells)
35
+ should also reset when the scope changes. Persistence itself does not require a remount.
36
+
37
+ `restoreBlockState` and `saveBlockState` accept a small storage interface for direct use and
38
+ contract tests. They contain no component-specific knowledge. `useTableState` supplies table
39
+ validation, column reconciliation, and the old table-key migration as one consumer of this API.
@@ -0,0 +1,73 @@
1
+ /** Identity of a reusable block within a document. IDs should survive content edits. */
2
+ export interface BlockStateScope {
3
+ documentPath: string;
4
+ blockId: string;
5
+ kind: string;
6
+ version: number;
7
+ }
8
+
9
+ export interface BlockStateSchema<State> {
10
+ safeParse(value: unknown): { success: true; data: State } | { success: false };
11
+ }
12
+
13
+ export type BlockStateStorage = Pick<Storage, "getItem" | "setItem">;
14
+
15
+ /** Tuple encoding prevents separators inside paths or IDs from creating collisions. */
16
+ export function blockStateKey(scope: BlockStateScope): string {
17
+ return `mdxserve:block:${JSON.stringify([scope.version, scope.kind, scope.documentPath, scope.blockId])}`;
18
+ }
19
+
20
+ export function browserBlockStorage(): BlockStateStorage | undefined {
21
+ try {
22
+ return typeof window === "undefined" ? undefined : window.localStorage;
23
+ } catch {
24
+ return undefined;
25
+ }
26
+ }
27
+
28
+ export function serializeBlockState<State>(state: State): string | undefined {
29
+ try {
30
+ return JSON.stringify(state);
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ /** Legacy keys are consulted only when the current key is absent. */
37
+ export function restoreBlockState<State>(
38
+ storage: BlockStateStorage | undefined,
39
+ scope: BlockStateScope,
40
+ schema: BlockStateSchema<State>,
41
+ legacyKeys: readonly string[] = [],
42
+ ): State | undefined {
43
+ if (!storage) return undefined;
44
+
45
+ try {
46
+ for (const key of [blockStateKey(scope), ...legacyKeys]) {
47
+ const saved = storage.getItem(key);
48
+ if (saved === null) continue;
49
+
50
+ const parsed = schema.safeParse(JSON.parse(saved));
51
+ return parsed.success ? parsed.data : undefined;
52
+ }
53
+ } catch {
54
+ // Storage access and untrusted saved data must never prevent rendering a block.
55
+ }
56
+
57
+ return undefined;
58
+ }
59
+
60
+ export function saveBlockState<State>(
61
+ storage: BlockStateStorage | undefined,
62
+ scope: BlockStateScope,
63
+ state: State,
64
+ ): void {
65
+ const serialized = serializeBlockState(state);
66
+ if (!storage || serialized === undefined) return;
67
+
68
+ try {
69
+ storage.setItem(blockStateKey(scope), serialized);
70
+ } catch {
71
+ // Disabled storage and quota failures leave the block usable in memory.
72
+ }
73
+ }
@@ -0,0 +1,120 @@
1
+ import {
2
+ useCallback,
3
+ useContext,
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ type Dispatch,
8
+ type SetStateAction,
9
+ } from "react";
10
+ import { DocContext } from "../DocContext";
11
+ import {
12
+ blockStateKey,
13
+ browserBlockStorage,
14
+ restoreBlockState,
15
+ saveBlockState,
16
+ serializeBlockState,
17
+ type BlockStateSchema,
18
+ type BlockStateScope,
19
+ } from "./storage";
20
+
21
+ export interface BlockStateOptions<State> {
22
+ schema: BlockStateSchema<State>;
23
+ persist?: boolean;
24
+ initialState: () => State;
25
+ normalize?: (state: State) => State;
26
+ legacyKeys?: (scope: BlockStateScope) => readonly string[];
27
+ }
28
+
29
+ interface StateSnapshot<State> {
30
+ key: string;
31
+ value: State;
32
+ loaded: boolean;
33
+ }
34
+
35
+ /** Standalone exports use their page path; SSR never touches browser globals. */
36
+ export function useDocumentBlockScope(
37
+ block: Omit<BlockStateScope, "documentPath">,
38
+ ): BlockStateScope {
39
+ const context = useContext(DocContext);
40
+ const documentPath = context?.path ?? (typeof location === "undefined" ? "" : location.pathname);
41
+
42
+ return { ...block, documentPath };
43
+ }
44
+
45
+ /** Persist JSON-compatible block state after hydration, isolated by document and block identity. */
46
+ export function useBlockState<State>(
47
+ scope: BlockStateScope,
48
+ options: BlockStateOptions<State>,
49
+ ): readonly [State, Dispatch<SetStateAction<State>>] {
50
+ const key = JSON.stringify([blockStateKey(scope), options.persist !== false]);
51
+ const latest = useRef({ scope, options, key });
52
+ latest.current = { scope, options, key };
53
+
54
+ const [snapshot, setSnapshot] = useState<StateSnapshot<State>>(() => ({
55
+ key,
56
+ value: options.initialState(),
57
+ loaded: false,
58
+ }));
59
+
60
+ // A scope change must not expose or persist the previous document's state while loading.
61
+ const current =
62
+ snapshot.key === key ? snapshot : { key, value: options.initialState(), loaded: false };
63
+ const normalized = options.normalize ? options.normalize(current.value) : current.value;
64
+
65
+ useEffect(() => {
66
+ const { scope: currentScope, options: currentOptions } = latest.current;
67
+ if (latest.current.key !== key) return;
68
+
69
+ const restored =
70
+ currentOptions.persist === false
71
+ ? undefined
72
+ : restoreBlockState(
73
+ browserBlockStorage(),
74
+ currentScope,
75
+ currentOptions.schema,
76
+ currentOptions.legacyKeys?.(currentScope),
77
+ );
78
+
79
+ setSnapshot({
80
+ key,
81
+ value: restored === undefined ? currentOptions.initialState() : restored,
82
+ loaded: true,
83
+ });
84
+ }, [key]);
85
+
86
+ useEffect(() => {
87
+ if (!current.loaded || latest.current.key !== key) return;
88
+
89
+ // Commit reconciliation so removed preferences cannot return on later schema edits.
90
+ setSnapshot((previous) => {
91
+ if (
92
+ previous.key !== key ||
93
+ serializeBlockState(previous.value) === serializeBlockState(normalized)
94
+ )
95
+ return previous;
96
+ return { ...previous, value: normalized };
97
+ });
98
+ if (latest.current.options.persist !== false)
99
+ saveBlockState(browserBlockStorage(), latest.current.scope, normalized);
100
+ }, [key, current.loaded, normalized]);
101
+
102
+ const setState = useCallback<Dispatch<SetStateAction<State>>>(
103
+ (update) => {
104
+ setSnapshot((previous) => {
105
+ if (latest.current.key !== key) return previous;
106
+
107
+ const currentOptions = latest.current.options;
108
+ const value = previous.key === key ? previous.value : currentOptions.initialState();
109
+ const reconciled = currentOptions.normalize ? currentOptions.normalize(value) : value;
110
+ const next =
111
+ typeof update === "function" ? (update as (state: State) => State)(reconciled) : update;
112
+
113
+ return { key, value: next, loaded: previous.key === key && previous.loaded };
114
+ });
115
+ },
116
+ [key],
117
+ );
118
+
119
+ return [normalized, setState];
120
+ }