@yuneta/gobj-ui 7.19.3 → 7.20.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.
package/src/c_yui_json.js CHANGED
@@ -24,9 +24,19 @@
24
24
  * Only expanded containers are materialised in the DOM, so the tree
25
25
  * stays bounded no matter how large the source document is.
26
26
  *
27
+ * TWO VIEWS over the same working document, chosen by the `view_mode`
28
+ * attr ("tree" | "text") and switched from the toolbar: the lazy tree
29
+ * above, and the raw `JSON.stringify(…, 4)` dump of what is currently
30
+ * loaded. The text view is what you reach for to read a document as
31
+ * it is written, to select a slab of it, or to search it with the
32
+ * browser's own Ctrl+F; it shows the `__collapsed__` sentinels
33
+ * verbatim, because that is honestly what the client holds. The
34
+ * tree-only controls (search, expand/collapse) hide with the tree.
35
+ *
27
36
  * DOM is self-describing (UPPER_SNAKE logical classes): JSON_VIEWER /
28
- * JSON_TOOLBAR / JSON_SEARCH / JSON_TREE / JSON_ROW / JSON_KEY /
29
- * JSON_VALUE / JSON_SUMMARY / JSON_COLLAPSED / JSON_TIME.
37
+ * JSON_TOOLBAR / JSON_SEARCH / JSON_VIEW_MODE / JSON_TREE / JSON_TEXT /
38
+ * JSON_TEXT_BODY / JSON_ROW / JSON_KEY / JSON_VALUE / JSON_SUMMARY /
39
+ * JSON_COLLAPSED / JSON_TIME.
30
40
  *
31
41
  * Copyright (c) 2026, ArtGins.
32
42
  * All Rights Reserved.
@@ -62,6 +72,7 @@ import {
62
72
  subtree_matches,
63
73
  is_time_field,
64
74
  format_epoch,
75
+ json_text_dump,
65
76
  } from "./json_view_helpers.js";
66
77
 
67
78
  import {yui_toolbar} from "./yui_toolbar.js";
@@ -84,6 +95,14 @@ const GCLASS_NAME = "C_YUI_JSON";
84
95
  */
85
96
  const MAX_RENDER_ROWS = 5000;
86
97
 
98
+ /*
99
+ * Hard cap on characters painted by the text view. Same guard as
100
+ * MAX_RENDER_ROWS, one layer down: a document the tree renders lazily
101
+ * is dumped whole here, so the cap is announced under the text and
102
+ * never applied silently.
103
+ */
104
+ const MAX_TEXT_CHARS = 2000000;
105
+
87
106
  /***************************************************************
88
107
  * Data
89
108
  ***************************************************************/
@@ -94,6 +113,7 @@ SDATA(data_type_t.DTP_POINTER, "subscriber", 0, null, "Subscriber of outpu
94
113
  /*---------------- Config ----------------*/
95
114
  SDATA(data_type_t.DTP_STRING, "title", 0, "", "Optional header title (i18n key)"),
96
115
  SDATA(data_type_t.DTP_JSON, "json_data", 0, null, "Initial JSON to render (usually already collapsed)"),
116
+ SDATA(data_type_t.DTP_STRING, "view_mode", 0, "tree", "View: 'tree' (lazy tree) or 'text' (raw JSON dump)"),
97
117
 
98
118
  /*---------------- UI ----------------*/
99
119
  SDATA(data_type_t.DTP_POINTER, "$container", 0, null, "HTMLElement root, mounted by the parent"),
@@ -107,7 +127,13 @@ let PRIVATE_DATA = {
107
127
  errors: null, // Map<string,string> of per-path expand errors
108
128
  search: "", // current search term (lower-cased)
109
129
  $tree: null, // the scrollable tree body element
130
+ $text: null, // the scrollable text body element
131
+ $text_body: null, // the <pre> inside it
110
132
  $search: null, // the search input element
133
+ $search_ctl: null, // the search control (hidden in text view)
134
+ $expand_btn: null, // "expand loaded" button (hidden in text view)
135
+ $collapse_btn: null,// "collapse all" button (hidden in text view)
136
+ $mode_btn: null, // the tree/text switch
111
137
  };
112
138
 
113
139
  let __gclass__ = null;
@@ -140,6 +166,7 @@ function mt_create(gobj)
140
166
  }
