S3g 0.1.0

dotnet add package S3g --version 0.1.0
                    
NuGet\Install-Package S3g -Version 0.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="S3g" Version="0.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="S3g" Version="0.1.0" />
                    
Directory.Packages.props
<PackageReference Include="S3g" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add S3g --version 0.1.0
                    
#r "nuget: S3g, 0.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package S3g@0.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=S3g&version=0.1.0
                    
Install as a Cake Addin
#tool nuget:?package=S3g&version=0.1.0
                    
Install as a Cake Tool

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:

  • layout is required and selects a registered plugin.
  • permalink is optional for emitted pages. It must be a root-relative directory URL ending in /.
  • emit defaults to true. An emit=false page is queryable/renderable as a child but cannot declare a permalink or write a root output.
  • resources maps 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.

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:

  • $hero resolves the page-local resource named hero;
  • $$hero produces the literal target $hero;
  • relative .md links resolve to the target emitted page's pretty permalink;
  • missing resources/pages and links to emit=false pages 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 layout for emit=true pages
  • Pages.queryAll layout for all pages
  • Page.render pageRef
  • Output.permalink ()
  • Resource.uri resource
  • Build.timestamp () and Build.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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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