Skip to main content

Views

@carno.js/views is an optional MVC view adapter. The HTTP core still returns whatever a controller produces: ctx.html(string) already builds a text/html Response, and buildResponse forwards any Response unchanged. This package adds template compilation on top of that path.

There is no @Render() decorator. Controllers inject ViewService and call html() or respond().

Installation

bun install @carno.js/views

Install only the engine you select. Official engines are optional peer dependencies and are loaded with dynamic import() on first render:

bun add handlebars
# or
bun add ejs
# or
bun add pug

Setup

engine is required. Omitting it is a configuration error so an optional library is never loaded by accident.

import { Carno } from '@carno.js/core';
import { CarnoViews } from '@carno.js/views';

const app = new Carno()
.use(CarnoViews({
engine: 'handlebars',
views: './views',
layout: 'layouts/main',
partials: 'partials',
helpers: {
shout: (value: string) => String(value).toUpperCase(),
},
}));

await app.listen(3000);

When views is omitted it resolves to path.resolve(process.cwd(), './views').

Rendering from a controller

import { Controller, Ctx, Get, type Context } from '@carno.js/core';
import { ViewService } from '@carno.js/views';

@Controller('/pages')
export class PagesController {
constructor(private views: ViewService) {}

@Get('/about')
async about() {
return this.views.html('about', { title: 'About' });
}

@Get('/profile')
async profile(@Ctx() ctx: Context) {
return this.views.respond(ctx, 'profile', { name: 'Ada' });
}
}

html() and respond() return promises. Mark the controller method async so Carno awaits the Response.

  • html(name, data) always returns Content-Type: text/html.
  • respond(ctx, name, data) inspects Accept, sets Vary: Accept, and chooses HTML or JSON. When both formats are refused, it returns 406 Not Acceptable.
AcceptResult
text/htmlRendered template
application/jsonResponse.json(data) without reading a template
text/* / application/*HTML or JSON respectively
a format listed with q=0Never selected, even when it is negotiate.default or covered by */*
both HTML and JSON listed with q=0406 Not Acceptable without reading a template
missing, */*, unmatched, or a html/json tienegotiate.default (html unless configured otherwise)
CarnoViews({
engine: 'handlebars',
negotiate: { default: 'html' },
});

Helpers from options.helpers are registered before the first render. Call views.registerHelper(name, fn) later to add or replace one.

Engines

engineExtensionsNotes
'handlebars'.hbs, .handlebarsCompile + cache, global helpers, partials from partials
'ejs'.ejsPasses filename, root and views so <%- include(...) %> works; includes cannot leave the views root
'pug'.pug, .jadePasses filename (current template) and basedir (views root) so extends / include work; nested files cannot leave the views root
custom ViewEngineadapter extensionsNo official package is loaded

A missing official library fails only when that engine is selected:

Unable to load the "handlebars" view engine. Install it with: bun add handlebars

Custom adapter

The service reads files, enforces the views root, and caches compiled templates. The adapter receives source and filename:

import type { ViewEngine } from '@carno.js/views';

const markdown: ViewEngine = {
name: 'plain',
extensions: ['.html'],
compile(source) {
return source;
},
render(template, data) {
return String(template).replace('{{name}}', String(data.name ?? ''));
},
};

CarnoViews({ engine: markdown, views: './views' });

compile(source, filename, options?) is optional and returns an opaque template. render(template, data, options?) returns string | Promise<string>. When compile is omitted, render receives the source string.

Layouts

When layout is set, the service renders the page first and then renders the layout with that HTML as trusted body. body is output from your own templates, not request input.

EngineLayout marker
Handlebars{{{body}}}
EJS<%- body %>
Pug!= body

Pug templates can still use native extends / include without the layout option; filename identifies the current template and basedir is the views root.

Cache

cache defaults to true when NODE_ENV === 'production', otherwise false.

  • cache: true stores compiled templates (and file contents) by absolute path. Official engines receive the same flag, so EJS includes stay frozen too. Editing a file on disk does not change the next response until the process restarts.
  • cache: false re-reads and recompiles on every render, including EJS partials pulled in with include. Handlebars partials that disappear from disk are unregistered so they are not still rendered.

Security

View names are resolved only under the configured views directory:

  • Absolute names and .. segments are rejected with HTTP 403.
  • Resolved paths, including realpath after a successful lookup, must stay inside the root (symlink escape is 403).
  • EJS include() and Pug include / extends are confined the same way: relative ../ escapes, absolute filesystem paths, and symlinks that leave views throw ViewForbiddenError (HTTP 403). The public message never includes absolute paths. Pug /-prefixed paths stay basedir-relative (include /layout${views}/layout).
  • A missing template throws ViewNotFoundError, which extends NotFoundException and becomes HTTP 404. Tried paths stay on the error object and are not included in the public message.

Options

OptionTypeDefaultDescription
engine'handlebars' | 'ejs' | 'pug' | ViewEnginerequiredOfficial engine name or custom adapter
viewsstring'./views'Template root, resolved from process.cwd()
cachebooleanNODE_ENV === 'production'Cache compiled templates and file contents
layoutstringnoneLayout view name resolved like any other template
partialsstringnonePartials directory, relative to views unless absolute
helpersRecord<string, Function>{}Helpers available from the first render
negotiate.default'html' | 'json''html'Fallback when Accept is missing, */*, unmatched, or tied. Not used for a format listed with q=0. When both HTML and JSON are refused, respond() returns 406 instead