@ksp-gonogo/ui-kit 1.0.0-rc.21 → 1.0.0-rc.25

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.
@@ -15,4 +15,4 @@ export {
15
15
  GRID_MARGIN,
16
16
  gridToPixels
17
17
  };
18
- //# sourceMappingURL=chunk-IZZF25H2.js.map
18
+ //# sourceMappingURL=chunk-SPBHQUHI.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/gridUnits.ts"],"sourcesContent":["/**\n * The width of one dashboard grid column in CSS pixels, an approximation for\n * the dashboard's widest layout (36 columns across): on the dashboard a\n * column's real width is a fraction of the container. Use it to lay a widget\n * out outside the dashboard, such as in a test harness or a docs page.\n *\n * @category Layout\n */\nexport const COL_WIDTH = 32;\n\n/**\n * The height of one dashboard grid row in CSS pixels.\n *\n * @category Layout\n */\nexport const ROW_HEIGHT = 25;\n\n/**\n * The gap between adjacent dashboard grid cells in CSS pixels.\n *\n * @category Layout\n */\nexport const GRID_MARGIN = 8;\n\n/**\n * The pixel box of a `w` by `h` grid-unit tile, as `{ pxW, pxH }`. The margin\n * falls between cells, so `n` cells carry `n - 1` gaps and a single cell\n * carries none.\n *\n * @category Layout\n * @categoryDescription Layout\n * Arranging a widget's content: stacks, rows, clusters, grids, boxes, cards and\n * sections, the spacing scale they share, and the dashboard grid's cell sizes\n * for turning a tile's columns and rows into pixels.\n */\nexport function gridToPixels(\n w: number,\n h: number,\n): {\n pxW: number;\n pxH: number;\n} {\n return {\n pxW: w * COL_WIDTH + (w - 1) * GRID_MARGIN,\n pxH: h * ROW_HEIGHT + (h - 1) * GRID_MARGIN,\n };\n}\n"],"mappings":";AAQO,IAAM,YAAY;AAOlB,IAAM,aAAa;AAOnB,IAAM,cAAc;AAapB,SAAS,aACd,GACA,GAIA;AACA,SAAO;AAAA,IACL,KAAK,IAAI,aAAa,IAAI,KAAK;AAAA,IAC/B,KAAK,IAAI,cAAc,IAAI,KAAK;AAAA,EAClC;AACF;","names":[]}
@@ -1,8 +1,8 @@
1
1
  /**
2
- * The width of one dashboard grid column in CSS pixels, an approximation at
3
- * the `lg` breakpoint (36 columns): on the dashboard a column's real width is a
4
- * fraction of the container. Use it to lay a widget out outside the dashboard,
5
- * such as in a test harness or a docs page.
2
+ * The width of one dashboard grid column in CSS pixels, an approximation for
3
+ * the dashboard's widest layout (36 columns across): on the dashboard a
4
+ * column's real width is a fraction of the container. Use it to lay a widget
5
+ * out outside the dashboard, such as in a test harness or a docs page.
6
6
  *
7
7
  * @category Layout
8
8
  */
package/dist/gridUnits.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  GRID_MARGIN,
4
4
  ROW_HEIGHT,
5
5
  gridToPixels
6
- } from "./chunk-IZZF25H2.js";
6
+ } from "./chunk-SPBHQUHI.js";
7
7
  import "./chunk-G3PMV62Z.js";
