S3g 0.1.0
dotnet add package S3g --version 0.1.0
NuGet\Install-Package S3g -Version 0.1.0
<PackageReference Include="S3g" Version="0.1.0" />
<PackageVersion Include="S3g" Version="0.1.0" />
<PackageReference Include="S3g" />
paket add S3g --version 0.1.0
#r "nuget: S3g, 0.1.0"
#:package S3g@0.1.0
#addin nuget:?package=S3g&version=0.1.0
#tool nuget:?package=S3g&version=0.1.0
S3g
S3g (simple-effectful-ssg) is a small, library-first static site generator for F#. A site is an ordinary compiled F# executable: it references the S3g package, defines typed layout plugins, and delegates its command line to S3g.run.
There is no template language, dynamic script loader, standalone global tool, watch server, or hidden dependency-injection container. Layout dependencies are explicit AlgEff programs interpreted by the build runtime.
Requirements
- .NET 10
- FSharp.Core 10.1.204 or newer
- Trusted site and content code. S3g's effects are an architectural/test boundary, not a sandbox.
FSharp.Formatting 22.1.0 requires FSharp.Core 10.1.204. Older .NET 10 SDK installations can select it explicitly:
<FSharpCoreImplicitPackageVersion>10.1.204</FSharpCoreImplicitPackageVersion>
Minimal site program
open AlgEff.Effect
open Falco.Markup
open S3g
type PostMetadata =
{ Title: string }
let post = Layout.create<PostMetadata> "post"
let stylesheet = Resource.create "site.css" "site.css"
let renderPost (page: Page<PostMetadata>) =
effect {
let! stylesheetUri = Resource.uri stylesheet
let! canonical = Output.permalink ()
return
Templates.html5 "en"
[ _title [] [ _text page.Metadata.Title ]
_link [ _rel_ "stylesheet"; _href_ stylesheetUri ]
_link [ _rel_ "canonical"; _href_ canonical ] ]
[ _main [] [ Markdown.fragment page.Document ] ]
}
let site =
Site.create [
Plugin.create post [ stylesheet ] renderPost
]
[<EntryPoint>]
let main argv = S3g.run argv site
Run the compiled site's inherited CLI:
dotnet run -- build
dotnet run -- build /path/to/site-root
dotnet run -- --help
The public programmatic entry point is:
S3g.build request site
// Result<BuildReport, BuildError list>
BuildRequest supplies the root, a fixed UTC timestamp, and an optional commit hash. The CLI captures the timestamp once from SOURCE_DATE_EPOCH or the current UTC clock, then obtains the revision from S3G_COMMIT_HASH or git rev-parse HEAD.
Site tree and pages
site-root/
content/
index.md
index.json
posts/hello/index.md
posts/hello/index.json
posts/hello/hero.webp
resources/
site.css
files/
robots.txt
dist/ # managed generated snapshot
Discovery is driven by .md files. Every Markdown page requires a same-name .json sidecar; unrelated JSON files are ignored unless declared as resources.
A sidecar is one flat JSON object:
{
"layout": "post",
"permalink": "/optional-override/",
"emit": true,
"resources": {
"hero": "hero.webp"
},
"title": "Hello",
"hero": "$hero"
}
Reserved fields:
layoutis required and selects a registered plugin.permalinkis optional for emitted pages. It must be a root-relative directory URL ending in/.emitdefaults totrue. Anemit=falsepage is queryable/renderable as a child but cannot declare a permalink or write a root output.resourcesmaps page-local short names to files beneath the sidecar directory..., absolute paths, and symbolic-link traversal are rejected.
All other properties are JSON authoring input for the selected layout's metadata type. During Preflight, S3g resolves the compiled layout, rewrites metadata resource tokens, and deserializes the custom properties exactly once using one fixed FSharp.SystemTextJson profile:
- camelCase property names;
- missing option fields become
None; - missing required record fields fail the build;
- fieldless union cases are camelCase strings;
- payload union cases use
{ "case": "...", "fields": [...] }.
Reserved sidecar fields are never exposed as plugin metadata. Every valid page is decoded during Preflight, including emit=false pages that are never queried or rendered, so metadata errors are aggregated before Runtime and before dist/ can change. JSON is the on-disk content adapter, not an in-process protocol: Runtime receives the already typed metadata object and parsed document. Layout<unit> explicitly declares no custom metadata; S3g still traverses custom properties for resource-token validation, then supplies ().
Each Layout<'metadata> binds one compiled layout name to exactly one metadata type. Pages.query layout and Pages.queryAll layout return that declared type; query-specific subset DTOs are not supported.
Routes and links
Default routes are deterministic:
content/index.md -> /
content/about.md -> /about/
content/posts/x/index.md -> /posts/x/
Implicit source segments must use safe ASCII letters, digits, ., _, or -; use an explicit permalink otherwise. Route comparisons are case-insensitive for portable collision detection, and /assets/ is reserved.
Before a plugin receives its MarkdownDocument, S3g rewrites structured Markdown links and reference definitions:
$heroresolves the page-local resource namedhero;$$heroproduces the literal target$hero;- relative
.mdlinks resolve to the target emitted page's pretty permalink; - missing resources/pages and links to
emit=falsepages fail preflight; - query strings and fragments are retained.
Exact $name/$$name strings in custom JSON metadata are rewritten by the same resource resolver. Raw inline HTML strings are deliberately not parsed or rewritten.
Theme resources are created once with Resource.create, declared by a plugin, resolved relative to the site's top-level resources/, and passed as values to Resource.uri. Theme short names remain local to the current plugin, including across nested Page.render calls.
Both theme and page resources use the same URI store:
/assets/<full-lowercase-sha256>.<extension>
Identical bytes with the same extension are emitted once across the whole site.
Files that require a stable, non-HTML output URL (for example robots.txt, RSS, or a sitemap) are declared individually rather than copied through a directory passthrough:
Site.create plugins
|> Site.withOutputFiles [
OutputFile.create "robots.txt" "robots.txt"
OutputFile.create "blog/index.xml" "blog/index.xml"
]
Sources are relative to files/; output paths are relative to dist/. Absolute paths, .., symlinks, duplicate paths, .s3g-output, assets/, and collisions with generated or paginated HTML are rejected. Stable files participate in the same staged snapshot commit.
Effects, page graphs, and pagination
A layout handle and its registered render share one metadata type:
Layout<'metadata>
Page<'metadata> -> Program<RenderContext, XmlNode>
The renderer receives the complete current source page directly:
type Page<'metadata> =
{ Ref: PageRef
Permalink: string option
Metadata: 'metadata
Document: MarkdownDocument }
Page.Permalink is the source page's optional route; it is None for emit=false pages. Pages.query layout returns EmittedPage<'metadata> list, whose Permalink is a nonoptional string. Pages.queryAll layout returns Page<'metadata> list and includes non-emitted pages.
The supported effects are:
Pages.query layoutforemit=truepagesPages.queryAll layoutfor all pagesPage.render pageRefOutput.permalink ()Resource.uri resourceBuild.timestamp ()andBuild.commitHash ()Pagination.paginate pageSize items- AlgEff
Log
The ordinary helper names are genuinely effect-polymorphic. For example, Pages.query returns Program<'ctx, _> when 'ctx :> PagesContext, and Resource.uri requires only ResourceContext. The other helpers similarly use PageContext, OutputContext, BuildContext, or PaginationContext. Registered renders receive the complete RenderContext; reusable inferred helpers retain only the marker constraints for effects they actually use. There are no separate *In wrappers.
Page.render runs another page's plugin inline. It switches the current page and plugin resource scope, passes that child's complete typed Page to its renderer, then restores the parent scope when the child returns its XmlNode. Render cycles are reported with the complete page trace.
A child can request pagination. The handler branches its continuation, so an outer main page can wrap every archive page without an emit API:
/
/page/2/
/page/3/
The first branch uses the root permalink; later branches use page/N/. The entire root render tree may request pagination once. Empty collections still produce page 1. Output.permalink () observes the pagination-aware output branch; unlike Page.Permalink, it changes for /page/N/ continuations and lets outer layouts emit correct canonical URLs.
Every final emit=true result must be a Falco <html> root. Child renders may return arbitrary fragments.
Output safety
Builds are serial and deterministic for fixed inputs/build facts. S3g performs all page, sidecar, route, resource, and link preflight before rendering. Preflight aggregates independent errors; rendering stops at its first error.
Output is written to a sibling staging directory and moved into place only after the complete render succeeds. dist/.s3g-output marks ownership. S3g will replace an empty directory or a valid owned snapshot, but refuses to overwrite an unmarked non-empty dist/. A failed build leaves the previous snapshot unchanged.
Development
dotnet build S3g.slnx
dotnet test S3g.slnx
dotnet run --project examples/MinimalSite -- build examples/MinimalSite
dotnet pack s3g.fsproj
Optional TextMate fenced-code highlighting is provided by the separate S3g.TextMate package under src/S3g.TextMate; the base package does not carry its Oniguruma/native dependency.
open S3g.TextMate
let standalone = Code.highlight "zig" "const answer: u8 = 42;"
let articleBody = S3g.TextMate.Markdown.fragment document
The companion emits HTML-encoded token spans with semantic CSS classes and complete data-tm-scopes, supports custom grammar overrides, and falls back to plain code for unknown or unlabelled fences. See src/S3g.TextMate/README.md for the full API.
License
S3g is licensed under AGPL-3.0-only. Dependencies retain their respective licenses; see THIRD_PARTY_NOTICES.md.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- AlgEff (>= 1.0.0)
- Falco.Markup (>= 1.4.0)
- FSharp.Core (>= 10.1.204)
- FSharp.Formatting (>= 22.1.0)
- FSharp.SystemTextJson (>= 1.4.36)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.1.0 | 46 | 8/31/2026 |