CSS preview

The CSS preview renders your Markdown in the browser (with the same remark and rehype pipeline the visual editor uses) and puts the result in a sandboxed frame, where it can be styled with your site’s actual stylesheets. You don’t get Hugo’s layouts, but you do get your fonts, colours and spacing, which is most of what matters while you’re writing. It updates half a second after you stop typing.

With no configuration at all you get a built-in default: the title, description and date in a header, then the body in a plain, readable typography that follows Emet’s light or dark theme. That’s fine for a lot of sites. The rest of this page is about making it look like yours.

Pointing it at your CSS#

Add a preview block to admin/site.yaml:

preview:
  styles:
    - assets/css/main.css
    - assets/css/typography.css

Paths are relative to the repository root, or to the Hugo site root if you’ve set base_path. Emet fetches each file through whatever backend it’s using (the GitHub API, or the filesystem in local and desktop modes), concatenates them in order and injects them into the frame.

Where your CSS lives What to write
assets/css/main.css assets/css/main.css
static/css/style.css static/css/style.css
A theme themes/my-theme/assets/css/main.css

Once styles or a wrapper are configured, a paintbrush button appears in the preview header to switch between the styled preview and the built-in default, which is useful when a site stylesheet makes the preview unreadable.

URLs in your CSS and content#

Hugo serves assets/css/fonts.woff from /css/fonts.woff, so Emet strips the themes/<name>/assets/, assets/ and static/ prefixes when it resolves a relative url() in a stylesheet. Then, if site.url is set, it resolves root-relative URLs (/css/fonts.woff, /images/hero.jpg, and href and src attributes in the rendered body) against that URL. The preview frame has no <base> tag, because Emet’s content security policy forbids one, so without site.url a root-relative URL points at the CMS rather than your site and the image won’t load.

site:
  url: https://example.com

If your pages live somewhere other than site.url (or one section’s do), put a <base href="https://…"> in the wrapper or in that section’s wrapper. Emet reads the href, removes the tag (the browser would ignore it anyway) and resolves relative URLs in the body and the CSS against it instead of site.url. Only absolute http(s) hrefs count; anything else is dropped with a warning in the console. Note that it doesn’t change which origins fonts may come from, which is the next section’s job.

Fonts from other origins#

Emet’s content security policy only lets the preview frame load fonts from the CMS itself, so a stylesheet that pulls fonts from Google Fonts, a CDN or your own domain falls back to system fonts, with a blocked request in the browser console. Rather than loosen the policy, Emet fetches fonts from origins you’ve allow-listed and inlines them into the stylesheet before it reaches the frame:

site:
  url: https://example.com
preview:
  styles:
    - assets/css/main.css
  allowed_origins:
    - https://fonts.googleapis.com
    - https://fonts.gstatic.com

Each entry must be a bare https:// origin: no path, no wildcard, no http://. Invalid entries are ignored with a console warning. Emet then rewrites url()s inside @font-face blocks whose origin is on the list, and follows one level of @import to the same origins, which is enough for the usual @import url("https://fonts.googleapis.com/css2?family=…"). The origin of site.url is always allowed if it’s https, so fonts your own site serves work with no extra configuration. Fonts from an origin that isn’t listed are left as they are, with a console warning naming the origin, and the browser keeps blocking them.

This is a deliberate narrowing. Anyone who can edit the repository already controls the preview CSS, so the allow-list doesn’t guard against them; it guards against the policy quietly growing. The frame still runs no scripts.

The policy is only applied in the built app. To reproduce a font or policy problem with the dev server, start it with EMET_DEV_CSP=1 npm run dev, which injects the production policy plus the two relaxations Vite’s hot reload needs.

Images referenced in the body are a bit different: on GitHub-hosted sites Emet fetches them through the API, so images in a private repository show up fine.

Light and dark#

Browsers evaluate prefers-color-scheme inside an iframe against the operating system, not against the page around it, so a site that goes dark with the OS would ignore Emet’s theme toggle. Emet rewrites those media queries in your CSS to force whichever theme it’s showing, and sets class="dark", data-theme and color-scheme on the frame’s <html> for stylesheets that key off those instead.

