LML is a proposed alternative to HTML, which is light-weight strictly typed, and indentation-based. Instead of typeing every closing html tag, LML proposes that these are unnecessary, and removing them makes typing the markup easier and faster. It also hase the benefit of reduced data transfer, as less bytes travel over the wire.
The lmlc compiler turns .lml files into standard .html files. In a dream world, this would be implemented on a browser level, so LML files could be understood and compiled natively. For now, we have a Javascript based compiler which can detect .lml responses, and transform them in to valid HTML for rendering by the current browser.
With Single Page Application(s) (SPA) dominating the world of web rendering, LML also proposes that links behave as SPA by default. When running a dev server, or builing in project mode, SPA behaviour happens automatically - no third party dependencies needed.
HTML becomes hard to read and edit when too many elements are not indented properly. By creating a strictly typed tabulated markup language that honours existing HTML elements, we can ensure clean codebases, and can forget about tracking down the correct closing tag.
npm install -g @tuun/lmlcHTML:
<div class="min-h-screen flex flex-col items-center justify-between px-4 py-6 bg-gray-100 dark:bg-gray-900">
<header class="w-full max-w-5xl">
<nav class="flex gap-4">
<a href="/" class="text-sm font-medium hover:underline">Home</a>
</nav>
</header>
</div>LML:
<div
class="min-h-screen flex flex-col items-center justify-between px-4 py-6 bg-gray-100 dark:bg-gray-900"
<header
class="w-full max-w-5xl"
<nav
class="flex gap-4"
<a
href="/"
class="text-sm font-medium hover:underline"
Home
>
>
>
>
| Construct | How to write it |
|---|---|
| Open an element | <tagname on its own line |
| Add attributes | Indented lines below the tag: name="value", name='value', name=value, or bare name for boolean attributes |
| Add child elements | Nest another <tagname at a deeper indent |
| Add text content | A plain text line inside the element |
| Close an element | > at the same indent level as the opening <tagname |
| Comments | Standard HTML comments <!-- ... --> — ignored by the compiler |
| Void elements | <br, <img, <input, etc. — close automatically, no > needed |
- Attributes must be indented more than their tag.
- Children must be indented more than their tag.
- The closing
>must align exactly with the opening<tagname. - Spaces and tabs are both accepted (tabs count as 2 spaces).
Or run directly from the project after building:
npm run build
npm link # makes `lmlc` available globallylmlc <input.lml> [output.html]If output.html is omitted the compiled file is written next to the input with an .html extension.
| Flag | Description |
|---|---|
--no-doctype |
Do not prepend <!DOCTYPE html> |
--minify |
Emit minified HTML (no indentation or newlines) |
-h, --help |
Show help |
# Compile and write to index.html
lmlc src/index.lml
# Compile to a specific output path
lmlc src/index.lml dist/index.html
# Compile for production (minified, no doctype for a partial)
lmlc --minify --no-doctype components/card.lml dist/card.htmllmlc serve [src-dir] [options]src-dir defaults to the current directory.
| Flag | Description |
|---|---|
--port <n> |
Port to listen on (default: 3000) |
--no-reload |
Disable live reload |
--title <text> |
Default <title> for the shell (default: LML App) |
--lang <code> |
html[lang] attribute (default: en) |
# Serve the current directory on http://localhost:3000
lmlc serve
# Serve a specific source directory on a custom port
lmlc serve src --port 8080
# Disable live reload (useful when running inside a container)
lmlc serve src --no-reloadThe dev server mirrors production exactly:
.lmlfiles are served astext/plain— the same fetch the browser runtime uses in productionlml-runtime.jsis served from memory (always reflects the installed version oflmlc)index.htmlshell is served from memory for every non-file URL (SPA fallback)- Other static assets (
*.css, images, fonts) are served fromsrc-dir - Any
.lmlfile change triggers an instant page reload via Server-Sent Events — no WebSocket, no extra dependencies
Testing single-file compilation locally
If you haven't runnpm linkyet, invoke the compiler directly:
node dist/index.js example.lmlornpm run compile -- example.lml
Compile an entire directory tree and generate everything needed to run the site as a single-page application: the .lml source files are copied as-is to the output directory, and lmlc generates a thin browser runtime that fetches and renders each page on demand.
- The browser only ever downloads the LML source for the page it's visiting — not the pre-rendered HTML for every page.
- LML files are smaller than equivalent HTML (no closing tags, no repetitive attribute markup).
- The runtime (~4 KB) is cached after the first load; subsequent navigations are a single small
fetch(). - The server needs no special per-page knowledge — just two rules: serve
.lmlfiles as text, send everything else toindex.html.
lmlc --project <src-dir> [out-dir] [options]out-dir defaults to ./dist.
| Flag | Description |
|---|---|
--apache |
Emit .htaccess in the output directory |
--nginx |
Emit nginx.conf in the output directory |
--custom-404 |
Wire up 404.lml as the SPA error page |
--title <text> |
Default <title> for the shell page (default: LML App) |
--lang <code> |
html[lang] attribute for the shell (default: en) |
--server-name <host> |
server_name for the nginx config (default: example.com) |
--nginx-root <path> |
root directive for the nginx config (default: /var/www/html) |
--ssl |
Add an SSL/TLS stanza to the nginx config |
--https-redirect |
Add an HTTP→HTTPS redirect server block (implies --ssl) |
# Compile src/ → dist/ and print the URL route table
lmlc --project src dist
# Also emit .htaccess for Apache
lmlc --project src dist --apache
# Also emit nginx.conf for nginx
lmlc --project src dist --nginx --server-name mysite.com --nginx-root /var/www/mysite
# Full production build: both configs, HTTPS redirect, custom 404
lmlc --project src dist --apache --nginx \
--server-name mysite.com --nginx-root /var/www/mysite \
--https-redirect --custom-404For a project with this source layout:
src/
index.lml
about.lml
blog/
index.lml
my-post.lml
Running lmlc --project src dist --apache produces:
dist/
index.lml ← copied source (fetched for /)
about.lml ← copied source (fetched for /about)
blog/
index.lml ← copied source (fetched for /blog/)
my-post.lml ← copied source (fetched for /blog/my-post)
index.html ← SPA shell (served for every route)
lml-runtime.js ← browser parser + SPA router
.htaccess ← Apache rewrite rules
| Source file | URL |
|---|---|
index.lml |
/ |
about.lml |
/about |
blog/index.lml |
/blog/ |
blog/my-post.lml |
/blog/my-post |
docs/api/ref.lml |
/docs/api/ref |
After build the CLI prints the full route table so you can verify the mapping before deploying:
Routes (4):
URL FILE
/ index.lml
/about about.lml
/blog/ blog/index.lml
/blog/my-post blog/my-post.lml
The LML SPA architecture has three moving parts in production:
| File | Role |
|---|---|
index.html |
Thin shell served for every HTML route — just a <div id="lml-app"> and a <script> tag |
lml-runtime.js |
~4 KB browser bundle — LML parser + renderer + SPA router |
*.lml |
Your page content — copied verbatim, fetched on demand |
The server only needs two rules: serve .lml files as static text and send everything else to index.html.
- User visits
https://mysite.com/blog/my-post - nginx/Apache serves
index.html(the SPA shell — same file for every route) - Browser executes
lml-runtime.js, which readslocation.pathname→/blog/my-post - Runtime fetches
/blog/my-post.lmlfrom the server - Runtime parses the LML source using the same rules as
lmlc - Rendered HTML is injected into
<div id="lml-app"> - User clicks
<a href="/about">→ runtime intercepts,pushState, fetches/about.lml, re-renders — no full page reload
lmlc --project src dist --apache --custom-404
rsync -av dist/ user@host:/var/www/html/Requires mod_rewrite, mod_headers, mod_expires, and mod_mime — enabled by default on most shared hosts.
Example .htaccess output
# Generated by lmlc — do not edit by hand
# Requires: mod_rewrite, mod_headers, mod_expires, mod_mime
Options -Indexes
DirectoryIndex index.html
# Serve .lml files as plain text so the JS runtime can fetch them
<IfModule mod_mime.c>
AddType "text/plain; charset=utf-8" .lml
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
# Pass .lml source files directly to the browser
RewriteRule \.lml$ - [L]
# Pass existing static assets (JS, CSS, images, fonts, etc.)
RewriteCond %{REQUEST_FILENAME} -f
RewriteRule ^ - [L]
# Everything else → SPA shell
RewriteRule ^ index.html [L]
</IfModule>
# Security headers
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
</IfModule>
# Cache-control
<IfModule mod_expires.c>
ExpiresActive On
# SPA shell — always revalidate
<FilesMatch "^index\.html$">
ExpiresDefault "access plus 0 seconds"
Header always set Cache-Control "no-cache, must-revalidate"
</FilesMatch>
# .lml sources — no cache so edits are always fetched fresh
<FilesMatch "\.lml$">
ExpiresDefault "access plus 0 seconds"
Header always set Cache-Control "no-cache, must-revalidate"
</FilesMatch>
# Fingerprinted static assets — immutable for 1 year
ExpiresByType text/css "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
</IfModule>lmlc --project src dist --nginx \
--server-name mysite.com \
--nginx-root /var/www/mysite \
--https-redirect \
--custom-404
rsync -av dist/ user@host:/var/www/mysite/
scp dist/nginx.conf user@host:/etc/nginx/sites-available/mysite
ssh user@host "ln -sf /etc/nginx/sites-available/mysite /etc/nginx/sites-enabled/ \
&& nginx -t && systemctl reload nginx"Example nginx.conf output (with --https-redirect)
server {
listen 80;
listen [::]:80;
server_name mysite.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name mysite.com;
root /var/www/mysite;
index index.html;
charset utf-8;
ssl_certificate /etc/ssl/certs/mysite.com.crt;
ssl_certificate_key /etc/ssl/private/mysite.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# .lml source files — fetched by lml-runtime.js on every navigation
location ~* \.lml$ {
default_type "text/plain; charset=utf-8";
add_header Cache-Control "no-cache, must-revalidate" always;
try_files $uri =404;
}
# SPA shell — all routes fall through to index.html
location / {
try_files $uri /index.html;
location = /index.html {
add_header Cache-Control "no-cache, must-revalidate" always;
}
}
# Fingerprinted static assets
location ~* \.(css|js|woff2|png|jpg|svg|webp|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable" always;
}
location ~ /\. { deny all; }
}Add a 404.lml to your source directory and pass --custom-404:
lmlc --project src dist --apache --custom-404When the runtime fetches a .lml file and gets a 404 response, it automatically falls back to /404.lml and renders it. The 404 page is fetched once and cached in memory for the session.
HTML emails require heavily attributed tables, td elements with inline styles, and deeply nested structures that are painful to maintain. LML keeps the hierarchy readable:
<table
role="presentation"
width="100%"
cellpadding="0"
cellspacing="0"
style="background-color:#f4f4f4"
<tr
<td
align="center"
style="padding:40px 0"
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="background-color:#ffffff;border-radius:8px"
<tr
<td
style="padding:32px 40px;font-family:sans-serif;font-size:16px;color:#333333"
Welcome to our service.
>
>
>
>
>
>
Drop lmlc into any build pipeline. The project mode SPA build fits naturally into an npm script:
"scripts": {
"build": "lmlc --project src dist --apache --nginx --server-name mysite.com --nginx-root /var/www/mysite"
}When a backend emits HTML fragments (HTMX partial swaps, Rails partials, Django includes), keep the fragments readable in LML and use single-file mode to compile them at build time:
# Compile a single partial (no doctype, minified)
lmlc --no-doctype --minify src/partials/card.lml dist/partials/card.htmllmlc exposes all its internals for use in Node.js tooling:
import { parse } from './src/parser.js';
import { emit } from './src/codegen.js';
import { generateApacheHtaccess, generateNginxConf, fileToUrl } from './src/server.js';
import { generateBrowserRuntime, generateShellHtml } from './src/runtime.js';
// Compile a single LML string to HTML
const ast = parse('<div\nclass="container"\n Hello, world!\n>');
const html = emit(ast, { pretty: true, doctype: false });
// <div class="container">
// Hello, world!
// </div>
// Generate the SPA shell and runtime programmatically
const shell = generateShellHtml({ title: 'My Site', lang: 'en' });
const runtime = generateBrowserRuntime();
// Generate server configs
const htaccess = generateApacheHtaccess({ custom404: true });
const nginxConf = generateNginxConf({
serverName: 'mysite.com',
root: '/var/www/mysite',
ssl: true,
httpsRedirect: true,
custom404: true,
});
// Map a .lml source path to its browser URL
fileToUrl('blog/my-post.lml'); // → '/blog/my-post'
fileToUrl('index.lml'); // → '/'Parses LML source text into an AST. Throws ParseError (with a 1-based line property) on invalid input.
Serialises an AST to an HTML string.
| Option | Type | Default | Description |
|---|---|---|---|
pretty |
boolean |
true |
Indent output |
doctype |
boolean |
true |
Prepend <!DOCTYPE html> when root is <html> |
Generates the index.html SPA shell.
| Option | Type | Default | Description |
|---|---|---|---|
title |
string |
'LML App' |
Default page title before first navigation |
lang |
string |
'en' |
<html lang="..."> attribute |
headExtra |
string |
— | Raw HTML injected at the end of <head> |
Returns the complete lml-runtime.js source as a string. The runtime is a self-contained IIFE (~4 KB) that includes the LML parser, HTML renderer, and SPA router. No dependencies.
Generates an Apache .htaccess for the LML SPA.
| Option | Type | Default | Description |
|---|---|---|---|
custom404 |
boolean |
false |
Include a note about /404.lml error handling |
Generates an nginx server block for the LML SPA.
| Option | Type | Default | Description |
|---|---|---|---|
serverName |
string |
'example.com' |
server_name directive |
root |
string |
'/var/www/html' |
root directive |
port |
number |
80 |
Listen port (ignored when ssl is true) |
ssl |
boolean |
false |
Add SSL/TLS stanza |
httpsRedirect |
boolean |
false |
Add HTTP→HTTPS redirect block |
custom404 |
boolean |
false |
Add error_page 404 for non-JS clients |
Converts a .lml file path (relative to source root) to its canonical browser URL.
Parse errors include the source line number:
Compile error: Line 4: Expected '>' to close <div>, got '<span'
MIT