From 565d8eadae0490812d9266568e8b82cb807a5379 Mon Sep 17 00:00:00 2001 From: Lea Rosema Date: Thu, 3 Oct 2024 20:09:24 +0200 Subject: [PATCH] feat: add basic built-in filters --- src/builtin-filters.js | 102 ++++++++++++++++++++++++++++++++ src/sissi-config.js | 3 +- src/transforms/template-data.js | 11 ++-- tests/builtin-filters.test.js | 69 +++++++++++++++++++++ 4 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 src/builtin-filters.js create mode 100644 tests/builtin-filters.test.js diff --git a/src/builtin-filters.js b/src/builtin-filters.js new file mode 100644 index 0000000..ddc3731 --- /dev/null +++ b/src/builtin-filters.js @@ -0,0 +1,102 @@ +/** + * Serialize JSON + * @param {any} arg any object to be serialized + * @param {boolean} pretty whether to indent json + * @returns + */ +export function json(arg, pretty = false) { + return pretty? JSON.stringify(arg, null, 2) : JSON.stringify(arg) +} + +/** + * Format date + * @param {string|Date|number} date + * @param {Intl.DateTimeFormatOptions} options + * @param {Intl.LocalesArgument} locales the locale (default: 'en-US') + * @returns {string} formatted date + */ +export function date(date, options = null, locales = 'en-US') { + return new Intl.DateTimeFormat(locales, options).format(typeof date === 'string' ? Date.parse(date): date) +} + +/** + * Format as currency. + * @param {number} amount + * @param {string?} currency + * @param {Intl.LocalesArgument} locales + * @returns + */ +export function currency(amount, currency = 'usd', locales = 'en-US') { + return new Intl.NumberFormat(locales, {style: 'currency', currency}).format(amount); +} + +/** + * Format number + * @param {number} value + * @param {Intl.NumberFormatOptions} options + * @param {Intl.LocalesArgument} locales + * @returns {string} formatted number + */ +export function numberFormat(value, options, locales) { + return new Intl.NumberFormat(locales || getEnvLocale(), options).format(value); +} + +/** + * Select a first N elements of an array + * @param {Iterable} array + * @param {number} limit + * @returns + */ +export function limit(array, limit) { + if (limit < 0) { + throw new Error(`Negative limits are not allowed: ${limit}.`); + } + return Array.from(array).slice(0, limit); +}; + +/** + * Copy an array and reverse + * @param {Iterable} array + * @returns new array, revered + */ +export function reverse(array) { + return Array.from(array).reverse(); +} + +/** + * Return a sorted copy of an array + * @param {Iterable} array + * @returns new array, sorted + */ +export function sort(array) { + const result = Array.from(array); + result.sort(); + return result; +} + +/** + * Return the last N elements, in reverse order + * @param {Iterable} array + * @param {number} amount + */ +export function last(amount = 1) { + return Array.from(array).reverse().slice(0, amount); +} + +/** + * Escape HTML (replace angle brackets and ampersands with entities) + * @param {string} str + * @returns escaped html + */ +export function htmlentities(str) { + return str.replace(/\&/, '&').replace(//gm, '>'); +} + +/** + * URL-encode + * @param {string} str + * @returns encoded string + */ +export function urlencode(str) { + return encodeURIComponent(str); +} diff --git a/src/sissi-config.js b/src/sissi-config.js index eb5120a..1683070 100644 --- a/src/sissi-config.js +++ b/src/sissi-config.js @@ -2,6 +2,7 @@ import css from "./css.js"; import html from "./html.js"; import md from "./md.js"; import { defaultNaming } from "./naming.js"; +import * as builtinFilters from './builtin-filters.js'; export class SissiConfig { @@ -18,7 +19,7 @@ export class SissiConfig { templateFormats = new Map(); extensions = new Map(); - filters = new Map(); + filters = new Map(Object.entries(builtinFilters)); constructor(options = null) { this.addPlugin(html); diff --git a/src/transforms/template-data.js b/src/transforms/template-data.js index b58abc6..d76d506 100644 --- a/src/transforms/template-data.js +++ b/src/transforms/template-data.js @@ -13,7 +13,7 @@ function mergeMaps(map1, map2) { } function htmlEscape(input) { - return input?.replace(/&/g, '&').replace(//g, '>'); + return input?.toString().replace(/\&/g, '&').replace(/\/g, '>'); } /** @@ -54,8 +54,9 @@ export function parseArguments(args, data) { } try { return JSON.parse(arg) - } catch (_err) { - return null; + } catch (err) { + console.error('error parsing JSON:', err.message); + return []; } }); } @@ -83,7 +84,7 @@ export function template(str) { if (filter && filters instanceof Map && filters.has(filter) && typeof filters.get(filter) === 'function') { const filterArgs = parseArguments(filterParams, data); - result = filters.get(filter)(result, ...filterArgs); + result = filters.get(filter)(result, ...(filterArgs||[])); } return isSafe ? result : htmlEscape(result); }); @@ -141,7 +142,7 @@ export async function handleTemplateFile(config, data, inputFile) { const processor = await plugin.compile(body, inputFile); - let fileContent = template(await processor(fileData))(fileData); + let fileContent = template(await processor(fileData))(fileData, config.filters); if (fileData.layout) { const layoutFilePath = path.normalize(path.join(config.dir.layouts, fileData.layout)); diff --git a/tests/builtin-filters.test.js b/tests/builtin-filters.test.js new file mode 100644 index 0000000..7e574d5 --- /dev/null +++ b/tests/builtin-filters.test.js @@ -0,0 +1,69 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict'; +import path from 'node:path' +import { handleTemplateFile } from '../src/transforms/template-data.js'; +import { SissiConfig } from '../src/sissi-config.js'; +describe('builtin filters', () => { + + const dummyResolver = (map) => (...paths) => map.get(path.normalize(path.join(...paths))); + + it('should format numbers', async () => { + const config = new SissiConfig(); + + const vFS = new Map(); + vFS.set('index.html', '{{ thousandPi | numberFormat: numberFormatOptions, "de-DE" }}'); + + config.resolve = dummyResolver(vFS); + + const data = { thousandPi: Math.PI * 1e3, numberFormatOptions: {maximumFractionDigits: 2, minimumFractionDigits: 2} }; + + const result = await handleTemplateFile(config, data, 'index.html'); + + assert.equal(result.content, '3.141,59'); + }); + + it('should format currencies', async () => { + const config = new SissiConfig(); + + const vFS = new Map(); + vFS.set('index.html', '{{ million | currency: "eur", "de-DE" }}'); + + config.resolve = dummyResolver(vFS); + + const data = { million: 1e6 }; + + const result = await handleTemplateFile(config, data, 'index.html'); + + assert.equal(result.content, '1.000.000,00 €'); + }); + + it('should format dates', async () => { + const config = new SissiConfig(); + + const vFS = new Map(); + vFS.set('index.html', '{{ newYear | date: dateFormatOptions, "de-DE" }}'); + + config.resolve = dummyResolver(vFS); + + const data = { newYear: new Date('2025-04-01'), dateFormatOptions: {"day": "2-digit", "month": "2-digit", "year": "numeric"} }; + + const result = await handleTemplateFile(config, data, 'index.html'); + + assert.equal(result.content, '01.04.2025'); + }); + + it('should serialize json', async () => { + const config = new SissiConfig(); + + const vFS = new Map(); + vFS.set('index.html', '{{ answer | json }}'); + + config.resolve = dummyResolver(vFS); + + const data = { answer: {result: 42} }; + + const result = await handleTemplateFile(config, data, 'index.html'); + + assert.equal(result.content, '{"result":42}'); + }); +});