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 the ViewProvider 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.
Vendor packages — 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.

Dev mode — watch for changes:
// 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.

CSS Compilation: The IslandBuilder can also compile Sass/SCSS files alongside your islands:
// 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 needs vue 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 on window.__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')
})