diff --git a/__tests__/plugins/formatters.core.test.ts b/__tests__/plugins/formatters.core.test.ts index 5f32bcd..b2f7a98 100644 --- a/__tests__/plugins/formatters.core.test.ts +++ b/__tests__/plugins/formatters.core.test.ts @@ -370,6 +370,14 @@ loader.paths('f-json-pretty-%N.html').forEach((path) => { test(`json pretty - ${path}`, () => loader.execute(path)); }); +loader.paths('f-find-first-%N.html').forEach((path) => { + test(`find-first - ${path}`, () => loader.execute(path)); +}); + +loader.paths('f-find-last-%N.html').forEach((path) => { + test(`find-last - ${path}`, () => loader.execute(path)); +}); + test('key-by', () => { let vars = variables([{ id: 1 }]); Core['key-by'].apply([], vars, CTX); diff --git a/__tests__/plugins/resources/f-find-first-1.html b/__tests__/plugins/resources/f-find-first-1.html new file mode 100644 index 0000000..a18b29c --- /dev/null +++ b/__tests__/plugins/resources/f-find-first-1.html @@ -0,0 +1,18 @@ +:JSON +{ + "items": [ + { "id": "a", "enabled": false }, + { "id": "b", "enabled": true }, + { "id": "c", "enabled": true } + ] +} + +:TEMPLATE +{.var @first items|find-first}{@first.id} +{.var @first items|find-first enabled}{@first.id} +{.var @first items|find-first disabled}{@first.id} {# Note: "disabled" is not a valid path, so it should return nothing #} + +:OUTPUT +a +b + diff --git a/__tests__/plugins/resources/f-find-first-2.html b/__tests__/plugins/resources/f-find-first-2.html new file mode 100644 index 0000000..4ddc9ec --- /dev/null +++ b/__tests__/plugins/resources/f-find-first-2.html @@ -0,0 +1,15 @@ +:JSON +{ + "keys": ["a", "b", "c"], + "map": { + "a": { "enabled": false }, + "b": { "enabled": true }, + "c": { "enabled": true } + } +} + +:TEMPLATE +{keys|find-first map enabled} + +:OUTPUT +b diff --git a/__tests__/plugins/resources/f-find-first-3.html b/__tests__/plugins/resources/f-find-first-3.html new file mode 100644 index 0000000..8e53240 --- /dev/null +++ b/__tests__/plugins/resources/f-find-first-3.html @@ -0,0 +1,22 @@ +:JSON +{ + "strings": ["a", "b", "c"], + "empty": [], + "notArray": "not-an-array", + "objects": [{ "x": 1 }, { "x": 2 }], + "noMatch": [{ "id": "a", "enabled": false }, { "id": "b" }] +} + +:TEMPLATE +{.var @r strings|find-first}{@r} +{.var @r empty|find-first}-{@r} +{.var @r notArray|find-first}-{@r} +{.var @r objects|find-first}{@r.x} +{.var @r noMatch|find-first enabled}-{@r} + +:OUTPUT +a +- +- +1 +- diff --git a/__tests__/plugins/resources/f-find-last-1.html b/__tests__/plugins/resources/f-find-last-1.html new file mode 100644 index 0000000..b3479da --- /dev/null +++ b/__tests__/plugins/resources/f-find-last-1.html @@ -0,0 +1,14 @@ +:JSON +{ + "items": [ + { "id": "a", "enabled": true }, + { "id": "b", "enabled": true }, + { "id": "c", "enabled": false } + ] +} + +:TEMPLATE +{.var @last items|find-last enabled}{@last.id} + +:OUTPUT +b diff --git a/__tests__/plugins/resources/f-find-last-2.html b/__tests__/plugins/resources/f-find-last-2.html new file mode 100644 index 0000000..6a22c85 --- /dev/null +++ b/__tests__/plugins/resources/f-find-last-2.html @@ -0,0 +1,15 @@ +:JSON +{ + "keys": ["a", "b", "c"], + "map": { + "a": { "enabled": true }, + "b": { "enabled": true }, + "c": { "enabled": false } + } +} + +:TEMPLATE +{keys|find-last map enabled} + +:OUTPUT +b diff --git a/__tests__/plugins/resources/f-find-last-3.html b/__tests__/plugins/resources/f-find-last-3.html new file mode 100644 index 0000000..95d6222 --- /dev/null +++ b/__tests__/plugins/resources/f-find-last-3.html @@ -0,0 +1,22 @@ +:JSON +{ + "strings": ["a", "b", "c"], + "empty": [], + "notArray": "not-an-array", + "objects": [{ "x": 1 }, { "x": 2 }], + "noMatch": [{ "id": "a", "enabled": false }, { "id": "b" }] +} + +:TEMPLATE +{.var @r strings|find-last}{@r} +{.var @r empty|find-last}-{@r} +{.var @r notArray|find-last}-{@r} +{.var @r objects|find-last}{@r.x} +{.var @r noMatch|find-last enabled}-{@r} + +:OUTPUT +c +- +- +2 +- diff --git a/src/plugins/formatters.core.ts b/src/plugins/formatters.core.ts index 48689d7..bdbc075 100644 --- a/src/plugins/formatters.core.ts +++ b/src/plugins/formatters.core.ts @@ -8,6 +8,7 @@ import { Variable } from '../variable'; import { Type } from '../types'; import { executeTemplate } from '../exec'; import { splitVariable } from '../util'; +import { findNthValidEntry, getLookupAndPath } from './util.find'; import { format } from './util.format'; import { escapeHtmlAttributes, escapeScriptTags, slugify, truncate } from './util.string'; import utf8 from 'utf8'; @@ -243,6 +244,22 @@ export class KeyByFormatter extends Formatter { } } +export class FindFirstFormatter extends Formatter { + apply(args: string[], vars: Variable[], ctx: Context): void { + const first = vars[0]; + const { lookup, path } = getLookupAndPath(ctx, args); + first.set(findNthValidEntry(first.get(), path, lookup, 1)); + } +} + +export class FindLastFormatter extends Formatter { + apply(args: string[], vars: Variable[], ctx: Context): void { + const first = vars[0]; + const { lookup, path } = getLookupAndPath(ctx, args); + first.set(findNthValidEntry(first.get(), path, lookup, -1)); + } +} + const NEWLINE = /\n/g; export class LineBreaksFormatter extends Formatter { @@ -421,6 +438,8 @@ export const CORE_FORMATTERS: FormatterTable = { 'encode-space': new EncodeSpaceFormatter(), 'encode-uri': new EncodeUriFormatter(), 'encode-uri-component': new EncodeUriComponentFormatter(), + 'find-first': new FindFirstFormatter(), + 'find-last': new FindLastFormatter(), format: new FormatFormatter(), get: new GetFormatter(), html: new HtmlFormatter(), diff --git a/src/plugins/util.find.ts b/src/plugins/util.find.ts new file mode 100644 index 0000000..945c943 --- /dev/null +++ b/src/plugins/util.find.ts @@ -0,0 +1,45 @@ +import { Context } from '../context'; +import { MISSING_NODE, Node, isTruthy, toNode } from '../node'; +import { splitVariable } from '../util'; + +export const getLookupAndPath = (ctx: Context, args: string[]) => { + const argsCount = args.length; + const hasLookup = argsCount === 2; + const hasPath = argsCount >= 1; + const lookup = hasLookup ? ctx.resolve(splitVariable(args[0])) : null; + const path = hasPath ? splitVariable(args[hasLookup ? 1 : 0]) : null; + return { lookup, path }; +}; + +export const findNthValidEntry = (items: Node, path: (string | number)[] | null, lookup: Node | null, nth: number): Node => { + if (!Array.isArray(items) || items.length === 0) { + return MISSING_NODE; + } + const forward = nth > 0; + const start = forward ? 0 : items.length - 1; + const end = forward ? items.length : -1; + const step = forward ? 1 : -1; + + let count = 0; + const hasLookup = lookup != null; + const hasPath = path != null; + + for (let i = start; forward ? i < end : i > end; i += step) { + const node = toNode(items[i]); + if (!hasPath) { + count += step; + if (count == nth) { + return node; + } + } else { + const candidate = hasLookup ? lookup.path([node.asString()]) : node; + if (isTruthy(candidate.path(path))) { + count += step; + if (count == nth) { + return node; + } + } + } + } + return MISSING_NODE; +};