@aircloudhq/web 1.0.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,10 @@
1
+ // Code generated by packages/npm/aircloud-web/tools/vendor-sanitizer.mjs from
2
+ // vendor/rails-html-sanitizer (rails-html-sanitizer 1.7.1, loofah 2.25.2) — DO NOT EDIT.
3
+ // The default allowlist of the Rails safe-list sanitizer and the URI rules of the Loofah scrubber it
4
+ // delegates to; regenerated and compared byte-for-byte by test/unit/aircloud_web_package.bats.
5
+
6
+ export const tags = Object.freeze(["a","abbr","acronym","address","b","big","blockquote","br","cite","code","dd","del","dfn","div","dl","dt","em","h1","h2","h3","h4","h5","h6","hr","i","img","ins","kbd","li","mark","ol","p","pre","samp","small","span","strong","sub","sup","time","tt","ul","var"]);
7
+ export const attributes = Object.freeze(["abbr","alt","cite","class","datetime","height","href","lang","name","src","title","width","xml:lang"]);
8
+ export const protocols = Object.freeze(["afs","aim","callto","data","ed2k","fax","ftp","gopher","http","https","irc","line","mailto","modem","news","nntp","rsync","rtsp","sftp","sms","ssh","tag","tel","telnet","urn","webcal","xmpp"]);
9
+ export const dataMediaTypes = Object.freeze(["image/gif","image/jpeg","image/png","text/css","text/plain"]);
10
+ export const uriAttributes = Object.freeze(["action","cite","href","longdesc","poster","preload","src","xlink:href","xml:base"]);
@@ -0,0 +1,73 @@
1
+ // ActionView::Helpers::SanitizeHelper with Rails' safe-list sanitizer: the default allowlist and the
2
+ // URI rules are the vendored rails-html-sanitizer / Loofah data (sanitize-allowlist.js, generated).
3
+ // Parsing uses the browser's HTML parser (a <template>), so the tree scrubbed is the tree a browser
4
+ // would build. `sanitize` and `stripLinks` return markup; `stripTags` returns plain text (a React
5
+ // text child is escaped by React — census § 4, output safety adapted).
6
+ import { attributes as ALLOWED_ATTRIBUTES, dataMediaTypes, protocols, tags as ALLOWED_TAGS, uriAttributes } from './sanitize-allowlist.js';
7
+
8
+ function parse(html) {
9
+ if (typeof document === 'undefined') throw new Error('sanitize: an HTML parser (document) is required');
10
+ const t = document.createElement('template');
11
+ t.innerHTML = String(html ?? '');
12
+ return t;
13
+ }
14
+
15
+ // Loofah CONTROL_CHARACTERS and its protocol grammar.
16
+ const CONTROL = /[`\u0000- \u007F\s\u0080-ā]/g;
17
+ const PROTOCOL = /^[a-z][a-z0-9+\-.]*(?::|&#0*58|&#x0*3a|(?:%|&#37;)3a)/i;
18
+ const SEPARATOR = /:|&#0*58|&#x0*3a|(?:%|&#37;)3a/i;
19
+
20
+ /** Loofah::HTML5::Scrub.allowed_uri? over an attribute value the parser already decoded. */
21
+ export function allowedUri(value) {
22
+ const v = String(value).replace(CONTROL, '').toLowerCase();
23
+ if (!PROTOCOL.test(v)) return true;
24
+ const protocol = v.split(SEPARATOR)[0];
25
+ if (!protocols.includes(protocol)) return false;
26
+ if (protocol === 'data') {
27
+ const m = /^data:([^;,]*)/.exec(v);
28
+ return Boolean(m) && dataMediaTypes.includes(m[1]);
29
+ }
30
+ return true;
31
+ }
32
+
33
+ function scrub(node, tags, attrs) {
34
+ for (const child of [...node.childNodes]) {
35
+ if (child.nodeType === 3) continue; // text
36
+ if (child.nodeType === 1) {
37
+ scrub(child.content ?? child, tags, attrs); // bottom-up, as the PermitScrubber
38
+ const name = child.localName;
39
+ const foreign = child.namespaceURI && child.namespaceURI !== 'http://www.w3.org/1999/xhtml';
40
+ if (!tags.has(name) || foreign) {
41
+ // A foreign (svg/math) element is pruned with its content (namespace confusion); any other
42
+ // disallowed element is replaced by its children.
43
+ if (!foreign) child.replaceWith(...child.childNodes);
44
+ else child.remove();
45
+ continue;
46
+ }
47
+ for (const a of [...child.attributes]) {
48
+ if (!attrs.has(a.name) || (uriAttributes.includes(a.name) && !allowedUri(a.value))) child.removeAttribute(a.name);
49
+ }
50
+ continue;
51
+ }
52
+ child.remove(); // comments, processing instructions
53
+ }
54
+ }
55
+
56
+ /** sanitize(@article.body) — `tags` / `attributes` replace the default allowlist, as in Rails. */
57
+ export function sanitize(html, options = {}) {
58
+ const t = parse(html);
59
+ scrub(t.content, new Set(options.tags ?? ALLOWED_TAGS), new Set(options.attributes ?? ALLOWED_ATTRIBUTES));
60
+ return t.innerHTML;
61
+ }
62
+
63
+ /** strip_tags("Strip <i>these</i> tags!") # => "Strip these tags!" */
64
+ export function stripTags(html) {
65
+ return parse(html).content.textContent;
66
+ }
67
+
68
+ /** strip_links('<a href="http://www.rubyonrails.org">Ruby on Rails</a>') # => "Ruby on Rails" */
69
+ export function stripLinks(html) {
70
+ const t = parse(html);
71
+ for (const a of [...t.content.querySelectorAll('a')]) a.replaceWith(...a.childNodes);
72
+ return t.innerHTML;
73
+ }
@@ -0,0 +1,37 @@
1
+ // ActionView::RecordIdentifier (dom_id, dom_class) and TagHelper#token_list. A record's class is its
2
+ // resource: the entity name is Rails' param_key (Flex names are already snake_case).
3
+
4
+ function paramKey(resource) {
5
+ const name = typeof resource === 'string' ? resource : resource?.entity;
6
+ if (!name) throw new TypeError('domClass: a resource (entity name or projection resource) is required');
7
+ return name;
8
+ }
9
+
10
+ /** dom_class(post) # => "post"; dom_class(post, :edit) # => "edit_post" */
11
+ export function domClass(resource, prefix) {
12
+ const key = paramKey(resource);
13
+ return prefix ? `${prefix}_${key}` : key;
14
+ }
15
+
16
+ /** dom_id(Post.find(45)) # => "post_45"; dom_id(Post.new) # => "new_post" */
17
+ export function domId(resource, record, prefix) {
18
+ const id = record?.id;
19
+ if (id === undefined || id === null || id === '') return domClass(resource, prefix ?? 'new');
20
+ return `${domClass(resource, prefix)}_${id}`;
21
+ }
22
+
23
+ /** token_list("foo", { bar: true, baz: false }) # => "foo bar" */
24
+ export function tokenList(...args) {
25
+ const out = [];
26
+ const add = (v) => {
27
+ if (v === null || v === undefined || v === false || v === '') return;
28
+ if (Array.isArray(v)) return v.forEach(add);
29
+ if (typeof v === 'object') {
30
+ for (const [k, on] of Object.entries(v)) if (on) add(k);
31
+ return;
32
+ }
33
+ for (const t of String(v).split(/\s+/)) if (t && !out.includes(t)) out.push(t);
34
+ };
35
+ args.forEach(add);
36
+ return out.join(' ');
37
+ }
@@ -0,0 +1,143 @@
1
+ // ActionView::Helpers::TextHelper at the census commit. `simpleFormat` and `highlight` return markup
2
+ // (sanitized first, as in Rails); the others return plain text.
3
+ import { i18nFor } from '../i18n/current.js';
4
+ import { escapeHtml } from '../i18n/index.js';
5
+ import { sanitize } from './sanitize.js';
6
+
7
+ /** pluralize(2, "person") # => "2 people" — the locale's inflections (ADR-223). */
8
+ export function pluralize(count, singular, plural, options = {}) {
9
+ const opts = typeof plural === 'object' && plural !== null ? plural : options;
10
+ const explicit = typeof plural === 'string' ? plural : opts.plural;
11
+ const i = i18nFor(opts);
12
+ if (i) return i.pluralize(count, singular, explicit);
13
+ const one = count === 1 || /^1(\.0+)?$/.test(String(count));
14
+ return `${count ?? 0} ${one ? singular : explicit ?? singular}`;
15
+ }
16
+
17
+ /** truncate("Once upon a time in a world far far away") # => "Once upon a time in a world..." */
18
+ export function truncate(text, options = {}) {
19
+ const s = String(text ?? '');
20
+ const length = options.length ?? 30;
21
+ const omission = options.omission ?? '...';
22
+ if ([...s].length <= length) return s;
23
+ const chars = [...s];
24
+ let stop = length - [...omission].length;
25
+ if (stop < 0) stop = 0;
26
+ if (options.separator !== undefined && options.separator !== null) {
27
+ const head = chars.slice(0, stop + 1).join('');
28
+ const sep = options.separator;
29
+ let at = -1;
30
+ if (sep instanceof RegExp) {
31
+ const re = new RegExp(sep.source, sep.flags.includes('g') ? sep.flags : sep.flags + 'g');
32
+ for (const m of head.matchAll(re)) if (m.index <= stop) at = m.index;
33
+ } else {
34
+ at = head.lastIndexOf(sep, stop);
35
+ }
36
+ if (at >= 0) stop = [...head.slice(0, at)].length;
37
+ }
38
+ return chars.slice(0, stop).join('') + omission;
39
+ }
40
+
41
+ /** simple_format("Here is some basic text...\n...with a line break.") # => "<p>Here is some basic text...\n<br />...with a line break.</p>" */
42
+ export function simpleFormat(text, htmlOptions = {}, options = {}) {
43
+ const wrapper = options.wrapperTag ?? options.wrapper_tag ?? 'p';
44
+ const s = options.sanitize === false ? String(text ?? '') : sanitize(String(text ?? ''), options.sanitizeOptions ?? {});
45
+ const attrs = Object.entries(htmlOptions)
46
+ .map(([k, v]) => ` ${k}="${escapeHtml(v)}"`)
47
+ .join('');
48
+ const paragraphs = s.replace(/\r\n?/g, '\n').split(/\n\n+/).filter((p) => p.length > 0);
49
+ if (paragraphs.length === 0) return `<${wrapper}${attrs}></${wrapper}>`;
50
+ return paragraphs
51
+ .map((p) => `<${wrapper}${attrs}>${p.replace(/([^\n]\n)(?=[^\n])/g, '$1<br />')}</${wrapper}>`)
52
+ .join('\n\n');
53
+ }
54
+
55
+ const escapeRegExp = (s) => String(s).replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&');
56
+
57
+ /** highlight("You searched for: rails", "rails") # => "You searched for: <mark>rails</mark>" */
58
+ export function highlight(text, phrases, options = {}) {
59
+ const s = options.sanitize === false ? String(text ?? '') : sanitize(String(text ?? ''));
60
+ const list = (Array.isArray(phrases) ? phrases : [phrases]).filter((p) => p !== null && p !== undefined && String(p) !== '');
61
+ if (!s || list.length === 0) return s;
62
+ const highlighter = options.highlighter ?? '<mark>\\1</mark>';
63
+ const match = list.map((p) => (p instanceof RegExp ? p.source : escapeRegExp(p))).join('|');
64
+ return s.replace(new RegExp(`(${match})(?![^<]*?>)`, 'gi'), (_, m) => highlighter.replace(/\\1/g, m));
65
+ }
66
+
67
+ function cutExcerptPart(position, part, separator, options) {
68
+ if (part === undefined) return ['', ''];
69
+ const radius = options.radius ?? 100;
70
+ const omission = options.omission ?? '...';
71
+ let pieces = separator === '' ? [...part] : part.split(separator);
72
+ pieces = pieces.filter((p) => p !== '');
73
+ const affix = pieces.length > radius ? omission : '';
74
+ pieces = position === 'first' ? pieces.slice(Math.max(pieces.length - radius, 0)) : pieces.slice(0, radius);
75
+ return [affix, pieces.join(separator)];
76
+ }
77
+
78
+ /** excerpt("This is an example", "an", radius: 5) # => "...s is an exam..." */
79
+ export function excerpt(text, phrase, options = {}) {
80
+ if (text === null || text === undefined || phrase === null || phrase === undefined) return undefined;
81
+ const separator = options.separator ?? '';
82
+ const regex = phrase instanceof RegExp ? phrase : new RegExp(escapeRegExp(phrase), 'i');
83
+ const matched = String(text).match(regex);
84
+ if (!matched) return undefined;
85
+ let found = matched[0];
86
+ if (separator !== '') {
87
+ const hit = String(text).split(separator).find((v) => regex.test(v));
88
+ if (hit !== undefined) found = hit;
89
+ }
90
+ const idx = String(text).indexOf(found);
91
+ const first = String(text).slice(0, idx);
92
+ const second = String(text).slice(idx + found.length);
93
+ const [prefix, firstPart] = cutExcerptPart('first', first, separator, options);
94
+ const [postfix, secondPart] = cutExcerptPart('second', second, separator, options);
95
+ const affix = [firstPart, separator, found, separator, secondPart].join('').trim();
96
+ return [prefix, affix, postfix].join('');
97
+ }
98
+
99
+ /** word_wrap("Once upon a time", line_width: 8) # => "Once\nupon a\ntime" */
100
+ export function wordWrap(text, options = {}) {
101
+ const width = options.lineWidth ?? options.line_width ?? 80;
102
+ const brk = options.breakSequence ?? options.break_sequence ?? '\n';
103
+ return String(text ?? '')
104
+ .split('\n')
105
+ .map((line) =>
106
+ line.length > width
107
+ ? line.replace(new RegExp(`(.{1,${width}})(\\s+|$)`, 'g'), `$1${brk}`).replace(/\s+$/, '')
108
+ : line,
109
+ )
110
+ .join(brk);
111
+ }
112
+
113
+ const cycles = new Map();
114
+
115
+ /** cycle("odd", "even") — alternates between its values on each call (one cycle per `name`). */
116
+ export function cycle(...values) {
117
+ let name = 'default';
118
+ const last = values[values.length - 1];
119
+ if (last && typeof last === 'object' && !Array.isArray(last)) {
120
+ name = last.name ?? name;
121
+ values = values.slice(0, -1);
122
+ }
123
+ const key = JSON.stringify(values);
124
+ let c = cycles.get(name);
125
+ if (!c || c.key !== key) {
126
+ c = { key, values, index: 0 };
127
+ cycles.set(name, c);
128
+ }
129
+ const v = c.values[c.index];
130
+ c.index = (c.index + 1) % c.values.length;
131
+ return v;
132
+ }
133
+
134
+ /** reset_cycle(name) */
135
+ export function resetCycle(name = 'default') {
136
+ cycles.delete(name);
137
+ }
138
+
139
+ /** current_cycle(name) */
140
+ export function currentCycle(name = 'default') {
141
+ const c = cycles.get(name);
142
+ return c ? c.values[(c.index + c.values.length - 1) % c.values.length] : undefined;
143
+ }
@@ -0,0 +1,23 @@
1
+ // ActionView::Helpers::TranslationHelper: `t`/`translate` and `l`/`localize` over the bound i18n
2
+ // instance (the React I18nProvider binds it; the lazy scope of a page comes from the router wrapper
3
+ // or `resourcePage`). The semantics are the i18n runtime's (contracts/deploy/v1/i18n-vectors.json).
4
+ import { requireI18n } from '../i18n/current.js';
5
+
6
+ /** t("membros.index.title") — or t(".title") inside a page (lazy lookup). */
7
+ export function translate(key, options = {}) {
8
+ return requireI18n(options, 'translate').t(key, stripI18n(options));
9
+ }
10
+
11
+ /** l(Date.today, format: :long) */
12
+ export function localize(value, options = {}) {
13
+ return requireI18n(options, 'localize').l(value, stripI18n(options));
14
+ }
15
+
16
+ export const t = translate;
17
+ export const l = localize;
18
+
19
+ function stripI18n(options) {
20
+ if (!options || !('i18n' in options)) return options;
21
+ const { i18n, ...rest } = options;
22
+ return rest;
23
+ }
@@ -0,0 +1,86 @@
1
+ // ActionView::Helpers::UrlHelper at the census commit: url_for, mail_to, sms_to, phone_to. The tag
2
+ // helpers return markup (escaped attributes and content, as Rails' tag builder).
3
+ //
4
+ // **The locale travels in the URL** (ADR-223 D7; spec S4 § Locale negotiation): a same-origin URL
5
+ // built while the current page carries a `locale` query parameter carries it forward, as Rails'
6
+ // `default_url_options` does. Nothing stores the choice elsewhere.
7
+ import { escapeHtml } from '../i18n/index.js';
8
+
9
+ function currentLocation(options) {
10
+ if (options?.currentUrl) return new URL(String(options.currentUrl), 'http://x');
11
+ if (typeof location !== 'undefined') return new URL(location.href);
12
+ return undefined;
13
+ }
14
+
15
+ /**
16
+ * url_for — a path (or `{path, params, anchor}`) with the current page's `locale` parameter
17
+ * carried forward. An absolute URL to another origin is returned as given.
18
+ */
19
+ export function urlFor(target, options = {}) {
20
+ const spec = typeof target === 'string' ? { path: target } : { ...target };
21
+ const here = currentLocation(options);
22
+ const base = here ? here.origin : 'http://x';
23
+ const url = new URL(spec.path ?? '', here ? here.href : 'http://x/');
24
+ if (url.origin !== base && /^[a-z][a-z0-9+.-]*:/i.test(spec.path ?? '')) return spec.path;
25
+ for (const [k, v] of Object.entries(spec.params ?? {})) {
26
+ if (v === undefined || v === null) url.searchParams.delete(k);
27
+ else url.searchParams.set(k, String(v));
28
+ }
29
+ const locale = here?.searchParams.get('locale');
30
+ if (locale && !url.searchParams.has('locale') && options.carryLocale !== false) url.searchParams.set('locale', locale);
31
+ if (spec.anchor) url.hash = spec.anchor;
32
+ return url.pathname + url.search + url.hash;
33
+ }
34
+
35
+ function attributes(html) {
36
+ return Object.entries(html ?? {})
37
+ .filter(([, v]) => v !== undefined && v !== null && v !== false)
38
+ .map(([k, v]) => (v === true ? ` ${k}` : ` ${k}="${escapeHtml(v)}"`))
39
+ .join('');
40
+ }
41
+
42
+ /** ERB::Util.url_encode: every byte outside [A-Za-z0-9_.~-] percent-encoded. */
43
+ export function urlEncode(s) {
44
+ return encodeURIComponent(String(s)).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
45
+ }
46
+
47
+ const present = (v) => v !== undefined && v !== null && v !== '';
48
+
49
+ function split(name, htmlOptions) {
50
+ return typeof name === 'object' && name !== null ? [undefined, name] : [name, htmlOptions ?? {}];
51
+ }
52
+
53
+ /** mail_to "me@domain.com", "My email" # => <a href="mailto:me@domain.com">My email</a> */
54
+ export function mailTo(email, name, htmlOptions) {
55
+ const [label, opts] = split(name, htmlOptions);
56
+ const { replyTo, ...rest } = opts;
57
+ const html = { ...rest };
58
+ if (present(replyTo)) html.reply_to = replyTo;
59
+ const extras = ['cc', 'bcc', 'body', 'subject', 'reply_to']
60
+ .filter((k) => present(html[k]))
61
+ .map((k) => `${k.replace('_', '-')}=${urlEncode(html[k])}`);
62
+ for (const k of ['cc', 'bcc', 'body', 'subject', 'reply_to']) delete html[k];
63
+ const href = `mailto:${urlEncode(email).replace(/%40/g, '@')}${extras.length ? '?' + extras.join('&') : ''}`;
64
+ return `<a${attributes({ ...html, href })}>${escapeHtml(label ?? email)}</a>`;
65
+ }
66
+
67
+ /** sms_to "5155555785", "Text me", body: "Hi" # => <a href="sms:5155555785;?&body=Hi">Text me</a> */
68
+ export function smsTo(phoneNumber, name, htmlOptions) {
69
+ const [label, opts] = split(name, htmlOptions);
70
+ const { countryCode, country_code, body, ...html } = opts;
71
+ const cc = countryCode ?? country_code;
72
+ const code = present(cc) ? `+${urlEncode(cc)}` : '';
73
+ const b = present(body) ? `?&body=${urlEncode(body)}` : '';
74
+ return `<a${attributes({ ...html, href: `sms:${code}${urlEncode(phoneNumber)};${b}` })}>${escapeHtml(label ?? phoneNumber)}</a>`;
75
+ }
76
+
77
+ /** phone_to "1234567890", "Call me" # => <a href="tel:1234567890">Call me</a> */
78
+ export function phoneTo(phoneNumber, name, htmlOptions) {
79
+ const [label, opts] = split(name, htmlOptions);
80
+ const { countryCode, country_code, ...html } = opts;
81
+ const cc = countryCode ?? country_code;
82
+ const code = cc === undefined || cc === null ? '' : `+${urlEncode(cc)}`;
83
+ return `<a${attributes({ ...html, href: `tel:${code}${urlEncode(phoneNumber)}` })}>${escapeHtml(label ?? phoneNumber)}</a>`;
84
+ }
85
+
86
+ export { attributes as htmlAttributes };
@@ -0,0 +1,43 @@
1
+ // Proleptic Gregorian civil-date arithmetic in whole days since 1970-01-01 (H. Hinnant's
2
+ // days_from_civil / civil_from_days) — independent of the host's Date time-zone behaviour.
3
+
4
+ export function floorDiv(a, b) {
5
+ return Math.floor(a / b);
6
+ }
7
+
8
+ export function daysFromCivil(y, m, d) {
9
+ const yy = m <= 2 ? y - 1 : y;
10
+ const era = floorDiv(yy, 400);
11
+ const yoe = yy - era * 400;
12
+ const mp = (m + 9) % 12;
13
+ const doy = Math.floor((153 * mp + 2) / 5) + d - 1;
14
+ const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy;
15
+ return era * 146097 + doe - 719468;
16
+ }
17
+
18
+ export function civilFromDays(days) {
19
+ const z = days + 719468;
20
+ const era = floorDiv(z, 146097);
21
+ const doe = z - era * 146097;
22
+ const yoe = Math.floor((doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365);
23
+ const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
24
+ const mp = Math.floor((5 * doy + 2) / 153);
25
+ const d = doy - Math.floor((153 * mp + 2) / 5) + 1;
26
+ const m = mp < 10 ? mp + 3 : mp - 9;
27
+ const y = yoe + era * 400 + (m <= 2 ? 1 : 0);
28
+ return [y, m, d];
29
+ }
30
+
31
+ export function isLeap(y) {
32
+ return y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0);
33
+ }
34
+
35
+ /** 1-based day of the year. */
36
+ export function dayOfYear(y, m, d) {
37
+ return daysFromCivil(y, m, d) - daysFromCivil(y, 1, 1) + 1;
38
+ }
39
+
40
+ /** 0 = Sunday. */
41
+ export function weekdayOf(days) {
42
+ return (((days % 7) + 7 + 4) % 7);
43
+ }
@@ -0,0 +1,25 @@
1
+ // The i18n instance the helpers render with — Rails' I18n.locale, scoped to the page. The React
2
+ // I18nProvider binds it; a framework-neutral caller binds it with setCurrentI18n, or passes `i18n`
3
+ // in a helper's options.
4
+
5
+ let current = null;
6
+
7
+ export function setCurrentI18n(i18n) {
8
+ current = i18n;
9
+ }
10
+
11
+ export function currentI18n() {
12
+ return current;
13
+ }
14
+
15
+ /** The instance a helper call uses: its `i18n` option, else the bound one. */
16
+ export function i18nFor(options) {
17
+ return options?.i18n ?? current;
18
+ }
19
+
20
+ /** Like i18nFor, but a helper that cannot render without translations refuses loudly. */
21
+ export function requireI18n(options, helper) {
22
+ const i = i18nFor(options);
23
+ if (!i) throw new Error(`${helper}: no i18n instance is bound (I18nProvider, setCurrentI18n, or the i18n option)`);
24
+ return i;
25
+ }
@@ -0,0 +1,109 @@
1
+ // Exact decimal arithmetic on the digits of a number, so rounding is Ruby BigDecimal's half-up and
2
+ // never a binary float's (ADR-223 D6: the JavaScript and Rust runtimes agree digit for digit).
3
+ //
4
+ // A decimal is { neg, int, frac } where int and frac are digit strings (int without leading zeros,
5
+ // "0" for zero).
6
+
7
+ /** Parse a number or a decimal string (optionally signed, optionally with an exponent). */
8
+ export function parseDecimal(value) {
9
+ let s;
10
+ if (value && typeof value === 'object' && typeof value.int === 'string' && typeof value.frac === 'string') {
11
+ return { neg: Boolean(value.neg), int: value.int, frac: value.frac };
12
+ }
13
+ if (typeof value === 'number') {
14
+ if (!Number.isFinite(value)) throw new RangeError(`not a finite number: ${value}`);
15
+ s = String(value); // the shortest round-trip representation
16
+ } else if (typeof value === 'bigint') {
17
+ s = value.toString();
18
+ } else if (typeof value === 'string') {
19
+ s = value.trim();
20
+ } else {
21
+ throw new TypeError(`not a number: ${value}`);
22
+ }
23
+ const m = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/.exec(s);
24
+ if (!m || (m[2] === '' && (m[3] ?? '') === '')) throw new RangeError(`not a decimal: ${JSON.stringify(value)}`);
25
+ let int = m[2] || '0';
26
+ let frac = m[3] ?? '';
27
+ const exp = m[4] ? Number(m[4]) : 0;
28
+ if (exp > 0) {
29
+ const shift = frac.padEnd(exp, '0');
30
+ int += shift.slice(0, exp);
31
+ frac = shift.slice(exp);
32
+ } else if (exp < 0) {
33
+ const pad = int.padStart(-exp + 1, '0');
34
+ frac = pad.slice(pad.length + exp) + frac;
35
+ int = pad.slice(0, pad.length + exp);
36
+ }
37
+ int = int.replace(/^0+(?=\d)/, '');
38
+ const zero = /^0*$/.test(int) && /^0*$/.test(frac);
39
+ return { neg: m[1] === '-' && !zero, int, frac };
40
+ }
41
+
42
+ /** Round half-up (away from zero on a tie) to `precision` fraction digits; pads with zeros. */
43
+ export function roundFixed(d, precision) {
44
+ if (precision < 0) throw new RangeError('negative precision');
45
+ if (d.frac.length <= precision) return { ...d, frac: d.frac.padEnd(precision, '0') };
46
+ const keep = d.int + d.frac.slice(0, precision);
47
+ const up = d.frac.charCodeAt(precision) >= 53; // '5'
48
+ let digits = up ? increment(keep) : keep;
49
+ const intLen = digits.length - precision;
50
+ const int = digits.slice(0, intLen).replace(/^0+(?=\d)/, '') || '0';
51
+ const frac = digits.slice(intLen);
52
+ const zero = /^0*$/.test(int) && /^0*$/.test(frac);
53
+ return { neg: d.neg && !zero, int, frac };
54
+ }
55
+
56
+ /** Round half-up to `digits` significant digits (Rails `significant: true`). */
57
+ export function roundSignificant(d, digits) {
58
+ const all = (d.int + d.frac).replace(/^0+/, '');
59
+ if (all === '') return { ...d, frac: '' };
60
+ const intDigits = d.int === '0' ? 0 : d.int.length;
61
+ // position of the first significant digit relative to the decimal point
62
+ const leadingFracZeros = d.int === '0' ? d.frac.match(/^0*/)[0].length : 0;
63
+ const precision = intDigits > 0 ? Math.max(digits - intDigits, 0) : digits + leadingFracZeros;
64
+ if (intDigits > digits) {
65
+ // round the integer part itself
66
+ const scaled = { neg: d.neg, int: d.int.slice(0, digits), frac: d.int.slice(digits) + d.frac };
67
+ const r = roundFixed(scaled, 0);
68
+ return { neg: r.neg, int: r.int + '0'.repeat(intDigits - digits), frac: '' };
69
+ }
70
+ return roundFixed(d, precision);
71
+ }
72
+
73
+ function increment(digits) {
74
+ const a = digits.split('');
75
+ let i = a.length - 1;
76
+ while (i >= 0) {
77
+ if (a[i] === '9') {
78
+ a[i] = '0';
79
+ i -= 1;
80
+ } else {
81
+ a[i] = String.fromCharCode(a[i].charCodeAt(0) + 1);
82
+ return a.join('');
83
+ }
84
+ }
85
+ return '1' + a.join('');
86
+ }
87
+
88
+ /** Group the integer digits with `delimiter` every three digits. */
89
+ export function delimit(int, delimiter) {
90
+ if (!delimiter) return int;
91
+ return int.replace(/\B(?=(\d{3})+(?!\d))/g, delimiter);
92
+ }
93
+
94
+ /** Compare a decimal to an integer. */
95
+ export function isInteger(d) {
96
+ return /^0*$/.test(d.frac);
97
+ }
98
+
99
+ export function toNumber(d) {
100
+ return Number(`${d.neg ? '-' : ''}${d.int}${d.frac ? '.' + d.frac : ''}`);
101
+ }
102
+
103
+ export function abs(d) {
104
+ return { ...d, neg: false };
105
+ }
106
+
107
+ export function toPlain(d) {
108
+ return `${d.neg ? '-' : ''}${d.int}${d.frac ? '.' + d.frac : ''}`;
109
+ }