# >< NMBL
**N**o **M**ore **B**rackets **L**anguage — a shorthand notation for HTML, built for modern web frameworks and token efficiency.
> ⚠️ **Early development.** The core compiler is solid and well-tested; the framework integrations work but are still maturing. Packages are not yet published to npm.
NMBL strips the noise from HTML — closing tags, angle brackets, `id`/`class` boilerplate — while compiling to plain HTML that your framework already understands. A typical template is 30–40% shorter than the equivalent HTML, which means less to read for you and fewer tokens for your AI.
```
#app
header.site-header
nav
a(href="/features") Features
a.btn.btn-primary(href="/signup") Get Started
main
h1 Ship faster with less code
p.lead Stop writing closing tags.
```
compiles to:
```html
<div id="app">
<header class="site-header">
<nav>
<a href="/features">Features</a>
<a class="btn btn-primary" href="/signup">Get Started</a>
</nav>
</header>
<main>
<h1>Ship faster with less code</h1>
<p class="lead">Stop writing closing tags.</p>
</main>
</div>
```
## Why not Pug?
Pug proved that indentation-based HTML is easier to read and write — but it was built as a complete templating engine for a pre-framework era (mixins, extends, interpolation, conditionals, its own expression language). Frameworks handle all of that better now, and Pug is effectively unmaintained.
NMBL keeps the good part — the shorthand notation — and drops the template-engine ambitions. Your framework owns the logic; NMBL owns the brevity.
## Features
- **Tagged template literal transform** — use `nmbl\`…\`` in `.tsx`/`.jsx` files with React, Solid, Qwik, or Preact; the Vite plugin compiles it to JSX at build time with zero runtime overhead. `.card` becomes `className` for React and `class` for Solid/Qwik
- **CSS-style selectors** — `#id` and `.class` shorthand, implicit `div` (`#app`, `.card.dark`)
- **Block expansion** — inline single children: `li > a(href="/") Home`
- **Multiline attributes** — paren-wrapped, whitespace-separated, with quoted strings, template literals, and `={expr}` raw expressions
- **Comments that actually work** — `//` is stripped from output, `//!` renders an HTML comment, and you can comment out *individual attributes* with `//` or `/* */` inside an attribute list (something HTML and every template language lack)
- **Framework directives pass through** — `v-if`, `@click.stop`, `bind:value={...}`, `client:load`, `#slot` shorthand all parse as plain attributes
- **Control flow blocks** — `@if` / `@elseif` / `@else` / `@each` / `@await` / `@key` / `@snippet` compile to the host framework's native syntax. `@each` accepts one canonical form everywhere: `@each(item of items :key="item.id")` — the `:key` attribute sits in the same paren list, whitespace-separated like any other NMBL attribute. Svelte's `as`-form (`@each(items as item, i)`) is also accepted. Templates using the canonical form are **portable across frameworks**: the same `@each` source compiles to `{#each items as item (item.id)}` in Svelte, `<template v-for="item of items" :key="item.id">` in Vue, and `{items.map((item) => …)}` in Astro (key not emitted). Vue compiles `@if`/`@elseif`/`@else` to renderless `<template v-if>`/`<template v-else-if>`/`<template v-else>` wrappers. Svelte gets the full block set (`@await`, `@key`, `@snippet`). Astro supports only `@if` and `@each`. Plain HTML treats `@`-blocks as hard errors
- **Content blocks** — `script:` / `style:` capture nested content verbatim; named modes like `article:md` hand content to a filter you register via the compiler API
- **Bidirectional** — a decompiler converts existing HTML to clean, idiomatic NMBL (round-trip tested)
- **Built for tooling** — a canonical formatter (CLI, Prettier plugin, format-on-save), a linter, character-level source mappings, diagnostics with precise spans, real source maps for Svelte, full Vue IntelliSense via a Volar plugin
## Packages
| Package | Description |
|---|---|
| [`@nmbl-lang/core`](packages/core) | Core lexer, parser, compiler (HTML + source mappings), HTML→NMBL decompiler, **formatter**, and **linter** |
| [`@nmbl-lang/cli`](packages/cli) | `nmbl format` / `nmbl lint` — standalone CLI for CI, pre-commit, and lint-staged |
| [`@nmbl-lang/prettier-plugin`](packages/prettier-plugin) | Prettier plugin for `.nmbl` files |
| [`@nmbl-lang/vite-plugin`](packages/vite-plugin) | Vite plugin: `.nmbl` files and `<template lang="nmbl">` in Vue SFCs |
| [`@nmbl-lang/svelte`](packages/svelte) | Svelte preprocessor with V3 source maps |
| [`@nmbl-lang/astro`](packages/astro) | Astro integration |
| [`@nmbl-lang/vue-language-plugin`](packages/vue-language-plugin) | Volar plugin: full IntelliSense for NMBL templates in Vue SFCs |
| [`@nmbl-lang/vscode-extension`](packages/vscode-extension) | Syntax highlighting, format-on-save, and HTML conversion for `.nmbl` and embedded templates (install from repo) |
| [`@nmbl-lang/website`](packages/website) | [nmbl.tools](https://nmbl.tools) — docs and interactive playground |
## Usage
### Vue
```ts
// vite.config.ts
import vue from '@vitejs/plugin-vue';
import nmbl from '@nmbl-lang/vite-plugin';
export default defineConfig({
plugins: [
nmbl(), // must come before vue()
vue(),
],
});
```
```jsonc
// tsconfig.json — enables IntelliSense via Volar
{
"vueCompilerOptions": {
"plugins": ["@nmbl-lang/vue-language-plugin"]
}
}
```
Then use `<template lang="nmbl">` in your SFCs, including `@if`/`@each` blocks which compile to Vue's renderless `<template v-if>`/`<template v-for>` wrappers:
```nmbl
<template lang="nmbl">
@if(loggedIn)
p Welcome back!
@each(item of items :key="item.id")
li {{ item.name }}
</template>
```
See [examples/vue](examples/vue).
### Svelte
```js
// svelte.config.js
import { nmblPreprocess } from '@nmbl-lang/svelte';
export default {
preprocess: [nmblPreprocess()],
};
```
Then use `<template lang="nmbl">` blocks in your components. See [examples/svelte](examples/svelte).
### Astro
```js
// astro.config.mjs
import nmbl from '@nmbl-lang/astro';
export default defineConfig({
integrations: [nmbl()],
});
```
See [examples/astro](examples/astro).
### React / Solid / Qwik (JSX)
Use the `nmbl\`…\`` tagged template literal tag in any `.tsx`/`.jsx` file. The plugin compiles it away at build time — zero runtime, full source maps.
```ts
// vite.config.ts
import react from '@vitejs/plugin-react';
import nmbl from '@nmbl-lang/vite-plugin';
export default defineConfig({
plugins: [
nmbl({ jsx: { framework: 'react' } }), // must come before react()
react(),
],
});
```
```tsx
// src/App.tsx
import { useState } from 'react';
import { nmbl } from '@nmbl-lang/vite-plugin/tag'; // compile-time-only stub
function Card({ item }) {
const [open, setOpen] = useState(false);
return nmbl`
div.card
//! Rendered as an HTML comment in the output
h3 ${item.name}
button(onClick=${() => setOpen(!open)}) Toggle
@if(${open})
p.detail ${item.description}
`;
}
```
The stub import (`@nmbl-lang/vite-plugin/tag`) provides TypeScript types and throws at runtime if the plugin is missing. Compiled away, it leaves no trace in your bundle.
Transform rules:
- `.class` / `#id` → `className` for React/Preact, `class` for Solid/Qwik
- `@if(${cond})` → `{cond && …}` ternary; `@if`/`@else` → full ternary
- `@each(item of ${items} :key="item.id")` → `{items.map((item) => …)}` with `key={item.id}` on the root
- `${expr}` holes in text position → `{expr}`; in attribute position → `={expr}`
- `//` comments stripped; `//!` rendered as `{/* … */}` brace comments (JSX has no HTML comments)
For Solid, pass `framework: 'solid'`; for Qwik/Preact, `framework: 'qwik'` / `framework: 'preact'`.
See [examples/react](examples/react) and [examples/solid](examples/solid).
### Formatting & linting
NMBL has one canonical shape, and the tooling keeps your files in it — wherever NMBL lives: standalone `.nmbl`, `<template lang="nmbl">` blocks in Vue/Svelte/Astro SFCs, and `nmbl\`…\`` tagged templates in JSX.
**CLI** ([`@nmbl-lang/cli`](packages/cli)) — for npm scripts, CI, pre-commit, and lint-staged:
```sh
nmbl format src --write # reformat in place
nmbl format src --check # exit non-zero if anything isn't formatted (CI)
nmbl lint src # report best-practice & correctness diagnostics
```
Paths may be files or directories; directories are searched for `.nmbl`, `.vue`, `.svelte`, `.astro`, `.jsx`, and `.tsx` (skipping `node_modules` and build output). The formatter never touches a file it can't fully parse, so it's safe on format-on-save.
**Prettier** ([`@nmbl-lang/prettier-plugin`](packages/prettier-plugin)) — drop it into an existing Prettier setup:
```jsonc
// .prettierrc
{ "plugins": ["@nmbl-lang/prettier-plugin"] }
```
```sh
prettier --write "**/*.nmbl"
```
**VS Code** — the [extension](packages/vscode-extension) registers a document formatter for `.nmbl` (Format Document / format-on-save) and an `NMBL: Format Document` command that reformats NMBL regions inside SFC and JSX files.
What the formatter canonicalizes: 2-space indentation, `tag#id.class` selector order, the redundant `div` before a shorthand (`div.card` → `.card`), and double-quoted attribute values. `@each` is rewritten to each target's idiomatic form — Svelte's native `@each(items as item (key))` inside `.svelte` files, and the portable `@each(item of items :key="…")` form everywhere else (which is also what Vue/Astro/JSX compile from). Multi-line attribute values (host-language arrays/objects/expressions) are preserved verbatim. The linter flags what a formatter shouldn't silently rewrite — duplicate attributes, duplicate classes, suspicious tag casing — and surfaces parser diagnostics with source positions.
### Agent skill
Working with AI coding agents? Install the [NMBL skill](skills/nmbl/SKILL.md) so they write the syntax correctly — it covers the grammar, per-framework control flow, the whitespace pitfalls, and how to verify output:
```sh
npx skills add theoephraim/nmbl
```
### Compiler API
```ts
import { compile, decompile } from '@nmbl-lang/core';
const { html, errors } = compile('p.lead Hello world');
const nmbl = decompile('<p class="lead">Hello world</p>');
```
## Architecture
The NMBL grammar is defined once in `packages/core/src/nmbl-grammar.ts` using [monogram](https://github.com/johnsoncodehk/monogram) (pulled from a pinned GitHub commit and bundled into `@nmbl-lang/core`'s dist). The runtime lexer/parser executes that grammar directly (`createLexer`/`createParser`), and `packages/core/scripts/gen-artifacts.ts` derives the editor artifacts from the same definition:
- The TextMate grammar + VS Code language configuration (checked into `packages/vscode-extension`)
- The tree-sitter grammar (`packages/core/generated/tree-sitter/nmbl`)
- A Monarch (Monaco) tokenizer and typed CST node definitions (`packages/core/generated`)
This means syntax highlighting can never drift from what the parser actually accepts. To regenerate the editor artifacts after changing the grammar, run `bun run gen` inside `packages/core`. Any local monogram patches live in `patches/monogram.patch`.
## Development
This is a [turborepo](https://turbo.build/) monorepo using [bun](https://bun.sh/) as the package manager.
```sh
bun install
bun run build:libs # build all packages (except the website)
bun run test # run tests (vitest)
```
In an individual package, `bun run tbuild` builds it along with its dependencies.