Frontend build
How stylesheets, scripts and static files reach a page through the assets helpers, with or without a Vite build, how the vite extension reads the build manifest and runs the dev server, and how to stay inside the content security policy with no inline script.
The engine does not own your frontend build. It serves what is in public/, and its asset helpers print the right <link> and <script> tags for a site’s bundle: plain files when you have no build step, or the content-hashed files a Vite build wrote when the project uses the vite extension. No Node runs in production.
The helpers
<head>
{{ head() }}
{{ preload() }}
{{ assets.css('main') }}
{{ assets.js('main') }}
<link rel="icon" href="{{ assets.url('favicon.svg') }}" type="image/svg+xml">
</head> | Helper | Prints |
|---|---|
assets.css('main') | The stylesheet links of the site’s bundle main |
assets.js('main') | Its script as <script type="module"> |
assets.js('legacy', false) | A classic <script defer>, for code that expects globals and order |
preload(), preload('legacy') | With the vite extension: <link rel="modulepreload"> for the bundle’s entry and the chunks it imports, and <link rel="preload" as="style"> for its stylesheets. main when no name is given |
assets.url('img/logo.svg') | A file’s path with ?v= and a version from its modification time |
Each site’s bundle is its own: the same assets.css('main') in a shared layout prints each site’s build.
Without a build step
With no extension, a bundle name maps to files under public/:
| Call | Prints |
|---|---|
assets.css('main') | <link rel="stylesheet" href="/css/main.css?v=..."> |
assets.js('main') | <script type="module" src="/js/main.js?v=..."></script> |
assets.js('main', false) | <script src="/js/main.js?v=..." defer></script> |
public/
css/main.css
js/main.js
favicon.svg This is how the bare starter project works, and how a project with a build of its own (any bundler that writes plain file names) can work.
Static files
Files under public/ are served at their path when no content route matches. They resolve along a chain per site, first match wins:
public/<site key>/public/_shared/public/
So public/town/favicon.svg is the town’s icon and public/favicon.svg everyone else’s. Only regular files are served, never directory listings.
| File | Cache-Control |
|---|---|
Requested with ?v= (as assets.url writes it), or under public/build/ | public, max-age=31536000, immutable |
| Anything else | public, max-age=3600 |
To serve another directory, such as uploads from a legacy site, at a URL prefix after the chain, add a mount to engine.yaml:
public:
mounts:
- { path: /legacy/, dir: ../legacy-site/files } path starts and ends with /; dir is relative to the project.
With Vite
The first-party vite extension connects a Vite build to the helpers. Vite writes the files and a manifest; the engine reads the manifest and prints the tags:
flowchart LR
SRC["src/entries/main.js<br/>CSS and JS"] --> V["vite build"]
M["engine-dmo assets manifest<br/>(read by meridian(), optional)"] -.-> V
V --> OUT["public/build/<br/>hashed files"]
V --> MAN["public/build/.vite/manifest.json"]
MAN --> EXT["vite extension"]
EXT --> H["assets.css('main')<br/>assets.js('main'), preload()"]
H --> P["link and script tags<br/>under /build/"]
OUT --> Passets.css(),assets.js()andpreload()read the build’s manifest and print the content-hashed files, under/build/;engine-dmo serve --devstarts the project’s Vite dev server as a child process and points the helpers at it, with hot module replacement;engine-dmo assets budgetandengine-dmo assets unusedreport on the build.
Name the extension in engine.yaml:
# config/engine.yaml
extensions:
- vite The extension ships beside the binary (extensions/vite.so; /app/extensions/vite.so in the -vips container image). Extensions load only in a binary built with cgo, on Linux, macOS or FreeBSD; the -vips image is one. A binary built without cgo refuses a project that names one: this engine is a pure Go build, which cannot load extensions: run the -vips image, a cgo build. The extensions: line of engine-dmo version names cgo when the binary can load them.
Write one entry per site at src/entries/<site key>.js. It imports the site’s stylesheet and script:
// src/entries/main.js
import '../css/site.css';
import '../js/site.js'; A site’s other bundles are src/entries/<site key>-<name>.js (main-legacy.js is the bundle legacy), or the paths its site.yaml names (below).
Configure Vite to write public/build/ with a manifest, served under /build/.
Which configuration you can use
The meridian() Vite plugin (vite-plugin-meridian) is not published to npm. The binary carries it, but writes it only when engine-dmo init --from copies a project whose package.json names it, which in practice means an example project in a Meridian source checkout. engine-dmo init with no flag writes no package.json and no plugin. Without a checkout, use the plain Vite configuration below: it works with the vite extension, and you list each site’s entry yourself. sigmx, the client for in-place updates, is published to npm.
With the plugin, the configuration is:
// vite.config.js
import { defineConfig } from 'vite';
import { meridian } from 'vite-plugin-meridian';
export default defineConfig({
plugins: [meridian()],
build: { target: 'es2022', cssMinify: true, sourcemap: false },
}); meridian() asks the engine for each site’s bundles (engine-dmo assets manifest --json), builds one entry per site bundle, adds to each site’s main entry the CSS and JS of every component that resolves for that site, writes public/build with a manifest under base /build/, and in development reloads the page when a template or a component contract changes.
Without the plugin, the same layout in plain Vite configuration. Add one input per site bundle; component CSS and JS are not collected for you, so import them from the entry:
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
base: '/build/',
build: {
outDir: 'public/build',
emptyOutDir: true,
manifest: true,
rollupOptions: { input: { 'main/main': 'src/entries/main.js' } },
},
server: process.env.MERIDIAN_VITE_ORIGIN
? { origin: process.env.MERIDIAN_VITE_ORIGIN, strictPort: true }
: {},
}); The helpers look the bundle up in public/build/.vite/manifest.json by its source path (src/entries/main.js), so the input’s key may be anything. A package.json with "build": "vite build" and vite as a development dependency is all engine-dmo build needs.
Build and check:
npm install
engine-dmo build engine-dmo build runs the build script of package.json through npm (or --command "...") with MERIDIAN_ENGINE set to the running binary, then runs engine-dmo check. A failed build exits 1 before the check.
| Flag | Effect |
|---|---|
--command string | The build command, instead of the package.json build script |
--no-check | Build only |
--no-db | Check without the database checks |
--strict | Check with warnings as errors |
What the helpers print from the build
| Call | Prints |
|---|---|
assets.css('main') | A <link rel="stylesheet"> for the entry’s CSS and the CSS of every chunk it imports statically, each under /build/ |
assets.js('main') | <script type="module" src="/build/..."> for the entry; assets.js('main', false) a classic deferred script |
preload() | modulepreload for the entry and its imported chunks, preload as=style for its stylesheets |
A bundle the manifest does not know falls back to css/<name>.css and js/<name>.js along the public chain. The manifest is read again when the file changes, so a rebuild needs no restart. The manifest itself is not served.
Development
engine-dmo serve --dev looks for vite.config.js (or .mjs, .ts, .mts, .cjs, .cts) in the project. With one, and Vite installed in node_modules/.bin/vite, it:
- starts Vite on a free loopback port with
--strictPort, passingMERIDIAN_VITE_ORIGIN,MERIDIAN_PROJECTandMERIDIAN_ENGINEin its environment; - writes Vite’s output into the engine’s log and stops Vite on Ctrl+C;
- prints the Vite client once per page and each entry’s source URL in place of the built files;
preload()prints nothing, andassets.css()prints nothing for a script entry, whose CSS the dev server injects; - widens the development content security policy to admit the dev server’s origin and inline styles (the dev server injects each stylesheet as a
<style>element). Only in development.
With a config and no installed Vite, it says so and serves public/ as it is. CSS changes reach the page through Vite’s hot module replacement; with the meridian() plugin, saving a template reloads the whole page.
site.yaml assets
# config/sites/<key>/site.yaml
assets:
bundles:
main: src/entries/town.js
legacy: src/legacy/town-legacy.js
budgets:
css: 60KB
js: 120KB
legacy_js: 200KB
external: false
manifest: "" | Key | Default | Meaning |
|---|---|---|
bundles | main is src/entries/<site>.js, any other name src/entries/<site>-<name>.js | Each bundle’s entry, relative to the project |
budgets | none | Size limits by kind (css, js, legacy_css, legacy_js), each the total of that kind across the site’s bundles, in bytes or with a unit (120KB, 1.5MB, 64KiB); legacy_ counts the bundle named legacy apart |
external | false | The site is built by a toolchain of its own: the meridian() plugin builds no entry for it, and the helpers read manifest |
manifest | public/build/.vite/manifest.json | A Vite manifest for an external site, relative to the project |
Reports
| Command | Reports |
|---|---|
engine-dmo assets manifest [--site key] [--json] | Each site’s template chain, the component directory each component resolves to, co-located component files, blocks, template globs and bundle entries: what a bundler needs. The engine’s own, with or without the extension |
engine-dmo assets budget [--site key] [--strict] | Each site’s CSS and JS sizes, raw and gzipped, against its budgets and the previous build (remembered in .meridian/assets-sizes.json); --strict exits 1 over a budget |
engine-dmo assets unused [--site key] [--bundle name] | CSS selectors in a site’s bundles that match nothing in that site’s templates. Report only |
engine-dmo render <path> --json lists the assets the rendered page references.
Content security policy
The public site’s policy is server.security.csp in engine.yaml. Each key is a directive, with underscores for hyphens, and each value a list of sources. With no csp block, no policy header is sent.
# config/engine.yaml
server:
security:
csp:
default_src: ["'self'"]
script_src: ["'self'"]
style_src: ["'self'"]
style_src_attr: ["'unsafe-inline'"]
img_src: ["'self'", "data:", "${MEDIA_CDN_ORIGIN:-}"]
font_src: ["'self'"]
connect_src: ["'self'"]
object_src: ["'none'"]
base_uri: ["'self'"]
form_action: ["'self'"]
frame_ancestors: ["'self'"] 'unsafe-inline'and'unsafe-eval'are refused inscript_srcanddefault_src: the engine’s scripts need neither.- An empty source is dropped, so an unset
${MEDIA_CDN_ORIGIN:-}leaves nothing behind. style_src_attr: ["'unsafe-inline'"]lets image placeholders paint (Images); no script can be injected through a style attribute, and templates cannot write one.- A page that renders a video or a third-party embed widens the policy for that response alone, to let the player’s host frame it.
The engine’s own scripts are files on the site’s origin, added only when a page needs them: the form challenge (/_e/forms.js), the impressions reporter (/_e.js), the video facade (/_mrd-video.js) and the embed facade (/_mrd-embed.js).
No inline script
Write behaviour in your bundle, not in the page. engine-dmo check enforces the template side:
| In a template | Result |
|---|---|
A variable inside <script> without the json filter | Error: a variable inside <script> must go through |json |
A variable in an inline event handler (onclick="...") | Error; use a sigmx data-on attribute with a static expression |
A variable in a style attribute or a <style> element | Error; use a class, or a custom property set from a data attribute |
A variable inside a <template> element | Error |
style= or on...= passed to a helper | Refused |
The safe filter | Refused unless the site sets templates.allow_unescaped: true |
Pass data to your script through data-* attributes, or a JSON block, which is data and not executed:
<div id="map" data-lat="{{ entry.location.lat }}" data-lon="{{ entry.location.lon }}"></div>
<script type="application/json" id="map-data">{{ entry.title|json }}</script> sigmx
The engine’s in-place updates (forms posted in place, fragments, the sigmx.fragment() helper) use sigmx attributes such as data-on:submit. The client is a module your bundle loads. The example projects use the sigmx npm package with its Vite plugins, which scan the templates at build time, bundle only the plugins used and precompile every expression, so the policy can refuse eval:
// vite.config.js
import { sigmxAuto, sigmxPrecompile } from 'sigmx/vite';
const scan = { root: here, include: ['templates', 'src'] };
// plugins: [meridian(), sigmxAuto(scan), sigmxPrecompile(scan)] // src/js/site.js
import { createSigmx, precompiled } from 'sigmx';
import { plugins } from 'virtual:sigmx-plugins';
import { table } from 'virtual:sigmx-expressions';
import { handlers } from 'vite-plugin-meridian/sigmx';
window.sigmx = createSigmx({
plugins: [...plugins, ...handlers],
expressions: precompiled(table, () => () => undefined, (src) => console.warn('not precompiled', src)),
}); vite-plugin-meridian/sigmx carries the handlers the engine’s fragment responses use, so it comes with the plugin and has the same availability. They act on four events the engine sends after a response’s patches: sigmx-focus ({% focus %}, and the error summary of a failed post), sigmx-announce ({% announce %}), sigmx-location ({% location %}) and sigmx-history ({% replaceurl %} and a facet’s new address). Without the handlers those four do nothing on the page; the patches themselves still apply. Every form and link works without sigmx; it is enhancement.
Related
- Multisite templates: per-site bundles and component overrides.
- Extensions: what first-party extensions are and how they load.
- Container images: which image carries extensions.
Multisite templates
How several sites in one project share templates through an ordered chain of template roots, override a component, a block or a whole file for one site, extend their own name across the chain, and detect copies that have drifted.
Caching
How the page cache and the cache tag for template fragments work, what a template does that makes a page or a block uncacheable, how cached output is invalidated on publish, and the configuration keys that bound it.