141
167
 
142
168
  build_ui(gobj);
169
+ apply_view_mode(gobj);
143
170
 
144
171
  /*
145
172
  * CHILD subscription model
@@ -156,7 +183,7 @@ function mt_create(gobj)
156
183
  ***************************************************************/
157
184
  function mt_start(gobj)
158
185
  {
159
- render_tree(gobj);
186
+ render_view(gobj);
160
187
  }
161
188
 
162
189
  /***************************************************************
@@ -198,13 +225,20 @@ function build_ui(gobj)
198
225
  style: 'height:100%; display:flex; flex-direction:column;'}, [
199
226
  ['div', {class: 'JSON_TOOLBAR is-flex-grow-0'}, [$toolbar]],
200
227
  ['div', {class: 'JSON_TREE is-flex-grow-1',
228
+ style: 'flex:1 1 auto; min-height:0; overflow:auto;'}, []],
229
+ ['div', {class: 'JSON_TEXT is-flex-grow-1 is-hidden',
201
230
  style: 'flex:1 1 auto; min-height:0; overflow:auto;'}, []]
202
231
  ]]
203
232
  );
204
233
 
205
234
  gobj_write_attr(gobj, "$container", $container);
206
235
  priv.$tree = $container.querySelector('.JSON_TREE');
236
+ priv.$text = $container.querySelector('.JSON_TEXT');
207
237
  priv.$search = $container.querySelector('.JSON_SEARCH');
238
+ priv.$search_ctl = $container.querySelector('.JSON_SEARCH_CONTROL');
239
+ priv.$expand_btn = $container.querySelector('.EV_EXPAND_ALL');
240
+ priv.$collapse_btn = $container.querySelector('.EV_COLLAPSE_ALL');
241
+ priv.$mode_btn = $container.querySelector('.JSON_VIEW_MODE');
208
242
 
209
243
  refresh_language($container, t);
210
244
  }
@@ -223,11 +257,18 @@ function destroy_ui(gobj)
223
257
  gobj_write_attr(gobj, "$container", null);
224
258
  }
225
259
  priv.$tree = null;
260
+ priv.$text = null;
261
+ priv.$text_body = null;
226
262
  priv.$search = null;
263
+ priv.$search_ctl = null;
264
+ priv.$expand_btn = null;
265
+ priv.$collapse_btn = null;
266
+ priv.$mode_btn = null;
227
267
  }
228
268
 
229
269
  /************************************************************
230
- * Toolbar: search + expand-all / collapse-all / copy
270
+ * Toolbar: search + tree/text switch + expand-all /
271
+ * collapse-all / copy
231
272
  ************************************************************/
232
273
  function make_toolbar(gobj)
233
274
  {
@@ -251,7 +292,8 @@ function make_toolbar(gobj)
251
292
  }
252
293
  }]);
253
294
  let $search_control = createElement2(
254
- ['div', {class: 'control has-icons-left', style: 'max-width:22em;'}, [
295
+ ['div', {class: 'control has-icons-left JSON_SEARCH_CONTROL',
296
+ style: 'max-width:22em;'}, [
255
297
  $search_input,
256
298
  ['span', {class: 'icon is-left'}, [['i', {class: 'yi-magnifying-glass'}]]]
257
299
  ]]);
@@ -259,6 +301,7 @@ function make_toolbar(gobj)
259
301
  left_items.push($search_control);
260
302
 
261
303
  let right_items = [
304
+ view_mode_button(gobj),
262
305
  icon_button(gobj, "yi-chevron-right", "EV_EXPAND_ALL", "expand loaded"),
263
306
  icon_button(gobj, "yi-chevron-right", "EV_COLLAPSE_ALL", "collapse all"),
264
307
  icon_button(gobj, "yi-copy", "EV_COPY_ALL", "copy json"),
@@ -293,6 +336,141 @@ function icon_button(gobj, icon, event_name, label_key)
293
336
  }];
294
337
  }
295
338
 
