feat(stdlib): new modules + eslint/tsconfig migration
- Add array/async/etc. modules and type tests; migrate to eslint flat config and composite tsconfig (vitest typecheck enabled). - Fix PubSub.emit to snapshot listeners before iterating (stable EventEmitter semantics; avoids invoking listeners added during the same emit).
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
export * from './levenshtein-distance';
|
||||
export * from './template';
|
||||
export * from './trigram-distance';
|
||||
// TODO: Template is not implemented yet
|
||||
// export * from './template';
|
||||
|
||||
@@ -29,4 +29,15 @@ describe('levenshteinDistance', () => {
|
||||
expect(levenshteinDistance('abc', '')).toBe(3);
|
||||
expect(levenshteinDistance('', 'abc')).toBe(3);
|
||||
});
|
||||
|
||||
it('is symmetric', () => {
|
||||
expect(levenshteinDistance('kitten', 'sitting')).toBe(levenshteinDistance('sitting', 'kitten'));
|
||||
expect(levenshteinDistance('football', 'foot')).toBe(levenshteinDistance('foot', 'football'));
|
||||
});
|
||||
|
||||
it('counts UTF-16 code units (surrogate pairs count as two)', () => {
|
||||
expect(levenshteinDistance('😀', '')).toBe(2); // surrogate pair = 2 code units
|
||||
expect(levenshteinDistance('a😀b', 'a😀b')).toBe(0);
|
||||
expect(levenshteinDistance('café', 'cafe')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,32 +15,34 @@ export function levenshteinDistance(left: string, right: string): number {
|
||||
if (left.length === 0) return right.length;
|
||||
if (right.length === 0) return left.length;
|
||||
|
||||
// Create empty edit distance matrix for all possible modifications of
|
||||
// substrings of left to substrings of right
|
||||
const distanceMatrix = Array(right.length + 1).fill(null).map(() => Array(left.length + 1).fill(null));
|
||||
// Iterate with the shorter string as the inner dimension so the rolling rows are
|
||||
// O(min(m, n)) memory instead of a full O(m * n) matrix.
|
||||
const outer = left.length >= right.length ? left : right;
|
||||
const inner = left.length >= right.length ? right : left;
|
||||
const innerLength = inner.length;
|
||||
|
||||
// Fill the first row of the matrix
|
||||
// If this is the first row, we're transforming from an empty string to left
|
||||
// In this case, the number of operations equals the length of left substring
|
||||
for (let i = 0; i <= left.length; i++)
|
||||
distanceMatrix[0]![i]! = i;
|
||||
// prev = previous row; current = row being computed. prev starts as the base row [0..innerLength].
|
||||
let prev = Array.from({ length: innerLength + 1 }, (_, i) => i);
|
||||
let current = Array.from<number>({ length: innerLength + 1 });
|
||||
|
||||
// Fill the first column of the matrix
|
||||
// If this is the first column, we're transforming empty string to right
|
||||
// In this case, the number of operations equals the length of right substring
|
||||
for (let j = 0; j <= right.length; j++)
|
||||
distanceMatrix[j]![0]! = j;
|
||||
for (let i = 1; i <= outer.length; i++) {
|
||||
current[0] = i;
|
||||
const outerChar = outer[i - 1];
|
||||
|
||||
for (let j = 1; j <= right.length; j++) {
|
||||
for (let i = 1; i <= left.length; i++) {
|
||||
const indicator = left[i - 1] === right[j - 1] ? 0 : 1;
|
||||
distanceMatrix[j]![i]! = Math.min(
|
||||
distanceMatrix[j]![i - 1]! + 1, // deletion
|
||||
distanceMatrix[j - 1]![i]! + 1, // insertion
|
||||
distanceMatrix[j - 1]![i - 1]! + indicator, // substitution
|
||||
for (let j = 1; j <= innerLength; j++) {
|
||||
const cost = outerChar === inner[j - 1] ? 0 : 1;
|
||||
current[j]! = Math.min(
|
||||
prev[j]! + 1, // insertion
|
||||
current[j - 1]! + 1, // deletion
|
||||
prev[j - 1]! + cost, // substitution
|
||||
);
|
||||
}
|
||||
|
||||
// Swap the rolling rows; the freshly computed row becomes `prev` for the next iteration.
|
||||
const next = prev;
|
||||
prev = current;
|
||||
current = next;
|
||||
}
|
||||
|
||||
return distanceMatrix[right.length]![left.length]!;
|
||||
return prev[innerLength]!;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expectTypeOf, it } from 'vitest';
|
||||
import type { ClearPlaceholder, ExtractPlaceholders } from './index';
|
||||
import type { ClearPlaceholder, ExtractPlaceholders, GenerateTypes } from './index';
|
||||
import { templateObject } from './index';
|
||||
|
||||
describe.skip('template', () => {
|
||||
describe('template', () => {
|
||||
describe('ClearPlaceholder', () => {
|
||||
it('ignores strings without braces', () => {
|
||||
type actual = ClearPlaceholder<'name'>;
|
||||
@@ -102,4 +103,36 @@ describe.skip('template', () => {
|
||||
expectTypeOf<actual>().toEqualTypeOf<expected>();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GenerateTypes', () => {
|
||||
type Shape = GenerateTypes<'user.name', string>;
|
||||
|
||||
it('accepts a fully-matching shape', () => {
|
||||
expectTypeOf<{ user: { name: 'John' } }>().toExtend<Shape>();
|
||||
});
|
||||
|
||||
it('accepts missing keys (every key is optional)', () => {
|
||||
expectTypeOf<{ unrelated: number }>().toExtend<Shape>();
|
||||
});
|
||||
|
||||
it('accepts extra keys (objects stay open)', () => {
|
||||
expectTypeOf<{ user: { name: 'John' }; extra: number }>().toExtend<Shape>();
|
||||
});
|
||||
|
||||
it('rejects a mistyped leaf value', () => {
|
||||
expectTypeOf<{ user: { name: number } }>().not.toExtend<Shape>();
|
||||
});
|
||||
});
|
||||
|
||||
describe('templateObject', () => {
|
||||
it('always returns a string', () => {
|
||||
expectTypeOf(templateObject('Hello, {name}!', { name: 'John' })).toEqualTypeOf<string>();
|
||||
expectTypeOf(templateObject('Hi {user.name}', { user: { name: 'John' } })).toEqualTypeOf<string>();
|
||||
});
|
||||
|
||||
it('accepts a string or factory fallback', () => {
|
||||
expectTypeOf(templateObject('Hello, {name}!', {}, 'Guest')).toEqualTypeOf<string>();
|
||||
expectTypeOf(templateObject('Hello, {name}!', {}, key => `<${key}>`)).toEqualTypeOf<string>();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { templateObject } from '.';
|
||||
|
||||
describe.skip('templateObject', () => {
|
||||
it('replace template placeholders with corresponding values from args', () => {
|
||||
describe('templateObject', () => {
|
||||
it('replace an indexed array placeholder', () => {
|
||||
const template = 'Hello, {names.0}!';
|
||||
const args = { names: ['John'] };
|
||||
const result = templateObject(template, args);
|
||||
expect(result).toBe('Hello, John!');
|
||||
});
|
||||
|
||||
it('replace template placeholders with corresponding values from args', () => {
|
||||
it('replace a simple key placeholder', () => {
|
||||
const template = 'Hello, {name}!';
|
||||
const args = { name: 'John' };
|
||||
const result = templateObject(template, args);
|
||||
@@ -45,4 +45,23 @@ describe.skip('templateObject', () => {
|
||||
|
||||
expect(result).toBe('Hello {John Doe, your address 123 Main St');
|
||||
});
|
||||
|
||||
it('replace a missing placeholder with an empty string by default', () => {
|
||||
expect(templateObject('Hello, {name}!', {})).toBe('Hello, !');
|
||||
});
|
||||
|
||||
it('render falsy-but-present values (0, false, empty string)', () => {
|
||||
expect(templateObject('count: {n}', { n: 0 })).toBe('count: 0');
|
||||
expect(templateObject('flag: {b}', { b: false })).toBe('flag: false');
|
||||
expect(templateObject('s:{s}.', { s: '' })).toBe('s:.');
|
||||
});
|
||||
|
||||
it('trim whitespace inside the braces', () => {
|
||||
expect(templateObject('Hi { name }!', { name: 'Jo' })).toBe('Hi Jo!');
|
||||
});
|
||||
|
||||
it('leave a template without placeholders untouched', () => {
|
||||
expect(templateObject('no placeholders here', {})).toBe('no placeholders here');
|
||||
expect(templateObject('', {})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Collection, Path, PathToPartialType, Stringable, Trim, UnionToIntersection } from '../../types';
|
||||
import { get } from '../../collections';
|
||||
import { isFunction } from '../../types';
|
||||
import type { Collection, Path, PathToType, Stringable, Trim, UnionToIntersection } from '../../types';
|
||||
|
||||
/**
|
||||
* Type of a value that will be used to replace a placeholder in a template.
|
||||
@@ -52,23 +52,45 @@ export type ExtractPlaceholders<In extends string>
|
||||
* type Base = GenerateTypes<'Hello {user.name}, your address {user.addresses.0.street}'>; // { user: { name: string; addresses: { 0: { street: string; }; }; }; }
|
||||
* type WithTarget = GenerateTypes<'Hello {user.age}', number>; // { user: { age: number; }; }
|
||||
*/
|
||||
export type GenerateTypes<T extends string, Target = string> = UnionToIntersection<PathToType<Path<T>, Target>>;
|
||||
export type GenerateTypes<T extends string, Target = string>
|
||||
// No placeholders (T is never) → impose no shape on the args object.
|
||||
= [T] extends [never]
|
||||
? Collection
|
||||
: UnionToIntersection<PathToPartialType<Path<T>, Target>>;
|
||||
|
||||
/**
|
||||
* @name templateObject
|
||||
* @category Text
|
||||
* @description Replace `{path}` placeholders in a template string with values
|
||||
* resolved from `args` by dot-path. Placeholder keys are inferred from the
|
||||
* template, so `args` is type-checked and auto-completed against them.
|
||||
*
|
||||
* @param {string} template - Template string with `{path}` placeholders
|
||||
* @param {object} args - Source values, keyed by the placeholder paths
|
||||
* @param {string | ((key: string) => string)} [fallback] - Value (or factory) used when a placeholder cannot be resolved; defaults to an empty string
|
||||
* @returns {string} The interpolated string
|
||||
*
|
||||
* @example
|
||||
* templateObject('Hello, {name}!', { name: 'John' }); // 'Hello, John!'
|
||||
* templateObject('Hi {user.addresses.0.city}', { user: { addresses: [{ city: 'NY' }] } }); // 'Hi NY'
|
||||
* templateObject('Hello, {name}!', {}, 'Guest'); // 'Hello, Guest!'
|
||||
* templateObject('Hello, {name}!', {}, key => `<${key}>`); // 'Hello, <name>!'
|
||||
*
|
||||
* @since 0.0.4
|
||||
*/
|
||||
export function templateObject<
|
||||
T extends string,
|
||||
A extends GenerateTypes<ExtractPlaceholders<T>, TemplateValue> & Collection,
|
||||
>(template: T, args: A, fallback?: TemplateFallback) {
|
||||
return template.replace(TEMPLATE_PLACEHOLDER, (_, key) => {
|
||||
const value = get(args, key)?.toString();
|
||||
return value !== undefined ? value : (isFunction(fallback) ? fallback(key) : '');
|
||||
>(template: T, args: A, fallback?: TemplateFallback): string {
|
||||
return template.replace(TEMPLATE_PLACEHOLDER, (_match, key: string) => {
|
||||
const value = get(args, key);
|
||||
|
||||
if (value !== null && value !== undefined)
|
||||
return String(value);
|
||||
|
||||
if (isFunction<(key: string) => string>(fallback))
|
||||
return fallback(key);
|
||||
|
||||
return fallback ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
templateObject('Hello {user.name}, your address {user.addresses.0.city}', {
|
||||
user: {
|
||||
name: 'John',
|
||||
addresses: [
|
||||
{ city: 'Kolpa' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { trigramDistance, trigramProfile } from '.';
|
||||
|
||||
describe('trigramProfile', () => {
|
||||
@@ -66,6 +66,13 @@ describe('trigramDistance', () => {
|
||||
expect(trigramDistance(profile1, profile2)).toBe(1);
|
||||
});
|
||||
|
||||
it('is symmetric', () => {
|
||||
const a = trigramProfile('hello world');
|
||||
const b = trigramProfile('hello lorem');
|
||||
|
||||
expect(trigramDistance(a, b)).toBe(trigramDistance(b, a));
|
||||
});
|
||||
|
||||
it('one for empty text and non-empty text', () => {
|
||||
const profile1 = trigramProfile('hello world');
|
||||
const profile2 = trigramProfile('');
|
||||
|
||||
Reference in New Issue
Block a user