No More Brackets Language — 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:
<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>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.
- Tagged template literal transform — use
nmbl\…`in.tsx/.jsxfiles with React, Solid, Qwik, or Preact; the Vite plugin compiles it to JSX at build time with zero runtime overhead..cardbecomesclassNamefor React andclass` for Solid/Qwik - CSS-style selectors —
#idand.classshorthand, implicitdiv(#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,#slotshorthand all parse as plain attributes - Control flow blocks —
@if/@elseif/@else/@each/@await/@key/@snippetcompile to the host framework's native syntax.@eachaccepts one canonical form everywhere:@each(item of items :key="item.id")— the:keyattribute sits in the same paren list, whitespace-separated like any other NMBL attribute. Svelte'sas-form (@each(items as item, i)) is also accepted. Templates using the canonical form are portable across frameworks: the same@eachsource 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/@elseto renderless<template v-if>/<template v-else-if>/<template v-else>wrappers. Svelte gets the full block set (@await,@key,@snippet). Astro supports only@ifand@each. Plain HTML treats@-blocks as hard errors - Content blocks —
script:/style:capture nested content verbatim; named modes likearticle:mdhand 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
| Package | Description |
|---|---|
@nmbl-lang/core |
Core lexer, parser, compiler (HTML + source mappings), HTML→NMBL decompiler, formatter, and linter |
@nmbl-lang/cli |
nmbl format / nmbl lint — standalone CLI for CI, pre-commit, and lint-staged |
@nmbl-lang/prettier-plugin |
Prettier plugin for .nmbl files |
@nmbl-lang/vite-plugin |
Vite plugin: .nmbl files and <template lang="nmbl"> in Vue SFCs |
@nmbl-lang/svelte |
Svelte preprocessor with V3 source maps |
@nmbl-lang/astro |
Astro integration |
@nmbl-lang/vue-language-plugin |
Volar plugin: full IntelliSense for NMBL templates in Vue SFCs |
@nmbl-lang/vscode-extension |
Syntax highlighting, format-on-save, and HTML conversion for .nmbl and embedded templates (install from repo) |
@nmbl-lang/website |
nmbl.tools — docs and interactive playground |
// 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(),
],
});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:
<template lang="nmbl">
@if(loggedIn)
p Welcome back!
@each(item of items :key="item.id")
li {{ item.name }}
</template>
See examples/vue.
// 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.
// astro.config.mjs
import nmbl from '@nmbl-lang/astro';
export default defineConfig({
integrations: [nmbl()],
});See examples/astro.
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.
// 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(),
],
});// 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→classNamefor React/Preact,classfor Solid/Qwik@if(${cond})→{cond && …}ternary;@if/@else→ full ternary@each(item of ${items} :key="item.id")→{items.map((item) => …)}withkey={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 and examples/solid.
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) — for npm scripts, CI, pre-commit, and lint-staged:
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 diagnosticsPaths 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) — drop it into an existing Prettier setup:
// .prettierrc
{ "plugins": ["@nmbl-lang/prettier-plugin"] }prettier --write "**/*.nmbl"VS Code — the 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.
Working with AI coding agents? Install the NMBL skill so they write the syntax correctly — it covers the grammar, per-framework control flow, the whitespace pitfalls, and how to verify output:
npx skills add theoephraim/nmblimport { compile, decompile } from '@nmbl-lang/core';
const { html, errors } = compile('p.lead Hello world');
const nmbl = decompile('<p class="lead">Hello world</p>');The NMBL grammar is defined once in packages/core/src/nmbl-grammar.ts using 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.
This is a turborepo monorepo using bun as the package manager.
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.