Wrapping the content#

styles gets you the right look for a paragraph, but most sites also need the body inside a container with a class or two. That’s what wrapper is for:

preview:
  styles:
    - assets/css/main.css
  wrapper: '<article class="prose max-w-2xl mx-auto px-4">{{content}}</article>'

The wrapper is a Mustache-ish template with the rendered body and the frontmatter available to it:

Syntax Meaning
{{content}} The rendered body, as raw HTML
{{title}}, {{date}}, any field That frontmatter value, HTML-escaped
{{image:cover}} An image field’s URL, run through the same rewriting as body images
{{#tags}}...{{/tags}} Rendered only if the field is truthy; repeated for each item if it’s a list
{{.}} The current item inside a list block

So a blog post header might be:

preview:
  wrapper: |
    <article class="post">
      {{#cover}}<img src="{{image:cover}}" alt="">{{/cover}}
      <h1>{{title}}</h1>
      <time>{{date}}</time>
      {{#tags}}<span class="tag">{{.}}</span>{{/tags}}
      {{content}}
    </article>

Values are coerced to strings in the obvious ways: numbers as themselves, lists joined with commas when used as a plain variable, true as the word true and false as nothing, objects as JSON. Dates come out as yyyy-MM-dd: a value with a time part is reformatted to that, and a bare date is left alone, which already looks like that. If you want “1 March 2026” you’ll have to live without it for now.

Per-section overrides#

Different sections usually want different containers:

preview:
  styles:
    - assets/css/main.css
  wrapper: '<div class="container">{{content}}</div>'
  sections:
    blog:
      styles:
        - assets/css/blog.css
      wrapper: '<article class="post">{{content}}</article>'
    landing:
      wrapper: '<main class="full-bleed">{{content}}</main>'

Keys match your sections. A section’s styles are added after the global ones; a section’s wrapper replaces the global one.

Shortcodes and refs#

Block shortcodes render with their preview templates and inline shortcodes with a best-effort pass over their Go templates.

ref and relref get special treatment: Emet finds the target file and works out its public URL from your Hugo config, the same way as for the View on site links (see Permalinks) and including output formats, so the link in the preview is the link Hugo would produce. The environment defaults to production; change it with preview.hugo_environment in site.yaml. A ref that can’t be resolved points at refLinksNotFoundURL if your config sets one, and otherwise at #. Links in the preview open in a new tab.

If typographer: true is set in site.yaml, the preview also applies smart quotes and dashes, to match Goldmark’s typographer extension.

Images#

An image with no source, ![A cat](), becomes a dashed placeholder in the preview. Click it to pick a file from the media library, or drop a file onto it to upload that file, choosing the folder and name first. Either way the image’s path is written into the body.

Scroll sync and annotations#

The link icon in the preview header toggles scroll sync, which keeps the preview lined up with the part of the body you’re editing. It’s on by default.

If AI is enabled, there’s also a speech-bubble button, Annotate preview for AI feedback. In annotation mode, moving over the preview highlights blocks; click one, type what should change, and repeat for as many blocks as you like. Send to AI hands the lot to the chat panel as a single “Review N annotations” request, with each note tied to the relevant lines of the body. Annotations are discarded when you leave the mode or the preview re-renders.

When it doesn’t look right#

Nothing changes when you add styles. Check the path is relative to the repository root and the file actually exists at that path on the branch Emet is using. Failed fetches are logged in the browser console.

Background images are missing. Set site.url. Without it, root-relative URLs resolve against the CMS.

Fonts are missing. Fonts from another origin are blocked by the content security policy until you add the origin to preview.allowed_origins (see above). Fonts served from site.url need only site.url set.

It looks different from the live site. If your CSS is compiled at build time (Sass, PostCSS, Tailwind, Hugo Pipes), Emet is loading the source rather than the output. Point styles at a compiled file instead, or commit one for the preview’s benefit. And layouts are out of reach entirely: the preview is your body plus a wrapper, not your theme’s page template. For that, use the Hugo server.