Skip to content

About

Static site generator for SolidJS as a Vite plugin

Resources

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

solid-static

npm version

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-static

Setup

Build 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";

export default defineConfig({
  plugins: [
    staticSite({
      collections: {},
      i18n: {
        defaultLocale: "en",
        locales: ["en"],
        routing: { prefixDefaultLocale: false },
      },
      integrations: [solidMarkdown()],
      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.

Pages and layouts own their metadata. Generate canonical links and social URLs directly in JSX, for example new URL(props.route.path, siteOrigin).href. sitemap.site configures the sitemap only; it never repairs or injects page metadata.

Server JSX produces a structured render tree. Images, island URLs, and stylesheet links are prepared from that tree and Vite's bundle metadata before HTML is serialized. Generated HTML is never parsed, scanned, or rewritten. Browser islands still use Solid's normal DOM compiler.

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.

Markdown headings and tables of contents

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.tree, 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><MarkdownContent content={entry.rendered} /></article>

Custom Markdown processors expose Unified's parse() and run() APIs and produce a HAST tree. They can provide heading metadata through the VFile's data.headings; missing metadata produces an empty headings list. HTML-only processors are not supported: the generator needs source structure, not HTML to parse again.

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. Import MarkdownContent from solid-static/render and use it for both Markdown and MDX entries so their nodes remain available to asset preparation and Markdown export without reparsing HTML.

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: [...] }).

Client islands

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 routes

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.

Responsive images

Git dependencies run the package's prepare script to build dist/ before packing. Pin the Git commit for reproducible installs; no globally installed TypeScript compiler or Moon executable is needed for that build.

Local images in Markdown pages and content collections use the image pipeline automatically. Keep the source image beside the Markdown file and use ordinary Markdown syntax, including reference-style images:

![Process monitor showing the application's memory use](./processes.png)

Solid Static resolves the image relative to the Markdown source, imports it through Vite, infers its dimensions, and renders it through ResponsiveImage. Development and production output include WebP variants and a responsive srcset; production URLs contain content hashes. This also works with a custom collection Markdown processor, without an extra integration. Remote URLs, data URLs, and root-relative public image URLs are left unchanged.

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"
    />
  );
}

ResponsiveImage prepares its variants and generates the final src and srcset attributes directly during development and production rendering. No additional responsive-image plugin is needed.

getImage()

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, in getStaticPaths(), or through Await for per-page data:

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.

Satori social images

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>
  );
}

Await getSatoriImage() during server rendering. Its promise resolves to a real asset URL before that URL is used in metadata or JSON. For per-page props, use Await to generate the document after its image is ready:

import { Await } from "solid-static/render";
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 }) {
  return (
    <Await value={getSatoriImage({
      element: SocialCard({ title: props.title }),
      fonts: [{ name: "Inter", src: font, weight: 700 }],
      width: 1200,
      height: 630,
    })}>
      {image => (
        <html>
          <head>
            <meta property="og:image" content={image.src} />
            <meta name="twitter:image" content={image.src} />
          </head>
          <body>{props.title}</body>
        </html>
      )}
    </Await>
  );
}

The result contains src, width, and height. The renderer prepares the template with local TTF, OTF, or WOFF fonts, rasterizes it with Sharp, emits a PNG under the configured assets directory before generating 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.

Building from a CLI

Solid Static is a Vite plugin. Use Vite's existing command surface with the configuration above; no separate Solid Static CLI is needed:

aube exec vite build
aube exec vite build --outDir public-site

For a data-driven executable, precompile its server JSX using Vite and renderJsx() from solid-static/vite, then call renderStaticSite() from solid-static/render with the supplied page data. Build browser entries with Vite separately and supply their already-resolved URLs to the page components. The executable can embed the precompiled renderer and browser artifacts; content changes do not need a fresh Vite build. Bun is only needed when packaging the self-contained executable, not as the general project runtime.

Use Vite's manifest and viteMetadata.importedCss for asset dependencies rather than discovering links in serialized HTML. Vite owns module resolution, browser bundles, source maps, and asset filenames. The generator owns its render tree and HTML/Markdown serialization.

Development and CI

Install Node.js 24, Aube 2.5.1, Vite 8.3.5, and Moon 2.5.5, then run:

aube install --frozen-lockfile
moon run solid-static:check

moon.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.

Documentation

Dedicated documentation is not available yet. For the concepts and intended behavior, see the corresponding Astro guides:

About

Static site generator for SolidJS as a Vite plugin

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages