@markii/stdlib 0.11.0 → 0.12.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/contracts.js +32 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/table-shape.d.ts +57 -0
- package/dist/table-shape.js +98 -0
- package/dist/text-align.d.ts +4 -4
- package/dist/text-align.js +3 -2
- package/dist/value-format.d.ts +59 -0
- package/dist/value-format.js +301 -0
- package/package.json +1 -1
package/dist/contracts.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { LAYOUT_ATTRIBUTES, layoutWrapperAxis, otherLayoutAxis, } from './layout.js';
|
|
17
17
|
import { TEXT_ALIGN_ATTRIBUTE } from './text-align.js';
|
|
18
|
+
import { DECIMALS_ATTRIBUTE, FORMAT_ATTRIBUTE } from './value-format.js';
|
|
18
19
|
/**
|
|
19
20
|
* Builds one of docs/format.md's layout-wrapper container contracts,
|
|
20
21
|
* `center`/`left`/`right`/`wide`/`narrow`/`full`/`fit`, which are otherwise
|
|
@@ -239,8 +240,10 @@ export const STANDARD_COMPONENTS = {
|
|
|
239
240
|
enum: ['up', 'down', 'flat'],
|
|
240
241
|
description: "Colors/annotates `delta` as an increase, decrease, or no change. Overridden by a bound `data=` object's `trend` field when both are present; absent/invalid means no trend styling.",
|
|
241
242
|
},
|
|
243
|
+
format: FORMAT_ATTRIBUTE,
|
|
244
|
+
decimals: DECIMALS_ATTRIBUTE,
|
|
242
245
|
},
|
|
243
|
-
description: 'A leaf directive rendering a big value plus label, e.g. `::stat{value=42 label="stars" trend=up}`. Data binding (§8): `data=name` resolves against the value store — a number/string value supplies `value` directly; an object may supply `value`/`label`/`delta`/`trend` fields (explicit attributes on the directive always take precedence over the bound object\'s fields). Degrades to `—` when both the attribute and the bound value are absent — never throws.',
|
|
246
|
+
description: 'A leaf directive rendering a big value plus label, e.g. `::stat{value=42 label="stars" trend=up}`. Data binding (§8): `data=name` resolves against the value store — a number/string value supplies `value` directly; an object may supply `value`/`label`/`delta`/`trend` fields (explicit attributes on the directive always take precedence over the bound object\'s fields). Degrades to `—` when both the attribute and the bound value are absent — never throws. `format`/`decimals` (docs/format.md) format the headline value before display.',
|
|
244
247
|
},
|
|
245
248
|
progress: {
|
|
246
249
|
name: 'progress',
|
|
@@ -261,8 +264,10 @@ export const STANDARD_COMPONENTS = {
|
|
|
261
264
|
required: false,
|
|
262
265
|
description: 'Optional caption shown above/alongside the bar. Absent means no caption line is rendered.',
|
|
263
266
|
},
|
|
267
|
+
format: FORMAT_ATTRIBUTE,
|
|
268
|
+
decimals: DECIMALS_ATTRIBUTE,
|
|
264
269
|
},
|
|
265
|
-
description: 'A leaf directive rendering a meter bar, e.g. `::progress{value=3 max=5 label="tasks"}`. Data binding (§8): `data=name` resolves against the value store — a bare number supplies `value` directly; an object may supply `value`/`max` fields (explicit attributes take precedence). Parses defensively and clamps rather than throwing; missing/invalid input renders an empty (0%) bar.',
|
|
270
|
+
description: 'A leaf directive rendering a meter bar, e.g. `::progress{value=3 max=5 label="tasks"}`. Data binding (§8): `data=name` resolves against the value store — a bare number supplies `value` directly; an object may supply `value`/`max` fields (explicit attributes take precedence). Parses defensively and clamps rather than throwing; missing/invalid input renders an empty (0%) bar. `format`/`decimals` (docs/format.md), when present, format the fraction `value/max` for the percent readout in place of the default rounded integer percent; absent means the unchanged default (`42%`).',
|
|
266
271
|
},
|
|
267
272
|
chart: {
|
|
268
273
|
name: 'chart',
|
|
@@ -282,6 +287,31 @@ export const STANDARD_COMPONENTS = {
|
|
|
282
287
|
},
|
|
283
288
|
description: 'A leaf directive rendering a minimal hand-rolled inline SVG chart, e.g. `::chart{kind=line values="1,3,2,5"}`. Sizes to its container — use docs/format.md\'s `width`/`align` layout presets to control its footprint, not a pixel attribute. Data binding (§8): `data=name` resolves against the value store — expects an array of numbers (typically a Lua script returning a table), or an array of `{value}` objects. Non-finite/non-numeric entries are filtered out and the point count is capped; an empty or all-invalid series renders a small neutral empty state rather than a broken chart. Never throws, and never places unescaped text into the emitted SVG markup.',
|
|
284
289
|
},
|
|
290
|
+
table: {
|
|
291
|
+
name: 'table',
|
|
292
|
+
kind: 'leaf',
|
|
293
|
+
attributes: {
|
|
294
|
+
columns: {
|
|
295
|
+
type: 'string',
|
|
296
|
+
required: false,
|
|
297
|
+
description: "Comma-separated key list, shown in that order instead of the bound data's own key order. A key no row has renders an empty cell for that column. Only meaningful for an array-of-objects or single-object binding.",
|
|
298
|
+
},
|
|
299
|
+
caption: {
|
|
300
|
+
type: 'string',
|
|
301
|
+
required: false,
|
|
302
|
+
description: 'Optional caption text shown with the table. Absent means no caption.',
|
|
303
|
+
},
|
|
304
|
+
limit: {
|
|
305
|
+
type: 'string',
|
|
306
|
+
required: false,
|
|
307
|
+
description: 'Maximum number of rows shown, as a positive integer. An absent, non-integer, or non-positive value is ignored and every row is shown.',
|
|
308
|
+
},
|
|
309
|
+
text: TEXT_ALIGN_ATTRIBUTE,
|
|
310
|
+
format: FORMAT_ATTRIBUTE,
|
|
311
|
+
decimals: DECIMALS_ATTRIBUTE,
|
|
312
|
+
},
|
|
313
|
+
description: 'A leaf directive rendering a data-bound table, e.g. `::table{data=users columns="name,role" limit=10}`. Data binding (§8): `data=name` resolves against the value store — an array of objects becomes columns from the union of keys in first-seen order (or `columns=`, when given); an array of arrays becomes rows as given; an array of primitives becomes a single column; a single object becomes key/value rows. `format`/`decimals` (docs/format.md) apply to numeric cells only; non-numeric cells render as plain text regardless. Degrades to the same quiet empty/stale/failure presentation as `stat` for a missing, stale, or failed binding — never throws.',
|
|
314
|
+
},
|
|
285
315
|
row: {
|
|
286
316
|
name: 'row',
|
|
287
317
|
kind: 'container',
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { getContract, STANDARD_COMPONENTS, type AttributeSchema, type ComponentContract, type ComponentKind, } from './contracts.js';
|
|
2
2
|
export { ALIGN_PRESETS, LAYOUT_ATTRIBUTE_KEYS, LAYOUT_ATTRIBUTES, WIDTH_PRESETS, layoutWrapperAxis, otherLayoutAxis, type LayoutAxis, } from './layout.js';
|
|
3
3
|
export { TEXT_ALIGN_ATTRIBUTE, TEXT_ALIGN_COMPONENTS, TEXT_ALIGN_PRESETS, type TextAlignPreset, } from './text-align.js';
|
|
4
|
+
export { formatValue, isNumericLike, FORMAT_ATTRIBUTE, DECIMALS_ATTRIBUTE, type ValueFormat, } from './value-format.js';
|
|
5
|
+
export { deriveTableShape, type TableShape } from './table-shape.js';
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { getContract, STANDARD_COMPONENTS, } from './contracts.js';
|
|
2
2
|
export { ALIGN_PRESETS, LAYOUT_ATTRIBUTE_KEYS, LAYOUT_ATTRIBUTES, WIDTH_PRESETS, layoutWrapperAxis, otherLayoutAxis, } from './layout.js';
|
|
3
3
|
export { TEXT_ALIGN_ATTRIBUTE, TEXT_ALIGN_COMPONENTS, TEXT_ALIGN_PRESETS, } from './text-align.js';
|
|
4
|
+
export { formatValue, isNumericLike, FORMAT_ATTRIBUTE, DECIMALS_ATTRIBUTE, } from './value-format.js';
|
|
5
|
+
export { deriveTableShape } from './table-shape.js';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure "what does a bound value look like as a table" logic behind
|
|
3
|
+
* `::table` (docs/format.md), shared by `@markii/react` and `@markii/html`
|
|
4
|
+
* so the same bound value produces the same rows/columns in both engines —
|
|
5
|
+
* exactly the reasoning `@markii/stdlib`'s `layout.ts` gives for keeping the
|
|
6
|
+
* width/align preset vocabulary in one neutral place instead of two
|
|
7
|
+
* hand-copied renderer implementations.
|
|
8
|
+
*
|
|
9
|
+
* Zero dependency, no rendering concerns: this module only classifies a
|
|
10
|
+
* JS value into rows and (when there are any) column headers. Formatting a
|
|
11
|
+
* cell's value to display text is `formatValue`'s job (`./value-format.ts`);
|
|
12
|
+
* building markup is each renderer's own.
|
|
13
|
+
*/
|
|
14
|
+
/** A bound array of plain objects: columns = the union of keys in first-seen order (or `columns=`, when given), each row's cells aligned to them. */
|
|
15
|
+
export interface ObjectRowsTableShape {
|
|
16
|
+
kind: 'objects';
|
|
17
|
+
columns: string[];
|
|
18
|
+
rows: unknown[][];
|
|
19
|
+
}
|
|
20
|
+
/** A bound array of arrays: rows exactly as given, no column headers. */
|
|
21
|
+
export interface ArrayRowsTableShape {
|
|
22
|
+
kind: 'arrays';
|
|
23
|
+
rows: unknown[][];
|
|
24
|
+
}
|
|
25
|
+
/** A bound array of primitives (numbers, strings, booleans, `null`): one column, one cell per row. */
|
|
26
|
+
export interface PrimitiveRowsTableShape {
|
|
27
|
+
kind: 'primitives';
|
|
28
|
+
rows: unknown[][];
|
|
29
|
+
}
|
|
30
|
+
/** A bound single plain object: one row per key, in `columns=` order when given, otherwise the object's own key order. */
|
|
31
|
+
export interface KeyValueTableShape {
|
|
32
|
+
kind: 'keyvalue';
|
|
33
|
+
rows: Array<[key: string, value: unknown]>;
|
|
34
|
+
}
|
|
35
|
+
/** An empty array, or a bound value that is neither an array nor a plain object (a bare number/string, `null`, `undefined`): nothing to show as a table. */
|
|
36
|
+
export interface EmptyTableShape {
|
|
37
|
+
kind: 'empty';
|
|
38
|
+
}
|
|
39
|
+
export type TableShape = ObjectRowsTableShape | ArrayRowsTableShape | PrimitiveRowsTableShape | KeyValueTableShape | EmptyTableShape;
|
|
40
|
+
/**
|
|
41
|
+
* Classifies a bound `data` value into a `TableShape`, per docs/format.md's
|
|
42
|
+
* four bindable shapes. `columnsOverride` is `::table`'s `columns=`
|
|
43
|
+
* attribute (already split on commas and trimmed by the caller); it
|
|
44
|
+
* reorders/restricts the columns of an `objects`/`keyvalue` shape and is
|
|
45
|
+
* ignored for `arrays`/`primitives`, which have no keys to select from.
|
|
46
|
+
*
|
|
47
|
+
* MAY THROW for a hostile bound value: `Array.isArray`, `Object.keys`, and
|
|
48
|
+
* every property read below go through whatever traps a host put on the
|
|
49
|
+
* object. The guard is at the call site (each renderer's own `safeRead`
|
|
50
|
+
* equivalent), which wraps the whole extraction rather than each read —
|
|
51
|
+
* matching `stat`/`progress`/`chart`'s existing `readStatFields`/
|
|
52
|
+
* `readProgressFields`/`resolvePoints` functions in both engines. Only
|
|
53
|
+
* plain data (strings, the union-of-keys array, cell values already read
|
|
54
|
+
* off the bound object) ever leaves here, so nothing hostile escapes the
|
|
55
|
+
* guard.
|
|
56
|
+
*/
|
|
57
|
+
export declare function deriveTableShape(data: unknown, columnsOverride?: readonly string[]): TableShape;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure "what does a bound value look like as a table" logic behind
|
|
3
|
+
* `::table` (docs/format.md), shared by `@markii/react` and `@markii/html`
|
|
4
|
+
* so the same bound value produces the same rows/columns in both engines —
|
|
5
|
+
* exactly the reasoning `@markii/stdlib`'s `layout.ts` gives for keeping the
|
|
6
|
+
* width/align preset vocabulary in one neutral place instead of two
|
|
7
|
+
* hand-copied renderer implementations.
|
|
8
|
+
*
|
|
9
|
+
* Zero dependency, no rendering concerns: this module only classifies a
|
|
10
|
+
* JS value into rows and (when there are any) column headers. Formatting a
|
|
11
|
+
* cell's value to display text is `formatValue`'s job (`./value-format.ts`);
|
|
12
|
+
* building markup is each renderer's own.
|
|
13
|
+
*/
|
|
14
|
+
/** A plain (non-array, non-`null`) object — the shape test every branch below needs. */
|
|
15
|
+
function isPlainObject(value) {
|
|
16
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Reads `key` off `object` without `in`/bracket access resolving a hostile
|
|
20
|
+
* key (`'__proto__'`, `'constructor'`, `'toString'`) through the prototype
|
|
21
|
+
* chain to an inherited `Object.prototype` member instead of correctly
|
|
22
|
+
* reporting "this row has no such key" — the same defensive pattern
|
|
23
|
+
* `./contracts.ts`'s `getContract` and every renderer's registry lookup use.
|
|
24
|
+
*/
|
|
25
|
+
function readOwn(object, key) {
|
|
26
|
+
return Object.hasOwn(object, key) ? object[key] : undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The union of every plain-object row's own keys, in first-seen order
|
|
30
|
+
* across the whole array — not just the first row's keys — so a row later
|
|
31
|
+
* in the array that carries an extra field still gets its own column
|
|
32
|
+
* rather than being silently dropped. A non-object entry mixed into an
|
|
33
|
+
* otherwise-object array (defensive: the format never asks authors to mix
|
|
34
|
+
* shapes) contributes no keys of its own.
|
|
35
|
+
*/
|
|
36
|
+
function unionKeysInFirstSeenOrder(rows) {
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
const columns = [];
|
|
39
|
+
for (const row of rows) {
|
|
40
|
+
if (!isPlainObject(row))
|
|
41
|
+
continue;
|
|
42
|
+
for (const key of Object.keys(row)) {
|
|
43
|
+
if (!seen.has(key)) {
|
|
44
|
+
seen.add(key);
|
|
45
|
+
columns.push(key);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return columns;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Classifies a bound `data` value into a `TableShape`, per docs/format.md's
|
|
53
|
+
* four bindable shapes. `columnsOverride` is `::table`'s `columns=`
|
|
54
|
+
* attribute (already split on commas and trimmed by the caller); it
|
|
55
|
+
* reorders/restricts the columns of an `objects`/`keyvalue` shape and is
|
|
56
|
+
* ignored for `arrays`/`primitives`, which have no keys to select from.
|
|
57
|
+
*
|
|
58
|
+
* MAY THROW for a hostile bound value: `Array.isArray`, `Object.keys`, and
|
|
59
|
+
* every property read below go through whatever traps a host put on the
|
|
60
|
+
* object. The guard is at the call site (each renderer's own `safeRead`
|
|
61
|
+
* equivalent), which wraps the whole extraction rather than each read —
|
|
62
|
+
* matching `stat`/`progress`/`chart`'s existing `readStatFields`/
|
|
63
|
+
* `readProgressFields`/`resolvePoints` functions in both engines. Only
|
|
64
|
+
* plain data (strings, the union-of-keys array, cell values already read
|
|
65
|
+
* off the bound object) ever leaves here, so nothing hostile escapes the
|
|
66
|
+
* guard.
|
|
67
|
+
*/
|
|
68
|
+
export function deriveTableShape(data, columnsOverride) {
|
|
69
|
+
const hasOverride = columnsOverride !== undefined && columnsOverride.length > 0;
|
|
70
|
+
if (Array.isArray(data)) {
|
|
71
|
+
if (data.length === 0)
|
|
72
|
+
return { kind: 'empty' };
|
|
73
|
+
const first = data[0];
|
|
74
|
+
if (isPlainObject(first)) {
|
|
75
|
+
const columns = hasOverride
|
|
76
|
+
? [...columnsOverride]
|
|
77
|
+
: unionKeysInFirstSeenOrder(data);
|
|
78
|
+
const rows = data.map((row) => isPlainObject(row)
|
|
79
|
+
? columns.map((column) => readOwn(row, column))
|
|
80
|
+
: columns.map(() => undefined));
|
|
81
|
+
return { kind: 'objects', columns, rows };
|
|
82
|
+
}
|
|
83
|
+
if (Array.isArray(first)) {
|
|
84
|
+
const rows = data.map((row) => (Array.isArray(row) ? row : [row]));
|
|
85
|
+
return { kind: 'arrays', rows };
|
|
86
|
+
}
|
|
87
|
+
return { kind: 'primitives', rows: data.map((value) => [value]) };
|
|
88
|
+
}
|
|
89
|
+
if (isPlainObject(data)) {
|
|
90
|
+
const keys = hasOverride ? [...columnsOverride] : Object.keys(data);
|
|
91
|
+
const rows = keys.map((key) => [
|
|
92
|
+
key,
|
|
93
|
+
readOwn(data, key),
|
|
94
|
+
]);
|
|
95
|
+
return { kind: 'keyvalue', rows };
|
|
96
|
+
}
|
|
97
|
+
return { kind: 'empty' };
|
|
98
|
+
}
|
package/dist/text-align.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* those components' contracts.
|
|
12
12
|
*
|
|
13
13
|
* This module carries values only: the closed value list and the schema
|
|
14
|
-
*
|
|
14
|
+
* five contracts share. It says nothing about markup or classes; each
|
|
15
15
|
* renderer's own `layout.ts` maps a value to a class.
|
|
16
16
|
*/
|
|
17
17
|
import type { AttributeSchema } from './contracts.js';
|
|
@@ -21,12 +21,12 @@ export declare const TEXT_ALIGN_PRESETS: readonly ["left", "center", "right"];
|
|
|
21
21
|
export type TextAlignPreset = (typeof TEXT_ALIGN_PRESETS)[number];
|
|
22
22
|
/**
|
|
23
23
|
* The standard components that accept `text` (docs/spec.md §3). Listed here
|
|
24
|
-
* so `./contracts.ts` builds all
|
|
24
|
+
* so `./contracts.ts` builds all five from one source, and so a test can
|
|
25
25
|
* assert the set both renderers honor is exactly this one.
|
|
26
26
|
*/
|
|
27
|
-
export declare const TEXT_ALIGN_COMPONENTS: readonly ["row", "cell", "card", "callout"];
|
|
27
|
+
export declare const TEXT_ALIGN_COMPONENTS: readonly ["row", "cell", "card", "callout", "table"];
|
|
28
28
|
/**
|
|
29
|
-
* The shared `text` schema. Identical wording on all
|
|
29
|
+
* The shared `text` schema. Identical wording on all five components on
|
|
30
30
|
* purpose: the attribute means the same thing everywhere, and a reader who
|
|
31
31
|
* learned it on `callout` should not have to re-read it on `card`. The
|
|
32
32
|
* row-specific cascade is spelled out because it is the one place `text`
|
package/dist/text-align.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export const TEXT_ALIGN_PRESETS = ['left', 'center', 'right'];
|
|
3
3
|
/**
|
|
4
4
|
* The standard components that accept `text` (docs/spec.md §3). Listed here
|
|
5
|
-
* so `./contracts.ts` builds all
|
|
5
|
+
* so `./contracts.ts` builds all five from one source, and so a test can
|
|
6
6
|
* assert the set both renderers honor is exactly this one.
|
|
7
7
|
*/
|
|
8
8
|
export const TEXT_ALIGN_COMPONENTS = [
|
|
@@ -10,9 +10,10 @@ export const TEXT_ALIGN_COMPONENTS = [
|
|
|
10
10
|
'cell',
|
|
11
11
|
'card',
|
|
12
12
|
'callout',
|
|
13
|
+
'table',
|
|
13
14
|
];
|
|
14
15
|
/**
|
|
15
|
-
* The shared `text` schema. Identical wording on all
|
|
16
|
+
* The shared `text` schema. Identical wording on all five components on
|
|
16
17
|
* purpose: the attribute means the same thing everywhere, and a reader who
|
|
17
18
|
* learned it on `callout` should not have to re-read it on `card`. The
|
|
18
19
|
* row-specific cascade is spelled out because it is the one place `text`
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE shared value-formatting primitive for the `format`/`decimals`
|
|
3
|
+
* attributes (docs/format.md, docs/scripting.md's "Where values go"):
|
|
4
|
+
* `:value[...]`, `::stat`, `::progress`, and `::table` all display a stored
|
|
5
|
+
* or attribute value through `formatValue`, so a number, date, or
|
|
6
|
+
* percentage reads identically no matter which component or which engine
|
|
7
|
+
* (`@markii/react`, `@markii/html`) rendered it.
|
|
8
|
+
*
|
|
9
|
+
* Zero dependency beyond the platform's own `Intl` — no third-party
|
|
10
|
+
* date/number library, matching this package's neutral, framework-free
|
|
11
|
+
* posture. Every branch is defensive: a hostile bound value (a throwing
|
|
12
|
+
* getter, a revoked `Proxy`, a `Symbol`, `NaN`, `±Infinity`, a huge number,
|
|
13
|
+
* negative zero) degrades to the plain stringified value rather than
|
|
14
|
+
* throwing, and an unrecognized `format` name behaves exactly like `plain`.
|
|
15
|
+
*
|
|
16
|
+
* Locale is pinned to `'en-US'` rather than left to the host environment:
|
|
17
|
+
* grouping separators, date wording, and the "3 hours ago" phrasing must
|
|
18
|
+
* read the same in a Node test runner, a browser preview, and a
|
|
19
|
+
* statically-exported HTML file, and an unpinned locale would make
|
|
20
|
+
* `@markii/html`'s conformance fixtures depend on the machine running them.
|
|
21
|
+
*/
|
|
22
|
+
import type { AttributeSchema } from './contracts.js';
|
|
23
|
+
/** The closed `format=` vocabulary. Any other string (including a typo or an author's guess) behaves as `'plain'`. */
|
|
24
|
+
export type ValueFormat = 'plain' | 'number' | 'compact' | 'percent' | 'date' | 'relative';
|
|
25
|
+
/**
|
|
26
|
+
* Whether `value` is something a numeric `format` would actually format —
|
|
27
|
+
* a `number` (any value, including `NaN`/`±Infinity`) or a numeric string.
|
|
28
|
+
* `::table`'s "`format`/`decimals` apply to numeric cells only" (docs/
|
|
29
|
+
* format.md) uses this to decide, per cell, whether to route through
|
|
30
|
+
* `formatValue` with the requested format or fall back to plain text.
|
|
31
|
+
*/
|
|
32
|
+
export declare function isNumericLike(value: unknown): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Formats `value` for display, per the closed `format=` vocabulary
|
|
35
|
+
* (docs/format.md): `plain` (default, and the fallback for an unrecognized
|
|
36
|
+
* name) reproduces exactly what `:value[...]` has always rendered;
|
|
37
|
+
* `number`/`compact`/`percent` render a coerced numeric value (falling back
|
|
38
|
+
* to `plain` for non-numeric input) and accept `decimals` (an integer
|
|
39
|
+
* `0`-`6`, otherwise ignored in favor of the format's own default);
|
|
40
|
+
* `date`/`relative` parse an ISO 8601 string or epoch-millisecond input
|
|
41
|
+
* (number or numeric string) into a local date, `relative` phrasing it
|
|
42
|
+
* against `now` (defaulting to render time, `Date.now()`) rather than
|
|
43
|
+
* carrying a clock read into the render itself — a caller in a script
|
|
44
|
+
* context can pass a fixed `now` to keep a render pure.
|
|
45
|
+
*
|
|
46
|
+
* Never throws, for any `value` (including a hostile object, a throwing
|
|
47
|
+
* getter, a `Symbol`, `NaN`, `±Infinity`, a huge number, or negative zero)
|
|
48
|
+
* and any `format`/`decimals` string.
|
|
49
|
+
*/
|
|
50
|
+
export declare function formatValue(value: unknown, format?: string, decimals?: string, now?: number): string;
|
|
51
|
+
/**
|
|
52
|
+
* The shared `format` schema, reused by every contract that renders a
|
|
53
|
+
* value through `formatValue` (`::stat`, `::progress`, `::table`). Wording
|
|
54
|
+
* is identical everywhere on purpose: an author who learned `format` on
|
|
55
|
+
* `stat` should not have to re-read it on `table`.
|
|
56
|
+
*/
|
|
57
|
+
export declare const FORMAT_ATTRIBUTE: AttributeSchema;
|
|
58
|
+
/** The shared `decimals` schema, reused alongside `FORMAT_ATTRIBUTE`. */
|
|
59
|
+
export declare const DECIMALS_ATTRIBUTE: AttributeSchema;
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
const VALUE_FORMATS = [
|
|
2
|
+
'plain',
|
|
3
|
+
'number',
|
|
4
|
+
'compact',
|
|
5
|
+
'percent',
|
|
6
|
+
'date',
|
|
7
|
+
'relative',
|
|
8
|
+
];
|
|
9
|
+
/** The one locale every `Intl` call in this module uses, so formatted output is identical across machines and CI runners. */
|
|
10
|
+
const LOCALE = 'en-US';
|
|
11
|
+
/** `decimals=` is accepted for these three formats only; `date`/`relative`/`plain` ignore it. */
|
|
12
|
+
const DECIMAL_AWARE_FORMATS = new Set([
|
|
13
|
+
'number',
|
|
14
|
+
'compact',
|
|
15
|
+
'percent',
|
|
16
|
+
]);
|
|
17
|
+
/** The default `maximumFractionDigits` per decimal-aware format when `decimals=` is absent or invalid. */
|
|
18
|
+
const DEFAULT_MAX_FRACTION_DIGITS = {
|
|
19
|
+
number: 3, // Intl.NumberFormat's own default for a plain decimal
|
|
20
|
+
compact: 1, // "2.3M" / "12.4k"
|
|
21
|
+
percent: 1, // "12.3%"
|
|
22
|
+
};
|
|
23
|
+
/** `decimals=` is clamped to this closed range; anything else is ignored (falls back to the format's own default). */
|
|
24
|
+
const MIN_DECIMALS = 0;
|
|
25
|
+
const MAX_DECIMALS = 6;
|
|
26
|
+
function normalizeFormat(format) {
|
|
27
|
+
if (format === undefined)
|
|
28
|
+
return 'plain';
|
|
29
|
+
return VALUE_FORMATS.includes(format)
|
|
30
|
+
? format
|
|
31
|
+
: 'plain';
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Parses `decimals=` to an integer in `[0, 6]`, or `undefined` for anything
|
|
35
|
+
* else: absent, empty, negative, above 6, non-integer (`2.5`), or
|
|
36
|
+
* non-numeric text. An out-of-range or malformed value is ignored rather
|
|
37
|
+
* than clamped or rejected — the format's own default decides instead.
|
|
38
|
+
*/
|
|
39
|
+
function parseDecimals(decimals) {
|
|
40
|
+
if (decimals === undefined)
|
|
41
|
+
return undefined;
|
|
42
|
+
const trimmed = decimals.trim();
|
|
43
|
+
if (!/^\d+$/.test(trimmed))
|
|
44
|
+
return undefined;
|
|
45
|
+
const parsed = Number(trimmed);
|
|
46
|
+
if (!Number.isInteger(parsed))
|
|
47
|
+
return undefined;
|
|
48
|
+
if (parsed < MIN_DECIMALS || parsed > MAX_DECIMALS)
|
|
49
|
+
return undefined;
|
|
50
|
+
return parsed;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* `String(value)` for a value that may be actively hostile — a revoked
|
|
54
|
+
* `Proxy`, an object with a throwing `toString`/`Symbol.toPrimitive`, an
|
|
55
|
+
* `Object.create(null)` with no `toString` at all. Degrades to the empty
|
|
56
|
+
* string rather than throwing.
|
|
57
|
+
*/
|
|
58
|
+
function safeString(value) {
|
|
59
|
+
try {
|
|
60
|
+
return String(value);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return '';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The `format: 'plain'` behavior, and the fallback every other format uses
|
|
68
|
+
* for input it cannot make sense of (docs/format.md: "non-numeric input
|
|
69
|
+
* under a numeric format renders the plain stringified value, never an
|
|
70
|
+
* error"). Objects/arrays render as JSON; `null`/`undefined` render as the
|
|
71
|
+
* empty string. Never throws for any value.
|
|
72
|
+
*/
|
|
73
|
+
function plainStringify(value) {
|
|
74
|
+
if (typeof value === 'string')
|
|
75
|
+
return value;
|
|
76
|
+
if (value === null || value === undefined)
|
|
77
|
+
return '';
|
|
78
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
79
|
+
return String(value);
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const json = JSON.stringify(value);
|
|
83
|
+
return json ?? '';
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return safeString(value);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Coerces `value` to a number for a numeric format (`number`/`compact`/
|
|
91
|
+
* `percent`), or `undefined` when it isn't one — a boolean, `null`,
|
|
92
|
+
* `undefined`, an object/array, a `Symbol`, or a non-numeric string all
|
|
93
|
+
* return `undefined` here rather than a guessed number. A `number` typed
|
|
94
|
+
* value (including `NaN`/`±Infinity`) is returned as-is: `Intl.NumberFormat`
|
|
95
|
+
* renders those without throwing (`"NaN"`, `"∞"`), so there is nothing to
|
|
96
|
+
* guard against by rejecting them. Negative zero is normalized to `0`
|
|
97
|
+
* before it ever reaches `Intl`, since a stray `-0` in the middle of a
|
|
98
|
+
* document reads as a bug, not a value worth preserving.
|
|
99
|
+
*/
|
|
100
|
+
function coerceNumericInput(value) {
|
|
101
|
+
if (typeof value === 'number')
|
|
102
|
+
return Object.is(value, -0) ? 0 : value;
|
|
103
|
+
if (typeof value === 'string') {
|
|
104
|
+
const trimmed = value.trim();
|
|
105
|
+
if (trimmed === '')
|
|
106
|
+
return undefined;
|
|
107
|
+
const parsed = Number(trimmed);
|
|
108
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Whether `value` is something a numeric `format` would actually format —
|
|
114
|
+
* a `number` (any value, including `NaN`/`±Infinity`) or a numeric string.
|
|
115
|
+
* `::table`'s "`format`/`decimals` apply to numeric cells only" (docs/
|
|
116
|
+
* format.md) uses this to decide, per cell, whether to route through
|
|
117
|
+
* `formatValue` with the requested format or fall back to plain text.
|
|
118
|
+
*/
|
|
119
|
+
export function isNumericLike(value) {
|
|
120
|
+
return coerceNumericInput(value) !== undefined;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Parses `value` into a `Date` for `date`/`relative`, accepting an ISO 8601
|
|
124
|
+
* string, a numeric string of epoch milliseconds, or a `number` of epoch
|
|
125
|
+
* milliseconds — exactly the two input shapes docs/format.md promises.
|
|
126
|
+
* Returns `undefined` for anything else, or for text/numbers that parse to
|
|
127
|
+
* an invalid date (`NaN`, `±Infinity`, out-of-range milliseconds, garbage
|
|
128
|
+
* text): the `Date` constructor never throws for these, it just produces an
|
|
129
|
+
* "Invalid Date", so the `isNaN` check is what turns that into "not a date"
|
|
130
|
+
* for this module's purposes.
|
|
131
|
+
*/
|
|
132
|
+
function parseDateInput(value) {
|
|
133
|
+
if (typeof value === 'number') {
|
|
134
|
+
if (!Number.isFinite(value))
|
|
135
|
+
return undefined;
|
|
136
|
+
const date = new Date(value);
|
|
137
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
138
|
+
}
|
|
139
|
+
if (typeof value === 'string') {
|
|
140
|
+
const trimmed = value.trim();
|
|
141
|
+
if (trimmed === '')
|
|
142
|
+
return undefined;
|
|
143
|
+
const asEpoch = /^-?\d+$/.test(trimmed) ? Number(trimmed) : undefined;
|
|
144
|
+
const date = new Date(asEpoch ?? trimmed);
|
|
145
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* `Intl.NumberFormat`'s compact notation renders the thousands magnitude as
|
|
151
|
+
* `"12K"` (uppercase); docs/format.md's example is lowercase (`"12.4k"`),
|
|
152
|
+
* following the common "k for thousand, M/B/T for the rest" convention. The
|
|
153
|
+
* uppercase `K` is the only magnitude suffix `Intl` renders that way for
|
|
154
|
+
* `en-US` (millions/billions/trillions are already `M`/`B`/`T`), so a single
|
|
155
|
+
* trailing-letter replacement is enough — never a broader case-fold, which
|
|
156
|
+
* would also mangle a negative sign or digits.
|
|
157
|
+
*/
|
|
158
|
+
function lowercaseThousandsSuffix(formatted) {
|
|
159
|
+
return formatted.endsWith('K') ? `${formatted.slice(0, -1)}k` : formatted;
|
|
160
|
+
}
|
|
161
|
+
/** Buckets a millisecond duration into the coarsest unit `Intl.RelativeTimeFormat` should phrase it in, rounding to the nearest whole unit. */
|
|
162
|
+
function relativeParts(diffMs) {
|
|
163
|
+
const seconds = diffMs / 1000;
|
|
164
|
+
if (Math.abs(seconds) < 60)
|
|
165
|
+
return { value: Math.round(seconds), unit: 'second' };
|
|
166
|
+
const minutes = seconds / 60;
|
|
167
|
+
if (Math.abs(minutes) < 60)
|
|
168
|
+
return { value: Math.round(minutes), unit: 'minute' };
|
|
169
|
+
const hours = minutes / 60;
|
|
170
|
+
if (Math.abs(hours) < 24)
|
|
171
|
+
return { value: Math.round(hours), unit: 'hour' };
|
|
172
|
+
const days = hours / 24;
|
|
173
|
+
if (Math.abs(days) < 30)
|
|
174
|
+
return { value: Math.round(days), unit: 'day' };
|
|
175
|
+
const months = days / 30;
|
|
176
|
+
if (Math.abs(months) < 12)
|
|
177
|
+
return { value: Math.round(months), unit: 'month' };
|
|
178
|
+
const years = days / 365;
|
|
179
|
+
return { value: Math.round(years), unit: 'year' };
|
|
180
|
+
}
|
|
181
|
+
function formatNumeric(format, value, decimals) {
|
|
182
|
+
const num = coerceNumericInput(value);
|
|
183
|
+
if (num === undefined)
|
|
184
|
+
return plainStringify(value);
|
|
185
|
+
const maxFractionDigits = decimals ?? DEFAULT_MAX_FRACTION_DIGITS[format];
|
|
186
|
+
try {
|
|
187
|
+
if (format === 'number') {
|
|
188
|
+
return new Intl.NumberFormat(LOCALE, {
|
|
189
|
+
minimumFractionDigits: decimals,
|
|
190
|
+
maximumFractionDigits: maxFractionDigits,
|
|
191
|
+
}).format(num);
|
|
192
|
+
}
|
|
193
|
+
if (format === 'compact') {
|
|
194
|
+
const formatted = new Intl.NumberFormat(LOCALE, {
|
|
195
|
+
notation: 'compact',
|
|
196
|
+
minimumFractionDigits: decimals,
|
|
197
|
+
maximumFractionDigits: maxFractionDigits,
|
|
198
|
+
}).format(num);
|
|
199
|
+
return lowercaseThousandsSuffix(formatted);
|
|
200
|
+
}
|
|
201
|
+
return new Intl.NumberFormat(LOCALE, {
|
|
202
|
+
style: 'percent',
|
|
203
|
+
minimumFractionDigits: decimals,
|
|
204
|
+
maximumFractionDigits: maxFractionDigits,
|
|
205
|
+
}).format(num);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// `Intl.NumberFormat` does not throw for any finite/NaN/Infinity number
|
|
209
|
+
// with these options, but this is not the place to bet a "never
|
|
210
|
+
// throws" contract on that: any failure here still degrades cleanly.
|
|
211
|
+
return plainStringify(value);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function formatDate(value) {
|
|
215
|
+
const date = parseDateInput(value);
|
|
216
|
+
if (!date)
|
|
217
|
+
return plainStringify(value);
|
|
218
|
+
try {
|
|
219
|
+
return new Intl.DateTimeFormat(LOCALE, { dateStyle: 'medium' }).format(date);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return plainStringify(value);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function formatRelative(value, now) {
|
|
226
|
+
const date = parseDateInput(value);
|
|
227
|
+
if (!date)
|
|
228
|
+
return plainStringify(value);
|
|
229
|
+
try {
|
|
230
|
+
const { value: amount, unit } = relativeParts(date.getTime() - now);
|
|
231
|
+
return new Intl.RelativeTimeFormat(LOCALE, { numeric: 'auto' }).format(amount, unit);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return plainStringify(value);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Formats `value` for display, per the closed `format=` vocabulary
|
|
239
|
+
* (docs/format.md): `plain` (default, and the fallback for an unrecognized
|
|
240
|
+
* name) reproduces exactly what `:value[...]` has always rendered;
|
|
241
|
+
* `number`/`compact`/`percent` render a coerced numeric value (falling back
|
|
242
|
+
* to `plain` for non-numeric input) and accept `decimals` (an integer
|
|
243
|
+
* `0`-`6`, otherwise ignored in favor of the format's own default);
|
|
244
|
+
* `date`/`relative` parse an ISO 8601 string or epoch-millisecond input
|
|
245
|
+
* (number or numeric string) into a local date, `relative` phrasing it
|
|
246
|
+
* against `now` (defaulting to render time, `Date.now()`) rather than
|
|
247
|
+
* carrying a clock read into the render itself — a caller in a script
|
|
248
|
+
* context can pass a fixed `now` to keep a render pure.
|
|
249
|
+
*
|
|
250
|
+
* Never throws, for any `value` (including a hostile object, a throwing
|
|
251
|
+
* getter, a `Symbol`, `NaN`, `±Infinity`, a huge number, or negative zero)
|
|
252
|
+
* and any `format`/`decimals` string.
|
|
253
|
+
*/
|
|
254
|
+
export function formatValue(value, format, decimals, now = Date.now()) {
|
|
255
|
+
const effectiveFormat = normalizeFormat(format);
|
|
256
|
+
if (effectiveFormat === 'plain')
|
|
257
|
+
return plainStringify(value);
|
|
258
|
+
const parsedDecimals = DECIMAL_AWARE_FORMATS.has(effectiveFormat)
|
|
259
|
+
? parseDecimals(decimals)
|
|
260
|
+
: undefined;
|
|
261
|
+
switch (effectiveFormat) {
|
|
262
|
+
case 'number':
|
|
263
|
+
case 'compact':
|
|
264
|
+
case 'percent':
|
|
265
|
+
return formatNumeric(effectiveFormat, value, parsedDecimals);
|
|
266
|
+
case 'date':
|
|
267
|
+
return formatDate(value);
|
|
268
|
+
case 'relative':
|
|
269
|
+
return formatRelative(value, now);
|
|
270
|
+
/* istanbul ignore next -- exhaustiveness guard; normalizeFormat never returns anything else here */
|
|
271
|
+
default:
|
|
272
|
+
return plainStringify(value);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* The shared `format` schema, reused by every contract that renders a
|
|
277
|
+
* value through `formatValue` (`::stat`, `::progress`, `::table`). Wording
|
|
278
|
+
* is identical everywhere on purpose: an author who learned `format` on
|
|
279
|
+
* `stat` should not have to re-read it on `table`.
|
|
280
|
+
*/
|
|
281
|
+
export const FORMAT_ATTRIBUTE = {
|
|
282
|
+
type: 'string',
|
|
283
|
+
required: false,
|
|
284
|
+
enum: VALUE_FORMATS,
|
|
285
|
+
description: 'Formats the displayed value: `plain` (default), `number` (grouped ' +
|
|
286
|
+
'thousands), `compact` (`2.3M`, `12.4k`), `percent` (`0.123` -> ' +
|
|
287
|
+
'`12.3%`), `date` (an ISO 8601 string or epoch milliseconds -> a local ' +
|
|
288
|
+
'date), or `relative` (`3 hours ago`, computed against render time). ' +
|
|
289
|
+
'Non-numeric input under a numeric format, or non-date input under ' +
|
|
290
|
+
'`date`/`relative`, renders the plain value instead of an error; an ' +
|
|
291
|
+
'unrecognized name behaves as `plain`.',
|
|
292
|
+
};
|
|
293
|
+
/** The shared `decimals` schema, reused alongside `FORMAT_ATTRIBUTE`. */
|
|
294
|
+
export const DECIMALS_ATTRIBUTE = {
|
|
295
|
+
type: 'string',
|
|
296
|
+
required: false,
|
|
297
|
+
description: 'Number of decimal places for the `number`, `compact`, and `percent` ' +
|
|
298
|
+
'formats, as an integer `0`-`6`. Ignored for `date`/`relative`/`plain`. ' +
|
|
299
|
+
"An out-of-range or non-integer value is ignored, and the format's own " +
|
|
300
|
+
'default decimal count is used instead.',
|
|
301
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markii/stdlib",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Neutral, framework-agnostic standard component contracts for Mark (.mk.md) — the kind/attribute-schema seam every renderer (e.g. @markii/react) implements against. No React, zero dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"markdown",
|