@codejen/pack-js

scripts.

Every generator, with the command, the output it prints, and the code it makes – running live.

A new Vite or esbuild project, then web components, keyboard navigation and type patterns for it – or for the project you already have. TypeScript, JSDoc or plain JavaScript – whatever your project uses.

Install

Same three ways as every jen pack. The pack writes source files into your project – nothing it writes depends on the pack or on any library.

Globally

$ npm install -g @codejen/jen @codejen/pack-js
$ jen js:element --name=my-counter --attrs=count:number

Locally

$ npm install --save-dev @codejen/jen @codejen/pack-js
$ npx jen js:element --name=my-counter --attrs=count:number

Not at all

Ask for a js: generator without the pack installed and jen fetches @codejen/pack-js into a throwaway directory, runs it once and removes it again (first-party @codejen scope only; JEN_NO_FETCH=1 turns it off).

$ npx @codejen/jen js:element --name=my-counter --attrs=count:number

Output styles

Every generator writes in one of three styles, picked from the nearest config file above the folder the files go to:

  • TypeScript, with a tsconfig.json: .ts files using only erasable syntax – no enum, no namespaces, no parameter properties – so Node runs them directly, and any bundler strips them
  • JSDoc, with a jsconfig.json, or a tsconfig.json that sets checkJs: .js files with // @ts-check and types in JSDoc comments, checked by your editor without a build step
  • Plain JavaScript, with neither: the same .js files without the types

--types=ts, --types=jsdoc or --types=none overrides it. Relative imports end in .ts where the tsconfig allows that (allowImportingTsExtensions or rewriteRelativeImportExtensions), else in .js, which TypeScript maps to the .ts file.

Generators that take --withTest write a test next to the file: with Vitest if the nearest package.json lists it or there's a Vite or Vitest config, else with node:test. --runner=node or --runner=vitest overrides it.

The keyboard modules are plain files in the pack's templates/ folder, as .ts and as .js with JSDoc; the plain JavaScript is the JSDoc file with its types taken out. Elements and type patterns are made in code, from your names and types.

Projects

A new project in its own directory, at the latest versions on npm – so these two need the network. --name is the directory and the package name; jen stops if that directory already exists and isn't empty. Like every jen run, the files go through the plan: --dry-run lists them first, and nothing is written before you see it. Nothing is installed either: run npm install in the project afterwards.

A new project starts as plain JavaScript, whatever tsconfig.json is around it; --types=ts makes it TypeScript. Every js: generator you run in the project later writes in its style.

js:vite

Runs npm create vite@latest with the template for --framework and --types – vanilla, vue, react, svelte or any other template create-vite offers – without its questions, in a scratch directory, and hands what it makes to jen's plan. An unknown framework fails with create-vite's own list.

$ jen js:vite --name=shop --framework=react --types=ts
js:vite (pack @codejen/pack-js) – create a new Vite project in its own directory, from npm create vite@latest

  + shop/.gitignore
  + shop/.oxlintrc.json
  + shop/README.md
  + shop/index.html
  + shop/package.json
  + shop/public/favicon.svg
  + shop/public/icons.svg
  + shop/src/App.css
  + shop/src/App.tsx
  + shop/src/assets/hero.png
  + shop/src/assets/react.svg
  + shop/src/assets/vite.svg
  + shop/src/index.css
  + shop/src/main.tsx
  + shop/tsconfig.app.json
  + shop/tsconfig.json
  + shop/tsconfig.node.json
  + shop/vite.config.ts

✓ 18 written

Params

name

the project and its new directory, e.g. shop

required

framework

a create-vite template: vanilla, vue, react, preact, lit, svelte, solid, qwik …

default vanilla

types

ts or none (plain JavaScript)

default none

one of ts, none

js:esbuild

A page with a counter button, bundled by esbuild into dist/: build bundles once, dev rebuilds on every change and serves the project on esbuild's own server. esbuild has no npm create, so the files come from the pack, and package.json gets the latest versions from npm. Besides --types=ts it takes --types=jsdoc, with a jsconfig.json. esbuild only strips types, so a TypeScript project gets a separate check script. --withTest adds a first test with node:test – --runner=vitest switches – and later --withTest runs use the same runner.

$ jen js:esbuild --name=tool --types=ts --withTest
js:esbuild (pack @codejen/pack-js) – create a new project bundled by esbuild, in its own directory

  + tool/package.json
  + tool/index.html
  + tool/.gitignore
  + tool/src/style.css
  + tool/src/main.ts
  + tool/src/counter.ts
  + tool/src/counter.test.ts
  + tool/tsconfig.json

✓ 8 written

Params

name

the project and its new directory, e.g. shop

