Precompile & CSP
Lips compiles templates in the browser by default. You can also compile them at build time. Two things follow:
- Smaller and faster — the parser and compiler never ship, and the app boots straight into cloning skeletons instead of parsing template strings.
- CSP-safe — combined with
mode: 'interpreted', the app never constructs aFunction, so it runs under a strictscript-srcwithoutunsafe-eval.
The IR is plain JSON. It can be embedded in a bundle, fetched from a server, or stored.
Entry points
| Import | Contents | gzip |
|---|---|---|
@lipsjs/lips |
full — runtime, parser/compiler, styles, router | ~21 KB |
@lipsjs/lips/runtime |
precompiled-only — no parser, compiler, Stylis or router | ~13 KB |
@lipsjs/lips/precompile |
build-time helpers and the bundler plugin | — |
@lipsjs/lips/dev |
unminified full build | — |
The compiler, the CSS preprocessor and <router> are injected by the full entry rather than
imported by the core, which is what lets the runtime entry drop them completely.
Precompiling a template
import { precompile } from '@lipsjs/lips/precompile'
const { template, diagnostics } = precompile({
state: { count: 0 },
handler: { inc(){ this.state.count++ } },
default: `<button on-click(inc)>{state.count}</button>`
})
diagnostics.length && console.error( diagnostics )
// template → { ir, state, handler } — `default` is gone
Macros are inlined during precompilation, so the macros source never reaches the client.
At runtime, register the result as usual:
import Lips from '@lipsjs/lips/runtime'
const lips = new Lips({ mode: 'interpreted' })
lips.register('counter', template)
A template carrying ir skips parsing entirely — even on the full build.
Single-file components
A .lips file is a script followed by a template. Anything before the first top-level tag is
the script; the rest is the template.
const state = {
count: 0,
step: 1
}
const handler = {
increment(){ this.state.count += this.state.step }
}
const stylesheet = `
.counter { display: flex; gap: .5rem; align-items: center }`
<div class="counter">
<span>{state.count}</span>
<button on-click(increment)>+{state.step}</button>
</div>
The plugin picks up state, handler, _static, context and stylesheet when they are
declared with const, and merges them with the compiled IR into the default export.
Bundler setup
// vite.config.js
import { lipsPlugin } from '@lipsjs/lips/precompile'
export default {
plugins: [ lipsPlugin() ]
}
import Counter from './counter.lips' // already-compiled IR
lips.register('counter', Counter)
Options:
| Option | Default | Meaning |
|---|---|---|
include |
/\.lips$/ |
which files to transform |
reportDiagnostics |
true |
emit parser/compiler warnings as build warnings |
Template errors fail the build, reported with file:line:col — a broken template is
caught before it ships, not at runtime.
Compiling by hand
For full control — a build script, a server, or a generated component:
import { compileTemplate, parseTemplate, parseSFC } from '@lipsjs/lips'
const { ir, diagnostics } = compileTemplate(`<p>{state.msg}</p>`, {
macros: `<macro [x] name="chip"><b>{x}</b></macro>`
})
if( diagnostics.some( d => d.severity === 'error' ) )
throw new Error('template failed to compile')
JSON.stringify( ir ) // ship it anywhere
Diagnostics never throw — you always get an IR plus a list of what went wrong:
{
code: 'LIPS-C013',
severity: 'error',
message: '…',
hint: '…',
loc: { line: 4, col: 12, offset: 88, length: 17 }
}
Running under a strict CSP
const lips = new Lips({ mode: 'interpreted' })
In interpreted mode expressions run through a sandboxed AST walker instead of a compiled
Function. Both modes are proven to render identical DOM — the difference is only how an
expression is evaluated.
Content-Security-Policy: script-src 'self'
compiled (default) |
interpreted |
|
|---|---|---|
| Expression evaluation | one cached Function per source |
AST walker |
Needs unsafe-eval |
yes | no |
| Speed | faster | slower per expression |
interpreted mode removes the need for unsafe-eval on its own. Precompiling additionally
removes the parser — pair them for the smallest, strictest deployment.
Putting it together
// vite.config.js
import { lipsPlugin } from '@lipsjs/lips/precompile'
export default { plugins: [ lipsPlugin() ] }
// app.js — the 13 KB runtime, no eval, no parser
import Lips from '@lipsjs/lips/runtime'
import Counter from './counter.lips'
import App from './app.lips'
const lips = new Lips({ mode: 'interpreted' })
lips.register('counter', Counter)
lips.root( App, '#app')
The runtime build has no CSS preprocessor, so component stylesheet sources are skipped
there with a warning. Ship those styles as a static CSS file, or use the full entry.