An Astro-inspired static site implementation built as a Vite plugin with SolidJS and TSX.
Read the introduction: Another static site generator.
Install from npm:
aube add solid-staticBuild this project and add it to your app as a local or workspace dependency. Then configure it in vite.config.ts:
import { defineConfig } from "vite";
import { staticSite } from "solid-static";
import {
createHtmlMarkdownProcessor,
solidMarkdown,
} from "solid-static/markdown";
import { responsiveImages } from "solid-static/responsive-images";
export default defineConfig({
plugins: [
staticSite({
collections: {},
i18n: {
defaultLocale: "en",
locales: ["en"],
routing: { prefixDefaultLocale: false },
},
integrations: [solidMarkdown(), responsiveImages()],
markdown: { processor: createHtmlMarkdownProcessor() },
markdownExport: { exclude: [], force404Markdown: true },
sitemap: { site: "https://example.com" },
trailingSlash: "always",
}),
],
});Set sitemap.site to the canonical site origin to generate sitemap.xml from
the rendered page routes. The sitemap follows trailingSlash and excludes the
404 page. Modification dates are omitted by default. Supply sitemap.lastmod
only when an accurate ISO 8601 modification date applies to every emitted page;
the build date is not used as a substitute for content modification dates.
Set markdownExport to generate a Markdown sibling for every emitted HTML
page at build time. The generated files are output-relative route documents
(index.md, about/index.md, and so on), with no extra slashless .md
aliases. For Cloudflare Pages, pair this with a Free Transform Rule matching
GET requests whose Accept header contains text/markdown and rewrite the
path dynamically with concat(http.request.uri.path, "index.md"). Thus /
maps to /index.md and /about/ maps to /about/index.md without a Worker.
The option is static-only and does not add a runtime server or Worker. Keep
404.html included and set force404Markdown: true when the same Free
Cloudflare setup should serve the generated Markdown body for missing paths.
The generated 404 asset remains a real 404 and can be labeled
Content-Type: text/markdown by a response-header Transform Rule when the
request accepts Markdown.
staticSite({
// ...other options
markdownExport: {
exclude: ["404.html"],
selectors: ["main", "article", "body"],
transform: (markdown, fileName) =>
`<!-- generated from ${fileName} -->\n\n${markdown}`,
},
});Add .tsx, .md, or .mdx pages under src/pages. The directory structure determines each page's route. Markdown pages must declare a SolidJS layout in their frontmatter.
createHtmlMarkdownProcessor() assigns stable, GitHub-style IDs to headings.
Repeated headings receive unique suffixes, with numbering scoped to each document.
Markdown and MDX pages using solidMarkdown() also receive heading IDs.
Rendered content collection entries expose rendered.html and
rendered.headings. Each heading contains depth (1–6), slug (the HTML ID),
and plain text. Build a table of contents from this metadata instead of parsing
the HTML a second time:
<nav aria-label="On this page">
<For each={entry.rendered.headings.filter(heading => heading.depth === 2)}>
{heading => <a href={`#${heading.slug}`}>{heading.text}</a>}
</For>
</nav>
<article innerHTML={entry.rendered.html} />Custom Markdown processors can provide the same metadata through their result's
data.headings. Results without heading metadata produce an empty headings list.
Content collections can include .mdx files with a pattern such as /\.mdx?$/.
Use solidMarkdown() in integrations: Vite compiles MDX imports, components,
and ?island entries, then renders collection HTML and heading metadata before
page generation. Render an MDX collection entry with innerHTML={entry.rendered.html}
just like a Markdown entry; its module scripts become browser island assets.
loadCollections() exposes each entry's absolute filePath and loads its
frontmatter and body. Standalone loading does not compile MDX; MDX entries gain
rendered during Vite page generation. Plain .md files use the configured
Markdown processor. To apply the same extra rehype plugins to compiled MDX,
pass them to solidMarkdown({ rehypePlugins: [...] }).
Import a self-mounting browser entry with the ?island query, then reference the returned URL from a module script. The page remains static HTML; only the named entry and its imports are compiled for the browser.
import counterIsland from "../app/counter-island.tsx?island";
export default () => (
<html>
<body>
<div id="counter">0</div>
<script type="module" src={counterIsland} />
</body>
</html>
);import { createSignal } from "solid-js";
import { render } from "solid-js/web";
const Counter = () => {
const [count, setCount] = createSignal(0);
return <button onClick={() => setCount(value => value + 1)}>{count()}</button>;
};
const root = document.querySelector("#counter");
if (!(root instanceof HTMLElement)) {
throw new TypeError("Missing #counter island root");
}
render(() => <Counter />, root);Vite serves the source entry during development. Production builds emit hashed JavaScript and CSS assets and rewrite only pages that reference the island.
Use client to configure the clean nested browser build explicitly. It accepts
Vite configuration such as aliases, defines, mode, CSS options, browser-only
plugins, and build target or minification settings. Server integrations are not
forwarded automatically.
The same ?island import works directly in an MDX page or collection body:
import downloadIsland from "../app/download.ts?island";
<a data-download href="/downloads/">View downloads</a>
<script type="module" src={downloadIsland} />staticSite({
client: {
build: { minify: false, target: "es2020" },
define: { __BROWSER__: "true" },
resolve: { alias: { "@client": "/src/client" } },
},
// Other static-site options.
});Page components and Markdown or MDX layouts receive a route prop. route.path is the page's absolute public URL pathname. It never contains a query or hash and never exposes an internal route ID or output file name. Dynamic parameters are expanded before the pathname is normalized.
| Page | trailingSlash: "always" |
trailingSlash: "never" |
|---|---|---|
| Root | / |
/ |
Static TSX guides.tsx |
/guides/ |
/guides |
Markdown or MDX guides.md |
/guides/ |
/guides |
Dynamic guides/[slug].tsx, slug example |
/guides/example/ |
/guides/example |
Custom 404.tsx |
/404 |
/404 |
route.fileName remains output-relative: for example, guides/index.html in "always" mode and guides.html in "never" mode. A custom 404 is always emitted as 404.html, while its route identity remains /404. When the development server uses that page to answer a missing URL, route.path remains /404; it does not represent the original request pathname.
Import an image through Vite, then render it with ResponsiveImage in a SolidJS page or component:
import hero from "../assets/hero.jpg";
import { ResponsiveImage } from "solid-static/image";
export default function Home() {
return (
<ResponsiveImage
src={hero}
alt="Mountain landscape"
width={1600}
height={900}
layout="constrained"
widths={[480, 768, 1200, 1600]}
sizes="(max-width: 768px) 100vw, 1200px"
format="webp"
loading="lazy"
/>
);
}The responsive images integration generates the requested variants and adds the resulting srcset during development and production builds.
Use getImage() during server rendering to generate one transformed image. It follows Astro's getImage() pattern for images used outside a standard image component. Import it from solid-static/image, then await it at module scope or inside an async server-rendered component:
import source from "../assets/social-preview.png";
import { getImage } from "solid-static/image";
const preview = await getImage({
src: source,
width: 1200,
height: 630,
format: "jpg",
quality: "high",
fit: "cover",
position: "center",
});
export default function Page() {
return (
<html>
<head>
<meta property="og:image" content={preview.src} />
</head>
<body>
<img src={preview.src} alt="" {...preview.attributes} />
</body>
</html>
);
}getImage(options) accepts:
| Option | Type | Default | Description |
|---|---|---|---|
src |
string | ImageMetadata |
required | Imported image URL or { src, width, height, format } metadata. |
width |
positive integer | source width | Output width. |
height |
positive integer | source height | Output height. |
format |
"avif" | "jpeg" | "jpg" | "png" | "webp" |
"webp" |
Output format. |
quality |
0–100 or "low" | "mid" | "high" | "max" |
encoder default | Output quality. |
fit |
"contain" | "cover" | "fill" | "inside" | "outside" |
"cover" |
How the source fits the requested dimensions. |
position |
string |
"center" |
Crop or embed position used by the image transformer. |
When src contains image metadata, specifying only width or height infers the other dimension while preserving the aspect ratio. The returned promise resolves to a GetImageResult containing the generated src, inferred attributes, normalized options, original rawOptions, and an Astro-compatible srcSet object. Generated URLs work in both the Vite development server and production builds. getImage() throws if called in the browser.
Keep parameterized social-card layouts in *.satori.tsx files. staticSite()
compiles these files with Satori's JSX runtime, separately from Solid pages;
they never become routes, even when colocated under src/pages.
/* @jsxImportSource satori/jsx */
import type { JSX } from "satori/jsx";
export default function SocialCard(props: { title: string }): JSX.Element {
return (
<div style={{ display: "flex", width: "100%", height: "100%", padding: 64, backgroundColor: "white", fontSize: 72, fontFamily: "Inter" }}>
{props.title}
</div>
);
}Call getSatoriImage() from a page or layout during server rendering. Unlike
getImage(), it is synchronous, so props can supply the card content directly:
import { createMemo } from "solid-js";
import { getSatoriImage } from "solid-static/satori";
import font from "./assets/inter-bold.woff?url&no-inline";
import SocialCard from "./social-card.satori.tsx";
export default function Page(props: { title: string }) {
const image = createMemo(() => getSatoriImage({
element: SocialCard({ title: props.title }),
fonts: [{ name: "Inter", src: font, weight: 700 }],
width: 1200,
height: 630,
}));
return (
<html>
<head>
<meta property="og:image" content={image().src} />
<meta name="twitter:image" content={image().src} />
</head>
<body>{props.title}</body>
</html>
);
}The result contains src, width, and height. The build renders the template
with local TTF, OTF, or WOFF fonts, rasterizes it with Sharp, emits a PNG under
the configured assets directory, and replaces image references in the final
HTML. Identical cards share one file; the filename hashes the rendered bytes,
so content, layout, logo, or font changes produce a new URL. The same renderer
serves previews in development. Missing fonts and rendering failures fail the
build instead of silently substituting another card. Templates must return
serializable intrinsic elements; render nested custom components before passing
the element to getSatoriImage(). Embed imported images as data URLs to keep
rendering independent of external image hosts. Font imports here are renderer
inputs and do not install browser fonts or add a font stylesheet to pages.
Font files used only by the renderer are removed from the final bundle.
Install Node.js 24, Aube 2.5.1, and Moon 2.5.5, then run:
aube install --frozen-lockfile
moon run solid-static:checkmoon.yml owns the build, lint, typecheck, and Vitest tasks. Tests depend
on the package build because Vite fixtures resolve the package's exported runtime
from dist. A clean checkout does not need prebuilt artifacts.
All tests run in Node without a browser or Docker. Integrated Vite fixtures build real sites, then Cheerio, PostCSS, and Acorn check generated HTML, CSS, and JavaScript: static fallback content, hashed links, stylesheet deduplication, page-specific styles, shared chunks, and root, relative, subpath, and CDN bases. Development tests request Vite's HTML and transformed assets directly over HTTP. Each fixture owns its temporary files and ephemeral ports.
Moon 2.5.5 has no native Aube package-manager integration. Tasks in moon.yml
invoke aube exec explicitly, and installation remains a separate setup step.
The JavaScript and Node toolchains provide project metadata and use the system
Node.js without installing another package manager or Node version.
The standalone GitHub workflow follows Moon's CI guide:
full Git history, dependency installation, then moon ci to select affected
tasks and run their dependencies. It uploads native reports and keeps
publication out of CI. There is no separate CI-only task graph or persisted
Moon workspace cache.
The same project tasks can be registered as solid-static in a parent Moon
workspace. Consumers should depend on solid-static:build and use a workspace
package dependency. The parent owns dependency installation. The submodule's
standalone workflow does not run inside the parent's workflow.
The parent can extend .moon/toolchains.yml from this package to share the
same toolchain configuration. The sources file group covers package source
and build configuration for consumers' cache inputs.
To publish an explicitly approved release, use moon run solid-static:publish.
Dedicated documentation is not available yet. For the concepts and intended behavior, see the corresponding Astro guides: