View Engine
Server-side template engine with Vue.js island support. Renders.strav templates to HTML strings, compiles them to cached async functions for fast repeated renders.
Quick start
import { ViewEngine, view } from '@strav/view'
// In a route handler via Context
router.get('/users', async (ctx) => {
const users = await User.all()
return ctx.view('pages/users', { users, title: 'Users' })
})
// Or with the standalone helper
router.get('/', async () => {
return view('pages/home', { title: 'Welcome' })
})
Setup
Register theViewProvider in your application:
import { ViewProvider } from '@strav/view'
app.use(new ViewProvider())
This registers the ViewEngine singleton and wires it into the HTTP context so ctx.view() works in all route handlers.
Templates live in the resources/views/ directory by default. Configure via config/view.ts:
import { env } from '@strav/kernel'
export default {
directory: env('VIEW_DIRECTORY', 'resources/views'),
cache: env.bool('VIEW_CACHE', true), // disable in development for auto-reload
}
Template syntax
Expressions
{{ user.name }} {{-- escaped output (HTML entities) --}}
{!! user.bio !!} {{-- raw output (no escaping) --}}
{{-- this is a comment, stripped from output --}}
Expressions are real JavaScript — {{ items.length + 1 }}, {{ user.name.toUpperCase() }}, and ternaries all work.
Conditionals
@if(user.isAdmin)
<span class="badge">Admin</span>
@elseif(user.isMod)
<span class="badge">Moderator</span>
@else
<span class="badge">Member</span>
@end
Any JS expression works as the condition: @if(items.length > 0), @if(user && user.verified).
Loops
<ul>
@each(item in items)
<li>{{ item.name }}</li>
@end
</ul>
Inside loops, these variables are available automatically:
| Variable | Type | Description |
|---|---|---|
$index |
number |
Current iteration index (0-based) |
$first |
boolean |
true on the first iteration |
$last |
boolean |
true on the last iteration |
@each(user in users)
<div class="{{ $first ? 'border-t' : '' }}">
{{ $index + 1 }}. {{ user.name }}
</div>
@end
Includes
Render a partial template with its own data:
@include('partials/nav', { user, notifications })
The included template receives both the parent data and any additional data passed. Template names use / as separators, mapping to file paths inside the views directory.
Layouts and blocks
Layouts define the page shell. Child templates fill named blocks.
{{-- resources/views/layouts/app.strav --}}
<!DOCTYPE html>
<html>
<head><title>{{ title }}</title></head>
<body>
@include('partials/nav', { user })
<main>
@if(content)
{!! content !!}
@else
<p>No content</p>
@end
</main>
</body>
</html>
{{-- resources/views/pages/dashboard.strav --}}
@layout('layouts/app')
@block('content')
<h1>Dashboard</h1>
<p>Welcome back, {{ user.name }}</p>
@end
The child template renders first, collecting its blocks. Then the layout renders with those blocks available as data.
Vue islands
For interactive components, use Vue islands. The server renders a placeholder<div> and Vue hydrates it on the client.
In templates
<vue:search-bar placeholder="Search users..." />
<vue:counter :initial="{{ startCount }}" label="Click me" />
Static attributes pass string values. Bound attributes (:prop) evaluate the expression at render time. The server output:
<div data-vue="search-bar" data-props='{"placeholder":"Search users..."}'></div>
<div data-vue="counter" data-props='{"initial":5,"label":"Click me"}'></div>
Vue SFC islands (recommended)
Write real.vue single-file components in a resources/islands/ directory. The framework compiles and bundles them automatically.
1. Create .vue files:
<!-- resources/islands/counter.vue -->
<template>
<div class="counter">
<button @click="count--">-</button>
<span>{{ count }}</span>
<button @click="count++">+</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({ initial: { type: Number, default: 0 } })
const count = ref(props.initial)
</script>
<style scoped>
.counter { display: flex; gap: 8px; align-items: center; }
</style>
Both <script setup> and Options API (<script>) are supported. <style scoped> works as expected.
2. Use @islands in your template:
{{-- resources/views/pages/home.strav --}}
@layout('layouts/app')
@block('content')
<h1>Welcome</h1>
<vue:counter :initial="{{ startCount }}" />
@end
@block('scripts')
@islands
@end
The @islands directive emits <script src="/islands.js"></script>. You can pass a custom path: @islands('/assets/islands.js').
3. Build islands before server start:
import { IslandBuilder } from '@strav/view'
const islands = new IslandBuilder()
await islands.build()
// Then start the server (scanPublicDir picks up the built islands.js)
server.start(router)
IslandBuilder.build() scans the resources/islands/ directory, compiles all .vue files using @vue/compiler-sfc, and bundles everything (Vue runtime + components + mount logic) into a single public/builds/islands.js.
Options:
const islands = new IslandBuilder({
islandsDir: './resources/islands', // default: './resources/islands'
outDir: './public/builds', // default: './public/builds'
outFile: 'islands.js', // default: 'islands.js'
minify: true, // default: true in production
basePath: '/builds/', // default: '/builds/'
compress: true, // default: true (generate .gz/.br files)
css: { // optional CSS compilation
entry: './resources/css/app.scss', // single entry
// OR entry: ['./src/app.scss', './src/admin.scss'], // multiple entries
// OR entry: { app: './src/app.scss', admin: './src/admin.scss' }, // named entries
outDir: './public/css', // default: './public/css'
basePath: '/css/', // default: '/css/'
},
})
Multiple sources — modules and vendor packages:
When an app is organized by module (e.g. app/modules/auth/islands, app/modules/billing/islands) instead of one flat islands/ directory, use sources to merge all of them into a single bundle. Each source contributes .vue files and optional CSS to the same output — one <script>, one Vue runtime, one mount loop.
const islands = new IslandBuilder({
sources: [
{ islandsDir: 'resources/islands' }, // app, anonymous
{ islandsDir: 'app/modules/auth/islands', namespace: 'auth' },
{ islandsDir: 'app/modules/billing/islands', namespace: 'billing' },
],
})
Component names are prefixed by namespace. A file at app/modules/auth/islands/login-form.vue is addressable as <vue:auth/login-form/> in templates. The unnamespaced source's components remain unprefixed (<vue:counter/>).
Rules:
- At most one source may omit
namespace— that's the host app's "root" islands. - All other sources must declare a unique namespace.
- Duplicate fully-qualified component names throw at build time with both source labels.
strav.islands manifest:
Packages declare their island contribution via package.json:
{
"name": "@strav/admin-ui",
"strav": {
"islands": {
"namespace": "admin", // required
"dir": "./islands", // required, relative to package root
"css": { "admin": "./css/admin.scss" } // optional, same shape as CssOptions.entry
}
}
}
Packages ship raw .vue and .scss files — the host bundles them with its own Vue runtime, so there's no per-package bundle and no Vue duplication. The host opts in explicitly by name:
new IslandBuilder({
sources: [
{ islandsDir: 'app/modules/auth/islands', namespace: 'auth' },
],
packages: ['@strav/admin-ui'], // resolves package.json, reads strav.islands
})
Each source may also ship a setup.ts (a (app: App) => void default export); all setups are invoked in source order on the shared Vue app, so vendor packages can register their own plugins or globals. CSS entries from each source merge into the same keyed map used by @css('key') — per-source keys are namespace-prefixed (@css('admin/admin')); top-level CSS keeps its plain key.
// Rebuild islands.js automatically when .vue files change.
// Watches every source directory and debounces rebuilds (50ms).
islands.watch()
// Stop watching
islands.unwatch()
Package sources aren't watched by default (assumed immutable in node_modules). Pass watchPackages: true for workspace-symlinked vendor cases.
// Build both islands and CSS
await islands.build()
// Or build CSS separately
await islands.buildCss()
CSS files are compiled with Sass, minified in production, and automatically versioned with content hashes. The builder generates multiple CSS files if you provide multiple entries and watches for changes in development mode.
Dependencies: The app package needsvue as a dependency (it gets bundled into islands.js). For CSS compilation, install sass:
{
"dependencies": {
"vue": "^3.5.28"
},
"devDependencies": {
"sass": "^1.70.0"
}
}
Manual bootstrap (alternative)
For apps that load Vue from a CDN or need custom control, you can manually register components onwindow.__vue_components and use the client-side islands bootstrap:
import SearchBar from './components/SearchBar.vue'
import Counter from './components/Counter.vue'
;(window as any).__vue_components = {
'search-bar': SearchBar,
'counter': Counter,
}
import '@strav/view/client/islands'
Include the bundled script in your layout:
<script type="module" src="/assets/app.js"></script>
Static file serving
The Strav HTTP server automatically serves static files from the configured public directory (default: public/). No middleware setup is required.
Configure the public directory in config/http.ts:
export default {
public: './public', // default: './public'
}
The server automatically serves any file that exists under the public directory, with support for content negotiation (gzip/brotli), falls through to route handlers when no file matches, and blocks directory traversal attacks automatically.
Template resolution
Template names map to file paths:
| Name | File path |
|---|---|
'pages/home' |
resources/views/pages/home.strav |
'layouts/app' |
resources/views/layouts/app.strav |
'partials/nav' |
resources/views/partials/nav.strav |
Caching
In production (VIEW_CACHE=true), templates are compiled once and cached in memory for the lifetime of the process — subsequent renders skip file I/O and parsing entirely.
In development (VIEW_CACHE=false), the engine checks file modification times before each render and recompiles automatically when the source changes.
Testing
Test templates directly with the engine:
import { test, expect, beforeAll } from 'bun:test'
import ViewEngine from '@strav/view'
import Configuration from '@strav/kernel'
let engine: ViewEngine
beforeAll(async () => {
const config = new Configuration('config')
config.set('view.directory', 'tests/view/fixtures')
config.set('view.cache', false)
engine = new ViewEngine(config)
})
test('renders user page', async () => {
const html = await engine.render('pages/users', {
users: [{ name: 'Alice' }],
title: 'Users',
})
expect(html).toContain('Alice')
})