diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..9a8de79 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,59 @@ +"use strict"; + +module.exports = { + env: { + es2023: true, + node: true, + }, + extends: [ + "airbnb-base", + "plugin:@eslint-community/eslint-comments/recommended", + "plugin:jsdoc/recommended", + "plugin:promise/recommended", + "plugin:regexp/recommended", + "plugin:security/recommended-legacy", + "prettier", + ], + overrides: [ + { + extends: ["plugin:jest/recommended", "plugin:jest/style"], + files: ["src/**/*.test.js"], + plugins: ["jest"], + rules: { + "jest/no-duplicate-hooks": "error", + "jest/no-test-return-statement": "error", + "jest/prefer-comparison-matcher": "error", + "jest/prefer-each": "warn", + "jest/prefer-equality-matcher": "error", + "jest/prefer-expect-resolves": "error", + "jest/prefer-hooks-in-order": "error", + "jest/prefer-hooks-on-top": "error", + "jest/prefer-mock-promise-shorthand": "error", + "jest/prefer-spy-on": "error", + "jest/require-top-level-describe": "error", + }, + }, + ], + parserOptions: { + ecmaVersion: 2023, + // Explicitly tell ESLint to parse JavaScript as CommonJS, as airbnb-base sets this to "modules" for ECMAScript + sourceType: "script", + }, + plugins: ["import", "jsdoc", "promise", "regexp", "security"], + root: true, + rules: { + "@eslint-community/eslint-comments/disable-enable-pair": "off", + "@eslint-community/eslint-comments/no-unused-disable": "error", + "@eslint-community/eslint-comments/require-description": "error", + "import/no-extraneous-dependencies": "error", + "jsdoc/check-syntax": "error", + "jsdoc/require-description-complete-sentence": "error", + "jsdoc/require-hyphen-before-param-description": "error", + "no-multiple-empty-lines": ["error", { max: 1 }], + "prefer-destructuring": ["error", { object: true, array: false }], + "promise/prefer-await-to-callbacks": "warn", + "promise/prefer-await-to-then": "warn", + "security/detect-object-injection": "off", + strict: ["error", "global"], + }, +}; diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2736361 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Set default behaviour to automatically convert line endings +* text=auto eol=lf \ No newline at end of file diff --git a/.github/codeql-config.yml b/.github/codeql-config.yml new file mode 100644 index 0000000..d6a992d --- /dev/null +++ b/.github/codeql-config.yml @@ -0,0 +1,13 @@ +name: CodeQL Config + +# **What it does**: This provides a config to the CodeQL GitHub Action in this repo. +# **Why we have it**: Security scanning. + +queries: + - uses: security-and-quality + +# Limit the paths scanning takes place, improving speed of scan +paths: + - "**/*.html" + - "**/*.js" + - "**/*.yml" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4076858 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,35 @@ +version: 2 +updates: + - package-ecosystem: github-actions + commit-message: + include: scope + prefix: ci + directory: / + ignore: + # Staying on v3 due to incomplete migration guide for v4 + - dependency-name: google-github-actions/release-please-action + update-types: ["version-update:semver-major"] + open-pull-requests-limit: 20 + schedule: + interval: monthly + + - package-ecosystem: npm + commit-message: + include: scope + prefix: build + directory: / + groups: + commitlint: + patterns: + - "@commitlint*" + eslint: + patterns: + - "eslint*" + ignore: + # Below are dependencies that have migrated to ESM + # in their next major version so we can't use them + - dependency-name: is-html + update-types: ["version-update:semver-major"] + open-pull-requests-limit: 20 + schedule: + interval: monthly diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml new file mode 100644 index 0000000..e61c010 --- /dev/null +++ b/.github/workflows/automerge.yml @@ -0,0 +1,66 @@ +name: Automerge Dependabot PRs + +# **What it does**: Automatically merge Dependabot PRs that pass the CI workflow run. +# **Why we have it**: To keep our dependencies up-to-date, to avoid security issues. + +on: + workflow_run: + workflows: ["CI"] + types: [completed] + +permissions: + contents: write + pull-requests: write + +jobs: + on-success: + if: > + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' && + github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + steps: + - name: Download artifact + uses: actions/github-script@v7 + with: + script: | + const { writeFile } = require("node:fs/promises"); + const { owner, repo } = context.repo; + + const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner, + repo, + run_id: ${{ github.event.workflow_run.id }}, + }); + const matchArtifact = artifacts.data.artifacts.find( + (artifact) => artifact.name == "pr" + ); + + const download = await github.rest.actions.downloadArtifact({ + owner, + repo, + artifact_id: matchArtifact.id, + archive_format: "zip", + }); + + await writeFile("${{github.workspace}}/pr.zip", Buffer.from(download.data)); + + - name: Unzip artifact + run: unzip pr.zip + + - name: Merge PR + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { readFile } = require("node:fs/promises"); + const { owner, repo } = context.repo; + + const pull_number = Number(await readFile("./NR", "utf8")); + + await github.rest.pulls.merge({ + merge_method: "squash", + owner, + repo, + pull_number, + }); diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..a0536b9 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,100 @@ +name: CD + +# **What it does**: Automatically generates releases and release notes. +# **Why we have it**: Allows development to focus on higher value work. + +on: + push: + branches: + - main + # Allows this workflow to be run manually from the Actions tab + workflow_dispatch: + +jobs: + release: + name: Create/Update Release Pull Request + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + release_created: ${{ steps.release.outputs.release_created }} + steps: + - name: Release Please + id: release + # Staying on v3 due to incomplete migration guide for v4 + uses: google-github-actions/release-please-action@v3 + with: + changelog-types: '[ { "type": "feat", "section": "Features", "hidden": false }, { "type": "fix", "section": "Bug fixes", "hidden": false }, { "type": "build", "section": "Dependencies", "hidden": false }, { "type": "chore", "section": "Miscellaneous", "hidden": false }, { "type": "ci", "section": "Continuous integration", "hidden": false }, { "type": "perf", "section": "Improvements", "hidden": false }, { "type": "refactor", "section": "Improvements", "hidden": false }, { "type": "style", "section": "Miscellaneous", "hidden": false }, { "type": "docs", "section": "Documentation", "hidden": false }]' + release-type: node + package-name: fix-latin1-to-utf8 + + publish-npm: + name: Publish to NPM + needs: release + if: needs.release.outputs.release_created == 'true' + runs-on: ubuntu-latest + environment: main + permissions: + contents: read + id-token: write + steps: + - name: Check out repo + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: lts/* + registry-url: https://registry.npmjs.org + + - name: Publish to NPM + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # Build docs and TS definitions, and remove dev values + # from package.json before publishing to reduce package size + run: | + npm i --ignore-scripts + npm run build + npm pkg delete commitlint devDependencies jest scripts + npm publish --access public --ignore-scripts --provenance + + publish-ghp: + name: Publish to GitHub Packages + needs: release + if: needs.release.outputs.release_created == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + packages: write + steps: + - name: Check out repo + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: lts/* + registry-url: https://npm.pkg.github.com + scope: "@fdawgs" + + - name: Scope package + run: | + pkgName=$(npm pkg get name | tr -d '"') + npm pkg set name="@fdawgs/$pkgName" + + - name: Publish to GitHub Packages + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Build docs and TS definitions, and remove dev values + # from package.json before publishing to reduce package size + run: | + npm i --ignore-scripts + npm run build + npm pkg delete commitlint devDependencies jest scripts + npm publish --access public --ignore-scripts --provenance diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d9869f0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,164 @@ +name: CI + +# **What it does**: Runs our tests. +# **Why we have it**: We want our tests to pass before merging code. + +on: + push: + branches: + - main + paths-ignore: + - "docs/**" + - "*.md" + pull_request: + branches: + - main + paths-ignore: + - "docs/**" + - "*.md" + types: [opened, ready_for_review, reopened, synchronize] + +permissions: + contents: read + +# This allows a subsequently queued workflow run to interrupt previous runs +concurrency: + group: "${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}" + cancel-in-progress: true + +jobs: + dependency-review: + name: Dependency Review + if: > + github.event.pull_request.draft == false && + github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Check out repo + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Dependency review + uses: actions/dependency-review-action@v4 + + lint: + name: Lint Code + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - name: Check out repo + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: lts/* + + - name: Install + run: npm i --ignore-scripts + + - name: Run ESLint + run: npm run lint + + - name: Run Prettier + run: npm run lint:prettier + + - name: Run License Checker + run: npm run lint:licenses + + - name: Compile TypeScript Definition File + run: npm run build + + commit-lint: + name: Lint Commit Messages + if: > + github.event.pull_request.draft == false && + github.actor != 'dependabot[bot]' + runs-on: ubuntu-latest + steps: + - name: Check out repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Run Commitlint + uses: wagoid/commitlint-github-action@v5 + with: + configFile: ./package.json + + unit-tests: + name: Unit Tests + if: github.event.pull_request.draft == false + strategy: + matrix: + node-version: [18, 20] + os: [macos-latest, ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Check out repo + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Node ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install + run: npm i --ignore-scripts + + - name: Run tests + run: npm run jest:coverage + + - name: Coveralls parallel + if: github.repository == 'Fdawgs/fix-latin1-to-utf8' + uses: coverallsapp/github-action@v2.2.3 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + parallel: true + flag-name: run-${{ matrix.node-version }}-${{ matrix.os }} + + coverage: + name: Aggregate Coverage Calculations + needs: unit-tests + if: > + github.event.pull_request.draft == false && + github.repository == 'Fdawgs/fix-latin1-to-utf8' + runs-on: ubuntu-latest + steps: + - name: Coveralls finished + uses: coverallsapp/github-action@v2.2.3 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + parallel-finished: true + + # This job is used to save the PR number in an artifact, for use in the automerge.yml workflow + save-pr-number: + name: Save Dependabot PR Number + needs: unit-tests + if: > + github.event.pull_request.draft == false && + github.event_name == 'pull_request' && + github.event.pull_request.user.login == 'dependabot[bot]' + runs-on: ubuntu-latest + steps: + - name: Check out repo + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Save PR number + run: | + mkdir -p ./pr + echo ${{ github.event.number }} > ./pr/NR + + - name: Upload PR number in artifact + uses: actions/upload-artifact@v4 + with: + name: pr + path: pr/ diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..d33010f --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,45 @@ +name: CodeQL Analysis + +# **What it does**: This runs CodeQL on this repo. +# **Why we have it**: Security scanning. + +on: + push: + branches: + - main + pull_request: + branches: + - main + paths: + - "**/*.html" + - "**/*.js" + - "**/*.yml" + types: [opened, ready_for_review, reopened, synchronize] + +permissions: + security-events: write + +# This allows a subsequently queued workflow run to interrupt previous runs +concurrency: + group: "${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}" + cancel-in-progress: true + +jobs: + build: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - name: Check out repo + uses: actions/checkout@v4 + with: + persist-credentials: false + + # Initialises the CodeQL tools for scanning + - name: Initialise CodeQL + uses: github/codeql-action/init@v3 + with: + languages: javascript + config-file: ./.github/codeql-config.yml + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml new file mode 100644 index 0000000..874e59e --- /dev/null +++ b/.github/workflows/link-check.yml @@ -0,0 +1,52 @@ +name: Check Markdown for Broken Links + +# **What it does**: Checks all links in markdown files, apart from links to repo commits and issues. +# **Why we have it**: We want to know if any links break. + +on: + push: + branches: + - main + paths: + - "**/*.md" + - "!CHANGELOG.md" + pull_request: + branches: + - main + paths: + - "**/*.md" + - "!CHANGELOG.md" + types: [opened, ready_for_review, reopened, synchronize] + schedule: + # ┌───────────── minute (0 - 59) + # │ ┌───────────── hour (0 - 23) + # │ │ ┌───────────── day of the month (1 - 31) + # │ │ │ ┌───────────── month (1 - 12 or JAN-DEC) + # │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT) + # │ │ │ │ │ + # │ │ │ │ │ + # │ │ │ │ │ + # * * * * * + - cron: "30 1 1 * *" + # Allows this workflow to be run manually from the Actions tab + workflow_dispatch: + +permissions: + contents: read + +jobs: + link-check: + name: Link Check + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - name: Check out repo + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Run Linkinator + uses: JustinBeckwith/linkinator-action@v1 + with: + paths: "**/*.md" + skip: "https://(?:www.|)github.com/Fdawgs/.*/(?:commit|issues|compare)/, http://0.0.0.0" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a091ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,142 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v1 +.yarnclean + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# Clinic.js +.clinic + +# lock files +bun.lockb +package-lock.json +pnpm-lock.yaml +yarn.lock \ No newline at end of file diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..d468455 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +npx --no-install commitlint --edit $1 \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..4c260d1 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npm run lint:licenses && npm test \ No newline at end of file diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..bbf2914 --- /dev/null +++ b/.npmignore @@ -0,0 +1,6 @@ +# `package.json` is published to npm, so everything we add is included in the installed tarball. +# Removing the `files` key and replacing it with this `.npmignore` saves some bytes, which +# should improve installation performance slightly +* +!src/index.js +!types/index.d.ts \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..2474798 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,148 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v1 +.yarnclean + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# Clinic.js +.clinic + +# Auto generated files +package.json +API.md +CHANGELOG.md +/types/index.d.ts + +# lock files +bun.lockb +package-lock.json +pnpm-lock.yaml +yarn.lock \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..bcc76ae --- /dev/null +++ b/.prettierrc @@ -0,0 +1,17 @@ +{ + "arrowParens": "always", + "endOfLine": "lf", + "semi": true, + "tabWidth": 4, + "trailingComma": "es5", + "useTabs": true, + "overrides": [ + { + "files": ["*.css", "*.html", "*.scss"], + "options": { + "bracketSameLine": true, + "printWidth": 120 + } + } + ] +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..45a7f49 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,11 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "bierner.github-markdown-preview", + "dbaeumer.vscode-eslint", + "eamodio.gitlens", + "esbenp.prettier-vscode", + "redhat.vscode-yaml" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6c87966 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "files.eol": "\n", + "gitlens.telemetry.enabled": false, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "javascript.updateImportsOnFileMove.enabled": "always", + "npm.packageManager": "npm", + "prettier.prettierPath": "./node_modules/prettier/index.cjs", + "redhat.telemetry.enabled": false +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a40a4ca --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Frazer Smith + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 61bbb95..5a51502 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,49 @@ # fix-latin1-to-utf8 -Node.js module to fix encoding errors when converting from Latin-1 to UTF-8 + +[![GitHub release](https://img.shields.io/github/release/Fdawgs/fix-latin1-to-utf8.svg)](https://github.com/Fdawgs/fix-latin1-to-utf8/releases/latest/) +[![npm version](https://img.shields.io/npm/v/fix-latin1-to-utf8)](https://npmjs.com/package/fix-latin1-to-utf8) +[![Build status](https://github.com/Fdawgs/fix-latin1-to-utf8/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Fdawgs/fix-latin1-to-utf8/actions/workflows/ci.yml) +[![Coverage status](https://coveralls.io/repos/github/Fdawgs/fix-latin1-to-utf8/badge.svg?branch=main)](https://coveralls.io/github/Fdawgs/fix-latin1-to-utf8?branch=main) +[![code style: Prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat)](https://github.com/prettier/prettier) + +> Node.js module to fix encoding errors when converting from Latin-1 to UTF-8 + +# Overview + +When converting Latin-1 (ISO-8859-1) or a superset like Windows-1252 to UTF-8, some characters may be incorrectly converted. This module fixes those errors. + +## Installation + +Install using `npm`: + +```bash +npm i fix-latin1-to-utf8 +``` + +## Example usage + +```js +const fixLatin1ToUtf8 = require("fix-latin1-to-utf8"); + +const latin1String = + "This is a UTF-8 string that was converted from Latin-1‚ but the conversion was not great."; +const utf8String = fixLatin1ToUtf8(latin1String); + +console.log(utf8String); +// This is a UTF-8 string that was converted from Latin-1, but the conversion was not great. +``` + +## Contributing + +Contributions are welcome, and any help is greatly appreciated! + +See [the contributing guide](https://github.com/Fdawgs/.github/blob/main/CONTRIBUTING.md) for details on how to get started. +Please adhere to this project's [Code of Conduct](https://github.com/Fdawgs/.github/blob/main/CODE_OF_CONDUCT.md) when contributing. + +## Acknowledgements + +- **Tex Texin** - Creator of the [UTF-8 Encoding Debugging Chart](http://www.i18nqa.com/debug/utf8-debug.html) + +## License + +`fix-latin1-to-utf8` is licensed under the [MIT](./LICENSE) license. diff --git a/package.json b/package.json new file mode 100644 index 0000000..649bd55 --- /dev/null +++ b/package.json @@ -0,0 +1,89 @@ +{ + "name": "fix-latin1-to-utf8", + "version": "1.0.0", + "description": "Fix encoding errors when converting from Latin-1 (and Windows-1252) to UTF-8", + "keywords": [ + "iso-8859-1", + "iso8859-1", + "latin-1", + "latin1", + "utf8", + "windows-1252", + "windows1252" + ], + "main": "src/index.js", + "type": "commonjs", + "types": "types/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/Fdawgs/fix-latin1-to-utf8.git" + }, + "homepage": "https://github.com/Fdawgs/fix-latin1-to-utf8", + "bugs": { + "url": "https://github.com/Fdawgs/fix-latin1-to-utf8/issues" + }, + "license": "MIT", + "author": "Frazer Smith ", + "funding": "https://github.com/sponsors/Fdawgs", + "engines": { + "node": ">=18.0.0" + }, + "scripts": { + "build": "tsc", + "jest": "jest", + "jest:coverage": "jest --coverage", + "lint": "eslint . --cache --ext js,jsx --ignore-path .gitignore", + "lint:fix": "npm run lint -- --fix", + "lint:licenses": "node scripts/license-checker.js", + "lint:prettier": "prettier . -c -u", + "lint:prettier:fix": "prettier . -w -u", + "prepare": "husky", + "test": "npm run lint && npm run lint:prettier && npm run jest" + }, + "commitlint": { + "extends": [ + "@commitlint/config-conventional" + ] + }, + "jest": { + "collectCoverageFrom": [ + "src/**/*.js" + ], + "coverageReporters": [ + "text", + "lcovonly" + ], + "coverageThreshold": { + "global": { + "branches": 100, + "functions": 100, + "lines": 100, + "statements": 100 + } + }, + "testEnvironment": "node", + "testTimeout": 10000 + }, + "devDependencies": { + "@commitlint/cli": "^18.5.0", + "@commitlint/config-conventional": "^18.5.0", + "@eslint-community/eslint-plugin-eslint-comments": "^4.1.0", + "@types/jest": "^29.5.11", + "eslint": "^8.56.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-jest": "^27.6.3", + "eslint-plugin-jsdoc": "^48.0.2", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-regexp": "^2.2.0", + "eslint-plugin-security": "^2.1.0", + "husky": "^9.0.6", + "jest": "^29.7.0", + "license-checker": "^25.0.1", + "prettier": "^3.2.4", + "spdx-copyleft": "^1.0.0", + "typescript": "^5.3.3", + "upath": "^2.0.1" + } +} diff --git a/scripts/.eslintrc.js b/scripts/.eslintrc.js new file mode 100644 index 0000000..975e027 --- /dev/null +++ b/scripts/.eslintrc.js @@ -0,0 +1,13 @@ +"use strict"; + +module.exports = { + rules: { + "import/no-extraneous-dependencies": [ + "error", + { + devDependencies: true, + }, + ], + "no-console": "off", + }, +}; diff --git a/scripts/license-checker.js b/scripts/license-checker.js new file mode 100644 index 0000000..a36cc25 --- /dev/null +++ b/scripts/license-checker.js @@ -0,0 +1,88 @@ +"use strict"; + +const { promisify } = require("node:util"); +const { init } = require("license-checker"); +/** @type {string[]} */ +// @ts-ignore: module is a JSON file +const copyLeftLicenses = require("spdx-copyleft"); +const { joinSafe } = require("upath"); + +const check = promisify(init); + +/** + * @author Frazer Smith + * @description Checks licenses of all direct production dependencies to + * ensure they are not copyleft. + */ +async function checkLicenses() { + console.log("Checking licenses of direct production dependencies..."); + + /** + * List of deprecated copyleft license identifiers. + * @see {@link https://spdx.org/licenses/#deprecated | SPDX Deprecated License Identifiers} + */ + const deprecatedLicenseList = [ + "AGPL-1.0", + "AGPL-3.0", + "GFDL-1.1", + "GFDL-1.2", + "GFDL-1.3", + "GPL-1.0", + "GPL-1.0+", + "GPL-2.0", + "GPL-2.0+", + "GPL-2.0-with-autoconf-exception", + "GPL-2.0-with-bison-exception", + "GPL-2.0-with-classpath-exception", + "GPL-2.0-with-font-exception", + "GPL-2.0-with-GCC-exception", + "GPL-3.0", + "GPL-3.0+", + "GPL-3.0-with-autoconf-exception", + "GPL-3.0-with-GCC-exception", + "LGPL-2.0", + "LGPL-2.0+", + "LGPL-2.1", + "LGPL-2.1+", + "LGPL-3.0", + "LGPL-3.0+", + ]; + + // Merge copyleft licenses with deprecated licenses list + copyLeftLicenses.push(...deprecatedLicenseList); + + const licenses = await check({ + direct: true, + production: true, + start: joinSafe(__dirname, ".."), + }); + + const copyLeftLicensesList = Object.keys(licenses).filter((license) => { + let lic = licenses[license].licenses; + + if (!lic) { + console.error( + `No license found for ${license}. Please check the package.json file.` + ); + process.exit(1); + } + + lic = Array.isArray(lic) ? lic : [lic]; + + return lic.some((l) => copyLeftLicenses.includes(l)); + }); + + if (copyLeftLicensesList.length > 0) { + console.error( + `The following dependencies are using copyleft licenses: ${copyLeftLicensesList.join( + ", " + )}` + ); + process.exit(1); + } + + console.log("No copyleft licenses found."); + process.exit(0); +} + +checkLicenses(); diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..5ab042f --- /dev/null +++ b/src/index.js @@ -0,0 +1,155 @@ +"use strict"; + +/** + * @description Object containing Latin-1 characters and their corresponding UTF-8 characters. + * @type {Record} + */ +const replacements = { + // Actual: Expected + "€": "€", + "‚": "‚", + "Æ’": "ƒ", + "„": "„", + "…": "…", + "â€\u00A0": "†", + "‡": "‡", + "ˆ": "ˆ", + "‰": "‰", + "Å\u00A0": "Š", + "‹": "‹", + "Å’": "Œ", + "Ž": "Ž", + "‘": "‘", + "’": "’", + "“": "“", + "â€\u009D": "”", + "•": "•", + "–": "–", + "—": "—", + Ëœ: "˜", + "â„¢": "™", + "Å¡": "š", + "›": "›", + "Å“": "œ", + "ž": "ž", + "Ÿ": "Ÿ", + " ": " ", + "¡": "¡", + "¢": "¢", + "£": "£", + "¤": "¤", + "Â¥": "¥", + "¦": "¦", + "§": "§", + "¨": "¨", + "©": "©", + ª: "ª", + "«": "«", + "¬": "¬", + "­": "­", + "®": "®", + "¯": "¯", + "°": "°", + "±": "±", + "²": "²", + "³": "³", + "´": "´", + µ: "µ", + "¶": "¶", + "·": "·", + "¸": "¸", + "¹": "¹", + º: "º", + "»": "»", + "¼": "¼", + "½": "½", + "¾": "¾", + "¿": "¿", + "À": "À", + "Â": "Â", + Ã: "Ã", + "Ä": "Ä", + "Ã…": "Å", + "Æ": "Æ", + "Ç": "Ç", + È: "È", + "É": "É", + Ê: "Ê", + "Ë": "Ë", + ÃŒ: "Ì", + "Ã\u008D": "Í", + ÃŽ: "Î", + "Ã\u008F": "Ï", + "Ã\u0090": "Ð", + "Ñ": "Ñ", + "Ã’": "Ò", + "Ó": "Ó", + "Ô": "Ô", + "Õ": "Õ", + "Ö": "Ö", + "×": "×", + "Ø": "Ø", + "Ù": "Ù", + Ú: "Ú", + "Û": "Û", + Ãœ: "Ü", + "Ã\u009D": "Ý", + Þ: "Þ", + ß: "ß", + "Ã\u00A0": "à", + "á": "á", + "â": "â", + "ã": "ã", + "ä": "ä", + "Ã¥": "å", + "æ": "æ", + "ç": "ç", + "è": "è", + "é": "é", + ê: "ê", + "ë": "ë", + "ì": "ì", + "Ã\u00AD": "í", + "î": "î", + "ï": "ï", + "ð": "ð", + "ñ": "ñ", + "ò": "ò", + "ó": "ó", + "ô": "ô", + õ: "õ", + "ö": "ö", + "÷": "÷", + "ø": "ø", + "ù": "ù", + ú: "ú", + "û": "û", + "ü": "ü", + "ý": "ý", + "þ": "þ", + "ÿ": "ÿ", +}; + +// Cache immutable regex as they are expensive to create and garbage collect +// eslint-disable-next-line security/detect-non-literal-regexp -- Static regex, no user input +const matchRegex = new RegExp(Object.keys(replacements).join("|"), "gu"); + +/** + * @author Frazer Smith + * @description Fixes common encoding errors when converting from Latin-1 (and Windows-1252) to UTF-8. + * @see {@link http://www.i18nqa.com/debug/utf8-debug.html | UTF-8 Encoding Debugging Chart} + * @param {string} str - The string to be converted. + * @returns {string} The converted string. + */ +function fixLatin1ToUtf8(str) { + if (typeof str !== "string") { + throw new TypeError("Expected a string"); + } + + return str.replace(matchRegex, (match) => replacements[match]).normalize(); +} + +module.exports = fixLatin1ToUtf8; // CommonJS default export +module.exports.default = fixLatin1ToUtf8; // ESM default export +module.exports.fixLatin1ToUtf8 = fixLatin1ToUtf8; // TypeScript and named export +module.exports.replacements = replacements; diff --git a/src/index.test.js b/src/index.test.js new file mode 100644 index 0000000..29d8b77 --- /dev/null +++ b/src/index.test.js @@ -0,0 +1,25 @@ +"use strict"; + +const { fixLatin1ToUtf8, replacements } = require("./index"); + +describe("fixLatin1ToUtf8", () => { + it.each(Object.entries(replacements))( + "Replaces %s with %s", + (actual, expected) => { + expect(fixLatin1ToUtf8(actual)).toBe(expected); + } + ); + + it("Replaces multiple characters", () => { + expect(fixLatin1ToUtf8("‚ƒ„…â€\u00A0")).toBe("‚ƒ„…†"); + }); + + it("Does not mutate a string without Latin-1 characters", () => { + const str = "Hello, world!"; + expect(fixLatin1ToUtf8(str)).toBe(str); + }); + + it("Throws an error if the argument is not a string", () => { + expect(() => fixLatin1ToUtf8(123)).toThrow(TypeError); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0f34a80 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "declaration": true, + "emitDeclarationOnly": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "types", + "resolveJsonModule": true, + "target": "ES2022", + }, + "include": ["src/index.js"], +} diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 0000000..9e78d59 --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1,17 @@ +export = fixLatin1ToUtf8; +/** + * @author Frazer Smith + * @description Fixes common encoding errors when converting from Latin-1 (and Windows-1252) to UTF-8. + * @see {@link http://www.i18nqa.com/debug/utf8-debug.html | UTF-8 Encoding Debugging Chart} + * @param {string} str - The string to be converted. + * @returns {string} The converted string. + */ +declare function fixLatin1ToUtf8(str: string): string; +declare namespace fixLatin1ToUtf8 { + export { fixLatin1ToUtf8 as default, fixLatin1ToUtf8, replacements }; +} +/** + * @description Object containing Latin-1 characters and their corresponding UTF-8 characters. + * @type {Record} + */ +declare const replacements: Record;