Skip to content

Repository files navigation

>< NMBL

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>

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/.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 — #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 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

Usage

Vue

// 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(),
  ],
});
// 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:

<template lang="nmbl">
@if(loggedIn)
  p Welcome back!
@each(item of items :key="item.id")
  li {{ item.name }}
</template>

See examples/vue.

Svelte

// 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

// astro.config.mjs
import nmbl from '@nmbl-lang/astro';

export default defineConfig({
  integrations: [nmbl()],
});

See 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.

// 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 → 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 and 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) — 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 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) — 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.

Agent skill

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/nmbl

Compiler API

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 (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 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.

About

NMBL = No More Brackets Lang - modern pug successor

Topics

Resources

Stars

6 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages