Meridian handbook
Templates

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>
HelperPrints
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/:

CallPrints
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:

  1. public/<site key>/
  2. public/_shared/
  3. 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.

FileCache-Control
Requested with ?v= (as assets.url writes it), or under public/build/public, max-age=31536000, immutable
Anything elsepublic, 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 --> P
  • assets.css(), assets.js() and preload() read the build’s manifest and print the content-hashed files, under /build/;
  • engine-dmo serve --dev starts the project’s Vite dev server as a child process and points the helpers at it, with hot module replacement;
  • engine-dmo assets budget and engine-dmo assets unused report 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.

FlagEffect
--command stringThe build command, instead of the package.json build script
--no-checkBuild only
--no-dbCheck without the database checks
--strictCheck with warnings as errors

What the helpers print from the build

CallPrints
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, passing MERIDIAN_VITE_ORIGIN, MERIDIAN_PROJECT and MERIDIAN_ENGINE in 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, and assets.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: ""
KeyDefaultMeaning
bundlesmain is src/entries/<site>.js, any other name src/entries/<site>-<name>.jsEach bundle’s entry, relative to the project
budgetsnoneSize 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
externalfalseThe site is built by a toolchain of its own: the meridian() plugin builds no entry for it, and the helpers read manifest
manifestpublic/build/.vite/manifest.jsonA Vite manifest for an external site, relative to the project

Reports

CommandReports
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 in script_src and default_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 templateResult
A variable inside <script> without the json filterError: 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> elementError; use a class, or a custom property set from a data attribute
A variable inside a <template> elementError
style= or on...= passed to a helperRefused
The safe filterRefused 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.