Bundling the Loom CLI
Vite bundles the CLI program, the language server's TypeScript source, and Effect into a single dist/main.js module and adds a Node shebang, the line that makes the file directly executable. The installed loom binary therefore carries no runtime dependencies and no TypeScript source to compile.
Loom Design is in neither this bundle nor any other file this build writes. Athrio Media OÜ licenses Design commercially, while the Quadrivium Academy foundation licenses this package under MIT, so a copy of Design inside dist/main.js would publish the paid product under the free product's terms — which is what version 0.0.10 did, and a permissive grant once made cannot be taken back. loom design runs Design's own binary and imports nothing from it, which leaves the bundler no import to follow.
Vite build
The node: built-in modules stay external, since the runtime supplies them and the bundle need not carry them. Everything else — the language server's TypeScript source and Effect — is inlined into dist/main.js.
The language server's exports field points to TypeScript source files rather than pre-compiled output. Bun runs these files directly, as do tools like tsx and code editors, with no compilation step. Vite inlines them at build time, so the published CLI is pure JavaScript. The loom lsp command pulls in the server, and with it Volar; some of Volar's dependencies — the vscode-*-languageservice packages and jsonc-parser — ship only UMD bundles the bundler cannot follow statically, so the umd2esm plugin rewrites each to its ESM sibling, the same trick the language-server build uses.
@athrio/loom-config reads the configuration with the yaml parser, whose CommonJS build calls require('process'). An ES-module bundle exposes no require, so that call would throw at load time. The config resolves yaml to its ES-module build instead, which uses imports throughout and needs no require.
ProseLanguage loads volar-service-markdown, whose vscode-markdown-languageservice dependency imports vscode-uri by its default binding. The vscode-uri ES-module build exports no default, so the bundle resolves vscode-uri to its UMD build instead, where the bundler's CommonJS interop supplies one.
import { defineConfig } from 'vite'
import { builtinModules, createRequire } from 'node:module'
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const requireFromHere = createRequire(import.meta.url)
const nodeBuiltins = [...builtinModules, ...builtinModules.map((m) => `node:${m}`)]
const umd2esm = {
name: 'umd2esm',
enforce: 'pre' as const,
resolveId(source: string, importer: string | undefined) {
if (/^(vscode-.*-languageservice|jsonc-parser)/.test(source)) {
const fromDir = importer ? dirname(importer) : here
const resolved = requireFromHere.resolve(source, { paths: [fromDir] })
return resolved.replace(/\/umd\//, '/esm/').replace(/\\umd\\/g, '\\esm\\')
}
return null
},
}loom helix and loom nvim compile Loom's tree-sitter grammar on the user's machine, so the bundle carries the grammar's sources: the generated parser, the scanner, the headers they include, and the queries Neovim and Helix colour with. grammarFiles lists them, a folder standing for every file inside it. filesUnder turns that list into single files, each named by its path in the tree-sitter-loom package.
editorGrammar is a Vite plugin that serves them as a module of their own, virtual:tree-sitter-loom. No file on disk holds that module. The plugin answers for its name and builds its text when the CLI imports it: a default export mapping each file's path to its contents. The CLI imports the module only from the two commands that need the grammar, so Vite puts it in a chunk of its own, which a plain tangle never loads. Both the npm package and the standalone executable carry that chunk, since Bun compiles the executable from this very bundle.
const grammarRoot = resolve(here, '..', 'tree-sitter-loom')
const grammarFiles = [
'src/parser.c',
'src/scanner.c',
'src/tree_sitter',
'queries',
'editors/helix/queries/loom',
]
const filesUnder = (path: string): ReadonlyArray<string> =>
statSync(resolve(grammarRoot, path)).isDirectory()
? readdirSync(resolve(grammarRoot, path)).flatMap((name) => filesUnder(`${path}/${name}`))
: [path]
const grammarModule = 'virtual:tree-sitter-loom'
const editorGrammar = {
name: 'editor-grammar',
resolveId: (source: string) => (source === grammarModule ? `\0${grammarModule}` : null),
load: (id: string) =>
id === `\0${grammarModule}`
? `export default ${JSON.stringify(
Object.fromEntries(
grammarFiles
.flatMap(filesUnder)
.map((file) => [file, readFileSync(resolve(grammarRoot, file), 'utf8')]),
),
)}`
: null,
}Vite's library mode builds a single entry point to dist/main.js. The bundle targets Node 20, so Node runs it directly and Bun, being Node-compatible, runs the same bundle unchanged. The CLI package uses the ESM format — ECMAScript modules — so Vite emits an ESM bundle with a #!/usr/bin/env node shebang, making it directly executable. The VS Code extension runs in the editor's CommonJS host, so its Vite config emits that format instead.
export default defineConfig({
plugins: [umd2esm, editorGrammar],
resolve: {
conditions: ['node'],
mainFields: ['main', 'module'],
alias: {
yaml: resolve(dirname(requireFromHere.resolve('yaml/package.json')), 'browser/index.js'),
'vscode-uri': requireFromHere
.resolve('vscode-uri')
.replace(/esm[\\/]index\.mjs$/, 'umd/index.js'),
},
},
build: {
target: 'node20',
outDir: 'dist',
emptyOutDir: true,
sourcemap: true,
minify: false,
lib: {
entry: resolve(here, 'src/main.ts'),
formats: ['es'],
fileName: () => 'main.js',
},
rollupOptions: {
external: [...nodeBuiltins, 'bun:sqlite', 'node:sqlite'],
output: { banner: '#!/usr/bin/env node' },
},
},
})The grammar's module, declared
TypeScript checks the CLI's source before Vite bundles it, and it cannot see a module that only a Vite plugin serves. This declaration tells it the module's shape: a default export that maps each file's path to its text.
declare module 'virtual:tree-sitter-loom' {
const files: Readonly<Record<string, string>>
export default files
}