339
+ /************************************************************
340
+ * The tree/text switch.
341
+ *
342
+ * It shows the view it takes you TO — the `code` glyph while the
343
+ * tree is on screen, the `sitemap` glyph while the text is — and
344
+ * sends EV_SET_VIEW_MODE with no mode, which the action reads as
345
+ * "toggle". apply_view_mode() keeps the icon and its i18n keys in
346
+ * step with the attr.
347
+ ************************************************************/
348
+ function view_mode_button(gobj)
349
+ {
350
+ return ['button', {class: 'button JSON_VIEW_MODE', style: 'width:2.5em;',
351
+ title: t("text view"), 'data-i18n-title': "text view",
352
+ 'aria-label': t("text view"), 'data-i18n-aria-label': "text view"}, [
353
+ ['span', {class: 'icon'}, [['i', {class: 'yi-code'}]]]
354
+ ], {
355
+ click: function(evt) {
356
+ evt.stopPropagation();
357
+ gobj_send_event(gobj, "EV_SET_VIEW_MODE", {}, gobj);
358
+ }
359
+ }];
360
+ }
361
+
362
+ /************************************************************
363
+ * The current view, normalised. Anything but "text" is the tree.
364
+ ************************************************************/
365
+ function current_view_mode(gobj)
366
+ {
367
+ return (gobj_read_str_attr(gobj, "view_mode") === "text")? "text": "tree";
368
+ }
369
+
370
+ /************************************************************
371
+ * Show the chrome of the current view and hide the other's.
372
+ ************************************************************/
373
+ function apply_view_mode(gobj)
374
+ {
375
+ let priv = gobj.priv;
376
+ let text_mode = current_view_mode(gobj) === "text";
377
+
378
+ if(priv.$tree) {
379
+ priv.$tree.classList.toggle('is-hidden', text_mode);
380
+ }
381
+ if(priv.$text) {
382
+ priv.$text.classList.toggle('is-hidden', !text_mode);
383
+ }
384
+
385
+ /*
386
+ * Search and expand/collapse act on TREE rows; in the text view
387
+ * they have nothing to act on, and a control that answers nothing
388
+ * is worse than an absent one. A <pre> is searched with the
389
+ * browser's own Ctrl+F.
390
+ */
391
+ [priv.$search_ctl, priv.$expand_btn, priv.$collapse_btn].forEach(function($el) {
392
+ if($el) {
393
+ $el.classList.toggle('is-hidden', text_mode);
394
+ }
395
+ });
396
+
397
+ if(priv.$mode_btn) {
398
+ let icon = text_mode? "yi-sitemap": "yi-code";
399
+ let key = text_mode? "tree view": "text view";
400
+ let $i = priv.$mode_btn.querySelector('i');
401
+ if($i) {
402
+ $i.className = icon;
403
+ }
404
+ priv.$mode_btn.setAttribute("data-i18n-title", key);
405
+ priv.$mode_btn.setAttribute("data-i18n-aria-label", key);
406
+ priv.$mode_btn.setAttribute("title", t(key));
407
+ priv.$mode_btn.setAttribute("aria-label", t(key));
408
+ }
409
+ }
410
+
411
+ /************************************************************
412
+ * Render whichever view is current.
413
+ ************************************************************/
414
+ function render_view(gobj)
415
+ {
416
+ if(current_view_mode(gobj) === "text") {
417
+ render_text(gobj);
418
+ } else {
419
+ render_tree(gobj);
420
+ }
421
+ }
422
+
423
+ /************************************************************
424
+ * Render the text view: the whole loaded document, verbatim.
425
+ *
426
+ * Unlike the tree, nothing here is lazy — what the client holds is
427
+ * what it prints, `__collapsed__` sentinels included. Over
428
+ * MAX_TEXT_CHARS the dump is cut and the cut is announced.
429
+ ************************************************************/
430
+ function render_text(gobj)
431
+ {
432
+ let priv = gobj.priv;
433
+ let $text = priv.$text;
434
+ if(!$text) {
435
+ return;
436
+ }
437
+
438
+ let scroll_top = $text.scrollTop;
439
+ $text.textContent = "";
440
+ priv.$text_body = null;
441
+
442
+ if(priv.root === null || priv.root === undefined) {
443
+ $text.appendChild(createElement2(
444
+ ['div', {class: 'JSON_EMPTY has-text-grey p-3', 'data-i18n': 'no data'}, 'no data']
445
+ ));
446
+ return;
447
+ }
448
+
449
+ let dump = json_text_dump(priv.root, MAX_TEXT_CHARS);
450
+ if(dump.error) {
451
+ log_error(`${GCLASS_NAME}: cannot dump the document as text: ${dump.error}`);
452
+ }
453
+
454
+ /* textContent, not a createElement2 child: the dump is raw text
455
+ * that must reach the DOM untouched, and createElement2 trims its
456
+ * text nodes. */
457
+ let $pre = createElement2(['pre', {class: 'JSON_TEXT_BODY'}, []]);
458
+ $pre.textContent = dump.text;
459
+ $text.appendChild($pre);
460
+ priv.$text_body = $pre;
461
+
462
+ if(dump.capped) {
463
+ $text.appendChild(createElement2(
464
+ ['div', {class: 'JSON_CAPPED has-text-warning p-2',
465
+ 'data-i18n': 'text truncated; collapse some branches'},
466
+ 'text truncated; collapse some branches']
467
+ ));
468
+ }
469
+
470
+ refresh_language($text, t);
471
+ $text.scrollTop = scroll_top;
472
+ }
473
+
296
474
  /************************************************************
297
475
  * Re-render the whole tree from priv.root + priv.expanded.
298
476
  *
@@ -629,7 +807,7 @@ function ac_set_json(gobj, event, kw, src)
629
807
  priv.pending.clear();
630
808
  priv.errors.clear();
631
809
 
632
- render_tree(gobj);
810
+ render_view(gobj);
633
811
  return 0;
634
812
  }
635
813
 
@@ -655,7 +833,7 @@ function ac_subtree_loaded(gobj, event, kw, src)
655
833
  priv.expanded.add(path); // reveal what we just loaded
656
834
  }
657
835
 
658
- render_tree(gobj);
836
+ render_view(gobj);
659
837
  return 0;
660
838
  }
661
839
 
@@ -672,7 +850,7 @@ function ac_subtree_error(gobj, event, kw, src)
672
850
 
673
851
  log_error(`${GCLASS_NAME}: subtree load failed at '${path}': ${kw.error || ""}`);
674
852
 
675
- render_tree(gobj);
853
+ render_view(gobj);
676
854
  return 0;
677
855
  }
678
856
 
@@ -690,7 +868,7 @@ function ac_toggle_node(gobj, event, kw, src)
690
868
  priv.expanded.add(path);
691
869
  }
692
870
 
693
- render_tree(gobj);
871
+ render_view(gobj);
694
872
  return 0;
695
873
  }
696
874
 
@@ -709,7 +887,7 @@ function ac_expand_collapsed(gobj, event, kw, src)
709
887
  priv.pending.add(path);
710
888
  priv.errors.delete(path);
711
889
 
712
- render_tree(gobj); // show the "loading…" state
890
+ render_view(gobj); // show the "loading…" state
713
891
 
714
892
  gobj_publish_event(gobj, "EV_EXPAND_PATH", {path: path, size: kw.size});
715
893
  return 0;
@@ -722,7 +900,7 @@ function ac_search(gobj, event, kw, src)
722
900
  {
723
901
  let priv = gobj.priv;
724
902
  priv.search = (kw.text || "").trim().toLowerCase();
725
- render_tree(gobj);
903
+ render_view(gobj);
726
904
  return 0;
727
905
  }
728
906
 
@@ -751,7 +929,7 @@ function ac_expand_all(gobj, event, kw, src)
751
929
  priv.expanded.add(p);
752
930
  });
753
931
 
754
- render_tree(gobj);
932
+ render_view(gobj);
755
933
  return 0;
756
934
  }
757
935
 
@@ -762,7 +940,7 @@ function ac_collapse_all(gobj, event, kw, src)
762
940
  {
763
941
  let priv = gobj.priv;
764
942
  priv.expanded.clear();
765
- render_tree(gobj);
943
+ render_view(gobj);
766
944
  return 0;
767
945
  }
768
946
 
@@ -775,7 +953,12 @@ function ac_copy_all(gobj, event, kw, src)
775
953
  if(priv.root === null || priv.root === undefined) {
776
954
  return 0;
777
955
  }
778
- let text = JSON.stringify(priv.root, null, 4);
956
+ let dump = json_text_dump(priv.root, 0);
957
+ if(dump.error) {
958
+ log_error(`${GCLASS_NAME}: cannot copy the document: ${dump.error}`);
959
+ return -1;
960
+ }
961
+ let text = dump.text;
779
962
  if(navigator.clipboard && navigator.clipboard.writeText) {
780
963
  navigator.clipboard.writeText(text).catch(function(e) {
781
964
  log_error(`${GCLASS_NAME}: clipboard write failed: ${e}`);
@@ -786,6 +969,29 @@ function ac_copy_all(gobj, event, kw, src)
786
969
  return 0;
787
970
  }
788
971
 
972
+ /************************************************************
973
+ * EV_SET_VIEW_MODE { mode } — "tree" | "text".
974
+ *
975
+ * With no mode it toggles, which is what the toolbar button sends.
976
+ ************************************************************/
977
+ function ac_set_view_mode(gobj, event, kw, src)
978
+ {
979
+ let mode = kw.mode;
980
+
981
+ if(mode === undefined || mode === null || mode === "") {
982
+ mode = (current_view_mode(gobj) === "text")? "tree": "text";
983
+ } else if(mode !== "tree" && mode !== "text") {
984
+ log_error(`${GCLASS_NAME}: unknown view_mode '${mode}'`);
985
+ return -1;
986
+ }
987
+
988
+ gobj_write_attr(gobj, "view_mode", mode);
989
+
990
+ apply_view_mode(gobj);
991
+ render_view(gobj);
992
+ return 0;
993
+ }
994
+
789
995
  /************************************************************
790
996
  * EV_LANGUAGE_CHANGED — re-translate chrome + re-render
791
997
  ************************************************************/
