import currencies from "sofie:currencies";
const SAFE_SCHEMES = ["http", "https", "mailto", "tel"];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const NO_BREAK_SPACE = " ";
export function text(value) {
switch (typeof value) {
case "string":
return value;
case "number":
return Number.isFinite(value) ? String(value) : "";
case "boolean":
return String(value);
case "object":
return localDateTime(value);
default:
return "";
}
}
function localDateTime(value) {
if (!isLocalDateTime(value)) return "";
const minute = value.local.length >= 16 && value.local[10] === "T" ? value.local.slice(0, 16) : value.local;
return `${minute.replace("T", " ")} ${value.zone}`;
}
function isLocalDateTime(value) {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
const names = Object.keys(value);
return names.length === 2 && typeof value.local === "string" && typeof value.zone === "string";
}
export function truthy(value) {
if (value === null || value === undefined) return false;
if (Array.isArray(value)) return value.length > 0;
switch (typeof value) {
case "boolean":
return value;
case "number":
return Number.isFinite(value) && value !== 0;
case "string":
return value.length > 0;
case "object":
return Object.keys(value).length > 0;
default:
return false;
}
}
export function items(value) {
return Array.isArray(value) ? value : [];
}
export function join(parts) {
return parts.map(text).join("");
}
export function encode(written) {
return encodeURIComponent(written).replace(
/[!'()*]/g,
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
export function urlPart(value) {
return Number.isInteger(value) ? String(value) : encode(text(value));
}
export function url(value) {
const written = text(value);
const end = written.search(/[:/?#]/);
if (end === -1 || written[end] !== ":") return written;
const scheme = [...written.slice(0, end)]
.filter((character) => character > " ")
.join("")
.toLowerCase();
return SAFE_SCHEMES.includes(scheme) ? written : "#";
}
export function gate(view, asks, name) {
return view.gates?.[asks]?.[name] === true;
}
export function t(view, key, values) {
const message = "count" in values ? counted(view, key, values.count) : view.messages?.[key];
if (message === undefined) return key;
return message.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => number(view, values[name]));
}
function counted(view, key, count) {
const messages = view.messages ?? {};
const one = isOne(view.conventions.plural, count) ? messages[`${key}.one`] : undefined;
return one ?? messages[`${key}.other`] ?? messages[key];
}
function isOne(rule, count) {
if (typeof count !== "number") return false;
const size = Math.abs(count);
if (rule === "one-is-one") return size === 1;
if (rule === "one-ends-in-one-not-eleven") return Number.isInteger(size) && size % 10 === 1 && size % 100 !== 11;
return rule === "one-is-zero-or-one" && size < 2;
}
export function message(view, key, fallback) {
const message = view.messages?.[key];
return message === undefined || /\{[A-Za-z_][A-Za-z0-9_]*\}/.test(message) ? fallback : message;
}
export { message as label };
export function number(view, value) {
if (typeof value !== "number") return text(value);
return writeNumber(view.conventions, text(value));
}
export function money(view, value) {
const amount = moneyOf(value);
return amount ? writeMoney(view.conventions, amount) : "";
}
export function date(view, value) {
if (isLocalDateTime(value)) return writeLocalDateTime(view.conventions, value);
const written = text(value);
if (!written) return "";
return writeDate(view.conventions, written);
}
export function moneyOf(value) {
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
const names = Object.keys(value);
if (names.length !== 2 || !Number.isInteger(value.minor)) return null;
const currency = currencies[value.currency];
return currency ? { minor: value.minor, code: value.currency, digits: currency[0], symbol: currency[1] } : null;
}
export function major(amount) {
const digits = String(Math.abs(amount.minor)).padStart(amount.digits + 1, "0");
const sign = amount.minor < 0 ? "-" : "";
if (amount.digits === 0) return sign + digits;
return `${sign}${digits.slice(0, -amount.digits)}.${digits.slice(-amount.digits)}`;
}
export function writeMoney(conventions, amount) {
const written = major(amount);
switch (conventions.money) {
case "symbol-before": {
const [sign, digits] = splitSign(written);
const prefix = amount.symbol ?? amount.code + NO_BREAK_SPACE;
return sign + prefix + writeNumber(conventions, digits);
}
case "symbol-after":
return writeNumber(conventions, written) + NO_BREAK_SPACE + (amount.symbol ?? amount.code);
default:
return `${writeNumber(conventions, written)} ${amount.code}`;
}
}
export function writeNumber(conventions, plain) {
if (/[eE]/.test(plain)) return plain;
const [sign, digits] = splitSign(plain);
const [whole, fraction] = digits.split(".");
let grouped = whole;
if (conventions.group) {
grouped = "";
for (let index = 0; index < whole.length; index += 1) {
if (index > 0 && (whole.length - index) % 3 === 0) grouped += conventions.group;
grouped += whole[index];
}
}
return sign + grouped + (fraction === undefined ? "" : conventions.decimal + fraction);
}
function splitSign(plain) {
return plain.startsWith("-") ? ["-", plain.slice(1)] : ["", plain];
}
function writeDate(conventions, written) {
if (/^\d{4}-\d{2}-\d{2}$/.test(written)) return writeDay(conventions, written);
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:[\d.]*)?$/.test(written)) {
return writeDayAndMinute(conventions, written.slice(0, 10), written.slice(11, 16));
}
const instant = /^\d{4}-\d{2}-\d{2}T/.test(written) ? new Date(written) : null;
if (!instant || Number.isNaN(instant.getTime())) return written;
const minute = wallClock(conventions, instant);
const offset = repeatedOffset(conventions, instant);
const zone = offset === null ? shownZone(conventions) : `${offset} ${shownZone(conventions)}`;
return `${writeDayAndMinute(conventions, minute.slice(0, 10), minute.slice(11, 16))} ${zone}`;
}
function zoneOf(conventions) {
return conventions.zone ?? "UTC";
}
const wallClocks = new Map();
export function instantFields(data, shape, found = new WeakMap()) {
if (!shape || typeof shape !== "object" || !data || typeof data !== "object") return found;
if (Array.isArray(data)) {
for (const item of data) instantFields(item, shape, found);
return found;
}
const names = Object.keys(shape).filter((name) => shape[name] === true);
if (names.length > 0) found.set(data, new Set(names));
for (const [name, inner] of Object.entries(shape)) instantFields(data[name], inner, found);
return found;
}
export const offsetPlaceholder = "with an offset, such as 2026-10-11T09:30:00Z";
export function knowsZone(conventions) {
return wallClockFormat(zoneOf(conventions)) !== null;
}
function shownZone(conventions) {
return knowsZone(conventions) ? zoneOf(conventions) : "UTC";
}
function wallClockFormat(zone) {
if (!wallClocks.has(zone)) wallClocks.set(zone, minuteFormat(zone));
return wallClocks.get(zone);
}
function minuteFormat(zone) {
const minutes = { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" };
try {
return new Intl.DateTimeFormat("en-US", { ...minutes, timeZone: zone, hourCycle: "h23" });
} catch (error) {
if (error instanceof RangeError) return null;
throw error;
}
}
export function wallClock(conventions, instant) {
const format = wallClockFormat(shownZone(conventions));
const parts = Object.fromEntries(format.formatToParts(instant).map((part) => [part.type, part.value]));
return `${parts.year.padStart(4, "0")}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}`;
}
export function repeatedOffset(conventions, instant) {
const format = wallClockFormat(shownZone(conventions));
const time = instant.getTime();
const own = offsetAt(format, time);
const hour = 3600000;
for (const other of [offsetAt(format, time - 3 * hour), offsetAt(format, time + 3 * hour)]) {
if (other !== own && offsetAt(format, time + own - other) === other) return offsetText(own);
}
return null;
}
export function offsetOf(conventions, instant) {
return offsetText(offsetAt(wallClockFormat(shownZone(conventions)), instant.getTime()));
}
export function repeats(conventions, wallClock) {
const format = wallClockFormat(shownZone(conventions));
const time = Date.parse(`${wallClock.slice(0, 16)}Z`);
const hour = 3600000;
const placing = [offsetAt(format, time - 3 * hour), offsetAt(format, time + 3 * hour)].filter(
(offset) => offsetAt(format, time - offset) === offset,
);
return placing.length === 2 && placing[0] !== placing[1];
}
function offsetAt(format, time) {
const parts = Object.fromEntries(format.formatToParts(time).map((part) => [part.type, part.value]));
const minute = time - (((time % 60000) + 60000) % 60000);
return Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute)) - minute;
}
function offsetText(milliseconds) {
const minutes = Math.abs(milliseconds) / 60000;
const pad = (number) => String(number).padStart(2, "0");
return `${milliseconds < 0 ? "-" : "+"}${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`;
}
function writeLocalDateTime(conventions, { local, zone }) {
const shaped = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(local);
const written = shaped ? writeDayAndMinute(conventions, local.slice(0, 10), local.slice(11, 16)) : local;
return `${written} ${zone}`;
}
function writeDayAndMinute(conventions, day, minute) {
const separator = conventions.days === "month-name-first" ? ", " : " ";
return `${writeDay(conventions, day)}${separator}${minute}`;
}
function writeDay(conventions, iso) {
const [year, month, day] = [iso.slice(0, 4), iso.slice(5, 7), iso.slice(8, 10)];
switch (conventions.days) {
case "month-name-first":
return `${MONTHS[Number(month) - 1] ?? ""} ${day.replace(/^0+/, "")}, ${year}`;
case "day-first-dotted":
return `${day}.${month}.${year}`;
default:
return iso;
}
}