@braccato/core 1.0.0 → 1.2.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/README.md CHANGED
@@ -81,7 +81,7 @@ connects, and everything it was handed by then is applied at once.
81
81
  | `mediaElement` | | `HTMLMediaElement \| null` (get) | `null` | What `source` resolved to. Null while disconnected, and null for a selector that missed. |
82
82
  | `currentTime` | `current-time` | `number` | `0` | Playback position in **seconds**. Writing it renders the view again, so whoever holds the clock drives the lyrics by writing this. |
83
83
  | `playing` | `playing` | `boolean` | `false` | A paused view animates differently from a playing one. |
84
- | `tickOptions` | | `ElementTickOptions` | `{}` | The rest of a tick: four offsets taken off the clock before it is matched, whether passive scrolling is on, and when the clock was sampled. |
84
+ | `tickOptions` | | `ElementTickOptions` | `{}` | The rest of a tick: four offsets taken off the clock before it is matched, whether passive scrolling is on, when the clock was sampled, and the rate the song is playing at. |
85
85
  | `theme` | `theme` | `string` | `""` | A compiled stylesheet. See Theming. |
86
86
  | `host` | | `Partial<LyricsRendererHost>` | `{}` | Overrides for what the renderer asks of its surroundings. Every member has a default. Writing it while connected rebuilds the view. |
87
87
  | `renderer` | | `LyricsRenderer \| null` (get) | `null` | The renderer underneath, for the day the tag runs out. A different one after every reconnection. |
@@ -179,6 +179,13 @@ The ones a theme reaches for first. `variables.css` declares the rest.
179
179
  `line-height`. Every word is given the glow, so a theme that wants it to mean something selects on
180
180
  `data-long-word`, which the module sets on any part held past `blyrics-long-word-threshold`.
181
181
 
182
+ The instrumental ripple is the one place a property carries geometry rather than a value.
183
+ `--blyrics-instrumental-wave-path-high` and `--blyrics-instrumental-wave-path-low` are the two shapes
184
+ it morphs between, each a `path()`, and a theme redrawing them is how the wave changes amplitude or
185
+ frequency. Both must use the same commands in the same order with the same number of arguments: a
186
+ browser only interpolates two paths smoothly when their command sequences match, and a mismatched
187
+ pair snaps at the halfway point instead of flowing.
188
+
182
189
  ### Class names
183
190
 
184
191
  These are published API rather than implementation. Renaming one costs a migration rather than a
@@ -256,6 +263,12 @@ either is dropped and the getter keeps reporting what the binding last read. Dro
256
263
  reported, because a consumer who bound a source and left their own frame loop running would otherwise
257
264
  be told about it sixty times a second. Unbind and the clock goes back to whoever asked for it.
258
265
 
266
+ The rate is read off the media element too, and passed on as `tickOptions.playbackRate`, so a song at
267
+ half or double speed animates at half or double speed rather than sweeping at 1x and being corrected
268
+ on the next tick. A consumer driving the clock itself sets that option instead. Only the animations
269
+ that follow the song are scaled: a line's exit, a word's fade and the scroll between lines keep the
270
+ timing the theme asked for at every rate.
271
+
259
272
  A reading the media element has not refreshed yet is carried forward at the playback rate it was
260
273
  taken at, capped at 100ms of frame time. That cap is what covers a stall: the view runs at most 100ms
261
274
  past the last real reading and then waits with it. What it costs is a step backwards when the clock
package/dist/README.md CHANGED
@@ -81,7 +81,7 @@ connects, and everything it was handed by then is applied at once.
81
81
  | `mediaElement` | | `HTMLMediaElement \| null` (get) | `null` | What `source` resolved to. Null while disconnected, and null for a selector that missed. |
82
82
  | `currentTime` | `current-time` | `number` | `0` | Playback position in **seconds**. Writing it renders the view again, so whoever holds the clock drives the lyrics by writing this. |
83
83
  | `playing` | `playing` | `boolean` | `false` | A paused view animates differently from a playing one. |