@@ -798,8 +1004,9 @@ function ac_language_changed(gobj, event, kw, src)
798
1004
  if(priv.$search) {
799
1005
  priv.$search.setAttribute("placeholder", t("search"));
800
1006
  }
1007
+ apply_view_mode(gobj);
801
1008
  }
802
- render_tree(gobj);
1009
+ render_view(gobj);
803
1010
  return 0;
804
1011
  }
805
1012
 
@@ -829,7 +1036,7 @@ function ac_hide(gobj, event, kw, src)
829
1036
  ************************************************************/
830
1037
  function ac_refresh(gobj, event, kw, src)
831
1038
  {
832
- render_tree(gobj);
1039
+ render_view(gobj);
833
1040
  return 0;
834
1041
  }
835
1042
 
@@ -869,6 +1076,7 @@ function create_gclass(gclass_name)
869
1076
  const states = [
870
1077
  ["ST_EMPTY", [
871
1078
  ["EV_SET_JSON", ac_set_json, "ST_READY"],
1079
+ ["EV_SET_VIEW_MODE", ac_set_view_mode, null],
872
1080
  ["EV_LANGUAGE_CHANGED", ac_language_changed, null],
873
1081
  ["EV_REFRESH", ac_refresh, null],
874
1082
  ["EV_SHOW", ac_show, null],
@@ -884,6 +1092,7 @@ function create_gclass(gclass_name)
884
1092
  ["EV_EXPAND_ALL", ac_expand_all, null],
885
1093
  ["EV_COLLAPSE_ALL", ac_collapse_all, null],
886
1094
  ["EV_COPY_ALL", ac_copy_all, null],
1095
+ ["EV_SET_VIEW_MODE", ac_set_view_mode, null],
887
1096
  ["EV_LANGUAGE_CHANGED", ac_language_changed, null],
888
1097
  ["EV_REFRESH", ac_refresh, null],
889
1098
  ["EV_SHOW", ac_show, null],
@@ -904,6 +1113,7 @@ function create_gclass(gclass_name)
904
1113
  ["EV_EXPAND_ALL", 0],
905
1114
  ["EV_COLLAPSE_ALL", 0],
906
1115
  ["EV_COPY_ALL", 0],
1116
+ ["EV_SET_VIEW_MODE", 0],
907
1117
  ["EV_LANGUAGE_CHANGED", 0],
908
1118
  ["EV_REFRESH", 0],
909
1119
  ["EV_SHOW", 0],
@@ -1282,7 +1282,24 @@ function navigate_to(gobj, route, depth, no_drain)
1282
1282
  }
1283
1283
  };
1284
1284
  let config = gobj_read_attr(gobj, "config") || {};
1285
- let prev = (priv.stages && priv.stages.main &&
1285
+ /* Where the user actually WAS, which is the url and not the
1286
+ * mount.
1287
+ *
1288
+ * `stages.main.active_route` is the DECLARED route the view is
1289
+ * mounted at, and under a `C_YUI_NODE` tree everything below it
1290
+ * is subpath the node owns -- so restoring THAT restores the
1291
+ * tree's root and throws the position away. Switching the theme
1292
+ * from a graph five levels down answered with the workspace
1293
+ * landing, and so would every other action route: preferences,
1294
+ * the site map, the dev tools.
1295
+ *
1296
+ * `current_route` is the same route WITH its subpath, and it is
1297
+ * only ever written for a RESTING route (an action returns
1298
+ * before that line), so it cannot be an action route itself.
1299
+ * It is what a deep link resolves, and therefore what can be
1300
+ * restored. */
1301
+ let prev = gobj_read_attr(gobj, "current_route") ||
1302
+ (priv.stages && priv.stages.main &&
1286
1303
  priv.stages.main.active_route) ||
1287
1304
  gobj_read_attr(gobj, "default_route") ||
1288
1305
  (config.shell && config.shell.stages &&
@@ -4,8 +4,8 @@
4
4
  * Pure, testable logic behind C_YUI_JSON (the lazy JSON tree
5
5
  * viewer). Kept out of the gclass so it can be unit-tested with
6
6
  * no DOM: collapsed-sentinel detection, path (segments) algebra,
7
- * search matching, timestamp recognition/formatting and the JSON
8
- * type discriminator.
7
+ * search matching, timestamp recognition/formatting, the JSON
8
+ * type discriminator and the text view's capped dump.
9
9
  *
10
10
  * Path convention mirrors the C kernel (kw_collapse / kw_find_path
11
11
  * in kwid.c): segments are joined by the backtick delimiter, arrays
@@ -212,3 +212,33 @@ export function format_epoch(value)
212
212
  }
213
213
  return d.toLocaleString();
214
214
  }
215
+
216
+
217
+ /************************************************************
218
+ * The text view's payload: the document as text, indented
219
+ * four characters, cut at `max_chars`.
220
+ *
221
+ * Returns {text, capped}. `capped` is what the caller
222
+ * announces under the dump — a cut that is not announced
223
+ * reads as a document that ends there.
224
+ ************************************************************/
225
+ export function json_text_dump(root, max_chars)
226
+ {
227
+ let text;
228
+ try {
229
+ text = JSON.stringify(root, null, 4);
230
+ } catch(e) {
231
+ return {text: "", capped: false, error: String(e)};
232
+ }
233
+
234
+ if(text === undefined) {
235
+ /* JSON.stringify() answers undefined for a value that has no
236
+ * JSON form at all (a function, a bare undefined). */
237
+ return {text: "", capped: false, error: "not representable as JSON"};
238
+ }
239
+
240
+ if(typeof max_chars === "number" && max_chars > 0 && text.length > max_chars) {
241
+ return {text: text.slice(0, max_chars), capped: true};
242
+ }
243
+ return {text: text, capped: false};
244
+ }
@@ -15,6 +15,7 @@ import {
15
15
  subtree_matches,
16
16
  is_time_field,
17
17
  format_epoch,
18
+ json_text_dump,
18
19
  } from "./json_view_helpers.js";
19
20
 
20
21
 
@@ -133,3 +134,44 @@ test("format_epoch handles seconds, milliseconds and the unset case", () => {
133
134
  expect(typeof format_epoch(1700000000)).toBe("string"); // seconds
134
135
  expect(typeof format_epoch(1700000000000)).toBe("string"); // milliseconds
135
136
  });
137
+
138
+
139
+ /*============================================================
140
+ * text view dump
141
+ *============================================================*/
142
+ test("json_text_dump indents four characters, like every other tree", () => {
143
+ let d = json_text_dump({a: {b: 1}}, 0);
144
+ expect(d.capped).toBe(false);
145
+ expect(d.error).toBeUndefined();
146
+ expect(d.text).toBe('{\n "a": {\n "b": 1\n }\n}');
147
+ });
148
+
149
+ test("json_text_dump prints a collapsed sentinel verbatim", () => {
150
+ let d = json_text_dump({rows: {__collapsed__: {path: "rows", size: 900}}}, 0);
151
+ expect(d.text).toContain("__collapsed__");
152
+ expect(d.text).toContain("900");
153
+ });
154
+
155
+ test("json_text_dump cuts at max_chars and says so", () => {
156
+ let d = json_text_dump({a: "x".repeat(500)}, 100);
157
+ expect(d.capped).toBe(true);
158
+ expect(d.text.length).toBe(100);
159
+ });
160
+
161
+ test("json_text_dump does not cap when max_chars is absent or zero", () => {
162
+ let big = {a: "x".repeat(500)};
163
+ expect(json_text_dump(big, 0).capped).toBe(false);
164
+ expect(json_text_dump(big).capped).toBe(false);
165
+ });
166
+
167
+ test("json_text_dump reports a document with no JSON form instead of printing nothing", () => {
168
+ let circular = {};
169
+ circular.self = circular;
170
+ let d = json_text_dump(circular, 0);
171
+ expect(d.text).toBe("");
172
+ expect(typeof d.error).toBe("string");
173
+
174
+ let d2 = json_text_dump(undefined, 0);
175
+ expect(d2.text).toBe("");
176
+ expect(typeof d2.error).toBe("string");
177
+ });
package/src/yui_icons.css CHANGED
@@ -363,3 +363,16 @@
363
363
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 448 512'%3E%3Cpath d='M448 80l0 48c0 44.2-100.3 80-224 80S0 172.2 0 128L0 80C0 35.8 100.3 0 224 0S448 35.8 448 80zM393.2 214.7c20.8-7.4 39.9-16.9 54.8-28.6L448 288c0 44.2-100.3 80-224 80S0 332.2 0 288L0 186.1c14.9 11.8 34 21.2 54.8 28.6C99.7 230.7 159.5 240 224 240s124.3-9.3 169.2-25.3zM0 346.1c14.9 11.8 34 21.2 54.8 28.6C99.7 390.7 159.5 400 224 400s124.3-9.3 169.2-25.3c20.8-7.4 39.9-16.9 54.8-28.6l0 85.9c0 44.2-100.3 80-224 80S0 476.2 0 432l0-85.9z'/%3E%3C/svg%3E");
364
364
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 448 512'%3E%3Cpath d='M448 80l0 48c0 44.2-100.3 80-224 80S0 172.2 0 128L0 80C0 35.8 100.3 0 224 0S448 35.8 448 80zM393.2 214.7c20.8-7.4 39.9-16.9 54.8-28.6L448 288c0 44.2-100.3 80-224 80S0 332.2 0 288L0 186.1c14.9 11.8 34 21.2 54.8 28.6C99.7 230.7 159.5 240 224 240s124.3-9.3 169.2-25.3zM0 346.1c14.9 11.8 34 21.2 54.8 28.6C99.7 390.7 159.5 400 224 400s124.3-9.3 169.2-25.3c20.8-7.4 39.9-16.9 54.8-28.6l0 85.9c0 44.2-100.3 80-224 80S0 476.2 0 432l0-85.9z'/%3E%3C/svg%3E");
365
365
  }