required

types

ts, jsdoc or none (plain JavaScript)

default none

one of ts, jsdoc, none

withTest

add a test setup and a first test

default false

runner

node (node:test) or vitest

default node

one of node, vitest

Web components

js:element

A custom element as a class, registered with customElements.define. Every attribute in --attrs gets a property that reflects it, converted to its type: numbers through Number(), booleans as present or absent (toggleAttribute), kebab-case names as camelCase properties (max-value becomes maxValue). observedAttributes lists them, and attributeChangedCallback takes only their names. Events from --events get a typed event map: emit('change', { value: 1 }) checks the detail, an event without a detail takes none, and listeners added with addEventListener('change', …) know event.detail.value is a number. Events bubble, and leave a shadow root the element sits in.

It renders into the light DOM: its children stay where they are, page styles reach them, and nothing needs a slot. Its own styles go in a constructable stylesheet, adopted once by the document – or the shadow root the element is in. With --shadow=open or --shadow=closed, it gets a shadow root with a <slot> instead, and the stylesheet is shared by every instance's shadow root. --form makes it form-associated: ElementInternals sends its value with the form, and it gets form, validity, checkValidity() and reportValidity(). In TypeScript, the tag joins HTMLElementTagNameMap, so document.querySelector('star-rating') is a StarRating.

--into loads the element: in a page, its tag and a module script go in before </body>; in a module like src/main.ts, an import line goes in above the first line of code.

$ jen js:element --name=star-rating --attrs=max:number,readonly:boolean --events="change:{value:number}" --form --into=index.html --dir=src/components
js:element (pack @codejen/pack-js) – create a custom element with typed attributes and events (--into loads it from a page or module)

  + src/components/star-rating.ts
  ~ index.html  + <star-rating></star-rating>
  ~ index.html  + <script type="module" src="./src/components/star-rating.ts"></script>

✓ 2 written
--attrs=count:number,label,open:boolean --events='change:{value:number}': the properties are typed, and every change shows in the attributes

JSDoc can't add to a global type from a .js file, so in the JSDoc style the tag goes into an elements.d.ts instead – the nearest one with a // jen:elements marker, or a new one next to the element:

$ jen js:element --name=my-counter --attrs=count:number --events=change --dir=src/components
js:element (pack @codejen/pack-js) – create a custom element with typed attributes and events (--into loads it from a page or module)

  + src/components/my-counter.js
  + src/components/elements.d.ts
  ~ src/components/elements.d.ts  + 'my-counter': import('./my-counter.js').MyCounter;

✓ 2 written

Params

name

the tag, lowercase with a hyphen: star-rating makes the class StarRating

required

attrs

comma-separated name:type attributes – string (default), number or boolean

events

comma-separated name:{detail} events, e.g. "change:{value: number}"; without a detail type, none

shadow

none (light DOM), or open or closed for a shadow root

default none

one of none, open, closed

form

form-associated, with a value attribute

default false

into

a page or module to load the element from

types

ts, jsdoc or none (default: from the nearest tsconfig.json or jsconfig.json)

one of ts, jsdoc, none

Keyboard patterns

The keyboard interaction of the ARIA Authoring Practices Guide, as plain modules any page or element can use. Each exports one function that takes the container and returns a function that removes its listeners again – or pass signal from an AbortController. Arrow keys follow the writing direction, so they swap in right-to-left text.

js:roving

Roving tabindex: a group of controls is one stop in the tab order, and arrow keys move within it. It knows the items and arrows from the container's role – toolbars go left and right, listboxes up and down, radio groups both ways – and aria-orientation, items, orientation and wrap change that. In tab lists, radio groups and listboxes, selection follows focus: aria-selected or aria-checked moves along, and a tab list shows the panel its tab aria-controls. onChange hears about every move.

$ jen js:roving --dir=src/lib
js:roving (pack @codejen/pack-js) – create roving tabindex for toolbars, tab lists, radio groups and listboxes (ARIA APG)

  + src/lib/roving-tabindex.ts

✓ 1 written
Click a control, or Tab into a group, then use the arrow keys

Params

types

ts, jsdoc or none (default: from the nearest tsconfig.json or jsconfig.json)

one of ts, jsdoc, none

js:menu

Menus and menubars: arrow keys move through the items, Home and End jump to the ends, and typing a letter or two moves to the item that starts with them. On a popover menu, its popovertarget button opens it with Arrow Down or Arrow Up (at the last item), the first item gets focus, and Escape, Tab or choosing an item closes it and returns focus to the button. A submenu's items stay out of their parent's way.

