Skip to content

Commit

Permalink
refactor: move escape/unescape APIs under in their own files to keep …
Browse files Browse the repository at this point in the history
…package size small
  • Loading branch information
nbouvrette committed Mar 18, 2023
1 parent f576a88 commit 95379d8
Show file tree
Hide file tree
Showing 6 changed files with 271 additions and 166 deletions.
37 changes: 35 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
![Dependencies](https://img.shields.io/badge/dependencies-0-green)
[![Known Vulnerabilities](https://snyk.io/test/github/Avansai/properties-file/badge.svg?targetFile=package.json)](https://snyk.io/test/github/Avansai/properties-file?targetFile=package.json)

.properties file parser, JSON converter and Webpack loader.
`.properties` JSON converter, file parser and Webpack loader.

## Installation 💻

Expand All @@ -25,6 +25,7 @@ npm install properties-file
- `propertiesToJson` allows quick conversion from `.properties` files to JSON.
- `getProperties` returns a `Properties` object that provides insights into parsing issues such as key collisions.
- `propertiesToJson` & `getProperties` also have a browser-compatible version when passing directly the content of a file using the APIs under `properties-file/content`.
- `escapeKey`, `escapeValue` that can allow you to convert any content to `.properties` compatible format.
- Out of the box Webpack loader to `import` `.properties` files directly in your application.
- 100% test coverage based on the output from a Java implementation.
- Active maintenance (many popular .properties packages have been inactive years).
Expand All @@ -35,7 +36,7 @@ We put a lot of effort into adding [TSDoc](https://tsdoc.org/) to all our APIs.

Both APIs (`getProperties` and `propertiesToJson`) directly under `properties-file` depend on [`fs`](https://nodejs.org/api/fs.html) which means they cannot be used by browsers. If you cannot use `fs` and already have a `.properties` file content, the same APIs are available under `properties-file/content`. Instead of taking the `filePath` as the first argument, they take `content`. The example below will use "`fs`" APIs since they are the most common use cases.

### `propertiesToJson`
### `propertiesToJson` (common use case)

This API is probably the most used. You have a `.properties` file that you want to open and access like a simple key/value JSON object. Here is how this can be done with a single API call:

Expand All @@ -61,6 +62,38 @@ import { propertiesToJson } from 'properties-file/content'
console.log(propertiesToJson(propertiesFileContent))
```

### `escapeKey` and `escapeValue` (converting content to `.properties` format)

> ⚠ This package does not offer full-fledged `.properties` file writer that would include a variety of options like modifying an existing file while keeping comments and line breaks intact. If you have any interest into adding this in, pull requests are welcomed!
It is possible to use this package to do basic conversion between key/value content into `.properties.` compatible format by using `escapeKey` and `escapeValue`. Here is an example of how it can be done:

```ts
import * as fs from 'node:fs'
import { EOL } from 'node:os'
import { getProperties } from 'properties-file'
import { escapeKey, escapeValue } from 'properties-file/escape'

const properties = getProperties('assets/tests/collisions-test.properties')
const newProperties: string[] = []
console.dir(properties)

properties.collection.forEach((property) => {
const value = property.value === 'world3' ? 'new world3' : property.value
newProperties.push(`${escapeKey(property.key)}: ${escapeValue(value)}`)
})

fs.writeFileSync('myNewFile.properties', newProperties.join(EOL))

/**
* Outputs:
*
* hello: hello2
* world: new world3
*
*/
```

### `getProperties` (advanced use case)

Java's implementation of `Properties` is quite resilient. In fact, there are only two ways an exception can be thrown:
Expand Down
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"exports": {
".": "./lib/index.js",
"./content": "./lib/content/index.js",
"./escape": "./lib/escape/index.js",
"./unescape": "./lib/unescape/index.js",
"./webpack-loader": "./lib/loader/webpack.js"
},
"main": "lib/index.js",
Expand All @@ -33,6 +35,12 @@
"content": [
"lib/content/index.d.ts"
],
"escape": [
"lib/escape/index.d.ts"
],
"unescape": [
"lib/unescape/index.d.ts"
],
"webpack-loader": [
"lib/loader/webpack.d.ts"
]
Expand Down
100 changes: 100 additions & 0 deletions src/escape/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Escape a property key.
*
* @param unescapedKey - A property key to be escaped.
* @param escapeUnicode - Escape unicode characters into ISO-8859-1 compatible encoding?
*
* @return The escaped key.
*/
export const escapeKey = (unescapedKey: string, escapeUnicode = false): string => {
return escapeContent(unescapedKey, true, escapeUnicode)
}

/**
* Escape property value.
*
* @param unescapedValue - Property value to be escaped.
* @param escapeUnicode - Escape unicode characters into ISO-8859-1 compatible encoding?
*
* @return The escaped value.
*/
export const escapeValue = (unescapedValue: string, escapeUnicode = false): string => {
return escapeContent(unescapedValue, false, escapeUnicode)
}

/**
* Escape the content from either key or value of a property.
*
* @param unescapedContent - The content to escape.
* @param escapeSpace - Escape spaces?
* @param escapeUnicode - Escape unicode characters into ISO-8859-1 compatible encoding?
*
* @returns The unescaped content.
*/
const escapeContent = (
unescapedContent: string,
escapeSpace: boolean,
escapeUnicode: boolean
): string => {
let escapedContent = ''
for (
let character = unescapedContent[0], position = 0;
position < unescapedContent.length;
position++, character = unescapedContent[position]
) {
switch (character) {
case ' ': {
// Escape space if required, or if it is first character.
escapedContent += escapeSpace || position === 0 ? '\\ ' : ' '
break
}
// Backslash.
case '\\': {
escapedContent += '\\\\'
break
}
case '\f': {
// Formfeed.
escapedContent += '\\f'
break
}
case '\n': {
// Newline.
escapedContent += '\\n'
break
}
case '\r': {
// Carriage return.
escapedContent += '\\r'
break
}
case '\t': {
// Tab.
escapedContent += '\\t'
break
}
case '=':
case ':':
case '#':
case '!': {
// Escapes =, :, # and !.
escapedContent += `\\${character}`
break
}
default: {
if (escapeUnicode) {
const codePoint: number = character.codePointAt(0) as number // Can never be `undefined`.
if (codePoint < 0x0020 || codePoint > 0x007e) {
escapedContent += `\\u${codePoint.toString(16).padStart(4, '0')}`
break
}
}
// Non-escapable characters.
escapedContent += character
break
}
}
}

return escapedContent
}
172 changes: 13 additions & 159 deletions src/property.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PropertyLine } from './property-line'
import { unescapeContent } from './unescape'

/**
* Object representing a property (key/value).
Expand Down Expand Up @@ -67,18 +68,18 @@ export class Property {
// Set key if present.
if (!this.hasNoKey) {
this.escapedKey = this.linesContent.slice(0, this.delimiterPosition)
this.key = Property.unescape(this.escapedKey, this.startingLineNumber)
this.key = this.unescapeLine(this.escapedKey, this.startingLineNumber)
}

// Set value if present.
if (!this.hasNoValue) {
this.escapedValue = this.linesContent.slice(this.delimiterPosition + this.delimiterLength)
this.value = Property.unescape(this.escapedValue, this.startingLineNumber)
this.value = this.unescapeLine(this.escapedValue, this.startingLineNumber)
}
} else if (this.hasNoValue) {
// Set key if present (no delimiter).
this.escapedKey = this.linesContent
this.key = Property.unescape(this.escapedKey, this.startingLineNumber)
this.key = this.unescapeLine(this.escapedKey, this.startingLineNumber)
}
}

Expand All @@ -89,165 +90,18 @@ export class Property {
* @param startingLineNumber - The starting line number of the content being unescaped.
*
* @returns The unescaped content.
*/
public static unescape(escapedContent: string, startingLineNumber: number): string {
let unescapedContent = ''
for (
let character = escapedContent[0], position = 0;
position < escapedContent.length;
position++, character = escapedContent[position]
) {
if (character === '\\') {
const nextCharacter = escapedContent[position + 1]

switch (nextCharacter) {
case 'f': {
// Formfeed/
unescapedContent += '\f'
position++
break
}
case 'n': {
// Newline.
unescapedContent += '\n'
position++
break
}
case 'r': {
// Carriage return.
unescapedContent += '\r'
position++
break
}
case 't': {
// Tab.
unescapedContent += '\t'
position++
break
}
case 'u': {
// Unicode character.
const codePoint = escapedContent.slice(position + 2, position + 6)
if (!/[\da-f]{4}/i.test(codePoint)) {
// Code point can only be within Unicode's Multilingual Plane (BMP).
throw new Error(
`malformed escaped unicode characters '\\u${codePoint}' in property starting at line ${startingLineNumber}`
)
}
unescapedContent += String.fromCodePoint(Number.parseInt(codePoint, 16))
position += 5
break
}
default: {
// Otherwise the escape character is not required.
unescapedContent += nextCharacter
position++
}
}
} else {
// When there is \, simply add the character.
unescapedContent += character
}
}

return unescapedContent
}

/**
* Escape property key.
*
* @param unescapedKey Property key to be escaped.
* @return Escaped string.
* @throws {@link Error}
* This exception is thrown if malformed escaped unicode characters are present.
*/
public static escapeKey(unescapedKey: string, escapeUnicode = true): string {
return Property.escape(unescapedKey, true, escapeUnicode)
}

/**
* Escape property value.
*
* @param unescapedValue Property value to be escaped.
* @return Escaped string.
*/
public static escapeValue(unescapedValue: string, escapeUnicode = true): string {
return Property.escape(unescapedValue, false, escapeUnicode)
}

/**
* Internal escape method.
*
* @param unescapedContent Text to be escaped.
* @param escapeSpace Whether all spaces should be escaped
* @param escapeUnicode Whether unicode chars should be escaped
* @return Escaped string.
*/
private static escape(
unescapedContent: string,
escapeSpace: boolean,
escapeUnicode: boolean
): string {
const result: string[] = []

// eslint-disable-next-line unicorn/no-for-loop
for (let index = 0; index < unescapedContent.length; index++) {
const char = unescapedContent[index]
switch (char) {
case ' ': {
// Escape space if required, or if it is first character
if (escapeSpace || index === 0) {
result.push('\\ ')
} else {
result.push(' ')
}
break
}
case '\\': {
result.push('\\\\')
break
}
case '\f': {
// Form-feed
result.push('\\f')
break
}
case '\n': {
// Newline
result.push('\\n')
break
}
case '\r': {
// Carriage return
result.push('\\r')
break
}
case '\t': {
// Tab
result.push('\\t')
break
}
case '=': // Fall through
case ':': // Fall through
case '#': // Fall through
case '!': {
result.push('\\', char)
break
}
default: {
if (escapeUnicode) {
const codePoint: number = char.codePointAt(0) as number // can never be undefined
if (codePoint < 0x0020 || codePoint > 0x007e) {
result.push('\\u', codePoint.toString(16).padStart(4, '0'))
break
}
}
// Normal char
result.push(char)
break
}
}
private unescapeLine(escapedContent: string, startingLineNumber: number): string {
try {
return unescapeContent(escapedContent)
} catch (error) {
throw new Error(
`${(error as Error).message} in property starting at line ${startingLineNumber}`
)
}

return result.join('')
}

/**
Expand Down
Loading

0 comments on commit 95379d8

Please sign in to comment.