diff --git a/.github/workflows/build.js.yml b/.github/workflows/build.js.yml index 8ec75bc..4739195 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: @@ -16,7 +16,7 @@ jobs: strategy: matrix: - node-version: [18.x, 20.x, 22.x, 24.x] + node-version: [22.x, 24.x] steps: - name: Check out Git repository 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..0bb2dfc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,22 +1,22 @@ # 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: - branches: [master] + branches: [main] pull_request: - branches: [master] + branches: [main] jobs: - build: - name: Run build + run-test: + name: Run test runs-on: ubuntu-latest strategy: matrix: - node-version: [18.x, 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 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 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..2d13f8e 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') }) 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, - 'Decompression should complete within 500ms', + decompressionTime < 50, + 'Decompression should complete within 50ms', ) }) 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', ) }) })