A complete F# → GitHub Pages workflow using Zensical (Material for MkDocs) with F# and DSL support.
Designed with subdomains in mind, this orchestrator allows each site folder (main/, docs/, app/, blog/) to build locally. GitHub Actions will build artifacts and push output to entirely separate GitHub repositories using token-authenticated git.
This is a continuation repository, that will be further kept up to date, based on the original project fsharp-material — Latest here
"""
∧_∧
(。・ω・。)つ━☆・*。
⊂ / / ・゜
しーJ °。+*°。
.・゜F#
゜。゚゚・。・゚゚
╱|、
(˚ˎ 。7
|、˜〵
じしˍ,)ノzensical
"""Write type-safe F# configurations, build beautiful static sites, and deploy them with Zensical using dynamic content in F#.
(Quick Jumps)
- Overview
- Features
- Local Setup Guide
- FAQ
- Guides for Devs
- Full Documentation (Wiki Hub)
- Architecture Diagrams
- Mathematical Foundations
- License
- F#-first configuration - All configs are F# sources that generate YAML/TOML
- Giraffe.ViewEngine DSL - Type-safe HTML generation with
index.fsfiles - F# code in Zensical - Beautiful documentation with
indexmd.fsfiles - HTML to DSL conversion - Drop HTML in
/throw/for automatic conversion to proper Giraffe DSL - Shared modules - Reusable components, AST manipulation, tree walking
For pages with complex JavaScript or F# logic:
module Blog.MyPage
open Giraffe.ViewEngine
let render() =
html [] [
head [] [ title [] [ str "My Page" ] ]
body [] [
h1 [] [ str "Hello from F#!" ]
script [] [
rawText """console.log("Hello!");"""
]
]
]
|> RenderView.AsString.htmlDocumentFor documentation with Zensical features:
module Docs.MyPage
open Giraffe.ViewEngine
let card =
div [ _class "card" ] [
h3 [] [ str "Welcome" ]
p [] [ str "Type-safe components!" ]
]
|> RenderView.AsString.htmlNode
let content = $"""---
title: My Page
---
# My Page
{card}
!!! tip "Zensical Features"
Admonitions, tabs, Mermaid diagrams all work!
"""
let render() = contentDrop HTML files in /throw/ for automatic conversion to proper Giraffe ViewEngine DSL:
# Add HTML to throw/
cp my-page.html throw/
# Push to trigger conversion
git add . && git push
# Workflow automatically:
# 1. Parses HTML element-by-element
# 2. Generates pages/my-page/index.fs with proper Giraffe DSL
# 3. Renders to HTML for deploymentThe GiraffeDslConverter parses HTML element-by-element and generates proper Giraffe ViewEngine DSL:
Input HTML:
<div class="container">
<h1>Hello World</h1>
<p>Welcome to my site</p>
</div>Output F#:
module Views
open Giraffe.ViewEngine
let page =
div [ _class "container" ] [
h1 [] [ str "Hello World" ]
p [] [ str "Welcome to my site" ]
]For <script> and <style> content, the converter uses triple-quoted strings - no escaping needed:
Input:
<script>
console.log("Hello \"World\"!");
var path = "C:\Users\test";
const regex = /"/g;
</script>Output:
script [] [
rawText """
console.log("Hello "World"!");
var path = "C:\Users\test";
const regex = /"/g;
"""
]F# triple-quoted strings do NOT interpret \, ", <, >, %, !, ^ - making them ideal for embedding JavaScript, CSS, SVG, or any content without escape-hell.
| Folder | Deploys To | Domain |
|---|---|---|
main/ |
website branch |
shel.sh |
docs/ |
docs branch |
docs.shel.sh |
app/ |
applications branch |
app.shel.sh |
blog/ |
blog branch |
blog.shel.sh |
Each subdomain contains:
zensical.fs→ generateszensical.tomlpyproject.fs→ generatespyproject.tomldocs/indexmd.fs→ generatesdocs/index.md.nojekyll→ disables Jekyll processing.gitignore→ ignores generated files
GitHub Pages has a limitation: one site per repository. This engine overcomes that by building sites locally and pushing the built output to entirely separate repositories using GitHub token authentication.
main/ -> OWNER.github.io (apex site: shel.sh)
docs/ -> OWNER/docs-pages (docs.shel.sh)
app/ -> OWNER/app-pages (app.shel.sh)
blog/ -> OWNER/blog-pages (blog.shel.sh)
During initialization, Actions will pull the latest Zensical, .NET 10 LTS, uv, Giraffe ViewEngine, and AngleSharp.
- Cross-repo GitHub Pages deployment — Push built sites to separate repositories via GitHub tokens
- F#-first configuration — All configs are F# sources that generate YAML/TOML/Markdown
- Giraffe.ViewEngine DSL — Type-safe HTML generation with
index.fsfiles - F# within markdown — Beautiful documentation with
indexmd.fsfiles - HTML to DSL conversion — Drop HTML in
/throw/for automatic conversion to proper Giraffe DSL - Sharpendabot state machine — Two-phase workflow generation from F# sources
- Shared reusable components — Cards, grids, badges, heroes, admonitions
- AST manipulation utilities — Tree walking, text search, class injection, structural transforms
- Bidirectional YML <-> .fs sync — Encode workflow state back to F# sources
- Triple-quoted string safety — Embed any HTML/JS/CSS without escaping hell
- CLI deploy commands —
convert,batch,verifyviahtml2giraffetool
With the idea that this workflow orchestrator was made primarily to allow for multidomain/multi-subdomain GitHub Pages, development of this project kept in the forefront possibility as a "fully-fledged F# deployment engine".
Deployments to any repository are made with F# as a wrapper. The included template is for Zensical static sites.
The CLI commands available are:
dotnet run --project src/generator -- help
dotnet run --project src/generator -- convert <input.html> <output.fs>
dotnet run --project src/generator -- batch <input-dir> <output-dir>git clone <repo-url>
cd <file>Copy the example config and set your GitHub username:
cp .github-repo-config.fs.example .github-repo-config.fs
# Edit .github-repo-config.fs: replace YOUR_USERNAME with your GitHub usernameWhat to change:
// The owner variable auto-detects from GITHUB_REPOSITORY_OWNER env var
// For local testing, change the default:
let owner =
System.Environment.GetEnvironmentVariable("GITHUB_REPOSITORY_OWNER")
|> Option.ofObj
|> Option.defaultValue "YOUR-USERNAME-HERE" // <-- CHANGE THIS
// The CNAME values control custom domains:
Cname = Some "shel.sh" // <-- Change to your domain
Cname = Some "docs.shel.sh" // <-- Change to your subdomain
// The target repo names:
Repo = sprintf "%s.github.io" owner // Apex site (GitHub Pages special repo)
Repo = "docs-pages" // Docs target repo
Repo = "app-pages" // App target repo
Repo = "blog-pages" // Blog target repoThe shel.sh domain is used throughout the documentation is a placeholder. Replace all occurrences with your actual domain.
Create your own file conversion schema! Preview conventions in .github/workflows/ -- you can rename these to whatever you prefer.
View Code CTRL+F: "Steamed"
For cross-repo deployment, you need a Personal Access Token with repo scope:
- GitHub -> Settings -> Developer settings -> Personal access tokens -> Tokens (classic)
- Generate new token with
reposcope - Add to your source repository (this repo) as
GH_PAGES_TOKENsecret
Note:
GITHUB_TOKEN(the default Actions token) only works for same-owner repos. For cross-org or fine-grained control,GH_PAGES_TOKENis required.
| Token | Scope | Use Case |
|---|---|---|
GITHUB_TOKEN |
Same repo only | PR checks, reading repo metadata |
GH_PAGES_TOKEN |
repo -- all repos |
Cross-repo git push for deployment |
Fallback pattern in all deploy scripts:
TOKEN="${GH_PAGES_TOKEN:-${GITHUB_TOKEN}}"This means: if GH_PAGES_TOKEN is set, use it; otherwise fall back to GITHUB_TOKEN (which only works for same-owner deployments).
Create empty repositories on GitHub for each site:
| Source | Target Repository | Enable Pages? |
|---|---|---|
main/ |
YOUR_USERNAME.github.io |
Yes, from main branch |
docs/ |
docs-pages |
Yes, from main branch |
app/ |
app-pages |
Yes, from main branch |
blog/ |
blog-pages |
Yes, from main branch |
| Steps: |
- Create empty repositories on GitHub (do not initialize with README)
- Go to Settings -> Pages in each target repo
- Set Source to "Deploy from a branch"
- Select the
mainbranch and/ (root)folder - The deploy workflow will create the first commit and set the CNAME
During initialization, Actions will automatically pull the latest Zensical, .NET 10 LTS, uv, and Giraffe ViewEngine
The project targets .NET 10 (specifically 10.0.7 or later recommended). All .fsproj files contain:
<TargetFramework>net10.0</TargetFramework>HTML /throw/ conversion:
## (/throw/myfile.html -> /throw/myfile/index.fs)
# 1. Clone (if not done already)
git clone <repo-url>
cd <file>
# 2. Restore .NET dependencies
dotnet restore
# 3. Build the solution
dotnet build
# 4. Build the generator CLI
dotnet build src/generator
# 5. Test the converter
dotnet run --project src/generator -- help
# Example:
dotnet run --project src/generator -- convert input.html output/index.fs
dotnet run --project src/generator -- batch throw/ output/Site Testing:
- uv (Python package manager)
# Recommended: Install uv (Python package manager)
curl -LsSf https://astral.sh/uv/install.sh | sh
# ===================Useful uv commands==================
uv --version # Check version at any time
uv self update # Enable automatic updates
# Create virtual env
cd <preferred dir>
uv venv --python 3.13 --seed --managed-python
source .venv/bin/activate
# Verify inside:
python --version
# Other:
deactivate # Exit virtual environment
which python # Shows active Python path
echo $VIRTUAL_ENV # Shows active venv path
uv python list # List available Python versions
rm -rf .venv # Delete .venv directory (after `deactivate`)
uv cache clean # Clean package cache
uv cache dir # Show cache directory
# =======================================================# Generate configs (zensical.fs -> zensical.toml)
dotnet fsi GenerateConfig.fsx all
# Generate deploy scripts only
dotnet fsi GenerateConfig.fsx deploy-scripts
# Install Python dependencies for each site
uv pip install zensical
cd main && uv pip install zensical && cd ..
cd docs && uv pip install zensical && cd ..
# Serve locally:
cd main && mkdocs serve # Main site (shel.sh)
cd docs && mkdocs serve # Docs site (docs.shel.sh)
# Deploy a single site using .NET (requires GH_PAGES_TOKEN env var)
export GH_PAGES_TOKEN=ghp_xxxxxxxx
dotnet run --project src/generator -- deploy mainGitHub Automated Deployment:
# Prerequisite: token=secret & empty repos (on GitHub)
git add .
git commit -m "Initial setup"
git push origin main- Zensical a continuation of :
- Material for MkDocs ⇢ Material for MkDocs (an Open-Source "ReadTheDocs" / "Docusaurus" alternative
- Material is an extension of MkDocs
- actions/upload-pages-artifact@v3 ⇢ note, separate from upload-artifact@v4. (v3 is the most updated version for pages)
- actions/deploy-pages@v4
- Giraffe.ViewEngine ⇢ Renders actual HTML pages from clean, organized F# DSL
- a subsidiary of giraffe-fsharp/Giraffe
- AngleSharp extensibility is included, allowing for library utilization for additional parsing in HTML5, MathML, SVG and CSS.
- The deepwiki can likewise be found here for further reference.
- For Git on Windows:
winget install --source winget --id Git.Gitandwinget <list/upgrade> ..
During initialization, Actions will automatically pull the latest Zensical, .NET 10 LTS, uv, and Giraffe ViewEngine
Further Reading:
documentation/architecture/README.md: /architecture/README.mdDocumentation on scripts and architecture, outlining libraries like the
generateAttrfunction
documentation/fsharp-in-zensical/README.md: /fsharp-in-zensical/README.mdDocumentation on string safety and features, including safety with embedded content
documentation/how-to-throw/README.md: /how-to-throw/README.mdDocumentation on quick-starting with the /throw/ engine's conversion: HTML to F# (index.html->index.fs)
Lastly, extra inspection of automatic workflows are available at /workflows-in-fsharp/README.md Documentation files include mermaid diagrams outlining deep-level mechanics in the #architecture-diagrams
Repository targets are configured in .github-repo-config.fs:
let sites : SiteMapping list = [
{
SourceFolder = "main"
BuildCommand = Some (sprintf "cd %s && zensical build" "main")
OutputFolder = "site"
Target = {
Owner = "yourusername"
Repo = "yourusername.github.io"
Branch = "main"
Cname = Some "shel.sh"
TokenName = "GH_PAGES_TOKEN"
}
}
// ... docs, app, blog
]- For more, see architecture docs: /architecture/README.md
Developer pushes to source repo
|
v
Sharpendabot (State 1) Generates deploy-*.yml from .fs sources
|
v (Next push, or same push if bool exists)
Build & Deploy Workflows
1. Generate zensical.toml from .fs
2. Build site with mkdocs
3. Push built site/ to target repo via git+token
|
v
Target Repos (GitHub Pages) Serve via custom domain (CNAME)
Sharpendabot is a two-phase state machine:
State 1 (bool absent): Translate F# -> target files + create bool + STOP
State 2 (bool present): Note workflows + delete non-essential YMLs + delete bool + STOP
The bool file is an empty marker (.sharpendabot-bool) that lives in the repo root only during the transition between State 1 and State 2. It ensures the two phases execute on separate pushes.
Two pushes are used because GitHub Actions evaluates workflow files before any workflow runs. A workflow generated during a run cannot execute until the next push. This allows for holding nearly the whole codebase (except 1 yaml) as F# files. See: sharpyml-filename.fs.
This orchestrator retains truism for F# with asynchronous file translation during runtime—and preparedly performs backups (if any) to fs file originals. This would allow for (as an example) a workflow yaml grabbing an updated versioning for a dependency, then writing that change to its fs spawn file for the next time it spawns.
Drop HTML files in /throw/ for automatic conversion to Giraffe ViewEngine DSL:
# Add HTML to throw/
cp my-page.html throw/
# Push to trigger conversion
git add . && git push
# Workflow automatically:
# 1. Parses HTML element-by-element
# 2. Generates throw/my-page/index.fs with proper Giraffe DSL
# 3. User manually moves converted files to target site directory# Converted file:
throw/my-page/index.fs
# Move to target site:
cp -r throw/my-page/ app/docs/widgets/
# Results in: app/docs/widgets/my-page/index.fsGitHub Pages serves index.html before index.md. Both can coexist:
index.fs(Giraffe DSL) -> rendered toindex.htmlindexmd.fs(Markdown generator) -> rendered toindex.md
The HTML page will be served by default.
Pages prioritizes index.html ⇠ index.md ⇠ README.md for which page has priority in each DIR.
index.fs ⇠ indexmd.fs ⇠ sharpmd-README.fs
When an index.fs file is deployed within a repo push, its module name determines the generated namespace. The converter produces:
// From flat module style (current):
module Generated.Views
open Giraffe.ViewEngine
let page =
html [] [
// ... content ...
]
let render() =
page |> Giraffe.ViewEngine.RenderView.AsString.htmlDocumentFor deployed sites, the deploy workflow calls render() and writes the output to index.html.
Naming convention for different site types:
| Site Type | Folder | Module Name Pattern | Example |
|---|---|---|---|
| Apex site | main/ |
Main.PageName |
module Main.Home |
| Docs site | docs/ |
Docs.PageName |
module Docs.Guide |
| App site | app/ |
App.PageName |
module App.Dashboard |
| Blog site | blog/ |
Blog.PageName |
module Blog.Post2024 |
| Standalone | pages/ |
Pages.PageName |
module Pages.About |
The module name is set via the Namespace and ModuleName fields in ConversionConfig:
let config = {
defaultConfig with
Namespace = "App"
ModuleName = "Widgets"
}
// Produces: module App.WidgetsIf you manually write index.fs files (rather than converting from HTML), use the same pattern:
module Docs.MyPage
open Giraffe.ViewEngine
let page =
html [] [
head [] [ title [] [ str "My Page" ] ]
body [] [ h1 [] [ str "Hello" ] ]
]
let render() =
page |> Giraffe.ViewEngine.RenderView.AsString.htmlDocumentThe deploy workflow looks for let render() and calls it to produce index.html.
Read more about automatic HTML conversion (index.html->index.fs) in the docs: /how-to-throw/README.md
Read:
documentation/workflows-in-fsharp/README.md, section "The Problem" + "The Solution"
In simpler terms: Sharpendabot is a two-push dance. Push 1 generates YAML workflow files from F# sources and drops a marker (the bool file). Push 2 cleans up the generated YAMLs, leaving only the F# sources as the permanent truth. This works around a GitHub limitation: you can't generate a workflow AND have GitHub run it in the same push.
The bool file is literally just an empty file named .sharpendabot-bool in the repo root. Its existence is the signal. Its absence triggers generation. This is elegant in its simplicity -- no databases, no state servers, just a file in git.
"GitHub Actions only sees workflow files that exist at push time. If we generate workflows DURING a build, GitHub won't know about them until the NEXT push."
From:
documentation/workflows-in-fsharp/README.md, section "The Solution: Two-Phase State Machine"Sharpendabot uses a simple state machine controlled by a `bool` file: STATE 1 (bool absent): Translate F# -> target files + create bool + STOP STATE 2 (bool present): Note workflows + delete non-essential YMLs + delete bool + STOP
In simpler terms: Sharpendabot is a two-push dance. Push 1 generates YAML workflow files from F# sources and drops a marker (the bool file). Push 2 cleans up the generated YAMLs, leaving only the F# sources as the permanent truth. This works around a GitHub limitation: you can't generate a workflow AND have GitHub run it in the same push.
The bool file is literally just an empty file named .sharpendabot-bool in the repo root. Its existence is the signal. Its absence triggers generation. This is elegant in its simplicity -- no databases, no state servers, just a file in git.
From:
README.md, section "Sharpendabot State Machine""The
boolfile is an empty marker (.sharpendabot-bool) that lives in the repo root only during the transition between State 1 and State 2. It ensures the two phases execute on separate pushes, because GitHub Actions evaluates workflow files before any workflow runs."
Essential vs Non-Essential workflows (current table):
From:
documentation/workflows-in-fsharp/README.md, section "Essential vs Non-Essential Workflows"
Type Kept Deleted in State 2 sharpendabot.ymlYes Never (the authority) deploy-*.ymlNo Yes (regenerated from config/) pr-check.ymlNo Yes (regenerated from config/) dependency-update.ymlNo Yes (regenerated from config/) dependabot.ymlNo Yes (regenerated from config/) steamedyam-*.ymlNo Yes (transient) build-and-deploy.ymlNo Yes (transient)
Amended: deploy-*.yml, pr-check.yml, and dependency-update.yml are now protected in the sharpendabot Step 9 essential list. They are still deleted in State 2 (transient), but sharpendabot will never accidentally delete them during its own operation. Only sharpendabot.yml is truly permanent.
From:
documentation/workflows-in-fsharp/README.md, section "GitHub Pages Behavior Note""Deleting workflows does NOT unpublish your site. GitHub Pages serves the last deployed snapshot indefinitely until you explicitly disable Pages or push a new deployment."
Key implication: Because Pages serves the last deployed snapshot, you can safely delete all workflow YAMLs, regenerate them from F# sources, and your site stays live the entire time. No downtime.
Read more about the workflows to get a full overview at: /workflows-in-fsharp/README.md
Each site folder (main/, docs/, app/, blog/) has an zensical.fs file that generates zensical.toml:
From:
documentation/architecture/README.md, section "F#-First Configuration"module Main.MkDocs // or Docs.MkDocs, App.MkDocs, Blog.MkDocs let content = """ site_name: shel.sh site_url: https://shel.sh nav: - Home: index.md """ let render() = content
For a complete site, also include pyproject.fs:
From:
main/pyproject.fs(actual file)
module Main.PyProject
let content = """
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "shel-sh"
version = "0.1.0"
dependencies = [
"mkdocs>=1.6.1",
"mkdocs-zensical>=9.7.6",
]
"""
let render() = contentNote: mkdocs>=1.6.1 and mkdocs-zensical>=9.7.6 are the current versions.
More in:
documentation/fsharp-in-zensical/README.md, section "The Key Insight"
Complete newbie pattern:
// docs/docs/getting-started/indexmd.fs
module Docs.GettingStarted
open Giraffe.ViewEngine
// Use shared components
open Shared.Components
let hero =
hero "Getting Started" "Learn F# web development" "Read Guide" "/guide/"
// hero function is in src/shared/Components.fs
let content = $"""
---
title: Getting Started
---
{hero}
## Installation
```bash
dotnet new giraffe -o MyWebApp
```
That's it! You're ready to go.
"""
let render() = contentFrom:
src/shared/Components.fs(actual component library)
// Import at the top of your indexmd.fs:
open Shared.Components
// Admonitions (callout boxes)
admonition "tip" (Some "Pro Tip") "<p>Your HTML content</p>"
admonition "warning" None "<p>Be careful here</p>"
// Cards
card (Some "Title") (Some "Footer") "<p>Body content</p>"
card None None "<p>Simple card</p>"
// Feature grids
featureGrid [
("🚀", "Fast", "Built for speed")
("🎯", "Precise", "Type-safe HTML")
]
// Buttons
button "primary" "Click Me" "/action/"
button "danger" "Delete" "/delete/"
// Badges
badge "success" "stable"
badge "warning" "beta"The deploy workflow renders indexmd.fs to index.md by calling render(), then runs zensical build to generate the final HTML site.
The current Components.fs has significantly expanded. The full API now includes:
admonition,card,featureGrid,button,badge(see Section 5.6)hero(title/subtitle/CTA hero section)codeBlockWithCopy(code blocks with copy-to-clipboard)tabContainer(tabbed content panels)searchAndMatch,appendToMatching,walkTree(AST utilities)HtmlAstmodule for programmatic HTML manipulation
Read more about this repo's architecture from the docs page: /architecture/README.md
Read more about Zensical from the official documentation here: Zensical by Squidfunk
repo/
├── README.md # This file
├── LICENSE # AGPLv3
├── GenerateConfig.fsx # Config generator + deploy scripts
├── .github-repo-config.fs.example # Optional: repo mapping config
│
├── .github/
│ ├── config/ # F# workflow sources
│ │ ├── sharpendabot.fs # State machine authority
│ │ ├── deploy-website.fs # Apex site deploy
│ │ ├── deploy-docs.fs # Docs site deploy
│ │ ├── deploy-app.fs # App site deploy
│ │ ├── deploy-blog.fs # Blog site deploy
│ │ ├── build-and-deploy.fs # Throw pipeline (convert only)
│ │ ├── pr-check.fs # PR validation
│ │ ├── dependency-update.fs # Weekly dependency checks
│ │ └── dependabot.fs # Dependabot configuration
│ ├── sources/ # Transient workflow sources
│ │ └── steamedyam-hello-world.fs # Pattern-based example
│ └── workflows/ # Generated YAML (transient)
│ └── sharpendabot.yml # Only essential committed file
│
├── main/ # Apex site (shel.sh)
│ ├── zensical.fs # -> zensical.toml
│ ├── pyproject.fs # -> pyproject.toml
│ ├── .nojekyll # Disable default jekyll
│ ├── .gitignore # Filetype blacklist for commits
│ └── docs/
│ └── indexmd.fs # -> index.md
│
├── docs/ # Docs site (docs.shel.sh)
│ ├── zensical.fs, pyproject.fs # (⇢ mkdocs.yaml & pyproject.toml)
│ ├── .nojekyll, .gitignore
│ └── docs/
│ ├── indexmd.fs
│ └── templates/ # Showcase examples
│ ├── basic/
│ | └── stylesheets/ # Common .css (sharpcss-extra.fs ⇢ extra.css)
│ ├── intermediate/
│ └── advanced/ # Utilizes "open Giraffe.ViewEngine"
│ ├── indexmd.fs # Example 1, Giraffe
│ ├── Components.fs # Reusable component library
│ └── advanced-example.md # Example 2, Giraffe + Components (Documentation)
│
├── app/ # App site (app.shel.sh)
├── blog/ # Blog site (blog.shel.sh)
├── pages/ # Standalone F# HTML pages
│ └── index.fs
├── throw/ # HTML -> DSL conversion input
│ └── README.md
│
├── src/
│ ├── shared/Components.fs # UI components + HtmlAst + SafeString
│ ├── html2giraffe/ # Core library
│ │ ├── Ast.fs, Parser.fs, Attributes.fs, Converter.fs, Roundtrip.fs, Library.fs
│ │ └── Html2Giraffe.fsproj
│ └── generator/ # CLI + conversion tool
│ ├── GiraffeDslConverter.fs # Proper DSL converter (fixed)
│ ├── SafeStringBuilder.fs # Triple-quote utilities (fixed)
│ ├── Program.fs # CLI entry point (fixed)
│ ├── EnhancedConverter.fs, DelimiterExtractor.fs
│ ├── XmlStyleLiteral.fs, DomRepresentation.fs
│ ├── HtmlToGiraffe.fs, Verification.fs
│ └── Generator.fsproj
│
└── documentation/
├── ARCHITECTURE.md
├── workflows-in-fsharp/README.md
├── how-to-throw/README.md
└── fsharp-web-patterns/README.md
Multi-repository, multi-domain setups on GitHub pages (with dynamic F# content) for free tier users!
- AGPLv3 - Permits nearly all (including commercial) use cases - outside of copywriting this source code and claiming it as your own.
- Dotnet makes for LESS MANAGEMENT - (ex. instead of manually editing a "timestamp" variable in source code, create a module—it updates every runtime)
- 650kb of overhead (not even a megabyte) out of total 1GB storage headroom. Give F# a try, it can sit on top of every existing architecture!
When you use a secret in a GitHub Actions workflow (like the GH_PAGES_TOKEN you use for cross-repo pushing), it undergoes a very specific lifecycle to ensure it isn't leaked.
1. Storage (Encryption at Rest)
When you type a secret into the GitHub Settings UI, your browser encrypts the secret before it leaves your machine using a public key tied to your repository (using the Libsodium sealed box algorithm). GitHub’s database stores this ciphertext. GitHub engineers and internal databases cannot read the raw string.
2. Injection (Decryption at Runtime)
When an Action runs and your sharpendabot.yml requests a secret using ${{ secrets.GH_PAGES_TOKEN }}, GitHub sends the encrypted payload down to the isolated Linux runner container that is processing your job. The runner’s internal agent decrypts it entirely in system memory.
3. Execution (How it's passed to your code)
You typically expose secrets to your scripts in one of two ways:
-
Best Practice (Environment Variable):
env: MY_TOKEN: ${{ secrets.GH_PAGES_TOKEN }} run: echo "Using token to authenticate..." && git push https://x-access-token:$MY_TOKEN@...
This is preferred because bash never "sees" the token as raw text in the script command, eliminating injection vulnerabilities.
4. The Masking Engine (Redaction)
The GitHub Actions Runner monitors every single line of output bound for your screen. It maintains a running dictionary of the active secrets in memory. If any script accidentally echoes your token, or if a stack trace outputs a URL containing it, the runner intercepts the stream and masks it out:
git remote add target https://x-access-token:***@github.com/OWNER/app-pages.git
Note on GITHUB_TOKEN: You also saw ${{ secrets.GITHUB_TOKEN }}. This is a special, temporary, short-lived secret created automatically for every workflow run, living only as long as the run lasts.
Multiple GitHub Pages sites can be run from separate repositories, with custom domains, entirely for free. There is really only one major catch on a free account.
On a GitHub Pro or Team tier, you can host Pages out of private repositories. On the Free tier, any repository you wish to serve via GitHub Pages MUST be public.
This workflow was built for sHEL @ shel.sh, docs.shel.sh, app.shel.sh, blog.shel.sh,
A typical user would create four target repositories:
- yourusername.github.io (Public) -> Hosts apex domain (mysite.tld)
- docs-pages (Public) -> Hosts sub-domain (docs.mysite.tld)
- app-pages (Public) -> Hosts sub-domain (app.mysite.tld)
- blog-pages (Public) -> Hosts sub-domain (blog.mysite.tld)
This central repository contains F# and automation source code (orchestrator). The output repositories merely receive the flattened HTML, meaning proprietary F# pipelines and uncompiled logic remain safe behind a separate repo! It can be public or private.
Because all of your sites are technically hosted on GitHub's central IPs (like 185.199.108.153), GitHub needs a way to route incoming traffic correctly. If a browser asks GitHub's server for blog.something.com, how does it know which repository to serve?
The CNAME File
The answer is the CNAME file. GitHub looks at the CNAME file inside your repository to establish the mapping rules in their load balancers. By default, this may be created by Github Pages if it does not exist. ("www" is an example subdomain added onto normal Pages users)
This is why F# deploy logic beautifully echoes the CNAME file during generation:
echo "app.mysite.tld" > CNAME
git add . && git commit -m "Deploy app"When GitHub receives that push, their internal DNS registers that app-pages now owns requests meant for app.mysite.tld.
On your registrar's side, you configure standard records to point back to GitHub:
See all of the following documentation for the most up-to-date information from GitHub (GitHub docs):
- About custom domains and GitHub Pages - GitHub Docs
- Verifying your custom domain for GitHub Pages - GitHub Docs
- Managing a custom domain for your GitHub Pages site - GitHub Docs
See the following for more information on GitHub Actions:
On the name service — wherever you bought a domain — look for advanced DNS configuration, and follow the above GitHub guides.
-
Apex (site.tld): You add A records pointing to GitHub's IPs (185.199.x.x).
-
Subdomains (blog, app, docs): You add CNAME records pointing to your GitHub host: yourusername.github.io.
-
**Do not rely solely on the information here, and read the full docs (above) if you are new to site hosting! GitHub has always recommended verifying a domain first with a TXT record before adding other records (cname, A, AAA...)
- This keeps your domain safe
Even if app.mysite.tld points to username.github.io, because GitHub sees the incoming Host Header (Host: app.mysite.tld), their router automatically routes the connection to the app-pages repository because it read the CNAME file during your last CI deploy.
GitHub Pages on a free tier utilizes Let's Encrypt for automatic HTTPS protection.
When you deploy a repository that contains a CNAME file mapping a custom domain, GitHub makes a background API call to Let's Encrypt. It passes a verification challenge to prove you pointed your DNS correctly. Upon success, Let's Encrypt mints an SSL certificate, and GitHub auto-renews it every 90 days completely behind the scenes without costing you anything.
GitHub Free permits excellent thresholds before turning sites off:
-
Size: 1 GB per repository. (HTML and standard scripts rarely hit 50 MB, so this is gigantic, unless).
-
Bandwidth: 100 GB per month (Roughly millions of simple documentation page requests per month).
-
Builds: A soft limit of 10 builds per hour per repo. Since you are building the files in your master orchestrator via uv/dotnet, and simply pushing static results via Git—this build restriction mostly doesn't apply to you.