366
+
367
+ /* The two faces of the JSON viewer's view switch: the tree it renders
368
+ * and the raw text behind it. Neither was in the set, and a missing
369
+ * glyph renders as a solid black square. */
370
+ .yi-code::before {
371
+ -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 512'%3E%3Cpath d='M392.8 1.2c-17-4.9-34.7 5-39.6 22l-128 448c-4.9 17 5 34.7 22 39.6s34.7-5 39.6-22l128-448c4.9-17-5-34.7-22-39.6zm80.6 120.1c-12.5 12.5-12.5 32.8 0 45.3L562.7 256l-89.4 89.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l112-112c12.5-12.5 12.5-32.8 0-45.3l-112-112c-12.5-12.5-32.8-12.5-45.3 0zm-306.7 0c-12.5-12.5-32.8-12.5-45.3 0l-112 112c-12.5 12.5-12.5 32.8 0 45.3l112 112c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256l89.4-89.4c12.5-12.5 12.5-32.8 0-45.3z'/%3E%3C/svg%3E");
372
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 512'%3E%3Cpath d='M392.8 1.2c-17-4.9-34.7 5-39.6 22l-128 448c-4.9 17 5 34.7 22 39.6s34.7-5 39.6-22l128-448c4.9-17-5-34.7-22-39.6zm80.6 120.1c-12.5 12.5-12.5 32.8 0 45.3L562.7 256l-89.4 89.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l112-112c12.5-12.5 12.5-32.8 0-45.3l-112-112c-12.5-12.5-32.8-12.5-45.3 0zm-306.7 0c-12.5-12.5-32.8-12.5-45.3 0l-112 112c-12.5 12.5-12.5 32.8 0 45.3l112 112c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256l89.4-89.4c12.5-12.5 12.5-32.8 0-45.3z'/%3E%3C/svg%3E");
373
+ }
374
+
375
+ .yi-sitemap::before {
376
+ -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 576 512'%3E%3Cpath d='M208 80c0-26.5 21.5-48 48-48l64 0c26.5 0 48 21.5 48 48l0 64c0 26.5-21.5 48-48 48l-8 0 0 40 152 0c30.9 0 56 25.1 56 56l0 32 8 0c26.5 0 48 21.5 48 48l0 64c0 26.5-21.5 48-48 48l-64 0c-26.5 0-48-21.5-48-48l0-64c0-26.5 21.5-48 48-48l8 0 0-32c0-4.4-3.6-8-8-8l-152 0 0 40 8 0c26.5 0 48 21.5 48 48l0 64c0 26.5-21.5 48-48 48l-64 0c-26.5 0-48-21.5-48-48l0-64c0-26.5 21.5-48 48-48l8 0 0-40-152 0c-4.4 0-8 3.6-8 8l0 32 8 0c26.5 0 48 21.5 48 48l0 64c0 26.5-21.5 48-48 48l-64 0c-26.5 0-48-21.5-48-48l0-64c0-26.5 21.5-48 48-48l8 0 0-32c0-30.9 25.1-56 56-56l152 0 0-40-8 0c-26.5 0-48-21.5-48-48l0-64z'/%3E%3C/svg%3E");
377
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 576 512'%3E%3Cpath d='M208 80c0-26.5 21.5-48 48-48l64 0c26.5 0 48 21.5 48 48l0 64c0 26.5-21.5 48-48 48l-8 0 0 40 152 0c30.9 0 56 25.1 56 56l0 32 8 0c26.5 0 48 21.5 48 48l0 64c0 26.5-21.5 48-48 48l-64 0c-26.5 0-48-21.5-48-48l0-64c0-26.5 21.5-48 48-48l8 0 0-32c0-4.4-3.6-8-8-8l-152 0 0 40 8 0c26.5 0 48 21.5 48 48l0 64c0 26.5-21.5 48-48 48l-64 0c-26.5 0-48-21.5-48-48l0-64c0-26.5 21.5-48 48-48l8 0 0-40-152 0c-4.4 0-8 3.6-8 8l0 32 8 0c26.5 0 48 21.5 48 48l0 64c0 26.5-21.5 48-48 48l-64 0c-26.5 0-48-21.5-48-48l0-64c0-26.5 21.5-48 48-48l8 0 0-32c0-30.9 25.1-56 56-56l152 0 0-40-8 0c-26.5 0-48-21.5-48-48l0-64z'/%3E%3C/svg%3E");
378
+ }