@promptctl/cc-candybar 1.5.0 → 1.7.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/dist/index.mjs +74 -74
- package/package.json +5 -5
- package/src/config/default-dsl-config.ts +59 -0
- package/src/daemon/cache/git.ts +98 -2
- package/src/daemon/cache/session-usage-store.ts +50 -14
- package/src/daemon/render-payload.ts +98 -1
- package/src/proc/launch.ts +4 -0
- package/src/segments/git.ts +228 -0
- package/src/template-engine/engine.ts +2 -1
- package/src/template-engine/funcs.ts +17 -0
- package/src/template-engine/sparkline.ts +79 -0
package/src/segments/git.ts
CHANGED
|
@@ -16,6 +16,17 @@ export interface AheadBehind {
|
|
|
16
16
|
behind: number;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// [LAW:types-are-the-program] The branch's open PR/MR as the forge reports it.
|
|
20
|
+
// `number` and `url` are the click target; `state` is the forge's status string
|
|
21
|
+
// (GitHub "OPEN", GitLab "opened") — carried so a consumer can color/label it,
|
|
22
|
+
// though resolvePullRequest only ever returns a PR whose state is open (a
|
|
23
|
+
// merged/closed PR for the branch is the domain's `absent`, not a value).
|
|
24
|
+
export interface PullRequest {
|
|
25
|
+
number: number;
|
|
26
|
+
state: string;
|
|
27
|
+
url: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
// [LAW:types-are-the-program] Every on-demand field is an Outcome, so "this
|
|
20
31
|
// value is unknown because the fetch failed" is representable distinct from
|
|
21
32
|
// a real 0/""/basename — the states the old catch-and-substitute blocks
|
|
@@ -37,6 +48,13 @@ export interface GitInfo {
|
|
|
37
48
|
upstream?: Outcome<string>;
|
|
38
49
|
repoName?: Outcome<string>;
|
|
39
50
|
isWorktree?: boolean;
|
|
51
|
+
// [LAW:no-silent-failure] The forge lookup's three outcomes are all kept
|
|
52
|
+
// distinct here: `ok` is an open PR, `absent` is "this branch has none / no
|
|
53
|
+
// forge / no forge CLI", `failed` is "the forge was asked but couldn't
|
|
54
|
+
// answer" (auth, network, API error). The render boundary surfaces `failed`
|
|
55
|
+
// as a VISIBLE marker — collapsing it to `absent` would make a transient
|
|
56
|
+
// outage look like the PR vanished. Undefined = `showPullRequest` was off.
|
|
57
|
+
pullRequest?: Outcome<PullRequest>;
|
|
40
58
|
}
|
|
41
59
|
|
|
42
60
|
// [LAW:one-source-of-truth] The one shape of getGitInfo's `show*` toggles. Each
|
|
@@ -53,6 +71,11 @@ export interface GitInfoOptions {
|
|
|
53
71
|
showStashCount?: boolean;
|
|
54
72
|
showUpstream?: boolean;
|
|
55
73
|
showRepoName?: boolean;
|
|
74
|
+
// Opts into the forge (gh/glab) PR/MR lookup — a network call, so it is the
|
|
75
|
+
// one option whose fetch the daemon caches on a longer, independent TTL than
|
|
76
|
+
// the rest of GitInfo (see src/daemon/cache/git.ts). Never resolved by the
|
|
77
|
+
// inner GitService's computeGitInfo; the cache layer owns the lookup+cache.
|
|
78
|
+
showPullRequest?: boolean;
|
|
56
79
|
}
|
|
57
80
|
|
|
58
81
|
// [LAW:dataflow-not-control-flow] One classifier for every git invocation.
|
|
@@ -89,6 +112,134 @@ function nonEmpty(o: Outcome<string>): Outcome<string> {
|
|
|
89
112
|
return v ? ok(v) : ABSENT;
|
|
90
113
|
}
|
|
91
114
|
|
|
115
|
+
// [LAW:one-type-per-behavior] `gh` and `glab` are two instances of one act:
|
|
116
|
+
// "ask a forge CLI for the branch's PR, fold the typed launch result into an
|
|
117
|
+
// Outcome<PullRequest>." The accept/reject shape table is identical across
|
|
118
|
+
// both — only the no-PR stderr signature and the JSON field names differ — so
|
|
119
|
+
// the classification lives here once and each forge supplies its own
|
|
120
|
+
// (noPrPattern, parse) as data.
|
|
121
|
+
//
|
|
122
|
+
// [LAW:no-silent-failure] The full shape table, enumerated so no input leaks:
|
|
123
|
+
// ok + parse ok (open PR) → ok (the value)
|
|
124
|
+
// ok + parse ok (not open) → absent (branch's PR is done)
|
|
125
|
+
// ok + parse fails → failed (forge answered garbage)
|
|
126
|
+
// non-zero + no-PR stderr → absent (genuine "none for branch")
|
|
127
|
+
// spawn-error ENOENT (no CLI) → absent (no forge integration)
|
|
128
|
+
// spawn-error other (EACCES, …) → failed (CLI present but unlaunchable)
|
|
129
|
+
// non-zero (auth/net/not-a-repo) → failed (forge couldn't answer)
|
|
130
|
+
// timeout / signal / rate-limited → failed (forge couldn't answer)
|
|
131
|
+
export type ForgeName = "github" | "gitlab";
|
|
132
|
+
|
|
133
|
+
// [LAW:types-are-the-program] Extract the host from a git remote, handling the
|
|
134
|
+
// two shapes git uses: scp-like `[user@]host:path` and URL `scheme://[user@]
|
|
135
|
+
// host[:port]/path`. The URL form is checked first — its `host` in a scp regex
|
|
136
|
+
// would mis-capture the scheme (`https` before `://`). Returns null for an
|
|
137
|
+
// unrecognized shape (local path, unknown syntax).
|
|
138
|
+
function remoteHost(remoteUrl: string): string | null {
|
|
139
|
+
const url = remoteUrl.trim();
|
|
140
|
+
const proto = url.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)/i);
|
|
141
|
+
if (proto) return proto[1]!.toLowerCase();
|
|
142
|
+
const scp = url.match(/^(?:[^@/]+@)?([^/:]+):/);
|
|
143
|
+
if (scp) return scp[1]!.toLowerCase();
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// [LAW:types-are-the-program] Branch on the HOST, not a substring of the whole
|
|
148
|
+
// URL — a non-GitLab remote whose path merely contains "gitlab" (a repo named
|
|
149
|
+
// `gitlab`) must not dispatch to glab. Self-hosted GitLab is detected by a
|
|
150
|
+
// `gitlab.`-prefixed host label (gitlab.example.com); a GitLab on an arbitrary
|
|
151
|
+
// hostname is undetectable here and falls through to null (absent), same as
|
|
152
|
+
// GitHub Enterprise on a custom domain.
|
|
153
|
+
export function detectForge(remoteUrl: string): ForgeName | null {
|
|
154
|
+
const host = remoteHost(remoteUrl);
|
|
155
|
+
if (!host) return null;
|
|
156
|
+
if (host === "github.com" || host.endsWith(".github.com")) return "github";
|
|
157
|
+
if (/(^|\.)gitlab\./.test(host)) return "gitlab";
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function classifyForgePr(
|
|
162
|
+
label: string,
|
|
163
|
+
result: LaunchResult,
|
|
164
|
+
noPrPattern: RegExp,
|
|
165
|
+
parse: (stdout: string) => Outcome<PullRequest>,
|
|
166
|
+
): Outcome<PullRequest> {
|
|
167
|
+
if (result.ok) return parse(result.stdout);
|
|
168
|
+
// ENOENT (no forge CLI on PATH) is a static configuration absence, not a
|
|
169
|
+
// transient lookup failure — it never showed a PR, so showing nothing costs
|
|
170
|
+
// nothing. [LAW:no-silent-failure] Every OTHER spawn failure (EACCES,
|
|
171
|
+
// resource limits) means the CLI is present but could not launch — a real
|
|
172
|
+
// failure that must stay visible, so it falls through to the `failed` path.
|
|
173
|
+
if (result.reason === "spawn-error" && /ENOENT/i.test(result.error ?? ""))
|
|
174
|
+
return ABSENT;
|
|
175
|
+
if (result.reason === "non-zero" && noPrPattern.test(result.stderr))
|
|
176
|
+
return ABSENT;
|
|
177
|
+
const detail = [
|
|
178
|
+
result.reason,
|
|
179
|
+
result.exitCode != null ? `exit ${result.exitCode}` : null,
|
|
180
|
+
result.error ?? firstLine(result.stderr),
|
|
181
|
+
]
|
|
182
|
+
.filter(Boolean)
|
|
183
|
+
.join(", ");
|
|
184
|
+
return failed(`${label}: ${detail}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
188
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// `gh pr view --json number,state,url` → one JSON object. Only an OPEN PR is a
|
|
192
|
+
// value; a MERGED/CLOSED PR for the branch is the domain's `absent`.
|
|
193
|
+
export function parseGithubPr(stdout: string): Outcome<PullRequest> {
|
|
194
|
+
let json: unknown;
|
|
195
|
+
try {
|
|
196
|
+
json = JSON.parse(stdout);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
return failed(
|
|
199
|
+
`gh pr view: unparseable JSON (${e instanceof Error ? e.message : String(e)})`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (!isRecord(json)) return failed("gh pr view: JSON is not an object");
|
|
203
|
+
const { number, state, url } = json;
|
|
204
|
+
if (
|
|
205
|
+
typeof number !== "number" ||
|
|
206
|
+
typeof state !== "string" ||
|
|
207
|
+
typeof url !== "string"
|
|
208
|
+
) {
|
|
209
|
+
return failed("gh pr view: missing number/state/url");
|
|
210
|
+
}
|
|
211
|
+
if (state.toUpperCase() !== "OPEN") return ABSENT;
|
|
212
|
+
return ok({ number, state, url });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// `glab mr view --output json` → one JSON object (iid / state / web_url). State
|
|
216
|
+
// "opened" is the open MR; anything else is `absent`. NOTE: verified against
|
|
217
|
+
// glab's documented JSON shape, not runtime-exercised here (glab not installed
|
|
218
|
+
// on the dev machine) — the github path is the runtime-verified one.
|
|
219
|
+
export function parseGitlabMr(stdout: string): Outcome<PullRequest> {
|
|
220
|
+
let json: unknown;
|
|
221
|
+
try {
|
|
222
|
+
json = JSON.parse(stdout);
|
|
223
|
+
} catch (e) {
|
|
224
|
+
return failed(
|
|
225
|
+
`glab mr view: unparseable JSON (${e instanceof Error ? e.message : String(e)})`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (!isRecord(json)) return failed("glab mr view: JSON is not an object");
|
|
229
|
+
const iid = json.iid;
|
|
230
|
+
const state = json.state;
|
|
231
|
+
const url = json.web_url;
|
|
232
|
+
if (
|
|
233
|
+
typeof iid !== "number" ||
|
|
234
|
+
typeof state !== "string" ||
|
|
235
|
+
typeof url !== "string"
|
|
236
|
+
) {
|
|
237
|
+
return failed("glab mr view: missing iid/state/web_url");
|
|
238
|
+
}
|
|
239
|
+
if (state.toLowerCase() !== "opened") return ABSENT;
|
|
240
|
+
return ok({ number: iid, state, url });
|
|
241
|
+
}
|
|
242
|
+
|
|
92
243
|
export class GitService {
|
|
93
244
|
private isGitRepo(workingDir: string): boolean {
|
|
94
245
|
try {
|
|
@@ -407,6 +558,83 @@ export class GitService {
|
|
|
407
558
|
return ok(match?.[1] || path.basename(workingDir));
|
|
408
559
|
}
|
|
409
560
|
|
|
561
|
+
// [LAW:locality-or-seam] Public so the daemon's GitDataProvider can read the
|
|
562
|
+
// remote to fold into its PR cache key (the PR value depends on the remote;
|
|
563
|
+
// a re-pointed origin must be a new key). Raw origin URL (unparsed) — the
|
|
564
|
+
// forge detector reads the host from it. `config --get` exits 1 when unset →
|
|
565
|
+
// `absent` (no remote, hence no forge PR concept), distinct from a failure.
|
|
566
|
+
async getRemoteOriginUrl(workingDir: string): Promise<Outcome<string>> {
|
|
567
|
+
return nonEmpty(
|
|
568
|
+
classify(
|
|
569
|
+
"git config remote.origin.url",
|
|
570
|
+
await this.execGitAsync(["config", "--get", "remote.origin.url"], {
|
|
571
|
+
cwd: workingDir,
|
|
572
|
+
timeout: 2000,
|
|
573
|
+
}),
|
|
574
|
+
"absent",
|
|
575
|
+
),
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// [LAW:single-enforcer] One boundary for forge-CLI spawns. Mirrors
|
|
580
|
+
// execGitAsync but carries the "forge" launch category and a longer timeout
|
|
581
|
+
// (this is a network call, not a local git read). Returns the typed
|
|
582
|
+
// LaunchResult so classifyForgePr maps every termination cause to an Outcome.
|
|
583
|
+
private async execForgeAsync(
|
|
584
|
+
bin: string,
|
|
585
|
+
args: readonly string[],
|
|
586
|
+
options: { cwd: string; timeout: number },
|
|
587
|
+
): Promise<LaunchResult> {
|
|
588
|
+
return launch({
|
|
589
|
+
bin,
|
|
590
|
+
args: [...args],
|
|
591
|
+
cwd: options.cwd,
|
|
592
|
+
env: { ...process.env },
|
|
593
|
+
timeoutMs: options.timeout,
|
|
594
|
+
category: "forge",
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// [LAW:effects-at-boundaries] Resolve the branch's open PR/MR via the forge
|
|
599
|
+
// CLI. Pure dispatch over a remote the CALLER has already read: pick the
|
|
600
|
+
// forge by host, run its CLI, fold the launch result into an Outcome. The
|
|
601
|
+
// remote is a parameter (not read here) so the cache layer can fold it into
|
|
602
|
+
// its key in the same read — no caching here; the daemon's GitDataProvider
|
|
603
|
+
// owns the PR cache + TTL (a network resource wants a longer, independent
|
|
604
|
+
// lifecycle than local git state). `absent` when the host is no recognized
|
|
605
|
+
// forge; the CLI dispatch then classifies the rest.
|
|
606
|
+
async resolvePullRequest(
|
|
607
|
+
workingDir: string,
|
|
608
|
+
remoteUrl: string,
|
|
609
|
+
): Promise<Outcome<PullRequest>> {
|
|
610
|
+
const forge = detectForge(remoteUrl);
|
|
611
|
+
if (forge === "github") {
|
|
612
|
+
return classifyForgePr(
|
|
613
|
+
"gh pr view",
|
|
614
|
+
await this.execForgeAsync(
|
|
615
|
+
"gh",
|
|
616
|
+
["pr", "view", "--json", "number,state,url"],
|
|
617
|
+
{ cwd: workingDir, timeout: 5000 },
|
|
618
|
+
),
|
|
619
|
+
/no (open )?pull requests? found/i,
|
|
620
|
+
parseGithubPr,
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
if (forge === "gitlab") {
|
|
624
|
+
return classifyForgePr(
|
|
625
|
+
"glab mr view",
|
|
626
|
+
await this.execForgeAsync("glab", ["mr", "view", "--output", "json"], {
|
|
627
|
+
cwd: workingDir,
|
|
628
|
+
timeout: 5000,
|
|
629
|
+
}),
|
|
630
|
+
/no (open )?merge requests? (found|available)/i,
|
|
631
|
+
parseGitlabMr,
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
// Recognized neither host → no forge integration for this remote.
|
|
635
|
+
return ABSENT;
|
|
636
|
+
}
|
|
637
|
+
|
|
410
638
|
private isWorktree(workingDir: string): boolean {
|
|
411
639
|
try {
|
|
412
640
|
const gitDir = path.join(workingDir, ".git");
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
// `dict` lets a helper take multiple named inputs through its one dot arg.
|
|
21
21
|
// • richTextFuncs: bold, italic, red, green, … (styling from rich-js).
|
|
22
22
|
// • paletteFuncs (when resolver provided): primary, accent, palette, paletteOver, auto.
|
|
23
|
-
// • ccCandybarFuncs: basename, dirname, int, string, bool, urlEncode
|
|
23
|
+
// • ccCandybarFuncs: basename, dirname, int, string, bool, urlEncode,
|
|
24
|
+
// themes, styles, sparkline.
|
|
24
25
|
// • formatterFuncs: minutesUntilReset (clock-reading numeric primitive),
|
|
25
26
|
// formatInteger, round, formatModelName, shortenModelName. (The cost/token/
|
|
26
27
|
// budget AND duration/time-remaining formatters moved to DSL helper templates
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
shortenModelName,
|
|
18
18
|
} from "../utils/formatters.js";
|
|
19
19
|
import { listResolvablePaletteNames, STRIP_STYLES } from "../themes/policy.js";
|
|
20
|
+
import { renderSparkline, parseSeries } from "./sparkline.js";
|
|
20
21
|
|
|
21
22
|
// [LAW:one-source-of-truth] The DSL `themes()` and `styles()` bindings
|
|
22
23
|
// project the SAME canonical sources the set-state validator consults
|
|
@@ -128,6 +129,22 @@ export function ccCandybarFuncs(): FuncMap {
|
|
|
128
129
|
fn: () => STYLES_LIST,
|
|
129
130
|
argTypes: [],
|
|
130
131
|
},
|
|
132
|
+
|
|
133
|
+
// [LAW:effects-at-boundaries] Pure trend renderer: a numeric series (the
|
|
134
|
+
// daemon-owned ring, projected through the payload as a delimited string)
|
|
135
|
+
// becomes a unicode mini-graph. The series crosses the scalar var-system
|
|
136
|
+
// seam as a string, so the FuncMap slot is "string"; `parseSeries` decodes
|
|
137
|
+
// it and `renderSparkline` draws it — neither accumulates state. The
|
|
138
|
+
// optional trailing "int" slot caps the glyph count to fit a cell (the
|
|
139
|
+
// evaluator validates only supplied args, so `{{ sparkline .series }}` and
|
|
140
|
+
// `{{ sparkline .series 24 }}` are both well-typed). Returns a bare string;
|
|
141
|
+
// the engine lifts it to RichText so the segment's fg/bg palette colors the
|
|
142
|
+
// whole graph — no per-glyph color math here.
|
|
143
|
+
sparkline: {
|
|
144
|
+
fn: (series: string, width?: number) =>
|
|
145
|
+
renderSparkline(parseSeries(series), width),
|
|
146
|
+
argTypes: ["string", "int"],
|
|
147
|
+
},
|
|
131
148
|
};
|
|
132
149
|
}
|
|
133
150
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// [LAW:effects-at-boundaries] A sparkline is a PURE projection: a numeric
|
|
2
|
+
// series in, a string of block glyphs out. It reads no clock, touches no
|
|
3
|
+
// store, accumulates nothing — any history it draws is owned by the daemon
|
|
4
|
+
// cache and handed in as data (cf. session-usage-store's burn-rate ring).
|
|
5
|
+
// [LAW:one-source-of-truth] The renderer operates on number[] (the real
|
|
6
|
+
// domain); the var-system can only carry a scalar across the payload→template
|
|
7
|
+
// seam, so `parseSeries` decodes the delimited string form at that one edge —
|
|
8
|
+
// the wire shape and the domain shape have a single, tested conversion.
|
|
9
|
+
|
|
10
|
+
// The eight-level block ramp, lowest→highest. Index into this is the only
|
|
11
|
+
// place a value's normalized height becomes a glyph.
|
|
12
|
+
export const SPARK_LEVELS = [
|
|
13
|
+
"▁", // ▁
|
|
14
|
+
"▂", // ▂
|
|
15
|
+
"▃", // ▃
|
|
16
|
+
"▄", // ▄
|
|
17
|
+
"▅", // ▅
|
|
18
|
+
"▆", // ▆
|
|
19
|
+
"▇", // ▇
|
|
20
|
+
"█", // █
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
const LEVELS = SPARK_LEVELS.length;
|
|
24
|
+
|
|
25
|
+
// Render a numeric series as a unicode mini-graph.
|
|
26
|
+
//
|
|
27
|
+
// `width` caps the glyph count to fit a cell: the MOST RECENT `width` samples
|
|
28
|
+
// are shown (tail slice), so a fixed-width cell tracks the live tail of a
|
|
29
|
+
// growing series. Omitted → every sample renders; `width <= 0` → empty.
|
|
30
|
+
//
|
|
31
|
+
// Heights are RELATIVE to the rendered window's own min/max — a sparkline shows
|
|
32
|
+
// shape, never absolute magnitude, so the full ramp is always used when the
|
|
33
|
+
// window varies. [LAW:dataflow-not-control-flow] The mapping is one unconditional
|
|
34
|
+
// fold over the values; the only data-driven value is `range`, and a flat
|
|
35
|
+
// window (range === 0) falls to the lowest tier by the same formula's limit
|
|
36
|
+
// (height-above-min is 0 for every sample), not a special-cased branch.
|
|
37
|
+
export function renderSparkline(values: number[], width?: number): string {
|
|
38
|
+
const window =
|
|
39
|
+
width === undefined ? values : width <= 0 ? [] : values.slice(-width);
|
|
40
|
+
if (window.length === 0) return "";
|
|
41
|
+
|
|
42
|
+
let min = window[0]!;
|
|
43
|
+
let max = window[0]!;
|
|
44
|
+
for (const v of window) {
|
|
45
|
+
if (v < min) min = v;
|
|
46
|
+
if (v > max) max = v;
|
|
47
|
+
}
|
|
48
|
+
const range = max - min;
|
|
49
|
+
|
|
50
|
+
let out = "";
|
|
51
|
+
for (const v of window) {
|
|
52
|
+
// range === 0 ⇒ every sample sits at its own min ⇒ height 0 ⇒ lowest tier.
|
|
53
|
+
const idx =
|
|
54
|
+
range === 0 ? 0 : Math.round(((v - min) / range) * (LEVELS - 1));
|
|
55
|
+
out += SPARK_LEVELS[idx]!;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Decode the delimited string a series travels as across the scalar var-system
|
|
61
|
+
// seam into the number[] the renderer consumes. Empty / blank tokens are the
|
|
62
|
+
// genuine "no sample" form (an empty payload field is ""), so they drop; a
|
|
63
|
+
// non-empty, non-numeric token is malformed input and fails LOUDLY rather than
|
|
64
|
+
// being silently skipped into a wrong-shaped graph. [LAW:no-silent-failure]
|
|
65
|
+
export function parseSeries(s: string): number[] {
|
|
66
|
+
const out: number[] = [];
|
|
67
|
+
for (const tok of s.split(",")) {
|
|
68
|
+
const t = tok.trim();
|
|
69
|
+
if (t === "") continue;
|
|
70
|
+
const n = Number(t);
|
|
71
|
+
if (!Number.isFinite(n)) {
|
|
72
|
+
throw new TypeError(
|
|
73
|
+
`sparkline: non-numeric series element ${JSON.stringify(tok)}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
out.push(n);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|