$ jen js:menu --dir=src/lib
js:menu (pack @codejen/pack-js) – create menu and menubar keyboard interaction: arrows, Home/End, typeahead, Escape, popover-aware (ARIA APG)

  + src/lib/menu.ts

✓ 1 written
Open the menu, then try the arrow keys and typing S

Params

types

ts, jsdoc or none (default: from the nearest tsconfig.json or jsconfig.json)

one of ts, jsdoc, none

js:grid

Two-dimensional navigation for role="grid": arrow keys move between cells, Home and End to the ends of the row, Ctrl+Home and Ctrl+End to the first and last cell, Page Up and Page Down by pageSize rows. A cell with a control in it focuses the control. rows and cells take other selectors, and onMove reports the row and column.

$ jen js:grid --dir=src/lib
js:grid (pack @codejen/pack-js) – create two-dimensional grid navigation with arrows, Home/End, Page Up/Down (ARIA APG)

  + src/lib/grid-navigation.ts

✓ 1 written
Click a day, then use the arrow keys

Params

types

ts, jsdoc or none (default: from the nearest tsconfig.json or jsconfig.json)

one of ts, jsdoc, none

js:hotkeys

A registry for keyboard shortcuts: const keys = createHotkeys(), then keys.bind('mod+k', openSearch, { description: 'Search' }). mod is ⌘ on Apple devices and Ctrl elsewhere. Symbols like ? match whatever Shift your keyboard layout needs for them. Bindings are ignored while someone types in a field, unless they set inputs: true. A binding with a scope only fires inside that element, and wins over a global one. createHotkeys({ help: true }) binds ? to a dialog that lists every binding with a description.

$ jen js:hotkeys --dir=src/lib
js:hotkeys (pack @codejen/pack-js) – create a hotkeys registry: scoped bindings, modifiers, ignores typing, optional help overlay

  + src/lib/hotkeys.ts

✓ 1 written
Click into the frame, then press ?

Params

types

ts, jsdoc or none (default: from the nearest tsconfig.json or jsconfig.json)

one of ts, jsdoc, none

Type patterns

The patterns of the C++ pack's types, for TypeScript – without the syntax Node can't strip. In the JSDoc style they come with the same types, and in plain JavaScript they still work at runtime.

js:class

A class with #private fields, a getter for each, and a static create() to put async setup or validation in later. --fields=id:number,label:string makes the fields and an options type for them. --disposable adds [Symbol.dispose](), so using timer = Timer.create(…) cleans it up at the end of the block (in TypeScript, that needs esnext.disposable in lib).

$ jen js:class --name=timer --fields=id:number,label:string --disposable --withTest --dir=src/lib
js:class (pack @codejen/pack-js) – create a class with #private fields, getters and a static create() (--disposable adds Symbol.dispose)

  + src/lib/timer.ts
  + src/lib/timer.test.ts

✓ 2 written

Params

name

the type, any case: user-id and UserId both make UserId in user-id.ts

required

fields

comma-separated name:type fields, e.g. id:number,label:string

disposable

add Symbol.dispose, for using declarations

default false

withTest

add a test next to it

default false

runner

node (node:test) or vitest (default: vitest in a Vite or Vitest project)

one of node, vitest

types

ts, jsdoc or none (default: from the nearest tsconfig.json or jsconfig.json)

one of ts, jsdoc, none

js:enum

An object as const instead of TypeScript's enum: Direction.Up is 'up', the type Direction is the union of the values, and isDirection(value) checks a value from outside.

$ jen js:enum --name=direction --values=up,down,left,right --withTest --dir=src/lib
js:enum (pack @codejen/pack-js) – create an enum as an `as const` object with its union type and a type guard

  + src/lib/direction.ts
  + src/lib/direction.test.ts

✓ 2 written

Params

name

the type, any case: user-id and UserId both make UserId in user-id.ts

required

values

comma-separated values, e.g. "draft, published"

required

withTest

add a test next to it

default false

runner

node (node:test) or vitest (default: vitest in a Vite or Vitest project)

one of node, vitest

types

ts, jsdoc or none (default: from the nearest tsconfig.json or jsconfig.json)

one of ts, jsdoc, none

js:union

A tagged union: an interface for each case with a kind, the union of them, and matchShape(shape, { circle: …, square: … }). The handlers have to cover every case, and the switch inside ends in assertNever – add a case and forget it somewhere, and TypeScript says where.

$ jen js:union --name=shape --cases="circle:{radius:number},square:{size:number}" --withTest --dir=src/lib
js:union (pack @codejen/pack-js) – create a tagged union type with an exhaustively checked match function

  + src/lib/shape.ts
  + src/lib/shape.test.ts

✓ 2 written

Params

name

the type, any case: user-id and UserId both make UserId in user-id.ts

