Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/build.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ name: Build

on:
push:
branches: [master]
branches: [main]
pull_request:
branches: [master]
branches: [main]

jobs:
build:
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/lint.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ name: Lint

on:
push:
branches: [master]
branches: [main]
pull_request:
branches: [master]
branches: [main]

jobs:
run-linter:
Expand Down
14 changes: 7 additions & 7 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -30,5 +30,5 @@ jobs:
- name: Install Node.js dependencies
run: yarn --frozen-lockfile

- name: Build source
- name: Test source
run: yarn test
21 changes: 12 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
```

Expand All @@ -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
Expand Down
26 changes: 24 additions & 2 deletions src/compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
23 changes: 22 additions & 1 deletion src/decompress.ts
Original file line number Diff line number Diff line change
@@ -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 = ''
Expand All @@ -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<string>(decompressedData.length)
for (let i = 0; i < decompressedData.length; i += 1) {
restoredData[permutation[i]] = decompressedData[i]
}

return restoredData
}
12 changes: 9 additions & 3 deletions tests/compress.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
6 changes: 3 additions & 3 deletions tests/decompress.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('', []), [])
})
})
46 changes: 26 additions & 20 deletions tests/integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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',
)
})
})