StaticExport for ToolUp.PublicRendering (consumer migration)
StaticExport for ToolUp.PublicRendering (consumer migration)
What changes. ToolUp.PublicRendering gains a build-time static-export terminus alongside the existing live-server run path. PublicRenderingServerApp.exportStatic outputDir renders every page exposed by IPublicContentApi.ListPages "" through the same layout map the live server uses and writes a static HTML tree under outputDir — hostable on Azure Static Web Apps, Netlify, GitHub Pages, S3 + CloudFront, or any other static-file host with no .NET runtime in production.
Scope. Additive only. The existing PublicRenderingServerApp.run path is unchanged byte-for-byte; consumers that don't call exportStatic see zero behavioural change. The new module sits in ToolUp.PublicRendering (no new package, no new dependency).
Diff to apply
The compose chain mirrors run exactly — the only divergence is the terminus.
// Before — live SSR server only:
PublicRenderingCompose.PublicRenderingServerApp.create ()
|> PublicRenderingServerApp.withConfig config
|> PublicRenderingServerApp.withLogger logger
|> PublicRenderingServerApp.withLayout (LayoutName "page") PageLayout.render
|> PublicRenderingServerApp.withLayout (LayoutName "doc") DocLayout.render
|> PublicRenderingServerApp.run
// After — same chain, build-time static export:
PublicRenderingCompose.PublicRenderingServerApp.create ()
|> PublicRenderingServerApp.withConfig config
|> PublicRenderingServerApp.withLogger logger
|> PublicRenderingServerApp.withLayout (LayoutName "page") PageLayout.render
|> PublicRenderingServerApp.withLayout (LayoutName "doc") DocLayout.render
|> PublicRenderingServerApp.exportStatic "dist"
|> Async.RunSynchronously
|> printfn "Static export: %d pages written"
Typically wired as a FAKE target in the consumer's Build.fs:
Target.create "StaticExport" (fun _ ->
let outputDir = Path.GetFullPath "dist"
let config = (* same ServerConfig used at runtime *)
let logger = ConsoleLogger.ConsoleLogger() :> ILogger
PublicRenderingCompose.PublicRenderingServerApp.create ()
|> PublicRenderingServerApp.withConfig config
|> PublicRenderingServerApp.withLogger logger
|> PublicRenderingServerApp.withLayout (LayoutName "page") Layouts.PageLayout.render
|> PublicRenderingServerApp.withLayout (LayoutName "doc") Layouts.DocLayout.render
|> PublicRenderingServerApp.exportStatic outputDir
|> Async.RunSynchronously
|> printfn "Static export: %d pages written")
"SyncDocs" ==> "Tailwind" ==> "StaticExport" |> ignore
Output layout
<outputDir>/
├── index.html — root `index` slug
├── <slug>/index.html — every other page (directory-index shape)
├── docs/forms/concepts/index.html — nested slugs preserve their tree
├── sitemap.xml — byte-identical to live SitemapGenerator output
├── favicon.ico, css/, img/, ... — verbatim copy of ServerConfig.PublicPath
└── ...
Directory-index shape (/<slug>/index.html) works across every static host without per-host rewrite rules — Azure SWA, Netlify, GitHub Pages, S3 + CloudFront all serve /<slug>/ from /<slug>/index.html by default.
Verification
dotnet buildclean —StaticExport.fscompiles alongside the rest ofToolUp.PublicRendering.- Wire
exportStaticinto the consumer'sBuild.fsper the snippet above; rundotnet run --project Build.fsproj -- StaticExport. - Inspect
dist/— every page incontent/has anindex.html;dist/sitemap.xmlexists and is byte-identical to the live SSR/sitemap.xmlfor the same content tree (curl http://localhost:<port>/sitemap.xml > sitemap-live.xml; diff dist/sitemap.xml sitemap-live.xmlproduces no output). - Serve
dist/locally (npx serve distorpython -m http.server) and navigate every section page — output should be visually identical to the live SSR server. - Static hosts: deploy
dist/to Azure Static Web Apps (or chosen host) — every page renders, navigation works, sitemap is reachable.
What v1 does NOT include
- Redirects translation.
<contentRoot>/redirects.csventries are loaded into the live SSR server but NOT translated into a static-host route config (staticwebapp.config.json,_redirects, etc.) — different hosts use incompatible formats. Consumers author their host-specific config by hand. Translation may be added in a follow-up. - Per-locale prerender. Out of v1 scope; composes with Phase 12a (i18n) when both ship.
- AdPanel / dynamic island injection.
AdPanelsubstrate is Phase 60; static export emits whatever the layout function produces, which is layout-author choice.
Rollback
Drop the StaticExport FAKE target from Build.fs; revert any deployment-pipeline edits (e.g. switch CI/CD back to a server-bundle publish + App Service deploy). The SDK addition is additive — no consumer-side rollback needed beyond removing the call site.
Consumers
The migration applies only to SSR-only website-class deployments — consumers whose ServerConfig.PublicRendering = EnabledPublicRendering _ AND who want to host on a static-file CDN instead of an always-on App Service / container. Application-class deployments (auth, AI streaming, per-user state) are intrinsically dynamic and cannot static-export.
toolup-forge-io is the first consumer; it adopts in the same change set that lands this SDK addition.