84
- | `tickOptions` | | `ElementTickOptions` | `{}` | The rest of a tick: four offsets taken off the clock before it is matched, whether passive scrolling is on, and when the clock was sampled. |
84
+ | `tickOptions` | | `ElementTickOptions` | `{}` | The rest of a tick: four offsets taken off the clock before it is matched, whether passive scrolling is on, when the clock was sampled, and the rate the song is playing at. |
85
85
  | `theme` | `theme` | `string` | `""` | A compiled stylesheet. See Theming. |
86
86
  | `host` | | `Partial<LyricsRendererHost>` | `{}` | Overrides for what the renderer asks of its surroundings. Every member has a default. Writing it while connected rebuilds the view. |
87
87
  | `renderer` | | `LyricsRenderer \| null` (get) | `null` | The renderer underneath, for the day the tag runs out. A different one after every reconnection. |
@@ -179,6 +179,13 @@ The ones a theme reaches for first. `variables.css` declares the rest.
179
179
  `line-height`. Every word is given the glow, so a theme that wants it to mean something selects on
180
180
  `data-long-word`, which the module sets on any part held past `blyrics-long-word-threshold`.
181
181
 
182
+ The instrumental ripple is the one place a property carries geometry rather than a value.
183
+ `--blyrics-instrumental-wave-path-high` and `--blyrics-instrumental-wave-path-low` are the two shapes
184
+ it morphs between, each a `path()`, and a theme redrawing them is how the wave changes amplitude or
185
+ frequency. Both must use the same commands in the same order with the same number of arguments: a
186
+ browser only interpolates two paths smoothly when their command sequences match, and a mismatched
187
+ pair snaps at the halfway point instead of flowing.
188
+
182
189
  ### Class names
183
190
 
184
191
  These are published API rather than implementation. Renaming one costs a migration rather than a
@@ -256,6 +263,12 @@ either is dropped and the getter keeps reporting what the binding last read. Dro
256
263
  reported, because a consumer who bound a source and left their own frame loop running would otherwise
257
264
  be told about it sixty times a second. Unbind and the clock goes back to whoever asked for it.
258
265
 
266
+ The rate is read off the media element too, and passed on as `tickOptions.playbackRate`, so a song at
267
+ half or double speed animates at half or double speed rather than sweeping at 1x and being corrected
268
+ on the next tick. A consumer driving the clock itself sets that option instead. Only the animations
269
+ that follow the song are scaled: a line's exit, a word's fade and the scroll between lines keep the
270
+ timing the theme asked for at every rate.
271
+
259
272
  A reading the media element has not refreshed yet is carried forward at the playback rate it was
260
273
  taken at, capped at 100ms of frame time. That cap is what covers a stall: the view runs at most 100ms
261
274
  past the last real reading and then waits with it. What it costs is a step backwards when the clock
package/dist/element.js CHANGED
@@ -422,8 +422,15 @@ export class BraccatoLyricsElement extends HTMLElement {
422
422
  // consumer whose clock runs before the lyrics arrive is the ordinary case rather than a fault.
423
423
  if (renderer === null || renderer.container === null)
424
424
  return;
425
+ // The rate a bound media element is playing at is the element's to report, the same way the
426
+ // clock is, so it overrides what the consumer wrote for exactly as long as the binding lasts.
427
+ const boundRate = this.#media === null ? undefined : this.#media.playbackRate;
425
428
  // The play state last, so a consumer writing plain JavaScript cannot answer that question twice.
426
- renderer.tick(this.#currentTimeS, { ...this.#tickOptions, isPlaying: this.#playing });
429
+ renderer.tick(this.#currentTimeS, {
430
+ ...this.#tickOptions,
431
+ ...(boundRate === undefined ? {} : { playbackRate: boundRate }),
432
+ isPlaying: this.#playing,
433
+ });
427
434
  }
428
435
  #upgradeProperty(name) {
429
436
  // Written through the class rather than `this`: TypeScript refuses an indexed write to a
