A static site generator written in Swift, with a SwiftUI-flavored API. You
describe your site entirely in Swift — no HTML, CSS, or JavaScript to write —
and Mahi renders it to a static dist/ tree: deduplicated CSS, clean-URL pages,
full server-rendered HTML, and a tiny generated runtime for instant navigation.
import Mahi
@main
struct MySite: Site {
var config = SiteConfig(
name: "My Book",
languages: ["en", "vi"], // first is the default
theme: .default
)
var body: some Routes {
Layout(Shell()) { // fixed header/footer for the whole site
Page("/", Home())
Layout("book", BookShell()) { // fixed sidebar for the /book branch
Page("intro", Intro())
Page("chapter-1", Chapter1())
}
Page("about", About()).title("About")
}
}
}- SwiftUI-shaped DSL.
Viewwith an@ViewBuilder body, primitives (Text,VStack,HStack,ZStack,Link,Image,Grid,List,Section,HTMLTag), and familiar modifiers (.padding(),.background(),.foregroundStyle(),.frame(),.font(), …). - Environment & theming. A real
EnvironmentValues/@Environment/.environment(_:_:)system.Themeemits:rootcustom properties with automatic light/dark viaprefers-color-schemeplus an explicit toggle. - Atomic CSS. Every unique style collapses to one
.m<hash>class, and the whole site shares a single deduplicatedassets/site.css. - Nested routing.
Layout/Outletwrap branches of the route tree. Each page renders full static HTML with its complete layout chain, so it works without JavaScript. - Instant navigation. A ~3 KB generated client router swaps only the deepest shared outlet — shared headers and sidebars stay put — with CSS view transitions. It falls back to normal navigation when JS is off.
- i18n with String Catalogs. Drop a
Localizable.xcstringsnext to your site;Text("home.title")resolves per locale. Mahi buildsdist/en/…,dist/vi/…, a root redirect, andhreflangalternates. - Components.
NavBar(responsive, zero-JS collapse),Footer,Card,Hero,Accordion(<details>),Tabs, andDarkModeToggle. Component scripts and CSS are emitted only on pages that use them. - Zero third-party dependencies in the core.
MahiCoreuses only Foundation; the CLI addsswift-argument-parser; the dev server uses Network.framework and FSEvents.
Build the tool once from this repo:
swift build # produces .build/debug/mahiThen, from a site package:
mahi init my-site # scaffold a new site package
mahi build # build to dist/ (--release, --base-url, --out)
mahi dev --port 3000 # build + serve + live-reload while you editmahi build and mahi dev never inspect your Swift types — they simply run
your site's own executable (via swift run), forwarding a small set of
--mahi-* arguments that the Site protocol understands.
A site is its own SwiftPM executable that depends on one product, Mahi.
@main struct MySite: Site gets a main() that parses --mahi-* and runs the
builder. The packages:
- MahiCore — the DSL and render engine (View/ViewBuilder, modifiers, environment, theme, Node IR, HTML renderer, atomic stylesheet, routing, i18n, the generated runtime, and the site builder).
- MahiComponents — higher-level views built on MahiCore.
- Mahi — an umbrella that re-exports both; the single dependency a site declares.
- MahiCLI — the
mahiexecutable.
Place Localizable.xcstrings (the Xcode 15+ String Catalog format) at your site
root. Text keys resolve against it for the active locale, with %@/%lld
interpolation and basic plural variations; unknown keys fall back to the literal
string. LanguageSwitcher links the current page across locales.
An island is an interactive component whose behavior is written in Swift and
compiled to WebAssembly — the rest of the page stays fully static. Conform to
IslandView: give it a stable island name, optional props, and a static
fallback view.
struct Counter: IslandView {
static let island = "Counter"
let start: Int
var props: IslandProps { ["start": .int(start)] }
var fallback: some View { // renders at build time (SEO + no-JS)
HStack(spacing: 16) {
Button("−"); Text(verbatim: String(start)).bold(); Button("+")
}
}
}At build time the fallback ships inside a hydration marker
(<div data-mahi-island="Counter" data-mahi-src="…/Counter.wasm" data-mahi-props="…">).
When a compiled assets/islands/Counter.wasm is present, the framework-generated
loader (included only when an island is used) streams and instantiates it and
hands over the mount element and props via the small Mahi island ABI
(mount_id / props / set_html / set_text_of / on_click + a
mahi_dispatch callback). Without the wasm, the static fallback simply stays —
so islands always degrade gracefully.
Producing the .wasm needs the Swift SDK for WebAssembly, a separate
toolchain:
swift sdk install <webassembly-sdk-bundle-url> # one-time, matches your SwiftWith the SDK installed, mahi build compiles each used island — it expects an
executable product named after the island (targeting the ABI; see
Examples/book-site/Islands/Counter) — and
copies the result into dist/assets/islands/. Without the SDK it prints a notice
and ships fallback only. Rendering, props, feature-gating, and hydration wiring
all work today; only the binary production depends on the external toolchain.
See Examples/book-site — a small bilingual (en/vi) book
with a global shell, a nested table-of-contents sidebar, dark mode, and the
component set. Build it with mahi build or run mahi dev inside that folder.
swift testCovers rendering snapshots, atomic-class dedup, environment propagation, nested routing and outlet stability, the String Catalog parser, the runtime assembler, and the components.