@markii/stdlib 0.11.0 → 0.12.1

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 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
+ }
@@ -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
- * four contracts share. It says nothing about markup or classes; each
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 four from one source, and so a test can
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 four components on
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`
@@ -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 four from one source, and so a test can
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 four components on
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.11.0",
3
+ "version": "0.12.1",
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",