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

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

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

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.

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

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

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.

Development and CI

Install Node.js 24, Aube 2.5.1, 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