8
8
  export {
9
9
  COL_WIDTH,
package/dist/guards.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Build-time guards an Uplink can run against its own source.
2
+ * Build-time guards an Uplink runs against its own source, from one test file
3
+ * in its client.
3
4
  *
4
5
  * Published as `@ksp-gonogo/ui-kit/guards`, separate from `./testing` because
5
6
  * it reads the filesystem (`node:fs`), which would break a browser test runner.
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/guards.ts"],"sourcesContent":["import { readdirSync, readFileSync, statSync } from \"node:fs\";\nimport { join, relative, sep } from \"node:path\";\n\n/**\n * Build-time guards an Uplink can run against its own source.\n *\n * Published as `@ksp-gonogo/ui-kit/guards`, separate from `./testing` because\n * it reads the filesystem (`node:fs`), which would break a browser test runner.\n *\n * ## Why\n *\n * A widget that writes\n *\n * `${closingSpeed.toFixed(1)} m/s`\n *\n * type-checks and renders, but the symbol cannot be dimmed, cannot be kept off\n * a line break, is announced as the letters \"m\", \"slash\", \"s\", and does not\n * follow when the value's ladder changes rung. `<Unit>` solves all four.\n *\n * ## Using it\n *\n * One test file, anywhere your test runner will find it:\n *\n * ```ts\n * import { expectNoHandTypedUnits } from \"@ksp-gonogo/ui-kit/guards\";\n * import { it } from \"vitest\";\n *\n * it(\"renders units through <Unit>, not by typing the symbol\", () => {\n * expectNoHandTypedUnits({ dir: \"src\" });\n * });\n * ```\n *\n * On an existing codebase with offenders already in it, seed a `baseline` and\n * lower it as you convert: a guessed conversion is worse than a hand-typed\n * symbol, because it renders a confident wrong label.\n */\n\n/**\n * The unit symbols the check looks for by default. It is a chosen list and\n * not every unit: a symbol nobody types beside a number, or one short enough\n * to turn up in ordinary prose, is left out.\n *\n * Pass your own through `symbols` when your Uplink registers a unit of its own.\n *\n * @category Testing\n */\nexport const HAND_TYPED_SYMBOLS: readonly string[] = [\n \"m/s²\",\n \"m/s\",\n \"km/h\",\n \"km\",\n \"Mm\",\n \"Gm\",\n \"mm\",\n \"cm\",\n \"kPa\",\n \"MPa\",\n \"kN\",\n \"MN\",\n \"kW\",\n \"MW\",\n \"GW\",\n \"kg\",\n \"°C\",\n \"°\",\n \"%\",\n \"m\",\n \"s\",\n \"t\",\n \"N\",\n \"W\",\n \"f\",\n \"sci\",\n \"rep\",\n \"Mit\",\n \"deg\",\n \"rad\",\n /*\n * Matching is case-sensitive, since single letters like `m` and `W` would\n * otherwise match prose and CSS, so a unit conventionally written both ways\n * (`rpm`, `RPM`) is listed in both spellings.\n */\n \"rpm\",\n \"RPM\",\n \"dB\",\n \"rad/s\",\n \"bit/s\",\n \"J/s\",\n \"N·m\",\n \"m²\",\n \"Pa\",\n \"kg/m³\",\n \"g/m³\",\n];\n\n/**\n * A CSS length or colour is not a readout. `[:(={]` covers a declaration\n * (`width: ...`), a call (`translate(...)`) and an attribute (`offset={...}`);\n * the colour functions cover `hsl(${h}deg ${s}% ${l}%)`.\n */\nconst CSS_PROPERTY =\n /(width|height|left|top|right|bottom|transform|translate|inset|margin|padding|gap|flex|stroke|offset|dasharray|dashoffset|hsl|hsla|rgb|rgba)\\s*[:(={]/i;\n\n/**\n * Somewhere a unit symbol was typed next to a number.\n *\n * @category Testing\n */\nexport interface HandTypedUnit {\n /** Path relative to the scanned directory, with `/` separators. */\n file: string;\n /** 1-based, so it is clickable in a terminal. */\n line: number;\n /** The offending source line, trimmed. */\n source: string;\n /** The symbol that matched. */\n symbol: string;\n}\n\n/**\n * Options for {@link findHandTypedUnits} and {@link expectNoHandTypedUnits}.\n *\n * @category Testing\n */\nexport interface HandTypedUnitOptions {\n /** Directory to scan. Defaults to `src` under the current directory. */\n dir?: string;\n /** Override the symbols looked for. See {@link HAND_TYPED_SYMBOLS}. */\n symbols?: readonly string[];\n /**\n * Per-file allowance, keyed by the path as it appears in a finding. A file\n * at its entry passes, and a file below it throws, so an allowance left\n * higher than needed is reported rather than left open.\n */\n baseline?: Readonly<Record<string, number>>;\n /**\n * Extra paths to skip, matched against the relative path. Tests, snapshots,\n * `node_modules` and build output are already skipped.\n */\n ignore?: (file: string) => boolean;\n}\n\nconst SKIP_DIRS = new Set([\n \"node_modules\",\n \"dist\",\n \"build\",\n \"coverage\",\n \".git\",\n \".turbo\",\n \"__snapshots__\",\n]);\n\nconst SOURCE = /\\.(ts|tsx|js|jsx)$/;\n\n/**\n * Blank out comments, keeping line structure so reported line numbers still\n * point at the source, so prose about the rule is not a breach of it. Not a\n * parser: over-blanking would at worst hide a symbol inside a string literal.\n */\nfunction stripComments(source: string): string[] {\n const out: string[] = [];\n let inBlock = false;\n for (const line of source.split(\"\\n\")) {\n let kept = \"\";\n let i = 0;\n while (i < line.length) {\n if (inBlock) {\n if (line.startsWith(\"*/\", i)) {\n inBlock = false;\n i += 2;\n } else {\n i += 1;\n }\n continue;\n }\n if (line.startsWith(\"/*\", i)) {\n inBlock = true;\n i += 2;\n continue;\n }\n if (line.startsWith(\"//\", i)) break;\n kept += line[i];\n i += 1;\n }\n out.push(kept);\n }\n return out;\n}\n\nfunction isTest(file: string): boolean {\n return file.includes(\".test.\") || file.includes(\".spec.\");\n}\n\nfunction walk(root: string, dir: string, out: string[]): void {\n for (const entry of readdirSync(dir)) {\n const full = join(dir, entry);\n if (statSync(full).isDirectory()) {\n if (!SKIP_DIRS.has(entry)) walk(root, full, out);\n continue;\n }\n if (SOURCE.test(entry)) out.push(relative(root, full).split(sep).join(\"/\"));\n }\n}\n\n/**\n * The pattern every offender takes: a `${...}` interpolation, then optionally\n * one space, then a unit symbol ending at a non-word boundary.\n *\n * Longest symbol first, so `m/s` cannot be matched as a bare `m` and reported\n * with the wrong symbol.\n */\nfunction patternFor(symbols: readonly string[]): RegExp {\n const alternatives = [...symbols].sort((a, b) => b.length - a.length);\n const escaped = alternatives.map((s) =>\n s.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"),\n );\n return new RegExp(`\\\\}\\\\s?(${escaped.join(\"|\")})([^A-Za-z0-9_/]|\\`|$)`);\n}\n\n/**\n * Every place a unit symbol is typed next to a number, under `dir`.\n *\n * Returns findings rather than throwing, so a caller can report them their own\n * way. {@link expectNoHandTypedUnits} is the assertion built on top.\n *\n * @category Testing\n */\nexport function findHandTypedUnits(\n options: HandTypedUnitOptions = {},\n): HandTypedUnit[] {\n const dir = options.dir ?? \"src\";\n const pattern = patternFor(options.symbols ?? HAND_TYPED_SYMBOLS);\n const files: string[] = [];\n walk(dir, dir, files);\n\n const found: HandTypedUnit[] = [];\n for (const file of files) {\n if (isTest(file)) continue;\n if (options.ignore?.(file)) continue;\n const raw = readFileSync(join(dir, file), \"utf8\").split(\"\\n\");\n stripComments(raw.join(\"\\n\")).forEach((code, index) => {\n const match = pattern.exec(code);\n if (match === null) return;\n if (CSS_PROPERTY.test(code)) return;\n found.push({\n file,\n line: index + 1,\n // The raw line, not the blanked form the scan matched against.\n source: raw[index].trim(),\n symbol: match[1],\n });\n });\n }\n return found;\n}\n\n/**\n * Throws when a unit symbol is typed next to a number, with the fix in the\n * message. Silent for a file at its `baseline` entry; a file below its entry\n * throws, so an allowance left higher than needed cannot let the symbol come\n * back unnoticed.\n *\n * @example\n * ```ts\n * import { expectNoHandTypedUnits } from \"@ksp-gonogo/ui-kit/guards\";\n * import { it } from \"vitest\";\n *\n * it(\"renders units through <Unit>, not by typing the symbol\", () => {\n * expectNoHandTypedUnits({ dir: \"src\" });\n * });\n * ```\n *\n * @category Testing\n * @categoryDescription Testing\n * Checks that a widget never types a unit symbol by hand, so every unit it\n * shows comes from the value it describes.\n */\nexport function expectNoHandTypedUnits(\n options: HandTypedUnitOptions = {},\n): void {\n const baseline = options.baseline ?? {};\n const found = findHandTypedUnits(options);\n\n const counts: Record<string, number> = {};\n for (const one of found) counts[one.file] = (counts[one.file] ?? 0) + 1;\n\n const over = found.filter(\n (one) => (counts[one.file] ?? 0) > (baseline[one.file] ?? 0),\n );\n if (over.length > 0) {\n throw new Error(\n `A unit symbol was typed next to a number in ${over.length} place(s). ` +\n \"Render <Unit value={x} /> instead, so the symbol keeps its styling, \" +\n \"follows the value's ladder, and is announced as a word rather than \" +\n \"as letters.\\n\\nWhere a string is genuinely required: speakQuantity \" +\n \"for an accessible name, writeQuantity for visible text that is \" +\n \"MEASURED (an SVG <text>, a canvas label), and nothing else.\\n\\n\" +\n over\n .map((one) => ` ${one.file}:${one.line} ${one.source}`)\n .join(\"\\n\"),\n );\n }\n\n const stale = Object.keys(baseline).filter(\n (file) => (counts[file] ?? 0) < baseline[file],\n );\n if (stale.length > 0) {\n throw new Error(\n \"These are below their baseline, which is good news. Lower or remove \" +\n \"the entry so the gain is locked in, otherwise the symbol is free to \" +\n `come back:\\n${stale\n .map((f) => ` ${f}: now ${counts[f] ?? 0}, baseline ${baseline[f]}`)\n .join(\"\\n\")}`,\n );\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/guards.ts"],"sourcesContent":["import { readdirSync, readFileSync, statSync } from \"node:fs\";\nimport { join, relative, sep } from \"node:path\";\n\n/**\n * Build-time guards an Uplink runs against its own source, from one test file\n * in its client.\n *\n * Published as `@ksp-gonogo/ui-kit/guards`, separate from `./testing` because\n * it reads the filesystem (`node:fs`), which would break a browser test runner.\n *\n * ## Why\n *\n * A widget that writes\n *\n * `${closingSpeed.toFixed(1)} m/s`\n *\n * type-checks and renders, but the symbol cannot be dimmed, cannot be kept off\n * a line break, is announced as the letters \"m\", \"slash\", \"s\", and does not\n * follow when the value's ladder changes rung. `<Unit>` solves all four.\n *\n * ## Using it\n *\n * One test file, anywhere your test runner will find it:\n *\n * ```ts\n * import { expectNoHandTypedUnits } from \"@ksp-gonogo/ui-kit/guards\";\n * import { it } from \"vitest\";\n *\n * it(\"renders units through <Unit>, not by typing the symbol\", () => {\n * expectNoHandTypedUnits({ dir: \"src\" });\n * });\n * ```\n *\n * On an existing codebase with offenders already in it, seed a `baseline` and\n * lower it as you convert: a guessed conversion is worse than a hand-typed\n * symbol, because it renders a confident wrong label.\n */\n\n/**\n * The unit symbols the check looks for by default. It is a chosen list and\n * not every unit: a symbol nobody types beside a number, or one short enough\n * to turn up in ordinary prose, is left out.\n *\n * Pass your own through `symbols` when your Uplink registers a unit of its own.\n *\n * @category Testing\n */\nexport const HAND_TYPED_SYMBOLS: readonly string[] = [\n \"m/s²\",\n \"m/s\",\n \"km/h\",\n \"km\",\n \"Mm\",\n \"Gm\",\n \"mm\",\n \"cm\",\n \"kPa\",\n \"MPa\",\n \"kN\",\n \"MN\",\n \"kW\",\n \"MW\",\n \"GW\",\n \"kg\",\n \"°C\",\n \"°\",\n \"%\",\n \"m\",\n \"s\",\n \"t\",\n \"N\",\n \"W\",\n \"f\",\n \"sci\",\n \"rep\",\n \"Mit\",\n \"deg\",\n \"rad\",\n /*\n * Matching is case-sensitive, since single letters like `m` and `W` would\n * otherwise match prose and CSS, so a unit conventionally written both ways\n * (`rpm`, `RPM`) is listed in both spellings.\n */\n \"rpm\",\n \"RPM\",\n \"dB\",\n \"rad/s\",\n \"bit/s\",\n \"J/s\",\n \"N·m\",\n \"m²\",\n \"Pa\",\n \"kg/m³\",\n \"g/m³\",\n];\n\n/**\n * A CSS length or colour is not a readout. `[:(={]` covers a declaration\n * (`width: ...`), a call (`translate(...)`) and an attribute (`offset={...}`);\n * the colour functions cover `hsl(${h}deg ${s}% ${l}%)`.\n */\nconst CSS_PROPERTY =\n /(width|height|left|top|right|bottom|transform|translate|inset|margin|padding|gap|flex|stroke|offset|dasharray|dashoffset|hsl|hsla|rgb|rgba)\\s*[:(={]/i;\n\n/**\n * Somewhere a unit symbol was typed next to a number.\n *\n * @category Testing\n */\nexport interface HandTypedUnit {\n /** Path relative to the scanned directory, with `/` separators. */\n file: string;\n /** 1-based, so it is clickable in a terminal. */\n line: number;\n /** The offending source line, trimmed. */\n source: string;\n /** The symbol that matched. */\n symbol: string;\n}\n\n/**\n * Options for {@link findHandTypedUnits} and {@link expectNoHandTypedUnits}.\n *\n * @category Testing\n */\nexport interface HandTypedUnitOptions {\n /** Directory to scan. Defaults to `src` under the current directory. */\n dir?: string;\n /** Override the symbols looked for. See {@link HAND_TYPED_SYMBOLS}. */\n symbols?: readonly string[];\n /**\n * Per-file allowance, keyed by the path as it appears in a finding. A file\n * at its entry passes, and a file below it throws, so an allowance left\n * higher than needed is reported rather than left open.\n */\n baseline?: Readonly<Record<string, number>>;\n /**\n * Extra paths to skip, matched against the relative path. Tests, snapshots,\n * `node_modules` and build output are already skipped.\n */\n ignore?: (file: string) => boolean;\n}\n\nconst SKIP_DIRS = new Set([\n \"node_modules\",\n \"dist\",\n \"build\",\n \"coverage\",\n \".git\",\n \".turbo\",\n \"__snapshots__\",\n]);\n\nconst SOURCE = /\\.(ts|tsx|js|jsx)$/;\n\n/**\n * Blank out comments, keeping line structure so reported line numbers still\n * point at the source, so prose about the rule is not a breach of it. Not a\n * parser: over-blanking would at worst hide a symbol inside a string literal.\n */\nfunction stripComments(source: string): string[] {\n const out: string[] = [];\n let inBlock = false;\n for (const line of source.split(\"\\n\")) {\n let kept = \"\";\n let i = 0;\n while (i < line.length) {\n if (inBlock) {\n if (line.startsWith(\"*/\", i)) {\n inBlock = false;\n i += 2;\n } else {\n i += 1;\n }\n continue;\n }\n if (line.startsWith(\"/*\", i)) {\n inBlock = true;\n i += 2;\n continue;\n }\n if (line.startsWith(\"//\", i)) break;\n kept += line[i];\n i += 1;\n }\n out.push(kept);\n }\n return out;\n}\n\nfunction isTest(file: string): boolean {\n return file.includes(\".test.\") || file.includes(\".spec.\");\n}\n\nfunction walk(root: string, dir: string, out: string[]): void {\n for (const entry of readdirSync(dir)) {\n const full = join(dir, entry);\n if (statSync(full).isDirectory()) {\n if (!SKIP_DIRS.has(entry)) walk(root, full, out);\n continue;\n }\n if (SOURCE.test(entry)) out.push(relative(root, full).split(sep).join(\"/\"));\n }\n}\n\n/**\n * The pattern every offender takes: a `${...}` interpolation, then optionally\n * one space, then a unit symbol ending at a non-word boundary.\n *\n * Longest symbol first, so `m/s` cannot be matched as a bare `m` and reported\n * with the wrong symbol.\n */\nfunction patternFor(symbols: readonly string[]): RegExp {\n const alternatives = [...symbols].sort((a, b) => b.length - a.length);\n const escaped = alternatives.map((s) =>\n s.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"),\n );\n return new RegExp(`\\\\}\\\\s?(${escaped.join(\"|\")})([^A-Za-z0-9_/]|\\`|$)`);\n}\n\n/**\n * Every place a unit symbol is typed next to a number, under `dir`.\n *\n * Returns findings rather than throwing, so a caller can report them their own\n * way. {@link expectNoHandTypedUnits} is the assertion built on top.\n *\n * @category Testing\n */\nexport function findHandTypedUnits(\n options: HandTypedUnitOptions = {},\n): HandTypedUnit[] {\n const dir = options.dir ?? \"src\";\n const pattern = patternFor(options.symbols ?? HAND_TYPED_SYMBOLS);\n const files: string[] = [];\n walk(dir, dir, files);\n\n const found: HandTypedUnit[] = [];\n for (const file of files) {\n if (isTest(file)) continue;\n if (options.ignore?.(file)) continue;\n const raw = readFileSync(join(dir, file), \"utf8\").split(\"\\n\");\n stripComments(raw.join(\"\\n\")).forEach((code, index) => {\n const match = pattern.exec(code);\n if (match === null) return;\n if (CSS_PROPERTY.test(code)) return;\n found.push({\n file,\n line: index + 1,\n // The raw line, not the blanked form the scan matched against.\n source: raw[index].trim(),\n symbol: match[1],\n });\n });\n }\n return found;\n}\n\n/**\n * Throws when a unit symbol is typed next to a number, with the fix in the\n * message. Silent for a file at its `baseline` entry; a file below its entry\n * throws, so an allowance left higher than needed cannot let the symbol come\n * back unnoticed.\n *\n * @example\n * ```ts\n * import { expectNoHandTypedUnits } from \"@ksp-gonogo/ui-kit/guards\";\n * import { it } from \"vitest\";\n *\n * it(\"renders units through <Unit>, not by typing the symbol\", () => {\n * expectNoHandTypedUnits({ dir: \"src\" });\n * });\n * ```\n *\n * @category Testing\n * @categoryDescription Testing\n * Checks that a widget never types a unit symbol by hand, so every unit it\n * shows comes from the value it describes.\n */\nexport function expectNoHandTypedUnits(\n options: HandTypedUnitOptions = {},\n): void {\n const baseline = options.baseline ?? {};\n const found = findHandTypedUnits(options);\n\n const counts: Record<string, number> = {};\n for (const one of found) counts[one.file] = (counts[one.file] ?? 0) + 1;\n\n const over = found.filter(\n (one) => (counts[one.file] ?? 0) > (baseline[one.file] ?? 0),\n );\n if (over.length > 0) {\n throw new Error(\n `A unit symbol was typed next to a number in ${over.length} place(s). ` +\n \"Render <Unit value={x} /> instead, so the symbol keeps its styling, \" +\n \"follows the value's ladder, and is announced as a word rather than \" +\n \"as letters.\\n\\nWhere a string is genuinely required: speakQuantity \" +\n \"for an accessible name, writeQuantity for visible text that is \" +\n \"MEASURED (an SVG <text>, a canvas label), and nothing else.\\n\\n\" +\n over\n .map((one) => ` ${one.file}:${one.line} ${one.source}`)\n .join(\"\\n\"),\n );\n }\n\n const stale = Object.keys(baseline).filter(\n (file) => (counts[file] ?? 0) < baseline[file],\n );\n if (stale.length > 0) {\n throw new Error(\n \"These are below their baseline, which is good news. Lower or remove \" +\n \"the entry so the gain is locked in, otherwise the symbol is free to \" +\n `come back:\\n${stale\n .map((f) => ` ${f}: now ${counts[f] ?? 0}, baseline ${baseline[f]}`)\n .join(\"\\n\")}`,\n );\n }\n}\n"],"mappings":"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","names":[]}