tsbars 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Farrel Darian
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,222 @@
1
+ # tsbars
2
+
3
+ ![tsbars: typed .hbs imports in TypeScript](docs/hero.gif)
4
+
5
+ Import `.hbs` files from TypeScript and get a call signature inferred from the template.
6
+
7
+ `prompt.hbs`:
8
+
9
+ ```hbs
10
+ Hello {{user.name}}!
11
+ {{#if user.admin}}You are an admin.{{/if}}
12
+ {{#each items}}- {{title}}
13
+ {{/each}}
14
+ ```
15
+
16
+ `index.ts`:
17
+
18
+ ```ts
19
+ import prompt from './prompt.hbs';
20
+
21
+ prompt({ user: { admin: true }, items: [{ title: 'Write docs' }] });
22
+ ```
23
+
24
+ ```text
25
+ index.ts(3,10): error TS2741: Property 'name' is missing in type '{ admin: true; }' but required in type '{ name: Printable; admin?: unknown; }'.
26
+ ```
27
+
28
+ The import is typed as `(context: Context) => string`, where `Context` is:
29
+
30
+ ```ts
31
+ type Printable = string | number | bigint | boolean;
32
+ type Context = {
33
+ user: { name: Printable; admin?: unknown };
34
+ items: ReadonlyArray<{ title: Printable }>;
35
+ };
36
+ ```
37
+
38
+ A [TypeScript content mapper](https://github.com/microsoft/typescript-go/pull/4712) does the inference: the compiler hands it the template, it returns a virtual `.ts` module, and errors are mapped back to the template. At runtime the template is compiled by Handlebars through a Vite or Bun plugin.
39
+
40
+ ## Requirements
41
+
42
+ - TypeScript `7.1.0-beta` (the native compiler), run as `tsc --noEmit --runExternalCode`.
43
+ - Node.js with `node:wasi`. The mapper is a bundled WebAssembly module that `bin/mapper.js` runs under it. CI uses Node 26; older versions are untested.
44
+ - [Handlebars](https://handlebarsjs.com) `^4.7.0` as a peer dependency.
45
+ - Imports must include the `.hbs` extension.
46
+
47
+ No Rust toolchain or build step is needed to use tsbars.
48
+
49
+ ## Install
50
+
51
+ Once published to npm (it isn't yet):
52
+
53
+ ```sh
54
+ pnpm add -D tsbars typescript@7.1.0-beta
55
+ pnpm add handlebars
56
+ ```
57
+
58
+ Vite is an optional peer dependency, only needed for `tsbars/vite`. To try tsbars before the first release, see [Development](#development).
59
+
60
+ ## Setup
61
+
62
+ Add a content mapper to your `tsconfig.json`:
63
+
64
+ ```jsonc
65
+ {
66
+ "compilerOptions": {
67
+ "module": "NodeNext",
68
+ "moduleResolution": "NodeNext",
69
+ "strict": true,
70
+ "noEmit": true
71
+ },
72
+ "contentMappers": [
73
+ {
74
+ "package": "tsbars",
75
+ "extensions": [".hbs"],
76
+ "options": { "helpers": ["now"] }
77
+ }
78
+ ]
79
+ }
80
+ ```
81
+
82
+ `options.helpers` lists helpers you call without arguments (`{{now}}`). Without it, a bare `{{now}}` is read as a context variable and becomes a required property.
83
+
84
+ Type-check with:
85
+
86
+ ```sh
87
+ tsc --noEmit --runExternalCode
88
+ ```
89
+
90
+ `examples/basic` is a working project.
91
+
92
+ <details>
93
+ <summary>Editor support (not tested by us yet)</summary>
94
+
95
+ Content mappers are external code, so the editor has to opt in. In VS Code this should mean:
96
+ - the TypeScript Native Preview extension,
97
+ - a trusted workspace, with the content mapper permission granted,
98
+ - `js/ts.contentMappers.enabled` turned on.
99
+
100
+ We haven't verified this in an editor. Only the `tsc` path above is covered by tests.
101
+
102
+ </details>
103
+
104
+ ## Runtime
105
+
106
+ Both plugins take [Handlebars compile options](https://handlebarsjs.com/api-reference/compilation.html) and pass them to `Handlebars.precompile`.
107
+
108
+ ```ts
109
+ // vite.config.ts
110
+ import tsbars from 'tsbars/vite';
111
+
112
+ export default {
113
+ plugins: [tsbars({ noEscape: true })],
114
+ };
115
+ ```
116
+
117
+ ```ts
118
+ // preload.ts, referenced from bunfig.toml: preload = ["./preload.ts"]
119
+ import { plugin } from 'bun';
120
+ import tsbars from 'tsbars/bun';
121
+
122
+ plugin(tsbars({ noEscape: true }));
123
+ ```
124
+
125
+ Use `noEscape: true` for LLM prompts. Handlebars HTML-escapes `{{value}}` by default, so `<` and `&` in your data would come out as `&lt;` and `&amp;`.
126
+
127
+ Compiled templates use `handlebars/runtime`, so register helpers there:
128
+
129
+ ```ts
130
+ import Handlebars from 'handlebars/runtime';
131
+
132
+ Handlebars.registerHelper('shout', (text: string) => text.toUpperCase());
133
+ ```
134
+
135
+ The helper must be registered before the template runs. tsbars doesn't see it, so list it in `options.helpers` if you call it with no arguments.
136
+
137
+ ## How types are inferred
138
+
139
+ | Template | Inferred |
140
+ | -------------------------------------------------------- | --------------------------------------------------------------------------- |
141
+ | `{{a.b}}` | `a: { b: Printable }` |
142
+ | `{{#if a}}`, `{{#unless a}}`, `{{else if a}}` | `a?: unknown`, or `a?: <type from other uses>` |
143
+ | `{{#each xs}}{{title}}{{/each}}` | `xs: ReadonlyArray<{ title: Printable }>` |
144
+ | `{{#each xs}}{{this}}{{/each}}` | `xs: ReadonlyArray<Printable>` |
145
+ | `{{#each xs}}{{@key}} {{this}}{{/each}}` | `xs: Readonly<Record<string, Printable>>` |
146
+ | `{{#each xs as \|x i\|}}{{x.id}}{{/each}}` | `xs: ReadonlyArray<{ id: Printable }>` (first block param names the item) |
147
+ | `{{#with a}}{{b}}{{/with}}` | `a: { b: Printable }`; optional if the block has `{{else}}` |
148
+ | `{{../b}}` inside a block | `b` is looked up in the parent scope, one level per `../` |
149
+ | `{{@root.b}}` | `b` on the root context |
150
+ | `{{shout name}}`, `{{upper (trim raw)}}` | `name: unknown`, `raw: unknown`; the helper itself is not part of `Context` |
151
+ | `{{lookup obj key}}` | `obj: unknown`, `key: Printable` |
152
+ | `{{#fancy z}}{{inner}}{{/fancy}}` (unknown block helper) | `z: unknown`; the body adds nothing |
153
+ | `{{now}}` with `"helpers": ["now"]` | nothing |
154
+ | no variables at all | `context?: Record<string, never>` |
155
+
156
+ Notes:
157
+ - `Printable` is `string | number | bigint | boolean`. A path that is both printed and used as an object (`{{a}}` and `{{a.b}}`) becomes the object.
158
+ - Property names that aren't valid identifiers (`{{[odd key]}}`) become quoted keys.
159
+ - Properties are listed in the order the template first mentions them.
160
+
161
+ ### Overriding inference
162
+
163
+ Top-level comments can replace an inferred type or import one:
164
+
165
+ ```hbs
166
+ {{!-- @import type { User } from "./user" --}}
167
+ {{!-- @param user User --}}
168
+ {{!-- @param stats.count number --}}
169
+ Hello {{user.name}}, you have {{stats.count}} messages.
170
+ ```
171
+
172
+ - `@param <path> <type>` replaces the inferred type at that dotted path with any TypeScript type.
173
+ - `@import <clause>` emits `import <clause>;` into the generated module. The clause is whatever follows `import`, for example `type { User } from "./user"`.
174
+ - A type error inside an annotation is reported on the template line (`profile.hbs(1,23): error TS2304: Cannot find name 'Missing'.`).
175
+ - A malformed directive is reported as `tsbars2` against the template.
176
+
177
+ ## Limitations
178
+
179
+ - Partials contribute no types: `{{> name}}` and `{{#> name}}...{{/name}}` are skipped, including everything inside a partial block. Variables used only there won't appear in `Context`.
180
+ - Decorators (`{{* ... }}`) and inline partials (`{{#*inline "x"}}`) are not supported. They are parse errors.
181
+ - Helpers have no signatures. Their arguments are typed `unknown`, and nothing checks the helper exists.
182
+ - A template that fails to parse is reported as `tsbars1` (for example `broken.hbs(2,1): error tsbars1: Block \`if\` is not closed`). Its default export then accepts `any`, so importers don't produce more errors.
183
+ - Content mappers are a beta TypeScript feature and may change.
184
+ - Not published to npm yet, and editor support is untested.
185
+
186
+ ## Development
187
+
188
+ Prerequisites: [pnpm](https://pnpm.io), [Bun](https://bun.sh) (test runner), and [rustup](https://rustup.rs). rustup installs the toolchain and the `wasm32-wasip1` target from `rust-toolchain.toml` on first use.
189
+
190
+ ```sh
191
+ pnpm install
192
+ pnpm run build # tsdown -> dist/, then the mapper -> wasm/tsbars-mapper.wasm
193
+ pnpm run check # tsc, Biome lint, clippy, bun test, cargo test
194
+ pnpm run format # Biome + cargo fmt
195
+ ```
196
+
197
+ `bin/mapper.js` exits with a hint to run `pnpm run build:wasm` if `wasm/tsbars-mapper.wasm` is missing. To use a clone from another project, symlink it to that project's `node_modules/tsbars` (tsc only resolves content mapper packages from `node_modules`; the e2e tests do the same).
198
+
199
+ CI auto-commits formatting on pull requests, then runs `pnpm run check`. See [AGENTS.md](AGENTS.md) for the repo layout.
200
+
201
+ <details>
202
+ <summary>Releasing</summary>
203
+
204
+ Add a changeset with your change:
205
+
206
+ ```sh
207
+ pnpm exec changeset
208
+ ```
209
+
210
+ Merging it to `main` opens a "Version Packages" PR; merging that PR publishes to npm through GitHub Actions with OIDC trusted publishing. The first publish has to be done manually, then a trusted publisher configured on npmjs.com.
211
+
212
+ </details>
213
+
214
+ ## Acknowledgements
215
+
216
+ tsbars is modelled on [reylo](https://github.com/colinhacks/reylo) by Colin McDonnell (MIT). Its content-mapper server, packaging and typed-import design were the reference. tsbars reimplements that approach in Rust for Handlebars.
217
+
218
+ Thanks to the TypeScript team for content mappers ([microsoft/typescript-go#4712](https://github.com/microsoft/typescript-go/pull/4712)).
219
+
220
+ ## License
221
+
222
+ [MIT](LICENSE)
package/bin/mapper.js ADDED
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const wasmPath = join(
7
+ dirname(fileURLToPath(import.meta.url)),
8
+ '..',
9
+ 'wasm',
10
+ 'tsbars-mapper.wasm',
11
+ );
12
+
13
+ // tsc discards our stderr, but keep it clean for direct runs. Must precede the node:wasi import.
14
+ process.removeAllListeners('warning');
15
+ const { WASI } = await import('node:wasi');
16
+
17
+ let bytes;
18
+ try {
19
+ bytes = await readFile(wasmPath);
20
+ } catch (error) {
21
+ if (error.code !== 'ENOENT') throw error;
22
+ process.stderr.write(
23
+ `tsbars: mapper not found at ${wasmPath}\nRun \`pnpm run build:wasm\` in the tsbars repo.\n`,
24
+ );
25
+ process.exit(1);
26
+ }
27
+
28
+ // Fds 0/1/2 are passed straight through; process.stdin is deliberately never touched so
29
+ // the WASI blocking read keeps working on the inherited pipe.
30
+ const wasi = new WASI({
31
+ version: 'preview1',
32
+ args: ['tsbars-mapper'],
33
+ env: {},
34
+ stdin: 0,
35
+ stdout: 1,
36
+ stderr: 2,
37
+ returnOnExit: true,
38
+ });
39
+ const instance = await WebAssembly.instantiate(
40
+ await WebAssembly.compile(bytes),
41
+ wasi.getImportObject(),
42
+ );
43
+ process.exit(wasi.start(instance));
package/dist/bun.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { CompileOptions } from "./compile.js";
2
+ import { BunPlugin } from "bun";
3
+ export default function tsbars(options?: CompileOptions): BunPlugin;
package/dist/bun.js ADDED
@@ -0,0 +1,17 @@
1
+ import { compile } from "./compile.js";
2
+ // src/bun.ts
3
+ function tsbars(options) {
4
+ return {
5
+ name: "tsbars",
6
+ setup(build) {
7
+ build.onLoad({ filter: /\.hbs$/ }, async (args) => {
8
+ const source = await Bun.file(args.path).text();
9
+ return {
10
+ contents: compile(source, options),
11
+ loader: "js"
12
+ };
13
+ });
14
+ }
15
+ };
16
+ }
17
+ export { tsbars as default };
@@ -0,0 +1,3 @@
1
+ import Handlebars from "handlebars";
2
+ export type CompileOptions = Parameters<typeof Handlebars.precompile>[1];
3
+ export declare function compile(source: string, options?: CompileOptions): string;
@@ -0,0 +1,6 @@
1
+ import Handlebars from "handlebars";
2
+ // src/compile.ts
3
+ function compile(source, options) {
4
+ return `import Handlebars from "handlebars/runtime";\nexport default Handlebars.template(${Handlebars.precompile(source, options)});\n`;
5
+ }
6
+ export { compile };
package/dist/vite.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { CompileOptions } from "./compile.js";
2
+ import { Plugin } from "vite";
3
+ export default function tsbars(options?: CompileOptions): Plugin;
package/dist/vite.js ADDED
@@ -0,0 +1,16 @@
1
+ import { compile } from "./compile.js";
2
+ // src/vite.ts
3
+ function tsbars(options) {
4
+ return {
5
+ name: "tsbars",
6
+ enforce: "pre",
7
+ transform(code, id) {
8
+ if (!id.replace(/\?.*$/, "").endsWith(".hbs")) return null;
9
+ return {
10
+ code: compile(code, options),
11
+ map: null
12
+ };
13
+ }
14
+ };
15
+ }
16
+ export { tsbars as default };
package/package.json CHANGED
@@ -1,4 +1,78 @@
1
1
  {
2
- "name": "tsbars",
3
- "version": "0.0.0"
4
- }
2
+ "name": "tsbars",
3
+ "version": "0.1.0",
4
+ "description": "Typed Handlebars imports via TypeScript content mappers",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/fdarian/tsbars.git"
10
+ },
11
+ "author": "Farrel Darian",
12
+ "files": [
13
+ "dist",
14
+ "wasm",
15
+ "bin",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "exports": {
20
+ "./compile": {
21
+ "types": "./dist/compile.d.ts",
22
+ "import": "./dist/compile.js"
23
+ },
24
+ "./vite": {
25
+ "types": "./dist/vite.d.ts",
26
+ "import": "./dist/vite.js"
27
+ },
28
+ "./bun": {
29
+ "types": "./dist/bun.d.ts",
30
+ "import": "./dist/bun.js"
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "imports": {
35
+ "#src/*": "./src/*"
36
+ },
37
+ "typescript": {
38
+ "contentMapper": {
39
+ "exec": [
40
+ "node",
41
+ "bin/mapper.js"
42
+ ],
43
+ "compilerOptions": []
44
+ }
45
+ },
46
+ "peerDependencies": {
47
+ "handlebars": "^4.7.0",
48
+ "vite": ">=5"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "vite": {
52
+ "optional": true
53
+ }
54
+ },
55
+ "devDependencies": {
56
+ "@biomejs/biome": "2.6.2",
57
+ "@changesets/cli": "3.0.3",
58
+ "@total-typescript/tsconfig": "1.0.4",
59
+ "@types/bun": "1.4.4",
60
+ "handlebars": "4.7.10",
61
+ "tsdown": "0.23.1",
62
+ "typescript": "7.1.0-beta",
63
+ "vite": "8.3.5"
64
+ },
65
+ "scripts": {
66
+ "build": "tsdown && pnpm run build:wasm",
67
+ "build:wasm": "cargo build --release --target wasm32-wasip1 -p tsbars-mapper && mkdir -p wasm && cp target/wasm32-wasip1/release/tsbars-mapper.wasm wasm/",
68
+ "format": "biome check --write && cargo fmt --all",
69
+ "check": "pnpm run check:type && pnpm run check:lint && pnpm run check:rust && pnpm run test",
70
+ "check:type": "tsc --noEmit",
71
+ "check:lint": "biome lint .",
72
+ "check:rust": "cargo clippy --all-targets -- -D warnings && cargo clippy --target wasm32-wasip1 -p tsbars-mapper -- -D warnings",
73
+ "test": "bun test && pnpm run test:rust",
74
+ "test:rust": "cargo test",
75
+ "ci:version": "changeset version",
76
+ "ci:release": "pnpm run build && changeset publish"
77
+ }
78
+ }
Binary file