diff --git a/package.json b/package.json index 4b966c9..41eb35b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ts.data.json", - "version": "3.3.0", + "version": "3.4.0-beta.0", "description": "A JSON decoding library for Typescript", "type": "module", "main": "./dist/cjs/index.min.js", diff --git a/src/schemas/template-literal.spec.ts b/src/schemas/template-literal.spec.ts new file mode 100644 index 0000000..f5c01b7 --- /dev/null +++ b/src/schemas/template-literal.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { literal } from './literal'; +import { number } from './number'; +import { oneOf } from './one-of'; +import { string } from './string'; +import { templateLiteral } from './template-literal'; + +describe('templateLiteral', () => { + describe('string literal', () => { + const decoder = templateLiteral<'hi'>(['hi']); + it('should succeed', () => { + expect(decoder.parse('hi')).toBe('hi'); + }); + it('should fail', () => { + expect(() => decoder.parse('bye')).toThrowError( + '"bye" is not exactly "hi"' + ); + }); + }); + + it('should fail to decode a number', () => { + const decoder = templateLiteral<'hi'>(['hi']); + expect(() => decoder.parse(99)).toThrowError('99 is not exactly "hi"'); + }); + + describe('prefix.${string}.suffix', () => { + type Tpl = `prefix.${string}.suffix`; + const decoder = templateLiteral(['prefix.', string(), '.suffix']); + it('should succeed', () => { + expect(decoder.parse('prefix.anything.suffix')).toBe( + 'prefix.anything.suffix' + ); + }); + it('should fail', () => { + expect(() => decoder.parse('prefix.anything')).toThrowError( + '"prefix.anything" is not exactly "prefix.?.suffix"' + ); + }); + }); + + describe(`100px via [100, 'px']`, () => { + const decoder = templateLiteral<'100px'>([100, 'px']); + it('should succeed', () => { + expect(decoder.parse('100px')).toBe('100px'); + }); + it('should fail providing 100rem', () => { + expect(() => decoder.parse('100rem')).toThrowError( + '"100rem" is not exactly "100px"' + ); + }); + it('should fail providing 99px', () => { + expect(() => decoder.parse('99px')).toThrowError( + '"99px" is not exactly "100px"' + ); + }); + }); + + describe('${number}px', () => { + it('should succeed', () => { + type Tpl = `${number}px`; + const decoder = templateLiteral([number(), 'px']); + expect(decoder.parse('100px')).toBe('100px'); + expect(decoder.parse('1px')).toBe('1px'); + }); + it('should fail', () => { + type Tpl = `${number}px`; + const decoder = templateLiteral([number(), 'px']); + expect(() => decoder.parse(2)).toThrowError('2 is not exactly "?px"'); + }); + }); + + // FIXME: how to allow various decoders one after the other??? + it('should decode template with number decoder and a oneOf decoder', () => { + type Unit = `${number}.${'px' | 'rem'}`; + const decoder = templateLiteral([ + number(), + '.', + oneOf([literal('px'), literal('rem')], 'px | rem') + ]); + expect(decoder.parse('100.px')).toBe('100.px'); + }); +}); diff --git a/src/schemas/template-literal.ts b/src/schemas/template-literal.ts new file mode 100644 index 0000000..b3777d1 --- /dev/null +++ b/src/schemas/template-literal.ts @@ -0,0 +1,91 @@ +/** + * @module + * @mergeModuleWith decoders + * @category Api docs + */ + +import { Decoder } from '../core'; +import { exactlyError } from '../errors/exactly-error'; +import * as Result from '../utils/result'; + +/** + * Decoder that validates a template literal. + * + * @category Utils + * @param parts Any number of string literals (e.g. "hello") and string or number decoders (e.g. JsonDecoder.string()). + * @returns A decoder that only accepts the specified value + * + * @example + * ```ts + * const pxDecoder = JsonDecoder.templateLiteral([JsonDecoder.number(), "px"]); + * + * pxDecoder.decode("99px"); // Ok<"99px">({value: "99px"}) + * pxDecoder.decode(2); // Err({error: '2 is not exactly "?px"'}) + * ``` + */ +export function templateLiteral( + parts: readonly (string | number | Decoder)[] +): Decoder { + return new Decoder((json: any) => { + if (typeof json !== 'string') { + const template = parts + .map(p => (p instanceof Decoder ? '?' : p)) + .join(''); + return Result.err(exactlyError(json, template)); + } + + let index = 0; + let jsonIndex = 0; + + for (const part of parts) { + if (part instanceof Decoder) { + // Find the next literal part to know where this value ends + const nextLiteralIndex = parts.findIndex( + (p, i) => i > index && typeof p === 'string' + ); + const endIndex = + nextLiteralIndex === -1 + ? json.length + : json.indexOf(parts[nextLiteralIndex] as string, jsonIndex); + + if (endIndex === -1) { + const template = parts + .map(p => (p instanceof Decoder ? '?' : p)) + .join(''); + return Result.err(exactlyError(json, template)); + } + + const value = json.substring(jsonIndex, endIndex); + let result = part.decode(value); + if (!result.isOk()) { + // Try again. It might be a number() decoder + result = part.decode(parseInt(value)); + } + if (!result.isOk()) { + return result; + } + jsonIndex = endIndex; + } else { + // String or number literal + const literal = String(part); + if (!json.startsWith(literal, jsonIndex)) { + const template = parts + .map(p => (p instanceof Decoder ? '?' : p)) + .join(''); + return Result.err(exactlyError(json, template)); + } + jsonIndex += literal.length; + } + index++; + } + + if (jsonIndex !== json.length) { + const template = parts + .map(p => (p instanceof Decoder ? '?' : p)) + .join(''); + return Result.err(exactlyError(json, template)); + } + + return Result.ok(json as T); + }); +}