@ai-matrx/kit 0.7.4 → 0.8.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.
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @ai-matrx/kit/format — THE one home for the tiny display formatters that
3
+ * every Matrx surface, in every repo, needs and that nothing owns.
4
+ *
5
+ * WHY THIS FILE EXISTS (the H1 finding, 2026-09-07 duplication census). These
6
+ * formatters were duplicated *between packages*: `@ai-matrx/meet` and
7
+ * `@ai-matrx/messaging` each defined `formatDuration` + `getInitials`,
8
+ * `@ai-matrx/associations` and `@ai-matrx/diff` each defined
9
+ * `formatRelativeTime`, and `@ai-matrx/media` hid `formatFileSize` inside a
10
+ * React upload component. Because the fleet's packages disagreed about who
11
+ * owned them, ~65 host twins across matrx-frontend / matrx-extend /
12
+ * matrx-local / matrx-games / aidream's two apps had *no correct package to
13
+ * point at* — a settings page cannot take a dependency on the video-calling
14
+ * package to print a duration.
15
+ *
16
+ * WHY KIT. `@ai-matrx/kit` has ZERO sibling dependencies (verified with
17
+ * `node scripts/check_ts_sibling_graph.mjs --order`), so it sits at the very
18
+ * bottom of the sibling DAG: meet, messaging, associations, diff, media and
19
+ * print can all depend on it without creating a cycle, and so can every host.
20
+ * It is also, by charter, the "little primitives every Matrx app speaks"
21
+ * package. This is not a new package — new packages need Arman's approval.
22
+ *
23
+ * THE UNIT LAW. A duration formatter that takes a bare `number` is a bug
24
+ * waiting to happen: the fleet's twins variously took milliseconds, seconds
25
+ * and minutes, and two of them were wrong by 1000×-worth of confusion. There
26
+ * is deliberately NO `formatDuration` here. The unit is in the name:
27
+ * `formatDurationMs`, `formatDurationSeconds`, `formatDurationMinutes`.
28
+ *
29
+ * Everything in this module is pure, synchronous, and free of React, DOM and
30
+ * `Intl` implicit-locale surprises except where a style explicitly asks for
31
+ * `Intl.RelativeTimeFormat`. Every time-dependent function takes an explicit
32
+ * `now` so tests never depend on the clock.
33
+ */
34
+ type TimestampInput = string | number | Date | null | undefined;
35
+ /** Parse any backend timestamp into a Date, or `null` when unparseable. */
36
+ declare function parseTimestamp(value: TimestampInput): Date | null;
37
+ /** Absolute local date+time, e.g. "Jun 13, 2026, 9:32 AM". */
38
+ declare function formatAbsoluteDate(value: TimestampInput, options?: Intl.DateTimeFormatOptions, fallback?: string): string;
39
+ /**
40
+ * The three relative-time voices the fleet actually speaks. They are display
41
+ * decisions, not implementations — one body serves all three.
42
+ *
43
+ * - `"short"` — "2m ago", "3d ago". The dense default (from `@ai-matrx/diff`).
44
+ * - `"long"` — "2 minutes ago", "3 days ago".
45
+ * - `"intl"` — `Intl.RelativeTimeFormat` with `numeric: "auto"`, so the
46
+ * viewer's locale conventions apply and "yesterday" reads as "yesterday"
47
+ * (from `@ai-matrx/associations`). Sub-minute reads "just now".
48
+ */
49
+ type RelativeTimeStyle = "short" | "long" | "intl";
50
+ interface RelativeTimeOptions {
51
+ /** Default `"short"`. */
52
+ style?: RelativeTimeStyle | undefined;
53
+ /** Injected clock. Default `Date.now()` — pass it in tests. */
54
+ now?: number | undefined;
55
+ /** Returned for null / unparseable input. Default `"—"`. */
56
+ fallback?: string | undefined;
57
+ /**
58
+ * Echo the raw input instead of `fallback` when it cannot be parsed —
59
+ * degraded, never broken. What the comments face wants: an odd server string
60
+ * is more useful on screen than an em-dash that hides it.
61
+ */
62
+ fallbackToInput?: boolean | undefined;
63
+ }
64
+ /**
65
+ * "2m ago" / "2 minutes ago" / a locale-aware "2 minutes ago", falling back to
66
+ * an absolute local date past a year. Timezone-agnostic by construction (a
67
+ * pure epoch difference over `parseTimestamp`'s corrected instant).
68
+ *
69
+ * Future timestamps read "just now" in `"short"`/`"long"` (a clock skew of a
70
+ * few seconds must not print "in 3 seconds"); `"intl"` formats them properly
71
+ * ("in 5 minutes") because that is the whole point of asking for `Intl`.
72
+ */
73
+ declare function formatRelativeTime(value: TimestampInput, options?: RelativeTimeOptions): string;
74
+ /**
75
+ * The three duration voices the fleet actually speaks. Picked once here so a
76
+ * call duration reads the same in the meeting, the history list and the
77
+ * notification.
78
+ *
79
+ * - `"clock"` (default) — `0:00`, `9:04`, `1:02:33`. For anything a human
80
+ * watches tick: recordings, calls, media players, timers.
81
+ * - `"compact"` — `250ms`, `5.2s`, `5m 30s`, `1h 02m`. For elapsed work:
82
+ * job runs, request timings, step durations. Sub-second is honest about
83
+ * being sub-second rather than collapsing to `0:00`.
84
+ * - `"coarse"` — `45 min`, `1h 30m`. For things measured in minutes and read
85
+ * at a glance: podcast episodes, lesson lengths, estimates. Never prints
86
+ * seconds.
87
+ */
88
+ type DurationStyle = "clock" | "compact" | "coarse";
89
+ interface DurationOptions {
90
+ /** Default `"clock"`. */
91
+ style?: DurationStyle | undefined;
92
+ /**
93
+ * Returned for `null` / `undefined` / non-finite input. Default `"0:00"` for
94
+ * `"clock"` and `"—"` for `"compact"` / `"coarse"`.
95
+ */
96
+ fallback?: string | undefined;
97
+ /**
98
+ * Keep the sign on a negative duration (`-1:23`) instead of clamping to
99
+ * zero. Default `false` — a negative elapsed time is nearly always a clock
100
+ * bug, and `0:00` is the honest reading. Opt in where a signed offset is the
101
+ * actual quantity (a field format for a stored `interval`, say).
102
+ */
103
+ signed?: boolean | undefined;
104
+ }
105
+ /**
106
+ * THE canonical duration formatter. Milliseconds in, a chosen voice out.
107
+ *
108
+ * `null`, `undefined`, `NaN` and `Infinity` all take the fallback — never
109
+ * `NaN:NaN`, which is what a null start time produces, and never a confident
110
+ * `0:00` for "we do not know".
111
+ */
112
+ declare function formatDurationMs(ms: number | null | undefined, options?: DurationOptions): string;
113
+ /** Seconds in. See {@link formatDurationMs}. */
114
+ declare function formatDurationSeconds(seconds: number | null | undefined, options?: DurationOptions): string;
115
+ /** Minutes in. See {@link formatDurationMs}. */
116
+ declare function formatDurationMinutes(minutes: number | null | undefined, options?: DurationOptions): string;
117
+ /**
118
+ * Duration between two timestamps, in milliseconds — `null` when the start is
119
+ * missing or either end is unparseable. An absent `to` means "still running",
120
+ * so it measures to `now`. Pair with {@link formatDurationMs}.
121
+ */
122
+ declare function durationMsBetween(from: TimestampInput, to: TimestampInput, now?: number): number | null;
123
+ interface FileSizeOptions {
124
+ /** Returned for `null` / `undefined` / non-finite / negative. Default `"—"`. */
125
+ fallback?: string | undefined;
126
+ }
127
+ /**
128
+ * `512` → `"512 B"`, `2048` → `"2.0 KB"`, `15360` → `"15 KB"`.
129
+ *
130
+ * THE DISPLAY DECISIONS, made once (unified 2026-09-07 from six host twins and
131
+ * `@ai-matrx/media`'s copy, which was the richest):
132
+ *
133
+ * 1. Binary units (1024), labelled with the short SI-ish names the whole fleet
134
+ * already used — `B`/`KB`/`MB`/`GB`/`TB`, never `"Bytes"`.
135
+ * 2. One decimal below 10 in a unit (`2.0 KB`, `9.9 MB`), whole numbers at or
136
+ * above it (`15 KB`, `340 MB`) — three significant figures is the most a
137
+ * file size is ever worth, and `1.5 GB` versus `1536.0 MB` is the whole
138
+ * point of the unit.
139
+ * 3. Whole bytes below 1 KB — `"512 B"`, never `"512.0 B"`.
140
+ * 4. `null` / `undefined` / `NaN` / `Infinity` / negative all collapse to an
141
+ * em-dash, NOT to `"0 B"`. A corrupt or mid-upload `size` row rendering as
142
+ * a confident, wrong `"0 B"` is a screen telling a lie; "size unknown" is
143
+ * the honest reading. (`0` itself is a real size and prints `"0 B"`.)
144
+ */
145
+ declare function formatFileSize(bytes: number | null | undefined, options?: FileSizeOptions): string;
146
+ interface InitialsOptions {
147
+ /** Returned when nothing usable is left. Default `"?"`. */
148
+ fallback?: string | undefined;
149
+ }
150
+ /**
151
+ * `"Ana Rivera"` → `"AR"`, `"Ana"` → `"A"`, `"Ana Maria Rivera"` → `"AR"`,
152
+ * `"ana@example.com"` → `"A"`, `" "` → `"?"`.
153
+ *
154
+ * THE DISPLAY DECISION, made once: a multi-part name takes FIRST + LAST, not
155
+ * first + second. `"Ana Maria Rivera"` is `AR`, because the family name is the
156
+ * half a reader recognises. (Six matrx-frontend twins took first + second and
157
+ * printed `AM`; the two package copies both took first + last, and both were
158
+ * tested. First + last wins.)
159
+ *
160
+ * Pass the email as the value when there is no name — a single token yields
161
+ * its first character, which is exactly what the host twins did by hand.
162
+ */
163
+ declare function getInitials(value: string | null | undefined, options?: InitialsOptions): string;
164
+ /**
165
+ * A stable palette index for an avatar with no image. Deterministic on the
166
+ * seed, so the same person is the same colour on every device and every
167
+ * reload — a random colour per render is a surprisingly loud bug.
168
+ */
169
+ declare function avatarPaletteIndex(seed: string, buckets?: number): number;
170
+
171
+ export { type DurationOptions, type DurationStyle, type FileSizeOptions, type InitialsOptions, type RelativeTimeOptions, type RelativeTimeStyle, type TimestampInput, avatarPaletteIndex, durationMsBetween, formatAbsoluteDate, formatDurationMinutes, formatDurationMs, formatDurationSeconds, formatFileSize, formatRelativeTime, getInitials, parseTimestamp };
package/dist/format.js ADDED
@@ -0,0 +1,189 @@
1
+ // src/format.ts
2
+ function hasTimezoneDesignator(value) {
3
+ if (/[zZ]$/.test(value)) return true;
4
+ if (/[+-]\d{2}(:?\d{2})?$/.test(value)) return true;
5
+ if (/\b(GMT|UTC)\b/i.test(value)) return true;
6
+ return false;
7
+ }
8
+ function normalizeTimestampString(raw) {
9
+ const value = raw.trim();
10
+ if (!value) return value;
11
+ if (!/\d{1,2}:\d{2}/.test(value)) return value;
12
+ if (hasTimezoneDesignator(value)) return value;
13
+ return `${value.replace(" ", "T")}Z`;
14
+ }
15
+ function parseTimestamp(value) {
16
+ if (value === null || value === void 0) return null;
17
+ if (value instanceof Date) {
18
+ return Number.isNaN(value.getTime()) ? null : value;
19
+ }
20
+ if (typeof value === "number") {
21
+ const fromNumber = new Date(value);
22
+ return Number.isNaN(fromNumber.getTime()) ? null : fromNumber;
23
+ }
24
+ if (typeof value !== "string") return null;
25
+ const normalized = normalizeTimestampString(value);
26
+ if (!normalized) return null;
27
+ const parsed = new Date(normalized);
28
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
29
+ }
30
+ var DEFAULT_ABSOLUTE_OPTIONS = {
31
+ year: "numeric",
32
+ month: "short",
33
+ day: "numeric",
34
+ hour: "numeric",
35
+ minute: "2-digit"
36
+ };
37
+ function formatAbsoluteDate(value, options = DEFAULT_ABSOLUTE_OPTIONS, fallback = "\u2014") {
38
+ const parsed = parseTimestamp(value);
39
+ if (!parsed) return fallback;
40
+ return parsed.toLocaleString(void 0, options);
41
+ }
42
+ var RELATIVE_UNITS = [
43
+ { limit: 6e4, divisor: 1e3, short: "s", long: "second" },
44
+ { limit: 36e5, divisor: 6e4, short: "m", long: "minute" },
45
+ { limit: 864e5, divisor: 36e5, short: "h", long: "hour" },
46
+ { limit: 6048e5, divisor: 864e5, short: "d", long: "day" },
47
+ { limit: 2592e6, divisor: 6048e5, short: "w", long: "week" },
48
+ { limit: 31536e6, divisor: 2592e6, short: "mo", long: "month" }
49
+ ];
50
+ var INTL_UNITS = [
51
+ { unit: "year", ms: 365 * 24 * 60 * 60 * 1e3 },
52
+ { unit: "month", ms: 30 * 24 * 60 * 60 * 1e3 },
53
+ { unit: "week", ms: 7 * 24 * 60 * 60 * 1e3 },
54
+ { unit: "day", ms: 24 * 60 * 60 * 1e3 },
55
+ { unit: "hour", ms: 60 * 60 * 1e3 },
56
+ { unit: "minute", ms: 60 * 1e3 }
57
+ ];
58
+ function formatRelativeTime(value, options = {}) {
59
+ const style = options.style ?? "short";
60
+ const now = options.now ?? Date.now();
61
+ const parsed = parseTimestamp(value);
62
+ if (!parsed) {
63
+ if (options.fallbackToInput === true && typeof value === "string") return value;
64
+ return options.fallback ?? "\u2014";
65
+ }
66
+ if (style === "intl") {
67
+ const delta = parsed.getTime() - now;
68
+ const magnitude = Math.abs(delta);
69
+ if (magnitude < 6e4) return "just now";
70
+ const rtf = new Intl.RelativeTimeFormat(void 0, { numeric: "auto" });
71
+ for (const { unit, ms } of INTL_UNITS) {
72
+ if (magnitude >= ms) return rtf.format(Math.trunc(delta / ms), unit);
73
+ }
74
+ return "just now";
75
+ }
76
+ const elapsed = now - parsed.getTime();
77
+ if (elapsed < 0) return "just now";
78
+ for (const unit of RELATIVE_UNITS) {
79
+ if (elapsed < unit.limit) {
80
+ const count = Math.max(1, Math.floor(elapsed / unit.divisor));
81
+ return style === "long" ? `${count} ${unit.long}${count === 1 ? "" : "s"} ago` : `${count}${unit.short} ago`;
82
+ }
83
+ }
84
+ return formatAbsoluteDate(parsed);
85
+ }
86
+ var pad2 = (value) => value.toString().padStart(2, "0");
87
+ function clockBody(totalSeconds) {
88
+ const seconds = totalSeconds % 60;
89
+ const minutes = Math.floor(totalSeconds / 60) % 60;
90
+ const hours = Math.floor(totalSeconds / 3600);
91
+ return hours > 0 ? `${hours}:${pad2(minutes)}:${pad2(seconds)}` : `${minutes}:${pad2(seconds)}`;
92
+ }
93
+ function compactBody(ms) {
94
+ if (ms < 1e3) return `${Math.round(ms)}ms`;
95
+ const totalSeconds = ms / 1e3;
96
+ if (totalSeconds < 60) {
97
+ return totalSeconds < 10 ? `${totalSeconds.toFixed(1)}s` : `${Math.round(totalSeconds)}s`;
98
+ }
99
+ const whole = Math.round(totalSeconds);
100
+ const minutes = Math.floor(whole / 60);
101
+ if (minutes < 60) return `${minutes}m ${pad2(whole % 60)}s`;
102
+ const hours = Math.floor(minutes / 60);
103
+ return `${hours}h ${pad2(minutes % 60)}m`;
104
+ }
105
+ function coarseBody(ms) {
106
+ const minutes = Math.round(ms / 6e4);
107
+ if (minutes < 1) return "< 1 min";
108
+ if (minutes < 60) return `${minutes} min`;
109
+ const hours = Math.floor(minutes / 60);
110
+ const rest = minutes % 60;
111
+ return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`;
112
+ }
113
+ function formatDurationMs(ms, options = {}) {
114
+ const style = options.style ?? "clock";
115
+ const fallback = options.fallback ?? (style === "clock" ? "0:00" : "\u2014");
116
+ if (ms === null || ms === void 0 || !Number.isFinite(ms)) return fallback;
117
+ const negative = ms < 0;
118
+ if (negative && options.signed !== true) {
119
+ return style === "clock" ? clockBody(0) : fallback;
120
+ }
121
+ const magnitude = Math.abs(ms);
122
+ const body = style === "clock" ? clockBody(Math.floor(magnitude / 1e3)) : style === "compact" ? compactBody(magnitude) : coarseBody(magnitude);
123
+ return negative ? `-${body}` : body;
124
+ }
125
+ function formatDurationSeconds(seconds, options = {}) {
126
+ if (seconds === null || seconds === void 0 || !Number.isFinite(seconds)) {
127
+ return formatDurationMs(seconds, options);
128
+ }
129
+ return formatDurationMs(seconds * 1e3, options);
130
+ }
131
+ function formatDurationMinutes(minutes, options = {}) {
132
+ if (minutes === null || minutes === void 0 || !Number.isFinite(minutes)) {
133
+ return formatDurationMs(minutes, options);
134
+ }
135
+ return formatDurationMs(minutes * 6e4, options);
136
+ }
137
+ function durationMsBetween(from, to, now = Date.now()) {
138
+ const start = parseTimestamp(from);
139
+ if (!start) return null;
140
+ if (to === null || to === void 0) return Math.max(0, now - start.getTime());
141
+ const end = parseTimestamp(to);
142
+ if (!end) return null;
143
+ return Math.max(0, end.getTime() - start.getTime());
144
+ }
145
+ var SIZE_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"];
146
+ function formatFileSize(bytes, options = {}) {
147
+ const fallback = options.fallback ?? "\u2014";
148
+ if (bytes === null || bytes === void 0 || !Number.isFinite(bytes) || bytes < 0) {
149
+ return fallback;
150
+ }
151
+ if (bytes < 1024) return `${Math.round(bytes)} B`;
152
+ let value = bytes;
153
+ let unit = 0;
154
+ while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
155
+ value /= 1024;
156
+ unit += 1;
157
+ }
158
+ const rendered = value >= 10 ? String(Math.round(value)) : value.toFixed(1);
159
+ return `${rendered} ${SIZE_UNITS[unit]}`;
160
+ }
161
+ function getInitials(value, options = {}) {
162
+ const fallback = options.fallback ?? "?";
163
+ if (typeof value !== "string") return fallback;
164
+ const parts = value.trim().split(/\s+/).filter((part) => part.length > 0);
165
+ if (parts.length === 0) return fallback;
166
+ const first = parts[0]?.charAt(0) ?? "";
167
+ const last = parts.length > 1 ? parts[parts.length - 1]?.charAt(0) ?? "" : "";
168
+ return `${first}${last}`.toUpperCase() || fallback;
169
+ }
170
+ function avatarPaletteIndex(seed, buckets = 8) {
171
+ let hash = 0;
172
+ for (let index = 0; index < seed.length; index += 1) {
173
+ hash = hash * 31 + seed.charCodeAt(index) | 0;
174
+ }
175
+ return Math.abs(hash) % buckets;
176
+ }
177
+ export {
178
+ avatarPaletteIndex,
179
+ durationMsBetween,
180
+ formatAbsoluteDate,
181
+ formatDurationMinutes,
182
+ formatDurationMs,
183
+ formatDurationSeconds,
184
+ formatFileSize,
185
+ formatRelativeTime,
186
+ getInitials,
187
+ parseTimestamp
188
+ };
189
+ //# sourceMappingURL=format.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/format.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/format — THE one home for the tiny display formatters that\n * every Matrx surface, in every repo, needs and that nothing owns.\n *\n * WHY THIS FILE EXISTS (the H1 finding, 2026-09-07 duplication census). These\n * formatters were duplicated *between packages*: `@ai-matrx/meet` and\n * `@ai-matrx/messaging` each defined `formatDuration` + `getInitials`,\n * `@ai-matrx/associations` and `@ai-matrx/diff` each defined\n * `formatRelativeTime`, and `@ai-matrx/media` hid `formatFileSize` inside a\n * React upload component. Because the fleet's packages disagreed about who\n * owned them, ~65 host twins across matrx-frontend / matrx-extend /\n * matrx-local / matrx-games / aidream's two apps had *no correct package to\n * point at* — a settings page cannot take a dependency on the video-calling\n * package to print a duration.\n *\n * WHY KIT. `@ai-matrx/kit` has ZERO sibling dependencies (verified with\n * `node scripts/check_ts_sibling_graph.mjs --order`), so it sits at the very\n * bottom of the sibling DAG: meet, messaging, associations, diff, media and\n * print can all depend on it without creating a cycle, and so can every host.\n * It is also, by charter, the \"little primitives every Matrx app speaks\"\n * package. This is not a new package — new packages need Arman's approval.\n *\n * THE UNIT LAW. A duration formatter that takes a bare `number` is a bug\n * waiting to happen: the fleet's twins variously took milliseconds, seconds\n * and minutes, and two of them were wrong by 1000×-worth of confusion. There\n * is deliberately NO `formatDuration` here. The unit is in the name:\n * `formatDurationMs`, `formatDurationSeconds`, `formatDurationMinutes`.\n *\n * Everything in this module is pure, synchronous, and free of React, DOM and\n * `Intl` implicit-locale surprises except where a style explicitly asks for\n * `Intl.RelativeTimeFormat`. Every time-dependent function takes an explicit\n * `now` so tests never depend on the clock.\n */\n\n// ───────────────────────── timestamps ─────────────────────────\n\nexport type TimestampInput = string | number | Date | null | undefined;\n\n/** True when a string already carries a timezone designator (Z or ±offset). */\nfunction hasTimezoneDesignator(value: string): boolean {\n if (/[zZ]$/.test(value)) return true;\n if (/[+-]\\d{2}(:?\\d{2})?$/.test(value)) return true;\n if (/\\b(GMT|UTC)\\b/i.test(value)) return true;\n return false;\n}\n\n/**\n * Normalise a raw timestamp string so `new Date()` parses it as an absolute\n * instant.\n *\n * THE CORRECTION THIS CARRIES (from `@ai-matrx/diff`, the richest of the\n * twins). Postgres serialises `timestamp with time zone` as\n * \"2026-06-13T16:32:26+00:00\" (parses correctly) and `timestamp without time\n * zone` as \"2026-06-13T16:32:26\" — which, per the ES spec, `new Date()` parses\n * as LOCAL time. Our backend writes those columns in UTC, so a bare\n * `new Date(...)` is wrong by the viewer's offset: the classic \"times are off\n * by N hours\" report. A zone-less string that HAS a time component is treated\n * as UTC; date-only strings are left untouched (a calendar day is not a\n * timestamp).\n */\nfunction normalizeTimestampString(raw: string): string {\n const value = raw.trim();\n if (!value) return value;\n if (!/\\d{1,2}:\\d{2}/.test(value)) return value;\n if (hasTimezoneDesignator(value)) return value;\n return `${value.replace(\" \", \"T\")}Z`;\n}\n\n/** Parse any backend timestamp into a Date, or `null` when unparseable. */\nexport function parseTimestamp(value: TimestampInput): Date | null {\n if (value === null || value === undefined) return null;\n if (value instanceof Date) {\n return Number.isNaN(value.getTime()) ? null : value;\n }\n if (typeof value === \"number\") {\n const fromNumber = new Date(value);\n return Number.isNaN(fromNumber.getTime()) ? null : fromNumber;\n }\n if (typeof value !== \"string\") return null;\n const normalized = normalizeTimestampString(value);\n if (!normalized) return null;\n const parsed = new Date(normalized);\n return Number.isNaN(parsed.getTime()) ? null : parsed;\n}\n\nconst DEFAULT_ABSOLUTE_OPTIONS: Intl.DateTimeFormatOptions = {\n year: \"numeric\",\n month: \"short\",\n day: \"numeric\",\n hour: \"numeric\",\n minute: \"2-digit\",\n};\n\n/** Absolute local date+time, e.g. \"Jun 13, 2026, 9:32 AM\". */\nexport function formatAbsoluteDate(\n value: TimestampInput,\n options: Intl.DateTimeFormatOptions = DEFAULT_ABSOLUTE_OPTIONS,\n fallback = \"—\",\n): string {\n const parsed = parseTimestamp(value);\n if (!parsed) return fallback;\n return parsed.toLocaleString(undefined, options);\n}\n\n// ───────────────────────── relative time ─────────────────────────\n\nconst RELATIVE_UNITS: readonly {\n limit: number;\n divisor: number;\n short: string;\n long: string;\n}[] = [\n { limit: 60_000, divisor: 1000, short: \"s\", long: \"second\" },\n { limit: 3_600_000, divisor: 60_000, short: \"m\", long: \"minute\" },\n { limit: 86_400_000, divisor: 3_600_000, short: \"h\", long: \"hour\" },\n { limit: 604_800_000, divisor: 86_400_000, short: \"d\", long: \"day\" },\n { limit: 2_592_000_000, divisor: 604_800_000, short: \"w\", long: \"week\" },\n { limit: 31_536_000_000, divisor: 2_592_000_000, short: \"mo\", long: \"month\" },\n];\n\nconst INTL_UNITS: readonly { unit: Intl.RelativeTimeFormatUnit; ms: number }[] = [\n { unit: \"year\", ms: 365 * 24 * 60 * 60 * 1000 },\n { unit: \"month\", ms: 30 * 24 * 60 * 60 * 1000 },\n { unit: \"week\", ms: 7 * 24 * 60 * 60 * 1000 },\n { unit: \"day\", ms: 24 * 60 * 60 * 1000 },\n { unit: \"hour\", ms: 60 * 60 * 1000 },\n { unit: \"minute\", ms: 60 * 1000 },\n];\n\n/**\n * The three relative-time voices the fleet actually speaks. They are display\n * decisions, not implementations — one body serves all three.\n *\n * - `\"short\"` — \"2m ago\", \"3d ago\". The dense default (from `@ai-matrx/diff`).\n * - `\"long\"` — \"2 minutes ago\", \"3 days ago\".\n * - `\"intl\"` — `Intl.RelativeTimeFormat` with `numeric: \"auto\"`, so the\n * viewer's locale conventions apply and \"yesterday\" reads as \"yesterday\"\n * (from `@ai-matrx/associations`). Sub-minute reads \"just now\".\n */\nexport type RelativeTimeStyle = \"short\" | \"long\" | \"intl\";\n\nexport interface RelativeTimeOptions {\n /** Default `\"short\"`. */\n style?: RelativeTimeStyle | undefined;\n /** Injected clock. Default `Date.now()` — pass it in tests. */\n now?: number | undefined;\n /** Returned for null / unparseable input. Default `\"—\"`. */\n fallback?: string | undefined;\n /**\n * Echo the raw input instead of `fallback` when it cannot be parsed —\n * degraded, never broken. What the comments face wants: an odd server string\n * is more useful on screen than an em-dash that hides it.\n */\n fallbackToInput?: boolean | undefined;\n}\n\n/**\n * \"2m ago\" / \"2 minutes ago\" / a locale-aware \"2 minutes ago\", falling back to\n * an absolute local date past a year. Timezone-agnostic by construction (a\n * pure epoch difference over `parseTimestamp`'s corrected instant).\n *\n * Future timestamps read \"just now\" in `\"short\"`/`\"long\"` (a clock skew of a\n * few seconds must not print \"in 3 seconds\"); `\"intl\"` formats them properly\n * (\"in 5 minutes\") because that is the whole point of asking for `Intl`.\n */\nexport function formatRelativeTime(\n value: TimestampInput,\n options: RelativeTimeOptions = {},\n): string {\n const style = options.style ?? \"short\";\n const now = options.now ?? Date.now();\n const parsed = parseTimestamp(value);\n if (!parsed) {\n if (options.fallbackToInput === true && typeof value === \"string\") return value;\n return options.fallback ?? \"—\";\n }\n\n if (style === \"intl\") {\n const delta = parsed.getTime() - now;\n const magnitude = Math.abs(delta);\n if (magnitude < 60_000) return \"just now\";\n const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: \"auto\" });\n for (const { unit, ms } of INTL_UNITS) {\n if (magnitude >= ms) return rtf.format(Math.trunc(delta / ms), unit);\n }\n return \"just now\";\n }\n\n const elapsed = now - parsed.getTime();\n if (elapsed < 0) return \"just now\";\n for (const unit of RELATIVE_UNITS) {\n if (elapsed < unit.limit) {\n const count = Math.max(1, Math.floor(elapsed / unit.divisor));\n return style === \"long\"\n ? `${count} ${unit.long}${count === 1 ? \"\" : \"s\"} ago`\n : `${count}${unit.short} ago`;\n }\n }\n return formatAbsoluteDate(parsed);\n}\n\n// ───────────────────────── durations ─────────────────────────\n\n/**\n * The three duration voices the fleet actually speaks. Picked once here so a\n * call duration reads the same in the meeting, the history list and the\n * notification.\n *\n * - `\"clock\"` (default) — `0:00`, `9:04`, `1:02:33`. For anything a human\n * watches tick: recordings, calls, media players, timers.\n * - `\"compact\"` — `250ms`, `5.2s`, `5m 30s`, `1h 02m`. For elapsed work:\n * job runs, request timings, step durations. Sub-second is honest about\n * being sub-second rather than collapsing to `0:00`.\n * - `\"coarse\"` — `45 min`, `1h 30m`. For things measured in minutes and read\n * at a glance: podcast episodes, lesson lengths, estimates. Never prints\n * seconds.\n */\nexport type DurationStyle = \"clock\" | \"compact\" | \"coarse\";\n\nexport interface DurationOptions {\n /** Default `\"clock\"`. */\n style?: DurationStyle | undefined;\n /**\n * Returned for `null` / `undefined` / non-finite input. Default `\"0:00\"` for\n * `\"clock\"` and `\"—\"` for `\"compact\"` / `\"coarse\"`.\n */\n fallback?: string | undefined;\n /**\n * Keep the sign on a negative duration (`-1:23`) instead of clamping to\n * zero. Default `false` — a negative elapsed time is nearly always a clock\n * bug, and `0:00` is the honest reading. Opt in where a signed offset is the\n * actual quantity (a field format for a stored `interval`, say).\n */\n signed?: boolean | undefined;\n}\n\nconst pad2 = (value: number): string => value.toString().padStart(2, \"0\");\n\nfunction clockBody(totalSeconds: number): string {\n const seconds = totalSeconds % 60;\n const minutes = Math.floor(totalSeconds / 60) % 60;\n const hours = Math.floor(totalSeconds / 3600);\n return hours > 0\n ? `${hours}:${pad2(minutes)}:${pad2(seconds)}`\n : `${minutes}:${pad2(seconds)}`;\n}\n\nfunction compactBody(ms: number): string {\n if (ms < 1000) return `${Math.round(ms)}ms`;\n const totalSeconds = ms / 1000;\n if (totalSeconds < 60) {\n // One decimal under 10s (0.1s is a visible difference at that scale),\n // whole seconds above it (nobody reads \"43.7s\" as more precise than \"44s\").\n return totalSeconds < 10\n ? `${totalSeconds.toFixed(1)}s`\n : `${Math.round(totalSeconds)}s`;\n }\n const whole = Math.round(totalSeconds);\n const minutes = Math.floor(whole / 60);\n if (minutes < 60) return `${minutes}m ${pad2(whole % 60)}s`;\n const hours = Math.floor(minutes / 60);\n return `${hours}h ${pad2(minutes % 60)}m`;\n}\n\nfunction coarseBody(ms: number): string {\n const minutes = Math.round(ms / 60_000);\n if (minutes < 1) return \"< 1 min\";\n if (minutes < 60) return `${minutes} min`;\n const hours = Math.floor(minutes / 60);\n const rest = minutes % 60;\n return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`;\n}\n\n/**\n * THE canonical duration formatter. Milliseconds in, a chosen voice out.\n *\n * `null`, `undefined`, `NaN` and `Infinity` all take the fallback — never\n * `NaN:NaN`, which is what a null start time produces, and never a confident\n * `0:00` for \"we do not know\".\n */\nexport function formatDurationMs(\n ms: number | null | undefined,\n options: DurationOptions = {},\n): string {\n const style = options.style ?? \"clock\";\n const fallback = options.fallback ?? (style === \"clock\" ? \"0:00\" : \"—\");\n if (ms === null || ms === undefined || !Number.isFinite(ms)) return fallback;\n\n const negative = ms < 0;\n if (negative && options.signed !== true) {\n return style === \"clock\" ? clockBody(0) : fallback;\n }\n const magnitude = Math.abs(ms);\n const body =\n style === \"clock\"\n ? clockBody(Math.floor(magnitude / 1000))\n : style === \"compact\"\n ? compactBody(magnitude)\n : coarseBody(magnitude);\n return negative ? `-${body}` : body;\n}\n\n/** Seconds in. See {@link formatDurationMs}. */\nexport function formatDurationSeconds(\n seconds: number | null | undefined,\n options: DurationOptions = {},\n): string {\n if (seconds === null || seconds === undefined || !Number.isFinite(seconds)) {\n return formatDurationMs(seconds as number | null | undefined, options);\n }\n return formatDurationMs(seconds * 1000, options);\n}\n\n/** Minutes in. See {@link formatDurationMs}. */\nexport function formatDurationMinutes(\n minutes: number | null | undefined,\n options: DurationOptions = {},\n): string {\n if (minutes === null || minutes === undefined || !Number.isFinite(minutes)) {\n return formatDurationMs(minutes as number | null | undefined, options);\n }\n return formatDurationMs(minutes * 60_000, options);\n}\n\n/**\n * Duration between two timestamps, in milliseconds — `null` when the start is\n * missing or either end is unparseable. An absent `to` means \"still running\",\n * so it measures to `now`. Pair with {@link formatDurationMs}.\n */\nexport function durationMsBetween(\n from: TimestampInput,\n to: TimestampInput,\n now: number = Date.now(),\n): number | null {\n const start = parseTimestamp(from);\n if (!start) return null;\n if (to === null || to === undefined) return Math.max(0, now - start.getTime());\n const end = parseTimestamp(to);\n if (!end) return null;\n return Math.max(0, end.getTime() - start.getTime());\n}\n\n// ───────────────────────── byte sizes ─────────────────────────\n\nconst SIZE_UNITS = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\"] as const;\n\nexport interface FileSizeOptions {\n /** Returned for `null` / `undefined` / non-finite / negative. Default `\"—\"`. */\n fallback?: string | undefined;\n}\n\n/**\n * `512` → `\"512 B\"`, `2048` → `\"2.0 KB\"`, `15360` → `\"15 KB\"`.\n *\n * THE DISPLAY DECISIONS, made once (unified 2026-09-07 from six host twins and\n * `@ai-matrx/media`'s copy, which was the richest):\n *\n * 1. Binary units (1024), labelled with the short SI-ish names the whole fleet\n * already used — `B`/`KB`/`MB`/`GB`/`TB`, never `\"Bytes\"`.\n * 2. One decimal below 10 in a unit (`2.0 KB`, `9.9 MB`), whole numbers at or\n * above it (`15 KB`, `340 MB`) — three significant figures is the most a\n * file size is ever worth, and `1.5 GB` versus `1536.0 MB` is the whole\n * point of the unit.\n * 3. Whole bytes below 1 KB — `\"512 B\"`, never `\"512.0 B\"`.\n * 4. `null` / `undefined` / `NaN` / `Infinity` / negative all collapse to an\n * em-dash, NOT to `\"0 B\"`. A corrupt or mid-upload `size` row rendering as\n * a confident, wrong `\"0 B\"` is a screen telling a lie; \"size unknown\" is\n * the honest reading. (`0` itself is a real size and prints `\"0 B\"`.)\n */\nexport function formatFileSize(\n bytes: number | null | undefined,\n options: FileSizeOptions = {},\n): string {\n const fallback = options.fallback ?? \"—\";\n if (bytes === null || bytes === undefined || !Number.isFinite(bytes) || bytes < 0) {\n return fallback;\n }\n if (bytes < 1024) return `${Math.round(bytes)} B`;\n let value = bytes;\n let unit = 0;\n while (value >= 1024 && unit < SIZE_UNITS.length - 1) {\n value /= 1024;\n unit += 1;\n }\n const rendered = value >= 10 ? String(Math.round(value)) : value.toFixed(1);\n return `${rendered} ${SIZE_UNITS[unit]}`;\n}\n\n// ───────────────────────── people ─────────────────────────\n\nexport interface InitialsOptions {\n /** Returned when nothing usable is left. Default `\"?\"`. */\n fallback?: string | undefined;\n}\n\n/**\n * `\"Ana Rivera\"` → `\"AR\"`, `\"Ana\"` → `\"A\"`, `\"Ana Maria Rivera\"` → `\"AR\"`,\n * `\"ana@example.com\"` → `\"A\"`, `\" \"` → `\"?\"`.\n *\n * THE DISPLAY DECISION, made once: a multi-part name takes FIRST + LAST, not\n * first + second. `\"Ana Maria Rivera\"` is `AR`, because the family name is the\n * half a reader recognises. (Six matrx-frontend twins took first + second and\n * printed `AM`; the two package copies both took first + last, and both were\n * tested. First + last wins.)\n *\n * Pass the email as the value when there is no name — a single token yields\n * its first character, which is exactly what the host twins did by hand.\n */\nexport function getInitials(\n value: string | null | undefined,\n options: InitialsOptions = {},\n): string {\n const fallback = options.fallback ?? \"?\";\n if (typeof value !== \"string\") return fallback;\n const parts = value.trim().split(/\\s+/).filter((part) => part.length > 0);\n if (parts.length === 0) return fallback;\n const first = parts[0]?.charAt(0) ?? \"\";\n const last = parts.length > 1 ? (parts[parts.length - 1]?.charAt(0) ?? \"\") : \"\";\n return `${first}${last}`.toUpperCase() || fallback;\n}\n\n/**\n * A stable palette index for an avatar with no image. Deterministic on the\n * seed, so the same person is the same colour on every device and every\n * reload — a random colour per render is a surprisingly loud bug.\n */\nexport function avatarPaletteIndex(seed: string, buckets = 8): number {\n let hash = 0;\n for (let index = 0; index < seed.length; index += 1) {\n hash = (hash * 31 + seed.charCodeAt(index)) | 0;\n }\n return Math.abs(hash) % buckets;\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/html-escape.ts
21
+ var html_escape_exports = {};
22
+ __export(html_escape_exports, {
23
+ escapeHtml: () => escapeHtml
24
+ });
25
+ module.exports = __toCommonJS(html_escape_exports);
26
+ var HTML_ESCAPES = {
27
+ "&": "&amp;",
28
+ "<": "&lt;",
29
+ ">": "&gt;",
30
+ '"': "&quot;",
31
+ "'": "&#39;"
32
+ };
33
+ var HTML_ESCAPE_RE = /[&<>"']/g;
34
+ function escapeHtml(value) {
35
+ if (typeof value !== "string") return "";
36
+ return value.replace(HTML_ESCAPE_RE, (char) => HTML_ESCAPES[char] ?? char);
37
+ }
38
+ //# sourceMappingURL=html-escape.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/html-escape.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/html-escape — ONE HTML escaper, with a decided character set.\n *\n * WHY THIS FILE EXISTS (2026-09-07 duplication census, row 14). Nine copies of\n * `escapeHtml` were live across the fleet — six in matrx-frontend, one in\n * matrx-extend, one in aidream's dashboard, and one private to\n * `@ai-matrx/print/core` — and **they did not escape the same characters**.\n * Three escaped only `& < >`; two added `\"`; four added `'` (two as `&#39;`,\n * two as `&#039;`). A string that is safe through one of them is an attribute\n * break-out through another. That is not a style difference; it is a latent\n * XSS class with nine independent chances to be wrong.\n *\n * The print package was the wrong owner (an HTML escaper must not require the\n * print engine's `jspdf` + `html2canvas` graph), so it lives here, in the\n * package with no sibling dependencies that everything can reach.\n *\n * THE DECIDED SET: `& < > \" '` → `&amp; &lt; &gt; &quot; &#39;`.\n *\n * All five, always. Escaping the two quote characters is what makes the result\n * safe to interpolate into an unquoted-or-quoted HTML *attribute*, not just\n * into element text — and every caller that only escaped three was one\n * refactor away from being an attribute caller. `'` uses the numeric `&#39;`\n * rather than the named `&apos;`, which is XML, not HTML 4, and rather than\n * `&#039;`, which is the same character with a pointless leading zero.\n *\n * WHAT THIS IS NOT. This escapes text for an HTML *document* context. It is\n * not a sanitiser for untrusted HTML markup (use a real sanitiser), not a\n * JavaScript-string escaper (a `</script>` inside a JSON blob needs\n * `<`), and not a URL encoder.\n */\n\nconst HTML_ESCAPES: Readonly<Record<string, string>> = {\n \"&\": \"&amp;\",\n \"<\": \"&lt;\",\n \">\": \"&gt;\",\n '\"': \"&quot;\",\n \"'\": \"&#39;\",\n};\n\nconst HTML_ESCAPE_RE = /[&<>\"']/g;\n\n/**\n * Escape `& < > \" '` so `value` is safe as HTML element text or as the\n * contents of a quoted HTML attribute.\n *\n * Non-string input returns the empty string rather than `\"undefined\"` — a\n * literal \"undefined\" rendered into a page is a screen telling a lie.\n */\nexport function escapeHtml(value: string | null | undefined): string {\n if (typeof value !== \"string\") return \"\";\n return value.replace(HTML_ESCAPE_RE, (char) => HTML_ESCAPES[char] ?? char);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AA+BA,IAAM,eAAiD;AAAA,EACrD,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AACP;AAEA,IAAM,iBAAiB;AAShB,SAAS,WAAW,OAA0C;AACnE,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,SAAO,MAAM,QAAQ,gBAAgB,CAAC,SAAS,aAAa,IAAI,KAAK,IAAI;AAC3E;","names":[]}
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @ai-matrx/kit/html-escape — ONE HTML escaper, with a decided character set.
3
+ *
4
+ * WHY THIS FILE EXISTS (2026-09-07 duplication census, row 14). Nine copies of
5
+ * `escapeHtml` were live across the fleet — six in matrx-frontend, one in
6
+ * matrx-extend, one in aidream's dashboard, and one private to
7
+ * `@ai-matrx/print/core` — and **they did not escape the same characters**.
8
+ * Three escaped only `& < >`; two added `"`; four added `'` (two as `&#39;`,
9
+ * two as `&#039;`). A string that is safe through one of them is an attribute
10
+ * break-out through another. That is not a style difference; it is a latent
11
+ * XSS class with nine independent chances to be wrong.
12
+ *
13
+ * The print package was the wrong owner (an HTML escaper must not require the
14
+ * print engine's `jspdf` + `html2canvas` graph), so it lives here, in the
15
+ * package with no sibling dependencies that everything can reach.
16
+ *
17
+ * THE DECIDED SET: `& < > " '` → `&amp; &lt; &gt; &quot; &#39;`.
18
+ *
19
+ * All five, always. Escaping the two quote characters is what makes the result
20
+ * safe to interpolate into an unquoted-or-quoted HTML *attribute*, not just
21
+ * into element text — and every caller that only escaped three was one
22
+ * refactor away from being an attribute caller. `'` uses the numeric `&#39;`
23
+ * rather than the named `&apos;`, which is XML, not HTML 4, and rather than
24
+ * `&#039;`, which is the same character with a pointless leading zero.
25
+ *
26
+ * WHAT THIS IS NOT. This escapes text for an HTML *document* context. It is
27
+ * not a sanitiser for untrusted HTML markup (use a real sanitiser), not a
28
+ * JavaScript-string escaper (a `</script>` inside a JSON blob needs
29
+ * `<`), and not a URL encoder.
30
+ */
31
+ /**
32
+ * Escape `& < > " '` so `value` is safe as HTML element text or as the
33
+ * contents of a quoted HTML attribute.
34
+ *
35
+ * Non-string input returns the empty string rather than `"undefined"` — a
36
+ * literal "undefined" rendered into a page is a screen telling a lie.
37
+ */
38
+ declare function escapeHtml(value: string | null | undefined): string;
39
+
40
+ export { escapeHtml };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @ai-matrx/kit/html-escape — ONE HTML escaper, with a decided character set.
3
+ *
4
+ * WHY THIS FILE EXISTS (2026-09-07 duplication census, row 14). Nine copies of
5
+ * `escapeHtml` were live across the fleet — six in matrx-frontend, one in
6
+ * matrx-extend, one in aidream's dashboard, and one private to
7
+ * `@ai-matrx/print/core` — and **they did not escape the same characters**.
8
+ * Three escaped only `& < >`; two added `"`; four added `'` (two as `&#39;`,
9
+ * two as `&#039;`). A string that is safe through one of them is an attribute
10
+ * break-out through another. That is not a style difference; it is a latent
11
+ * XSS class with nine independent chances to be wrong.
12
+ *
13
+ * The print package was the wrong owner (an HTML escaper must not require the
14
+ * print engine's `jspdf` + `html2canvas` graph), so it lives here, in the
15
+ * package with no sibling dependencies that everything can reach.
16
+ *
17
+ * THE DECIDED SET: `& < > " '` → `&amp; &lt; &gt; &quot; &#39;`.
18
+ *
19
+ * All five, always. Escaping the two quote characters is what makes the result
20
+ * safe to interpolate into an unquoted-or-quoted HTML *attribute*, not just
21
+ * into element text — and every caller that only escaped three was one
22
+ * refactor away from being an attribute caller. `'` uses the numeric `&#39;`
23
+ * rather than the named `&apos;`, which is XML, not HTML 4, and rather than
24
+ * `&#039;`, which is the same character with a pointless leading zero.
25
+ *
26
+ * WHAT THIS IS NOT. This escapes text for an HTML *document* context. It is
27
+ * not a sanitiser for untrusted HTML markup (use a real sanitiser), not a
28
+ * JavaScript-string escaper (a `</script>` inside a JSON blob needs
29
+ * `<`), and not a URL encoder.
30
+ */
31
+ /**
32
+ * Escape `& < > " '` so `value` is safe as HTML element text or as the
33
+ * contents of a quoted HTML attribute.
34
+ *
35
+ * Non-string input returns the empty string rather than `"undefined"` — a
36
+ * literal "undefined" rendered into a page is a screen telling a lie.
37
+ */
38
+ declare function escapeHtml(value: string | null | undefined): string;
39
+
40
+ export { escapeHtml };
@@ -0,0 +1,17 @@
1
+ // src/html-escape.ts
2
+ var HTML_ESCAPES = {
3
+ "&": "&amp;",
4
+ "<": "&lt;",
5
+ ">": "&gt;",
6
+ '"': "&quot;",
7
+ "'": "&#39;"
8
+ };
9
+ var HTML_ESCAPE_RE = /[&<>"']/g;
10
+ function escapeHtml(value) {
11
+ if (typeof value !== "string") return "";
12
+ return value.replace(HTML_ESCAPE_RE, (char) => HTML_ESCAPES[char] ?? char);
13
+ }
14
+ export {
15
+ escapeHtml
16
+ };
17
+ //# sourceMappingURL=html-escape.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/html-escape.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/html-escape — ONE HTML escaper, with a decided character set.\n *\n * WHY THIS FILE EXISTS (2026-09-07 duplication census, row 14). Nine copies of\n * `escapeHtml` were live across the fleet — six in matrx-frontend, one in\n * matrx-extend, one in aidream's dashboard, and one private to\n * `@ai-matrx/print/core` — and **they did not escape the same characters**.\n * Three escaped only `& < >`; two added `\"`; four added `'` (two as `&#39;`,\n * two as `&#039;`). A string that is safe through one of them is an attribute\n * break-out through another. That is not a style difference; it is a latent\n * XSS class with nine independent chances to be wrong.\n *\n * The print package was the wrong owner (an HTML escaper must not require the\n * print engine's `jspdf` + `html2canvas` graph), so it lives here, in the\n * package with no sibling dependencies that everything can reach.\n *\n * THE DECIDED SET: `& < > \" '` → `&amp; &lt; &gt; &quot; &#39;`.\n *\n * All five, always. Escaping the two quote characters is what makes the result\n * safe to interpolate into an unquoted-or-quoted HTML *attribute*, not just\n * into element text — and every caller that only escaped three was one\n * refactor away from being an attribute caller. `'` uses the numeric `&#39;`\n * rather than the named `&apos;`, which is XML, not HTML 4, and rather than\n * `&#039;`, which is the same character with a pointless leading zero.\n *\n * WHAT THIS IS NOT. This escapes text for an HTML *document* context. It is\n * not a sanitiser for untrusted HTML markup (use a real sanitiser), not a\n * JavaScript-string escaper (a `</script>` inside a JSON blob needs\n * `<`), and not a URL encoder.\n */\n\nconst HTML_ESCAPES: Readonly<Record<string, string>> = {\n \"&\": \"&amp;\",\n \"<\": \"&lt;\",\n \">\": \"&gt;\",\n '\"': \"&quot;\",\n \"'\": \"&#39;\",\n};\n\nconst HTML_ESCAPE_RE = /[&<>\"']/g;\n\n/**\n * Escape `& < > \" '` so `value` is safe as HTML element text or as the\n * contents of a quoted HTML attribute.\n *\n * Non-string input returns the empty string rather than `\"undefined\"` — a\n * literal \"undefined\" rendered into a page is a screen telling a lie.\n */\nexport function escapeHtml(value: string | null | undefined): string {\n if (typeof value !== \"string\") return \"\";\n return value.replace(HTML_ESCAPE_RE, (char) => HTML_ESCAPES[char] ?? char);\n}\n"],"mappings":";AA+BA,IAAM,eAAiD;AAAA,EACrD,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AACP;AAEA,IAAM,iBAAiB;AAShB,SAAS,WAAW,OAA0C;AACnE,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,SAAO,MAAM,QAAQ,gBAAgB,CAAC,SAAS,aAAa,IAAI,KAAK,IAAI;AAC3E;","names":[]}