diff --git a/providers/edge_provider.ts b/providers/edge_provider.ts index cd1a211a..9a6e4212 100644 --- a/providers/edge_provider.ts +++ b/providers/edge_provider.ts @@ -10,6 +10,7 @@ import edge, { type Edge } from 'edge.js' import { type URLOptions } from '../types/http.ts' import type { ApplicationService } from '../src/types.ts' +import numberHelpers from '../src/helpers/number.ts' import { pluginEdgeDumper } from '../modules/dumper/plugins/edge.ts' import { BriskRoute, HttpContext, Qs, type Route, type Router } from '../modules/http/main.ts' import { type ClientRouteJSON } from '@adonisjs/http-server/client/url_builder' @@ -145,6 +146,7 @@ export default class EdgeServiceProvider { edge.global('app', app) edge.global('config', edgeConfigResolver) + edge.global('number', numberHelpers) edge.global('routes', function () { return clientRoutes() }) diff --git a/src/helpers/number.ts b/src/helpers/number.ts new file mode 100644 index 00000000..b265f9cc --- /dev/null +++ b/src/helpers/number.ts @@ -0,0 +1,131 @@ +/* + * @adonisjs/core + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/** + * Collection of number helpers to clamp, parse, and format numeric values. + * + * @example + * // Constrain and inspect values + * numberHelpers.clamp(15, 0, 10) // 10 + * numberHelpers.between(5, 0, 10) // true + * + * @example + * // Parse untrusted input and format output + * numberHelpers.parse('42') // 42 + * numberHelpers.toFinite('abc', 0) // 0 + * numberHelpers.format(1500, { compact: true }) // '1.5K' + */ +const numberHelpers = { + /** + * Constrain a number to stay within the given bounds. + * + * @param value - The number to constrain + * @param min - The lower bound + * @param max - The upper bound + * + * @example + * numberHelpers.clamp(15, 0, 10) // 10 + * numberHelpers.clamp(-2, 0, 10) // 0 + * numberHelpers.clamp(5, 0, 10) // 5 + */ + clamp(value: number, min: number, max: number): number { + if (value < min) { + return min + } else if (value > max) { + return max + } else { + return value + } + }, + + /** + * Check if a number is inside an inclusive range. The bounds may be + * passed in either order. + * + * @param value - The number to test + * @param min - One end of the range + * @param max - The other end of the range + * + * @example + * numberHelpers.between(5, 0, 10) // true + * numberHelpers.between(0, 0, 10) // true + * numberHelpers.between(11, 0, 10) // false + * numberHelpers.between(5, 10, 0) // true + */ + between(value: number, min: number, max: number): boolean { + const lo = Math.min(min, max) + const hi = Math.max(min, max) + return value >= lo && value <= hi + }, + + /** + * Convert a value to a finite number. Returns the fallback when the + * result is `NaN` or `Infinity`. + * + * @param value - The value to convert + * @param fallback - Value to return when conversion fails. Defaults to `0` + * + * @example + * numberHelpers.toFinite(5) // 5 + * numberHelpers.toFinite('42') // 42 + * numberHelpers.toFinite(Number.NaN) // 0 + * numberHelpers.toFinite(Number.POSITIVE_INFINITY) // 0 + * numberHelpers.toFinite('abc', 10) // 10 + */ + toFinite(value: unknown, fallback = 0): number { + const n = typeof value === 'number' ? value : Number(value) + return Number.isFinite(n) ? n : fallback + }, + + /** + * Parse a value into a finite number. Returns `null` for empty input, + * `NaN`, and `Infinity` instead of substituting a fallback. + * + * @param value - The value to parse + * + * @example + * numberHelpers.parse(5) // 5 + * numberHelpers.parse('42') // 42 + * numberHelpers.parse('') // null + * numberHelpers.parse(null) // null + * numberHelpers.parse(Number.NaN) // null + * numberHelpers.parse(Number.POSITIVE_INFINITY) // null + */ + parse(value: unknown): number | null { + if (value === null || value === undefined || value === '') { + return null + } + + const n = typeof value === 'number' ? value : Number(value) + return Number.isFinite(n) ? n : null + }, + + /** + * Format a number using `Intl.NumberFormat`. + * + * @param value - The number to format + * @param options - Formatting options + * @param options.digits - Maximum fraction digits. Defaults to `2` + * @param options.compact - Use compact notation (e.g. `1.5K`) + * + * @example + * numberHelpers.format(12.3456) // '12.35' + * numberHelpers.format(12.3456, { digits: 1 }) // '12.3' + * numberHelpers.format(1500, { compact: true }) // '1.5K' + */ + format(value: number, options?: { digits?: number; compact?: boolean }): string { + return new Intl.NumberFormat('en', { + maximumFractionDigits: options?.digits ?? 2, + notation: options?.compact ? 'compact' : 'standard', + }).format(value) + }, +} + +export default numberHelpers + diff --git a/tests/bindings/edge.spec.ts b/tests/bindings/edge.spec.ts index 935615a3..61be68e8 100644 --- a/tests/bindings/edge.spec.ts +++ b/tests/bindings/edge.spec.ts @@ -40,6 +40,7 @@ test.group('Bindings | Edge', () => { assert.isFalse(edge.globals.config.has('foobar')) assert.strictEqual(edge.globals.app, app) assert.instanceOf(edge.globals.qs, Qs) + assert.equal(edge.globals.number.clamp(15, 0, 10), 10) const router = await app.container.make('router') router.get('/users/:id', () => {}) @@ -81,6 +82,31 @@ test.group('Bindings | Edge', () => { assert.equal(ctx.response.getBody(), 'Hello virk') }) + test('use number helpers inside templates', async ({ assert }) => { + const ignitor = new IgnitorFactory() + .merge({ + rcFileContents: { + providers: [ + () => import('../../providers/app_provider.js'), + () => import('../../providers/edge_provider.js'), + ], + }, + }) + .withCoreConfig() + .create(BASE_URL) + + const app = ignitor.createApp('console') + await app.init() + await app.boot() + + edge.registerTemplate('score', { + template: `{{ number.clamp(score, 0, 10) }} {{ number.between(score, 0, 10) }} {{ number.toFinite(raw, 0) }} {{ number.parse(valid) }} {{ number.format(amount, { compact: true }) }}`, + }) + + const html = await edge.render('score', { score: 15, raw: 'abc', valid: '8', amount: 1500 }) + assert.equal(html, '10 false 0 8 1.5K') + }) + test('make form action using formAttributes helper', async ({ assert }) => { const ignitor = new IgnitorFactory() .merge({ diff --git a/tests/helpers.spec.ts b/tests/helpers.spec.ts index 069d49d9..7ae6f69c 100644 --- a/tests/helpers.spec.ts +++ b/tests/helpers.spec.ts @@ -10,6 +10,51 @@ import { test } from '@japa/runner' import StringBuilder from '@poppinss/utils/string_builder' import stringHelpers from '../src/helpers/string.ts' +import numberHelpers from '../src/helpers/number.ts' + +test.group('Number helpers', () => { + test('clamp value between min and max', ({ assert }) => { + assert.equal(numberHelpers.clamp(15, 0, 10), 10) + assert.equal(numberHelpers.clamp(-2, 0, 10), 0) + assert.equal(numberHelpers.clamp(5, 0, 10), 5) + }) + + test('check if number is between min and max', ({ assert }) => { + assert.isTrue(numberHelpers.between(5, 0, 10)) + assert.isTrue(numberHelpers.between(0, 0, 10)) + assert.isTrue(numberHelpers.between(10, 0, 10)) + assert.isFalse(numberHelpers.between(-1, 0, 10)) + assert.isFalse(numberHelpers.between(11, 0, 10)) + assert.isTrue(numberHelpers.between(5, 10, 0)) + }) + + test('convert value to a finite number', ({ assert }) => { + assert.equal(numberHelpers.toFinite(5), 5) + assert.equal(numberHelpers.toFinite('42'), 42) + assert.equal(numberHelpers.toFinite(Number.NaN), 0) + assert.equal(numberHelpers.toFinite(Number.POSITIVE_INFINITY), 0) + assert.equal(numberHelpers.toFinite(Number.NEGATIVE_INFINITY, 3), 3) + assert.equal(numberHelpers.toFinite('abc', 10), 10) + assert.equal(numberHelpers.toFinite(undefined), 0) + }) + + test('parse value into a finite number or null', ({ assert }) => { + assert.equal(numberHelpers.parse(5), 5) + assert.equal(numberHelpers.parse('42'), 42) + assert.isNull(numberHelpers.parse('')) + assert.isNull(numberHelpers.parse(null)) + assert.isNull(numberHelpers.parse(undefined)) + assert.isNull(numberHelpers.parse(Number.NaN)) + assert.isNull(numberHelpers.parse(Number.POSITIVE_INFINITY)) + assert.isNull(numberHelpers.parse('abc')) + }) + + test('format a number', ({ assert }) => { + assert.equal(numberHelpers.format(12.3456), '12.35') + assert.equal(numberHelpers.format(12.3456, { digits: 1 }), '12.3') + assert.equal(numberHelpers.format(1500, { compact: true }), '1.5K') + }) +}) test.group('String helpers', () => { test('check if string is empty', ({ assert }) => {