Concepts
Markdown and HTML imports
Import Markdown and HTML files as components, compiled at build time, with frontmatter, heading lists, scoped styles and client scripts.
#Concept
Both are opt-in options of the Vite plugin. A .md or .html file in your project then imports
as a component that renders the file's content. The build compiles it into a static template,
like any .stsx markup, so it costs a clone in the browser and a string on the server. It works
the same in static, server and spa routes and inside islands.
Trusted files only. An import is part of your app's code. Its raw HTML, inline handlers and, in
.html, scripts reach the page as written, and nothing sanitizes them at runtime. Never fill a.mdor.htmlimport with end-user content (comments, profiles, input from a CMS that untrusted people edit): render such content as text in a component. The compiler still escapes an inline SVG's<style>text and limits nesting depth, but that is defence in depth, not a sanitizer.
Markdown is plain CommonMark with the GitHub extensions: tables, strikethrough, task lists,
autolinks (www.example.com, bare URLs) and footnotes. There is no MDX: Markdown cannot use
components or JS expressions. Raw HTML is allowed, since the file is part of your project, and
follows the HTML rules below, except for <script>: Markdown is content only, so a <script>
fails the build (LUM1008). Put scripts in an .html import or a
component.
- Heading ids. Each heading gets a GitHub-style id from its text:
## Hello, World!becomesid="hello-world", and repeats get-1,-2. A heading written as raw HTML with its ownidkeeps it. - Frontmatter. A leading block between
---lines is YAML (1.2 core schema: dates stay strings). The module exports it asfrontmatter: plain data,{}without a block, typedFrontmatter(any keys,unknownvalues). Lumiose has no schema conventions. To validate or transform it at build time, pass a hook:lumiose({ md: { frontmatter(data, { file }) { …; return data } } }). What the hook returns is exported (returningundefinedkeepsdata), and it may be async. A hook that throws fails the build with the file's name. Values may be JSON data,Date,URL,Map,Set,BigInt,undefinedorRegExp. - Headings list. The module also exports
headings: each heading'sdepth(1 to 6),textandid, in document order, raw HTML headings included. The ids are the rendered anchors', so a table of contents links with#${id}. A heading whose text has no slug (## !!!) has no id and is not listed. - Code blocks render as
<pre><code class="language-js">, without highlighting.
HTML files are parsed the way a browser parses them.
- Documents and fragments. A full document (a doctype, or an
<html>,<head>or<body>tag) contributes the content of its<body>, plus the<style>elements and scripts of its<head>. The rest of the head (<title>,<meta>,<link>) is dropped. Anything else is a fragment and is used as it is. The rootindex.htmland other HTML entries of Vite, and files inpublic/, are never components. - Styles. A top-level
<style>is scoped to the file's content, like a top-level block in a.stsxfile, and<style global>is not scoped. A<style>nested inside an element scopes to that element's subtree, as in.stsx. A<style>inside<template>(a web component's shadow DOM) or<svg>is left as it is.$namereferences are not available: there is no JS scope. - Scripts.
<script>and<script type="module">run as client modules: bundled by Vite and loaded in the browser on the pages that render the component, as a?clientscript is, without the island runtime. Inline scripts andsrcfiles are both bundled. A relativesrcis resolved against the HTML file. Classic scripts become modules too, so they are deferred and strict. Scripts with other types (application/ld+json,importmap) stay in the markup. - Templates. A
<template>'s content stays verbatim, unscoped. So does the content of<noscript>. Comments and the doctype are dropped. - Attributes are kept as written, including inline handlers (
onclick="…") and names JSX cannot write (@click,x-on:click.prevent), which are passed through a spread. - Asset URLs. Relative URLs in
<img src>andsrcset,<source src>andsrcset,<video src>andposter,<audio src>,<track src>, and<a href>to a file (an extension other than.htmlor.md) are bundled. They resolve against the.mdor.htmlfile, asimport url from "./photo.jpg?url"does in a.stsxmodule: Vite serves them in dev and fingerprints them in builds (/assets/photo-[hash].jpg, or a data URL under its inline limit). Markdown images and links render as these elements, so their relative URLs are bundled too. Absolute paths (/logo.svg, frompublic/), URLs with a scheme or a query, fragments (#top) and links to pages (./about,./next.md) are kept as written. A missing file fails the build.
Raw <style> in Markdown follows the same rules. Raw <script> in Markdown is an error, with
the file and line (LUM1008); scripts belong in .html imports.
How scripts load. A file's scripts take the path of a ?client import
("Scripts of server-rendered components"). A server-rendered page that renders the component gets
one <script type="module" async> for them, in the <head> or with the streamed chunk of the
<Suspense> boundary that rendered it. There is no island: a page with no islands loads that
script and no island runtime. The scripts run once per page however many times the component
renders. In a client render (in an island or an SPA page), they load with the component's module.
A src file runs for its side effects even in a package with "sideEffects": false. An edit to
such a file reloads the page in dev, since scripts such as customElements.define cannot run
twice. An .html component takes no props: for a script that needs data from the server, render
the element from a .stsx component with a ?client script, and pass the data in attributes
(Web components).
Status: available in
lumiose devand builds, with HMR: an edit re-renders the content and hot-updates its CSS. The dev server follows Vite'sserver.fs.allow: the browser gets the modules of files inside it, or of files the app imports, and a 403 for any other file.
#API
| Import / option | Gives |
|---|---|
lumiose({ md: true }) |
Enables .md imports |
lumiose({ md: { frontmatter(data, { file }) {} } }) |
Enables them, with a build-time frontmatter hook |
lumiose({ html: true }) |
Enables .html imports |
import Post, { frontmatter } from "./post.md" |
Post: () => JSX.Element; frontmatter: Frontmatter |
import { headings } from "./post.md" |
ReadonlyArray<{ depth; text; id }>, document order |
declare module "*.md" { interface Frontmatter {} } |
Declares the fields your hook guarantees |
import Card from "./card.html" |
Card: () => JSX.Element |
import.meta.glob("./posts/*.md", { eager: true }) |
Every post's module (component and frontmatter) |
The types come with lumiose/env ("types": ["lumiose/env"] in tsconfig.json).
#Example
// vite.config.ts: Markdown with validated frontmatter, and HTML imports
import { defineConfig } from "vite";
import lumiose from "@lumiose/vite";
import * as z from "zod";
const Post = z.object({
title: z.string(),
date: z.coerce.date(),
draft: z.boolean().default(false),
});
export default defineConfig({
plugins: [lumiose({ md: { frontmatter: (data) => Post.parse(data) }, html: true })],
});// src/content.d.ts: the fields the hook guarantees, merged into lumiose/env's `Frontmatter`
declare module "*.md" {
interface Frontmatter {
title: string;
date: Date;
draft: boolean;
}
}// src/routes/blog/page.stsx: a list of posts and the latest one, with its table of contents
import type { Frontmatter, Heading } from "*.md";
type Post = {
default: () => JSX.Element;
frontmatter: Frontmatter;
headings: ReadonlyArray<Heading>;
};
// `import.meta.glob` is Vite's (types from `vite/client`).
const posts = Object.values(import.meta.glob<Post>("../../posts/*.md", { eager: true })).toSorted(
(a, b) => b.frontmatter.date.getTime() - a.frontmatter.date.getTime(),
);
export default function Blog() {
const { default: Latest, headings } = posts[0]!;
return (
<main>
<ul>
{posts.map((post) => (
<li>{post.frontmatter.title}</li>
))}
</ul>
<nav aria-label="On this page">
{headings
.filter((heading) => heading.depth === 2)
.map((heading) => (
<a href={`#${heading.id}`}>{heading.text}</a>
))}
</nav>
<article class="post">
<Latest />
</article>
<style>
.post :deep(h2) { margin-top: 2rem; }
</style>
</main>
);
}<template id="user-card">
<style>
:host {
display: block;
}
</style>
<slot></slot>
</template>
<user-card>Ada Lovelace</user-card>
<script type="module">
customElements.define(
"user-card",
class extends HTMLElement {
connectedCallback() {
const template = document.getElementById("user-card");
this.attachShadow({ mode: "open" }).append(template.content.cloneNode(true));
}
},
);
</script>
<style>
user-card {
margin-block: 1rem;
}
</style>The Markdown's elements belong to the post's component, so the route styles them with
:deep() under its own .post element. The web component's shadow styles stay in its template;
its light-DOM rule is scoped to the file.
#Common mistakes
- Forgetting the option.
.mdand.htmlimports are off by default; withoutlumiose({ md: true })Vite tries to read the file as JavaScript. - Trusting frontmatter types without a check.
frontmatterhasunknownvalues. Declare fields inFrontmatter(as above) only when the hook validates the data. - Styling Markdown from a scoped block without
:deep(). The rendered elements belong to the Markdown component. Use:deep(), a class on a wrapper plus:deep(), or a<style>in the.mdfile itself. - Targeting
bodyorhtmlfrom an HTML file's<style>. Its top-level styles are scoped to the file's content (LUM3005: the selector matches no element). Use<style global>. - A
<script>in Markdown. It fails with LUM1008. Move the markup and script to an.htmlfile and import that, or render the Markdown next to an island. - Expecting a script per use. A file's scripts run once per page, not once per rendered
component. Query the elements they work on (
document.querySelectorAll) or use a custom element, which runs its code for every instance. - Linking to a file that is not there. A relative asset URL (
<img src="./photo.jpg">) is an import: a typo or a missing file fails the build. Files meant to be served as they are belong inpublic/, with an absolute URL (/downloads/app.zip). - Relative links between pages. A Markdown link to
./next-postis kept as written, so it resolves against the page's URL, not the file's. Link to the route's URL instead (/blog/next-post).
#Related diagnostics
The generated component compiles like a .stsx file, so style diagnostics apply to its blocks,
for example LUM3005 for a selector that matches nothing.
LUM1008 reports a <script> in a Markdown file. A frontmatter
block that is not a mapping, or a hook that throws, fails the build with the file's name.
See also: Styles, Islands, SVG imports, reference: @lumiose/vite, reference: JSX types.