package/dist/engine.d.ts CHANGED
@@ -56,6 +56,7 @@ export interface AnimationEngineInstance extends AnimEngineViewState {
56
56
  * from a tick, so it has no options object to read.
57
57
  */
58
58
  passiveScrollEnabled: boolean;
59
+ playbackRate: number;
59
60
  passiveRAFId: number | null;
60
61
  pendingLyricsUpdateFrame: number | null;
61
62
  learnedAnimationTimingOffsetMs: number;
@@ -183,6 +184,8 @@ interface AnimationConfig {
183
184
  waveFrom: string;
184
185
  waveTo: string;
185
186
  waveEasing: string;
187
+ wavePathHigh: string;
188
+ wavePathLow: string;
186
189
  waveOscillationDurationMs: number;
187
190
  waveOscillationEasing: string;
188
191
  };
package/dist/engine.js CHANGED
@@ -17,6 +17,7 @@
17
17
  // module is bundled into the isolated world and the page world separately, so those are two clocks
18
18
  // that never meet. `themeSettings.ts` holds the third thing under that rule.
19
19
  import { ANIMATING_CLASS, CURRENT_LYRICS_CLASS, FOOTER_CLASS, LINE_CLASS, PAUSED_CLASS, USER_SCROLLING_CLASS, } from "./constants.js";
20
+ import { INSTRUMENTAL_WAVE_PATH_HIGH, INSTRUMENTAL_WAVE_PATH_LOW } from "./instrumental.js";
20
21
  import { registerThemeSetting } from "./themeSettings.js";
21
22
  import { clamp, getRelativeLayoutBounds, positiveModulo, roundedMs, toMs } from "./util.js";
22
23
  const NO_LYRICS_ELEMENT_LOG = "No lyrics element found on the page, skipping lyrics injection";
@@ -145,6 +146,7 @@ export function createAnimationEngineInstance(engineDocument, engineWindow, host
145
146
  cachedCSSValues: new Map(),
146
147
  cachedAnimationSettings: null,
147
148
  passiveScrollEnabled: false,
149
+ playbackRate: 1,
148
150
  passiveRAFId: null,
149
151
  pendingLyricsUpdateFrame: null,
150
152
  learnedAnimationTimingOffsetMs: 0,
@@ -325,8 +327,8 @@ const WORD_HIGHLIGHT_SELECTOR = ".blyrics-word-highlight";
325
327
  const INSTRUMENTAL_FILL_SELECTOR = ".blyrics--instrumental-fill";
326
328
  const INSTRUMENTAL_WAVE_CLIP_SELECTOR = ".blyrics--wave-clip";
327
329
  const INSTRUMENTAL_WAVE_PATH_SELECTOR = ".blyrics--wave-path";
328
- const INSTRUMENTAL_WAVE_PATH_HIGH = 'path("M -4 3 Q 1 2 5 3 Q 10 4 14 3 Q 18 2 22 3 Q 26 4 30 3 L 30 4 L -4 4 Z")';
329
- const INSTRUMENTAL_WAVE_PATH_LOW = 'path("M -4 3 Q 1 4 5 3 Q 10 2 14 3 Q 18 4 22 3 Q 26 2 30 3 L 30 4 L -4 4 Z")';
330
+ const INSTRUMENTAL_WAVE_PATH_HIGH_FALLBACK = `path("${INSTRUMENTAL_WAVE_PATH_HIGH}")`;
331
+ const INSTRUMENTAL_WAVE_PATH_LOW_FALLBACK = `path("${INSTRUMENTAL_WAVE_PATH_LOW}")`;
330
332
  const LINE_SCROLL_INDEX_PROPERTY = "--blyrics-line-scroll-index";
331
333
  const LINE_SCROLL_ACTIVE_INDEX_PROPERTY = "--blyrics-line-scroll-active-index";
332
334
  const LINE_SCROLL_RELATIVE_INDEX_PROPERTY = "--blyrics-line-scroll-relative-index";
@@ -351,8 +353,29 @@ function trackLyricAnimationTiming(engine, animation, timing) {
351
353
  ...timing,
352
354
  appliedTimingOffsetMs: timing.appliedTimingOffsetMs ?? engine.learnedAnimationTimingOffsetMs,
353
355
  });
356
+ // Being tracked is what makes an animation the song's rather than the interface's, so it is also
357
+ // what decides which ones follow the song's rate.
358
+ animation.playbackRate = engine.playbackRate;
354
359
  return animation;
355
360
  }