required

cases

comma-separated kind:{fields} cases, or a bare kind without fields

required

withTest

add a test next to it

default false

runner

node (node:test) or vitest (default: vitest in a Vite or Vitest project)

one of node, vitest

types

ts, jsdoc or none (default: from the nearest tsconfig.json or jsconfig.json)

one of ts, jsdoc, none

js:brand

A branded type: a UserId is a string, but not every string is a UserId. userId(value) checks a value and brands it, isUserId(value) is the check on its own – put your validation there. --underlying takes string (the default), number (finite only) or bigint.

$ jen js:brand --name=user-id --dir=src/lib
js:brand (pack @codejen/pack-js) – create a branded type with a type guard and a checked constructor

  + src/lib/user-id.ts

✓ 1 written

Params

name

the type, any case: user-id and UserId both make UserId in user-id.ts

required

underlying

the branded type: string, number or bigint

default string

one of string, number, bigint

withTest

add a test next to it

default false

runner

node (node:test) or vitest (default: vitest in a Vite or Vitest project)

one of node, vitest

types

ts, jsdoc or none (default: from the nearest tsconfig.json or jsconfig.json)

one of ts, jsdoc, none

js:error

An Error subclass, named after itself so stack traces and logs say what went wrong, with extra fields from --fields and the standard cause: throw new NotFoundError('No such file', { path, cause }). The name gets an Error at the end if it doesn't have one.

$ jen js:error --name=not-found --fields=path:string --dir=src/lib
js:error (pack @codejen/pack-js) – create an Error subclass with its own name, extra fields and cause

  + src/lib/not-found-error.ts

✓ 1 written

Params

name

the type, any case: user-id and UserId both make UserId in user-id.ts

required

fields

comma-separated name:type extra fields, e.g. status:number

withTest

add a test next to it

default false

runner

node (node:test) or vitest (default: vitest in a Vite or Vitest project)

one of node, vitest

types

ts, jsdoc or none (default: from the nearest tsconfig.json or jsconfig.json)

one of ts, jsdoc, none

js:emitter

An event emitter on the platform's own EventTarget, with the same typed event map, emit() and listeners as js:element – for stores and services outside the DOM.

$ jen js:emitter --name=store --events="change:{value:number},reset" --dir=src/lib
js:emitter (pack @codejen/pack-js) – create an EventTarget subclass with a typed event map

  + src/lib/store.ts

✓ 1 written

Params

name

the type, any case: user-id and UserId both make UserId in user-id.ts

required

events

comma-separated name:{detail} events, e.g. "change:{value: number}"; without a detail type, none

required

withTest

add a test next to it

default false

runner

node (node:test) or vitest (default: vitest in a Vite or Vitest project)

one of node, vitest

types

ts, jsdoc or none (default: from the nearest tsconfig.json or jsconfig.json)

one of ts, jsdoc, none

Modules and tests

js:module

A module with one exported function of the same name, to fill in – and with --withTest, its test.

$ jen js:module --name=format-date --withTest --dir=src/lib
js:module (pack @codejen/pack-js) – create a module exporting a function of the same name

  + src/lib/format-date.ts
  + src/lib/format-date.test.ts

✓ 2 written

Params

name

any case: format-date makes formatDate() in format-date.ts

required

withTest

add a test next to it

default false

runner

node (node:test) or vitest (default: vitest in a Vite or Vitest project)

one of node, vitest

types

ts, jsdoc or none (default: from the nearest tsconfig.json or jsconfig.json)

one of ts, jsdoc, none

js:test

A test file for a module that's already there: it imports every function, class and constant the module exports and starts a test for each, with node:test or Vitest. For a module that doesn't exist yet, it imports the module as a whole, ready for test-first.

$ jen js:test --name=slugify --dir=src/lib
js:test (pack @codejen/pack-js) – create a test file for a module: node:test, or Vitest in a Vite project

  + src/lib/slugify.test.ts

✓ 1 written

Params

name

the module to test, e.g. format-date

required

runner

node (node:test) or vitest (default: vitest in a Vite or Vitest project)

one of node, vitest

types

ts, jsdoc or none (default: from the nearest tsconfig.json or jsconfig.json)

one of ts, jsdoc, none

Credits

The keyboard patterns follow the ARIA Authoring Practices Guide by the W3C Web Accessibility Initiative – roving tabindex and keyboard shortcuts from its keyboard interface practices, menus from the menu and menubar pattern, grids from the grid pattern. Each module credits it in its first line.

js:vite runs create-vite by the Vite team, and js:esbuild's project layout follows it; each esbuild project's index.html credits it.

The code is written fresh for this pack, not copied. The pack is ISC licensed.