From d4be653756121111619b07d8e18e6e43bd789578 Mon Sep 17 00:00:00 2001 From: MrWook Date: Sun, 10 May 2026 20:49:12 +0200 Subject: [PATCH 1/6] feat: use Separate Index Mapping to use alphabetical sorting BREAKING CHANGE: return value of the compress changed to {compressedData: string, permutation: number[]} and the decompress arguments are both new values --- src/compress.ts | 26 ++++++++++++++++++++-- src/decompress.ts | 23 +++++++++++++++++++- tests/compress.spec.ts | 12 +++++++--- tests/decompress.spec.ts | 6 ++--- tests/integration.spec.ts | 46 ++++++++++++++++++++++----------------- 5 files changed, 84 insertions(+), 29 deletions(-) diff --git a/src/compress.ts b/src/compress.ts index 3db8e47..0ec511e 100644 --- a/src/compress.ts +++ b/src/compress.ts @@ -35,6 +35,28 @@ const compressWithPrefix = (parsed: string[]) => { return deltas.join('') } -export default function compress(data: string[]) { - return compressWithPrefix(data) +export default function compressOrdered(data: string[]) { + if (!Array.isArray(data)) { + return data + } + const indexed = data.map((value, index) => ({ value, index })) + indexed.sort((a, b) => { + if (a.value < b.value) return -1 + if (a.value > b.value) return 1 + return 0 + }) + + const sortedData = indexed.map((item) => item.value) + const permutation = indexed.map((item) => item.index) + + const compressedData = compressWithPrefix(sortedData) + + if (typeof compressedData !== 'string') { + return data + } + + return { + compressedData, + permutation, + } } diff --git a/src/decompress.ts b/src/decompress.ts index 9ad0ba1..8727a34 100644 --- a/src/decompress.ts +++ b/src/decompress.ts @@ -1,4 +1,4 @@ -export default function decompress(encodedString: string) { +function decompress(encodedString: string) { const decompressedArray = encodedString.split(/([A-Z])/g) const decompressedData = [] let last = '' @@ -12,3 +12,24 @@ export default function decompress(encodedString: string) { return decompressedData } + +export default function decompressOrdered( + encodedString: string, + permutation: number[], +) { + const decompressedData = decompress(encodedString) + if ( + !Array.isArray(decompressedData) || + !Array.isArray(permutation) || + decompressedData.length !== permutation.length + ) { + return decompressedData + } + + const restoredData = new Array(decompressedData.length) + for (let i = 0; i < decompressedData.length; i += 1) { + restoredData[permutation[i]] = decompressedData[i] + } + + return restoredData +} diff --git a/tests/compress.spec.ts b/tests/compress.spec.ts index e309210..123f233 100644 --- a/tests/compress.spec.ts +++ b/tests/compress.spec.ts @@ -5,12 +5,18 @@ import compress from '../src/compress' describe('compress', () => { it('should compress an array of strings with prefix compression', () => { const input = ['test', 'testing', 'tester'] - const expected = 'AtestEingEer' - assert.strictEqual(compress(input), expected) + const expected = 'AtestEerEing' + assert.deepStrictEqual(compress(input), { + compressedData: expected, + permutation: [0, 2, 1], + }) }) it('should handle empty array', () => { - assert.strictEqual(compress([]), '') + assert.deepStrictEqual(compress([]), { + compressedData: '', + permutation: [], + }) }) it('should return non-array input as is', () => { diff --git a/tests/decompress.spec.ts b/tests/decompress.spec.ts index bb7b98a..d5ea35c 100644 --- a/tests/decompress.spec.ts +++ b/tests/decompress.spec.ts @@ -4,12 +4,12 @@ import decompress from '../src/decompress' describe('decompress', () => { it('should decompress a string with prefix compression', () => { - const input = 'AtestEingEer' + const input = 'AtestEerEing' const expected = ['test', 'testing', 'tester'] - assert.deepStrictEqual(decompress(input), expected) + assert.deepStrictEqual(decompress(input, [0, 2, 1]), expected) }) it('should handle empty string', () => { - assert.deepStrictEqual(decompress(''), []) + assert.deepStrictEqual(decompress('', []), []) }) }) diff --git a/tests/integration.spec.ts b/tests/integration.spec.ts index 0f08404..0d23736 100644 --- a/tests/integration.spec.ts +++ b/tests/integration.spec.ts @@ -2,8 +2,8 @@ import { describe, it } from 'node:test' import assert from 'node:assert' import fs from 'node:fs' import path from 'node:path' -import compress from '../src/compress' -import decompress from '../src/decompress' +import compressOrdered from '../src/compress' +import decompressOrdered from '../src/decompress' const fixturePath = path.join( __dirname, @@ -15,44 +15,50 @@ const data = JSON.parse(rawData) as string[] describe('integration', () => { it('should compress and decompress correctly', () => { - const compressed = compress(data) - assert.deepStrictEqual(typeof compressed, 'string') + const result = compressOrdered(data) + if (Array.isArray(result)) { + assert.fail('Expected object with compressedData and permutation') + } - const decompressed = decompress(compressed as string) + const decompressed = decompressOrdered( + result.compressedData, + result.permutation, + ) assert.deepStrictEqual(decompressed, data) }) it('should compress in reasonable time', () => { const startTime = performance.now() - compress(data) + compressOrdered(data) const compressionTime = performance.now() - startTime - assert.ok(compressionTime < 50, 'Compression should complete within 50ms') + assert.ok(compressionTime < 20, 'Compression should complete within 50ms') }) it('should decompress in reasonable time', () => { - const compressed = compress(data) + const result = compressOrdered(data) + if (Array.isArray(result)) { + assert.fail('Expected object with compressedData and permutation') + } const startTime = performance.now() - decompress(compressed as string) + decompressOrdered(result.compressedData, result.permutation) const decompressionTime = performance.now() - startTime assert.ok( - decompressionTime < 500, + decompressionTime < 20, 'Decompression should complete within 500ms', ) }) it('should significantly reduce the size of a big fixture file', () => { - const compressed = compress(data) - - assert.strictEqual( - typeof compressed, - 'string', - 'Compressed result should be a string', - ) + const result = compressOrdered(data) + if (Array.isArray(result)) { + assert.fail('Expected object with compressedData and permutation') + } const originalSize = rawData.length - const compressedSize = compressed.length + const compressedSize = + result.compressedData.length + result.permutation.length const reduction = ((originalSize - compressedSize) / originalSize) * 100 assert.ok( @@ -61,8 +67,8 @@ describe('integration', () => { ) assert.ok( - reduction > 18, - 'Compressed size percentage should be at least 18% smaller than original', + reduction > 50, + 'Compressed size percentage should be at least 50% smaller than original', ) }) }) From e0047e96ecb638d1e9b351cc4b3b3109328ea528 Mon Sep 17 00:00:00 2001 From: MrWook Date: Sun, 10 May 2026 20:53:28 +0200 Subject: [PATCH 2/6] chore: use correct on branches workflow --- .github/workflows/build.js.yml | 4 ++-- .github/workflows/lint.js.yml | 4 ++-- .github/workflows/test.yml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.js.yml b/.github/workflows/build.js.yml index 8ec75bc..45c4712 100644 --- a/.github/workflows/build.js.yml +++ b/.github/workflows/build.js.yml @@ -5,9 +5,9 @@ name: Build on: push: - branches: [master] + branches: [main] pull_request: - branches: [master] + branches: [main] jobs: build: diff --git a/.github/workflows/lint.js.yml b/.github/workflows/lint.js.yml index 185ef32..efd3f57 100644 --- a/.github/workflows/lint.js.yml +++ b/.github/workflows/lint.js.yml @@ -5,9 +5,9 @@ name: Lint on: push: - branches: [master] + branches: [main] pull_request: - branches: [master] + branches: [main] jobs: run-linter: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9f8e4bc..1e4cbb8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,9 +5,9 @@ name: Build on: push: - branches: [master] + branches: [main] pull_request: - branches: [master] + branches: [main] jobs: build: From 51fde3ee423198845a5e894264cdaddd719052ea Mon Sep 17 00:00:00 2001 From: MrWook Date: Sun, 10 May 2026 20:54:21 +0200 Subject: [PATCH 3/6] chore: adjust readme to new ordering --- README.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 549765c..b7d16a2 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ Without this repository, each zxcvbn-ts language package would need to include i ## Features -- **Incremental Encoding**: Compresses sorted wordlists by storing common prefixes as a single character (A-Z). +- **Incremental Encoding**: Compresses wordlists by storing common prefixes as a single character (A-Z). +- **Order Preservation**: Maintains the original order of the input data using a separate index mapping (permutation). - **Lightweight**: Zero runtime dependencies. - **TypeScript Support**: Full type definitions included. - **Dual Build**: Supports both CommonJS (CJS) and ES Modules (ESM). @@ -41,14 +42,15 @@ yarn add @zxcvbn-ts/dictionary-compression ### Compression -The `compress` function takes an array of strings and returns a compressed string. The input array should ideally be sorted to maximize compression. +The `compress` function takes an array of strings and returns an object containing the compressed string and a permutation array to maintain the original order. ```typescript import compress from '@zxcvbn-ts/dictionary-compression/compress' -const data = ['alpha', 'alphabet', 'beta'] -const compressed = compress(data) -// Result: "AalphaFbetAbeta" +const data = ['beta', 'alpha', 'alphabet'] +const { compressedData, permutation } = compress(data) +// compressedData: "AalphaFbetAbeta" +// permutation: [2, 0, 1] // A = 0 shared chars, F = 5 shared chars, A = 0 shared chars ``` @@ -59,14 +61,15 @@ const compressed = compress(data) ### Decompression -The `decompress` function restores the original array from the compressed string. +The `decompress` function restores the original array from the compressed string and permutation array. ```typescript import decompress from '@zxcvbn-ts/dictionary-compression/decompress' -const compressed = 'AalphaFbetAbeta' -const decompressed = decompress(compressed) -// Result: ['alpha', 'alphabet', 'beta'] +const compressedData = 'AalphaFbetAbeta' +const permutation = [2, 0, 1] +const decompressed = decompress(compressedData, permutation) +// Result: ['beta', 'alpha', 'alphabet'] ``` ## Scripts From 4b4037a9bc2d4531972084cd6d876b97801fdfda Mon Sep 17 00:00:00 2001 From: MrWook Date: Sun, 10 May 2026 20:56:33 +0200 Subject: [PATCH 4/6] chore: adjust node version for dev dependencies --- .github/workflows/build.js.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.js.yml b/.github/workflows/build.js.yml index 45c4712..76d2f5b 100644 --- a/.github/workflows/build.js.yml +++ b/.github/workflows/build.js.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: - node-version: [18.x, 20.x, 22.x, 24.x] + node-version: [20.x, 22.x, 24.x] steps: - name: Check out Git repository diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1e4cbb8..9e5688b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: - node-version: [18.x, 20.x, 22.x, 24.x] + node-version: [20.x, 22.x, 24.x] steps: - name: Check out Git repository From 0734cb96849b625c6d13e705f0f47fc7e5ef2398 Mon Sep 17 00:00:00 2001 From: MrWook Date: Sun, 10 May 2026 20:57:39 +0200 Subject: [PATCH 5/6] chore: adjust node version for dev dependencies --- .github/workflows/build.js.yml | 2 +- .github/workflows/test.yml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.js.yml b/.github/workflows/build.js.yml index 76d2f5b..4739195 100644 --- a/.github/workflows/build.js.yml +++ b/.github/workflows/build.js.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: - node-version: [20.x, 22.x, 24.x] + node-version: [22.x, 24.x] steps: - name: Check out Git repository diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9e5688b..0bb2dfc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,7 +1,7 @@ # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions -name: Build +name: Test on: push: @@ -10,13 +10,13 @@ on: branches: [main] jobs: - build: - name: Run build + run-test: + name: Run test runs-on: ubuntu-latest strategy: matrix: - node-version: [20.x, 22.x, 24.x] + node-version: [22.x, 24.x] steps: - name: Check out Git repository @@ -30,5 +30,5 @@ jobs: - name: Install Node.js dependencies run: yarn --frozen-lockfile - - name: Build source + - name: Test source run: yarn test From 40131c2d7b470efd2b0eee86e3585f205e263988 Mon Sep 17 00:00:00 2001 From: MrWook Date: Sun, 10 May 2026 20:59:03 +0200 Subject: [PATCH 6/6] chore: correct wrong test value --- tests/integration.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration.spec.ts b/tests/integration.spec.ts index 0d23736..2d13f8e 100644 --- a/tests/integration.spec.ts +++ b/tests/integration.spec.ts @@ -32,7 +32,7 @@ describe('integration', () => { compressOrdered(data) const compressionTime = performance.now() - startTime - assert.ok(compressionTime < 20, 'Compression should complete within 50ms') + assert.ok(compressionTime < 50, 'Compression should complete within 50ms') }) it('should decompress in reasonable time', () => { @@ -45,8 +45,8 @@ describe('integration', () => { const decompressionTime = performance.now() - startTime assert.ok( - decompressionTime < 20, - 'Decompression should complete within 500ms', + decompressionTime < 50, + 'Decompression should complete within 50ms', ) })