361
+ /**
362
+ * Puts the animations already running onto a new rate. Setting `playbackRate` keeps `currentTime`,
363
+ * so each one carries on from where the song left it rather than restarting.
364
+ */
365
+ function applyPlaybackRateToRunningAnimations(engine) {
366
+ for (const line of engine.lines) {
367
+ for (const animation of line.animations) {
368
+ if (animationTimingTracks.has(animation))
369
+ animation.playbackRate = engine.playbackRate;
370
+ }
371
+ for (const part of line.parts) {
372
+ for (const animation of part.animations) {
373
+ if (animationTimingTracks.has(animation))
374
+ animation.playbackRate = engine.playbackRate;
375
+ }
376
+ }
377
+ }
378
+ }
356
379
  function correctedAnimationTimeMs(targetTimeMs, appliedTimingOffsetMs, maxTimeMs) {
357
380
  const scheduledTimeMs = targetTimeMs - appliedTimingOffsetMs;
358
381
  return maxTimeMs === undefined ? scheduledTimeMs : Math.min(scheduledTimeMs, maxTimeMs);
@@ -834,9 +857,9 @@ function startInstrumentalAnimations(engine, lineData, config, currentTime, appl
834
857
  fill: "both",
835
858
  }, { appliedTimingOffsetMs, offsetMs: 0 });
