JavaScript and TypeScript

Note

Don’t use CoffeeScript. Unless the repository is a fork, use Decaffeinate to convert CoffeeScript to ECMAScript.

License

Use the MIT license.

Version

ECMAScript

Write modern JavaScript. If needed, use a bundler to transpile code for older browsers.

Modernizing legacy code

Use lebab, but be aware of its bugs. There is a lebab plugin for Sublime Text. Use these preferences (Preferences > Package Settings > Lebab > Settings - User):

{
  "transforms": [
    "arrow",
    "arrow-return",
    "let",
    "for-of",
    "for-each",
    "arg-rest",
    "arg-spread",
    "obj-method",
    "obj-shorthand",
    "no-strict",
    "exponent",
    "class",
    "commonjs",
    "template",
    "default-param",
    "includes"
  ]
}

Node

Applications are written for the latest LTS version of Node. Packages are written for non-end-of-life versions (see the status of Node versions).

To upgrade Node, change the node-version key in GitHub Actions workflows and the node (or nikolaik/python-nodejs) image in Dockerfiles. Check the relevant changelog for breaking changes.

Preferences

Use plain JavasScript:

Package manager

pnpm, for its built-in supply-chain protections (dependency cooldown, trust policy, build scripts blocked by default) and its improved node_modules structure. Do not use npm or yarn.

UI framework

Vue or React. That said, do not use frameworks for simple interfaces.

Bundler

esbuild to bundle assets for a server-rendered application (e.g. assets referenced by script tags in Django templates) or for an npm package. Vite for a single-page application.

Sass

sass (dart-sass). Do not use node-sass, which is deprecated.

Formatter

Biome. Do not use Prettier.

Linter

Biome. Do not use ESLint.

Requirements

Set the pnpm version in the packageManager property of package.json.

List outdated dependencies:

pnpm outdated

Upgrade outdated dependencies:

pnpm update

Upgrade Vue dependencies:

vue upgrade --next

Supply chain

To protect against supply chain attacks, set in pnpm-workspace.yaml:

minimumReleaseAge: 10080
trustPolicy: no-downgrade

no-downgrade has false positives if packages drop provenance attestation. Exclude the versions that pnpm install reports:

trustPolicyExclude:
  - chokidar@4.0.3
  - semver@6.3.1

By default, pnpm enables minimumReleaseAge (1 day) and blockExoticSubdeps (prevents transitive dependencies from using git or tarballs).

Vulnerabilities

Dependabot alerts

If the Dependabot alert is for a build dependency (like node-sass) or a test dependency (like mocha), you can dismiss it with “Risk is tolerable for this project”. The npm ecosystem has false positives.

To check for vulnerable dependencies:

pnpm audit --prod

Note

pnpm audit (without --prod) has false positives (Vue example).

To upgrade vulnerable dependencies:

pnpm audit --fix update

This updates the lockfile to non-vulnerable versions, where the dependency ranges allow it. Check each package’s changelog before committing.

Where the dependency ranges don’t allow it, pnpm audit --fix override can add overrides to package.json to force non-vulnerable versions, instead.

Linting

Use knip to find unused files, dependencies and exports. Install it as a development dependency, configure it in a knip.jsonc file, and keep it up-to-date with Dependabot:

.github/dependabot.yml
- package-ecosystem: "npm"
  directories:
    - "/"
    - "**/*"
  schedule:
    interval: "yearly"
  allow:
    - dependency-name: "knip"
  cooldown:
    default-days: 7

Continuous integration runs knip if you reuse the js workflow.

Code style

package.json

  • Do not set the scripts property. Instead, document the full commands in the readme, to reduce indirection and obfuscation.

Biome

Check and style many languages using Biome. For example:

biome.json
{
  "vcs": {
    "enabled": true,
    "clientKind": "git",
    "useIgnoreFile": true,
    "defaultBranch": "main"
  },
  "formatter": {
    "indentStyle": "space",
    "lineWidth": 119
  }
}

By default, Biome runs the formatter, linter (with recommended preset) and assist (with recommended actions).

Skip formatting of generated files and OCDS schema:

  "formatter": {
    "indentStyle": "space",
    "lineWidth": 119,
    "includes": ["**", "!path/pattern"]
  },

Skip vendored files and mis-parsed files, like Django and Jinja templates that use {% %} tags:

"files": {
  "includes": ["**", "!path/pattern"]
}

Run Biome with pre-commit:

.pre-commit-config.yaml
repos:
  - repo: https://github.com/biomejs/pre-commit
    rev: v2.5.2
    hooks:
      - id: biome-check

Continuous integration runs Biome if you reuse the js workflow.

build.js

Configure esbuild in a build.js file (or build.mjs, if package.json doesn’t set "type": "module").

build.js
import autoprefixer from "autoprefixer";
import browserslist from "browserslist";
import * as esbuild from "esbuild";
import { esbuildPluginBrowserslist } from "esbuild-plugin-browserslist";
import { sassPlugin } from "esbuild-sass-plugin";
import postcss from "postcss";

const production = process.env.NODE_ENV === "production";

const options = {
    entryPoints: { // edit as needed
        main: "src/scss/main.scss",
        script: "src/js/main.js",
    },
    bundle: true,
    outdir: "core/static",
    minify: production,
    sourcemap: !production,
    legalComments: "linked",
    logLevel: "info",
    // any other options
    plugins: [
        esbuildPluginBrowserslist(browserslist(), { printUnknownTargets: false }),
        sassPlugin({
            async transform(source) {
                const { css } = await postcss([autoprefixer]).process(source, { from: undefined });
                return css;
            },
        }),
    ],
};

if (process.argv.includes("--watch")) {
    const context = await esbuild.context(options);
    await context.watch();
    console.log("Watching for changes …");
} else {
    await esbuild.build(options);
}

esbuild-plugin-browserslist sets esbuild’s target from browserslist’s defaults query, so that esbuild transpiles any JavaScript syntax that those browsers don’t support.

Compile Sass with esbuild-sass-plugin, adding vendor prefixes with Autoprefixer (via PostCSS).

Set NODE_ENV=production in Dockerfiles so that esbuild minifies bundles and omits sourcemaps.

With bundle: true, esbuild resolves each url() in Sass, to copy and content-hash it. However:

  • An asset from a dependency may not resolve; for example, the dependency points url() to a path that isn’t built yet. If so, configure your application to build and serve that file (for example, add it to Django’s STATICFILES_DIRS), and mark the path as external:

    external: ["/static/*"],
    
  • If a file is used outside Sass (like images in HTML templates), configure as above.

  • If a file is exclusive to Sass, point url() to its source file with a relative path. If the file is a font or image, map its extension to the file loader, because esbuild assigns no loader to fonts or images by default:

    loader: {
       ".woff2": "file",
       ".otf": "file",
    },
    

Vue

  • When navigating between “pages”, respect native browser behaviors (open in new tab, etc.) by using an <a> link or <router-link>.

Continuous integration

Important

In GitHub workflows, install project dependencies safely with:

- run: pnpm install --frozen-lockfile --ignore-scripts

Create a .github/workflows/js.yml file. In most cases, you can reuse the js workflow, like:

name: Lint JavaScript
on: [push, pull_request]
jobs:
  lint:
    uses: open-contracting/.github/.github/workflows/js.yml@main
    permissions:
      contents: read

If you don’t use this workflow, and the project uses npm, include this step:

- run: npx lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https

Reference