836
859
  waveOscillationAnimation = animateInstrumentalChild(engine, lineData, INSTRUMENTAL_WAVE_PATH_SELECTOR, [
837
- { d: INSTRUMENTAL_WAVE_PATH_HIGH },
838
- { d: INSTRUMENTAL_WAVE_PATH_LOW, offset: 0.5 },
839
- { d: INSTRUMENTAL_WAVE_PATH_HIGH },
860
+ { d: config.instrumental.wavePathHigh },
861
+ { d: config.instrumental.wavePathLow, offset: 0.5 },
862
+ { d: config.instrumental.wavePathHigh },
840
863
  ], {
841
864
  duration: config.instrumental.waveOscillationDurationMs,
842
865
  easing: config.instrumental.waveOscillationEasing,
@@ -994,6 +1017,8 @@ function readAnimationConfig(engine, lyricsElement) {
994
1017
  waveFrom: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-wave-transform-from", "scaleY(1.2)"),
995
1018
  waveTo: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-wave-transform-to", "scaleY(0.0001)"),
996
1019
  waveEasing: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-wave-easing", "ease-in"),
1020
+ wavePathHigh: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-wave-path-high", INSTRUMENTAL_WAVE_PATH_HIGH_FALLBACK),
1021
+ wavePathLow: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-wave-path-low", INSTRUMENTAL_WAVE_PATH_LOW_FALLBACK),
997
1022
  waveOscillationDurationMs: getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-instrumental-wave-oscillation-duration", "1.25s"),
998
1023
  waveOscillationEasing: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-wave-oscillation-easing", "ease-in-out"),
999
1024
  },
@@ -1452,14 +1477,24 @@ export function resolveTickOptions(options) {
1452
1477
  richsyncOffsetTrim: options.richsyncOffsetTrim ?? 0,
1453
1478
  lineOffsetTrim: options.lineOffsetTrim ?? 0,
1454
1479
  passiveScrollEnabled: options.passiveScrollEnabled ?? false,
1480
+ playbackRate: resolvePlaybackRate(options.playbackRate),
1455
1481
  };
1456
1482
  }
1483
+ // A rate of zero or less would freeze every animation that follows the song, which is a second
1484
+ // answer to the question `isPlaying` already answers.
1485
+ function resolvePlaybackRate(rate) {
1486
+ return rate !== undefined && Number.isFinite(rate) && rate > 0 ? rate : 1;
1487
+ }
1457
1488
  /**
1458
1489
  * Renders one view against a tick with nothing left out.
1459
1490
  */
1460
1491
  export function tickView(engine, currentTime, options) {
1461
1492
  const { eventCreationTime, isPlaying, smoothScroll } = options;
1462
1493
  engine.passiveScrollEnabled = options.passiveScrollEnabled;
1494
+ if (engine.playbackRate !== options.playbackRate) {
1495
+ engine.playbackRate = options.playbackRate;
1496
+ applyPlaybackRateToRunningAnimations(engine);
1497
+ }
1463
1498
  const now = Date.now();
1464
1499
  if (currentTime === 0 && !isPlaying) {
1465
1500
  return "ok";
@@ -1528,6 +1563,7 @@ export function tickView(engine, currentTime, options) {
1528
1563
  }
1529
1564
  const tabRendererHeight = engine.cachedTabRendererHeight ?? tabRenderer.getBoundingClientRect().height;
1530
1565
  let scrollTop = tabRenderer.scrollTop;
1566
+ const maxScrollTop = Math.max(0, tabRenderer.scrollHeight - tabRenderer.clientHeight);
1531
1567
  if (animationConfig.enabled.scroll) {
1532
1568
  updateVisibleLyricWillChange(engine, lines, scrollTop, engine.pendingLineScroll?.toScrollTop ?? scrollTop, tabRendererHeight);
1533
1569
  }
@@ -1681,8 +1717,9 @@ export function tickView(engine, currentTime, options) {
1681
1717
  scrollPos = Math.max(scrollPos, lastActiveLyric.position - tabRendererHeight + lastActiveLyric.height);
1682
1718
  // Make sure top of last active lyric is visible.
1683
1719
  scrollPos = Math.min(scrollPos, lastActiveLyric.position);
1684
- // Make sure we're not trying to scroll to negative values
1685
- scrollPos = Math.max(0, scrollPos);
1720
+ // Past either end the browser clamps the write and reports nothing, leaving the view aiming
1721
+ // at a position it never reached and re-aiming once per remaining line.
1722
+ scrollPos = clamp(scrollPos, 0, maxScrollTop);
1686
1723
  if (ENABLE_DEBUG_RENDER.getBooleanValue()) {
1687
1724
  let transform = engine.window.getComputedStyle(lyricsElement).transform;
1688
1725
  const matrix = new engine.window.DOMMatrix(transform);
@@ -1867,6 +1904,9 @@ function applyScrollPadding(engine) {
1867
1904
  });
1868
1905
  engine.document.documentElement.style.setProperty("--blyrics-padding-top", top + "px");
1869
1906
  engine.document.documentElement.style.setProperty("--blyrics-padding-bottom", bottom + "px");
1907
+ // Inline, because a theme's own `.blyrics-container { padding }` is appended after the package's
1908
+ // stylesheet at the same weight and would otherwise take this room away.
1909
+ lyricsElement.style.setProperty("padding-bottom", bottom + "px");
1870
1910
  }
1871
1911
  /**
1872
1912
  * Re-reads the view's layout: the scroll padding first, then the line positions the padding moved.
@@ -1,3 +1,5 @@
1
+ export declare const INSTRUMENTAL_WAVE_PATH_HIGH = "M -4 3 Q 1 2 5 3 Q 10 4 14 3 Q 18 2 22 3 Q 26 4 30 3 L 30 4 L -4 4 Z";
2
+ export declare const INSTRUMENTAL_WAVE_PATH_LOW = "M -4 3 Q 1 4 5 3 Q 10 2 14 3 Q 18 4 22 3 Q 26 2 30 3 L 30 4 L -4 4 Z";
1
3
  /**
2
4
  * Creates an HTML element representing an instrumental break in the lyrics.
3
5
  *
@@ -1,3 +1,5 @@
1
+ export const INSTRUMENTAL_WAVE_PATH_HIGH = "M -4 3 Q 1 2 5 3 Q 10 4 14 3 Q 18 2 22 3 Q 26 4 30 3 L 30 4 L -4 4 Z";
2
+ export const INSTRUMENTAL_WAVE_PATH_LOW = "M -4 3 Q 1 4 5 3 Q 10 2 14 3 Q 18 4 22 3 Q 26 2 30 3 L 30 4 L -4 4 Z";
1
3
  /**
2
4
  * Creates an HTML element representing an instrumental break in the lyrics.
3
5
  *
@@ -56,8 +58,7 @@ export function createInstrumentalElement(doc, container, durationMs, lineIndex)
56
58
  // This only contains the surface water. It closes at y=4.
57
59
  const wavePath = doc.createElementNS(svgNS, "path");
58
60
  wavePath.classList.add("blyrics--wave-path");
59
- // Initial draw (matches the 0% keyframe below)
60
- wavePath.setAttribute("d", "M -4 3 Q 1 2 5 3 Q 10 4 14 3 Q 18 2 22 3 Q 26 4 30 3 L 30 4 L -4 4 Z");
61
+ wavePath.setAttribute("d", INSTRUMENTAL_WAVE_PATH_HIGH);
61
62
  clipPath.appendChild(wavePath);
62
63
  defs.appendChild(clipPath);
63
64
  svg.appendChild(defs);
package/dist/renderer.js CHANGED
@@ -269,6 +269,10 @@ export function createLyricsRenderer(rendererOptions) {
269
269
  // Everything the engine resolved off the document was resolved against the theme that just
270
270
  // went away.
271
271
  clearEngineStyleCaches(engine);
272
+ // The target scroll position is one of the settings a theme carries, and the scroll padding is
273
+ // sized against it, so a theme that moves the target moves the layout even when no rule in it
274
+ // does.
275
+ measure();
272
276
  return needsLyricRebuild;
273
277
  },
274
278
  tick(currentTimeS, options) {
@@ -69,6 +69,8 @@
69
69
 
70
70
  /* Prepare the WAAPI flatten transition. */
71
71
  transform: scaleY(1.2);
72
+
73
+ d: var(--blyrics-instrumental-wave-path-high);
72
74
  }
73
75
 
74
76
  /* Trigger the Flattening */
@@ -90,14 +92,3 @@
90
92
  transition-timing-function: ease-out;
91
93
  transition-delay: 0s;
92
94
  }
93
-
94
- /* Update Keyframes for the "Split" shape */
95
- /* These paths must close at 'L 30 4 L -4 4' to match the static rect overlap */
96
- @keyframes blyrics-wave {
97
- 0%, 100% {
98
- d: path("M -4 3 Q 1 2 5 3 Q 10 4 14 3 Q 18 2 22 3 Q 26 4 30 3 L 30 4 L -4 4 Z");
99
- }
100
- 50% {
101
- d: path("M -4 3 Q 1 4 5 3 Q 10 2 14 3 Q 18 4 22 3 Q 26 2 30 3 L 30 4 L -4 4 Z");
102
- }
103
- }
@@ -117,6 +117,8 @@
117
117
  --blyrics-instrumental-wave-transform-from: scaleY(1.2);
118
118
  --blyrics-instrumental-wave-transform-to: scaleY(0.0001);
119
119
  --blyrics-instrumental-wave-easing: ease-in;
120
+ --blyrics-instrumental-wave-path-high: path("M -4 3 Q 1 2 5 3 Q 10 4 14 3 Q 18 2 22 3 Q 26 4 30 3 L 30 4 L -4 4 Z");
121
+ --blyrics-instrumental-wave-path-low: path("M -4 3 Q 1 4 5 3 Q 10 2 14 3 Q 18 4 22 3 Q 26 2 30 3 L 30 4 L -4 4 Z");
120
122
  --blyrics-instrumental-wave-oscillation-duration: 1.25s;
121
123
  --blyrics-instrumental-wave-oscillation-easing: ease-in-out;
122
124
 
package/dist/types.d.ts CHANGED
@@ -28,6 +28,13 @@ export interface TickOptions {
28
28
  * false.
29
29
  */
30
30
  passiveScrollEnabled?: boolean;
31
+ /**
32
+ * How fast the song moves through its own timeline, as a multiple of real time. Defaults to 1, and
33
+ * zero or less reads as 1 because `isPlaying` is what says a song is stopped. Scales the animations
34
+ * that follow the song; a line's exit, a word's fade and the scroll between lines are the
35
+ * interface's own gestures and keep the timing the theme asked for at every rate.
36
+ */
37
+ playbackRate?: number;
31
38
  }
32
39
  /**
33
40
  * A tick with nothing left out. The engine reads every field unconditionally, so the defaults are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braccato/core",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Synchronized lyrics renderer with word-by-word animations",
5
5
  "type": "module",
6
6
  "license": "MIT",