diff --git a/.github/workflows/Deploy.yml b/.github/workflows/Deploy.yml
index b596787..e629c83 100644
--- a/.github/workflows/Deploy.yml
+++ b/.github/workflows/Deploy.yml
@@ -4,19 +4,84 @@ on:
branches:
- main
pull_request:
+
+# One run per branch: superseded PR runs are cancelled, while pushes to main
+# queue behind the run in progress, so close-together commits can never cancel
+# or interleave with a deploy mid-run.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
jobs:
- docs:
+ build:
runs-on: ubuntu-latest
- permissions: write-all
+ permissions:
+ contents: read
steps:
- uses: actions/checkout@v4
+ - uses: julia-actions/setup-julia@v2
+ with:
+ # The committed MoJuWoPreprocessor Manifest is resolved for 1.12.
+ version: '1.12'
+ - uses: julia-actions/cache@v2
+ - name: Instantiate the preprocessor
+ run: julia --project=MoJuWoPreprocessor -e 'using Pkg; Pkg.instantiate()'
- name: Set git user config
+ # PkgTemplates reads user.name/user.email from the global git config
+ # when the sharing page generates its demo package.
run: |
git config --global user.name "myusername"
git config --global user.email "myusername@modernjuliaworkflows.github"
- - name: π Build and Deploy
- uses: tlienart/xranklin-build-action@main
+ - name: Execute src/ into content/
+ # Section envs (src/*/Project.toml) are instantiated per page by the
+ # preprocessor itself; their Manifests are not committed.
+ run: julia --project=MoJuWoPreprocessor -m MoJuWoPreprocessor preprocess src content
+ - name: Install Zola
+ uses: taiki-e/install-action@v2
+ with:
+ tool: zola@0.23.3
+ - name: Build the site
+ run: zola build
+ - name: Index the site for search
+ # Builds the Pagefind bundle (public/pagefind/) that js/search.js
+ # loads for the full-text result tier. Runners ship Node for npx.
+ run: npx -y pagefind@1.5.2 --site public
+ - name: Check links
+ # Broken internal links fail the build.
+ # Broken external links only warn (external_level = "warn" in zola.toml's [link_checker]),
+ # since CI cannot reliably reach every site.
+ env:
+ # Zola swallows link-checker warnings at its default log level.
+ RUST_LOG: info
+ run: |
+ set -o pipefail
+ zola check 2>&1 | tee zola-check.log
+ if grep -q "broken external link" zola-check.log; then
+ {
+ echo '### :warning: Broken external links'
+ echo '```'
+ grep -E 'broken external link|^ *[0-9]+\. Broken link' zola-check.log
+ echo '```'
+ } >> "$GITHUB_STEP_SUMMARY"
+ echo "::warning title=Broken external links::zola check found broken external links β see the job summary for the list."
+ fi
+ - name: Upload Pages artifact
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ uses: actions/upload-pages-artifact@v3
with:
- DEPLOY: ${{ github.event_name == 'push' }}
- DEPLOY_BRANCH: 'gh-pages'
- JULIA_VERSION: '1.11'
\ No newline at end of file
+ path: ./public
+
+ deploy:
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ needs: build
+ runs-on: ubuntu-latest
+ permissions:
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index 4aa4264..45ba661 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,10 @@
-__cache/
-__site/
+content/
+_workdir/
+public/
.DS_Store
node_modules/
package-lock.json
.vscode
-writing/Manifest.toml
-sharing/Manifest.toml
-optimizing/Manifest.toml
\ No newline at end of file
+src/writing/Manifest.toml
+src/sharing/Manifest.toml
+src/optimizing/Manifest.toml
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..3c59a04
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,29 @@
+# Vendored third-party code
+exclude: ^static/libs/
+
+repos:
+ - repo: https://github.com/crate-ci/typos
+ rev: v1.48.0
+ hooks:
+ - id: typos
+ priority: 0
+ # Use empty args to avoid overwriting false positives.
+ # Instead, simply fail hook.
+ args: []
+
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v6.0.0
+ hooks:
+ - id: trailing-whitespace
+ - id: end-of-file-fixer
+ - id: check-merge-conflict
+ - id: check-added-large-files
+ - id: check-toml
+ - id: check-yaml
+
+ - repo: https://github.com/fredrikekre/runic-pre-commit
+ rev: v1.0.0
+ hooks:
+ - id: runic
+
+fail_fast: true
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c4ad4a0..0855327 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -3,7 +3,33 @@
If you want to contribute to the blog, start by filing an issue to discuss potential additions.
Then, you can open a pull request.
-We suggest you read the [Franklin documentation](https://franklinjl.org/) first to get familiar with the variant of Markdown syntax used here.
+The pages under `src/` are written in Markdown with executable code blocks, documented below.
+
+## Building the site locally
+
+MoJuWo is built using Julia 1.12 and [Zola](https://www.getzola.org/documentation/getting-started/installation/) `v0.23`.
+
+Install the preprocessor's dependencies once:
+
+```bash
+julia --project=MoJuWoPreprocessor -e 'using Pkg; Pkg.instantiate()'
+```
+
+Then, from the repository root:
+
+```bash
+julia --project=MoJuWoPreprocessor -m MoJuWoPreprocessor serve
+```
+
+The site is built in two steps:
+
+1. a Julia preprocessor ([MoJuWoPreprocessor](MoJuWoPreprocessor/README.md)) executes the code blocks in the authored pages under `src/` and writes the results to the gitignored `content/` directory;
+2. [Zola](https://www.getzola.org) renders `content/` into the static site.
+
+This executes every page (the first pass will take some time),
+serves the site, and watches `src/`:
+saving a page re-runs just that page and live-reloads the browser.
+See [MoJuWoPreprocessor/README.md](MoJuWoPreprocessor/README.md) for the remaining commands (e.g., `preprocess`, `build`, `check`, `clean`).
## Style guide
@@ -20,4 +46,46 @@ Every time a new resource is introduced, it should be accompanied with a link to
Links to the package documentation are not necessary, unless they are meant to highlight a specific part.
Package names are written as normal text with the .jl extension, while functions or objects are written between backticks.
-Multi-line scripts are given as Julia code or REPL blocks following the Franklin specification.
+Multi-line scripts are given as executable code blocks, so that readers always see real, up-to-date output.
+
+## Executable code blocks
+
+Julia code blocks are executed when the site is built, and their output is rendered below the code like a REPL session.
+The first character of the fence's info string selects the REPL mode, and the rest names the block:
+
+```>example
+x = 1
+x + 1
+```
+
+- ` ```>name ` β julia mode: expressions are echoed and evaluated one by one, like in the REPL;
+- ` ```?name ` β help mode;
+- ` ```]name ` β pkg mode;
+- ` ```;name ` β shell mode;
+- ` ```!name ` β script mode: the code runs silently and is shown as a plain ` ```julia ` block, followed by its printed output. A `# hideall` comment hides the whole block, and a trailing `# hide` hides a single line.
+
+A code block whose error is part of the lesson must be marked with an ` allow-error` flag, e.g. ` ```>name allow-error `: the error message then renders like any other REPL output.
+Without the flag, an erroring code block fails the site build, as does any unclosed fence.
+
+All named fences on a page share one sandbox module, so later blocks can use variables defined in earlier ones.
+Two conventions replace explicit setup code:
+
+- If the page's directory contains a `Project.toml`, that environment is activated while the page runs. Add the packages your code blocks need there.
+- Code blocks run inside a per-page scratch directory, so files are created and read with plain relative paths, exactly as a reader would type them.
+
+## Admonitions
+
+Callout boxes use the components defined in [`templates/components.html`](templates/components.html): `tldr`, `advanced`, and `vscode`.
+The body is regular Markdown:
+
+```markdown
+{% %}
+A quick summary of the section.
+{% %}
+```
+
+Everything else is standard [Zola](https://www.getzola.org/documentation/):
+the layout lives in `templates/`,
+written as [Tera templates](https://www.getzola.org/documentation/templates/overview/),
+styles live in `static/css/`,
+and the site configuration in `zola.toml`.
diff --git a/Manifest.toml b/Manifest.toml
deleted file mode 100644
index 7612280..0000000
--- a/Manifest.toml
+++ /dev/null
@@ -1,308 +0,0 @@
-# This file is machine-generated - editing it directly is not advised
-
-julia_version = "1.11.7"
-manifest_format = "2.0"
-project_hash = "fd73ef33d480b0816d7728a667dba4ef62c44c52"
-
-[[deps.ANSIColoredPrinters]]
-git-tree-sha1 = "574baf8110975760d391c710b6341da1afa48d8c"
-uuid = "a4c015fc-c6ff-483c-b24f-f7ea428134e9"
-version = "0.0.1"
-
-[[deps.ArgTools]]
-uuid = "0dad84c5-d112-42e6-8d28-ef12dabb789f"
-version = "1.1.2"
-
-[[deps.Artifacts]]
-uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33"
-version = "1.11.0"
-
-[[deps.Base64]]
-uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
-version = "1.11.0"
-
-[[deps.BitFlags]]
-git-tree-sha1 = "0691e34b3bb8be9307330f88d1a3c3f25466c24d"
-uuid = "d1d4a3ce-64b1-5f1a-9ba4-7e7e69966f35"
-version = "0.1.9"
-
-[[deps.CRC32c]]
-uuid = "8bf52ea8-c179-5cab-976a-9e18b702a9bc"
-version = "1.11.0"
-
-[[deps.CodecZlib]]
-deps = ["TranscodingStreams", "Zlib_jll"]
-git-tree-sha1 = "962834c22b66e32aa10f7611c08c8ca4e20749a9"
-uuid = "944b1d66-785c-5afd-91f1-9de20f533193"
-version = "0.7.8"
-
-[[deps.ConcurrentUtilities]]
-deps = ["Serialization", "Sockets"]
-git-tree-sha1 = "d9d26935a0bcffc87d2613ce14c527c99fc543fd"
-uuid = "f0e56b4a-5159-44fe-b623-3e5288b988bb"
-version = "2.5.0"
-
-[[deps.Dates]]
-deps = ["Printf"]
-uuid = "ade2ca70-3891-5945-98fb-dc099432e06a"
-version = "1.11.0"
-
-[[deps.Downloads]]
-deps = ["ArgTools", "FileWatching", "LibCURL", "NetworkOptions"]
-uuid = "f43a241f-c20a-4ad4-852c-f6b1247861c6"
-version = "1.6.0"
-
-[[deps.ExceptionUnwrapping]]
-deps = ["Test"]
-git-tree-sha1 = "d36f682e590a83d63d1c7dbd287573764682d12a"
-uuid = "460bff9d-24e4-43bc-9d9f-a8973cb893f4"
-version = "0.1.11"
-
-[[deps.FileWatching]]
-uuid = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee"
-version = "1.11.0"
-
-[[deps.FranklinParser]]
-deps = ["PrecompileTools", "REPL"]
-git-tree-sha1 = "7daf95d2334d4c0f73353e110c9396e9d5258afb"
-uuid = "796511e7-1510-466f-ad0c-1823c64bcafa"
-version = "0.7.1"
-
-[[deps.HTTP]]
-deps = ["Base64", "CodecZlib", "ConcurrentUtilities", "Dates", "ExceptionUnwrapping", "Logging", "LoggingExtras", "MbedTLS", "NetworkOptions", "OpenSSL", "PrecompileTools", "Random", "SimpleBufferStream", "Sockets", "URIs", "UUIDs"]
-git-tree-sha1 = "5e6fe50ae7f23d171f44e311c2960294aaa0beb5"
-uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3"
-version = "1.10.19"
-
-[[deps.IOCapture]]
-deps = ["Logging", "Random"]
-git-tree-sha1 = "b6d6bfdd7ce25b0f9b2f6b3dd56b2673a66c8770"
-uuid = "b5f81e59-6552-4d32-b1f0-c071b021bf89"
-version = "0.2.5"
-
-[[deps.InteractiveUtils]]
-deps = ["Markdown"]
-uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240"
-version = "1.11.0"
-
-[[deps.JLLWrappers]]
-deps = ["Artifacts", "Preferences"]
-git-tree-sha1 = "0533e564aae234aff59ab625543145446d8b6ec2"
-uuid = "692b3bcd-3c85-4b1f-b108-f13ce0eb3210"
-version = "1.7.1"
-
-[[deps.LibCURL]]
-deps = ["LibCURL_jll", "MozillaCACerts_jll"]
-uuid = "b27032c2-a3e7-50c8-80cd-2d36dbcbfd21"
-version = "0.6.4"
-
-[[deps.LibCURL_jll]]
-deps = ["Artifacts", "LibSSH2_jll", "Libdl", "MbedTLS_jll", "Zlib_jll", "nghttp2_jll"]
-uuid = "deac9b47-8bc7-5906-a0fe-35ac56dc84c0"
-version = "8.6.0+0"
-
-[[deps.LibGit2]]
-deps = ["Base64", "LibGit2_jll", "NetworkOptions", "Printf", "SHA"]
-uuid = "76f85450-5226-5b5a-8eaa-529ad045b433"
-version = "1.11.0"
-
-[[deps.LibGit2_jll]]
-deps = ["Artifacts", "LibSSH2_jll", "Libdl", "MbedTLS_jll"]
-uuid = "e37daf67-58a4-590a-8e99-b0245dd2ffc5"
-version = "1.7.2+0"
-
-[[deps.LibSSH2_jll]]
-deps = ["Artifacts", "Libdl", "MbedTLS_jll"]
-uuid = "29816b5a-b9ab-546f-933c-edad1886dfa8"
-version = "1.11.0+1"
-
-[[deps.Libdl]]
-uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb"
-version = "1.11.0"
-
-[[deps.LiveServer]]
-deps = ["HTTP", "LoggingExtras", "MIMEs", "Sockets", "Test"]
-git-tree-sha1 = "9f65b8a9989e6acb6e216785dd0c748d9569fc9b"
-uuid = "16fef848-5104-11e9-1b77-fb7a48bbb589"
-version = "1.5.0"
-
-[[deps.Logging]]
-uuid = "56ddb016-857b-54e1-b83d-db4d58db5568"
-version = "1.11.0"
-
-[[deps.LoggingExtras]]
-deps = ["Dates", "Logging"]
-git-tree-sha1 = "f00544d95982ea270145636c181ceda21c4e2575"
-uuid = "e6f89c97-d47a-5376-807f-9c37f3926c36"
-version = "1.2.0"
-
-[[deps.MIMEs]]
-git-tree-sha1 = "65f28ad4b594aebe22157d6fac869786a255b7eb"
-uuid = "6c6e2e6c-3030-632d-7369-2d6c69616d65"
-version = "0.1.4"
-
-[[deps.Markdown]]
-deps = ["Base64"]
-uuid = "d6f4376e-aef5-505a-96c1-9c027394607a"
-version = "1.11.0"
-
-[[deps.MbedTLS]]
-deps = ["Dates", "MbedTLS_jll", "MozillaCACerts_jll", "NetworkOptions", "Random", "Sockets"]
-git-tree-sha1 = "c067a280ddc25f196b5e7df3877c6b226d390aaf"
-uuid = "739be429-bea8-5141-9913-cc70e7f3736d"
-version = "1.1.9"
-
-[[deps.MbedTLS_jll]]
-deps = ["Artifacts", "Libdl"]
-uuid = "c8ffd9c3-330d-5841-b78e-0817d7145fa1"
-version = "2.28.6+0"
-
-[[deps.MozillaCACerts_jll]]
-uuid = "14a3606d-f60d-562e-9121-12d972cd8159"
-version = "2023.12.12"
-
-[[deps.NetworkOptions]]
-uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908"
-version = "1.2.0"
-
-[[deps.NodeJS]]
-deps = ["Pkg"]
-git-tree-sha1 = "bf1f49fd62754064bc42490a8ddc2aa3694a8e7a"
-uuid = "2bd173c7-0d6d-553b-b6af-13a54713934c"
-version = "2.0.0"
-
-[[deps.OpenSSL]]
-deps = ["BitFlags", "Dates", "MozillaCACerts_jll", "OpenSSL_jll", "Sockets"]
-git-tree-sha1 = "f1a7e086c677df53e064e0fdd2c9d0b0833e3f6e"
-uuid = "4d8831e6-92b7-49fb-bdf8-b643e874388c"
-version = "1.5.0"
-
-[[deps.OpenSSL_jll]]
-deps = ["Artifacts", "JLLWrappers", "Libdl"]
-git-tree-sha1 = "f19301ae653233bc88b1810ae908194f07f8db9d"
-uuid = "458c3c95-2e84-50aa-8efc-19380b2a3a95"
-version = "3.5.4+0"
-
-[[deps.OrderedCollections]]
-git-tree-sha1 = "05868e21324cede2207c6f0f466b4bfef6d5e7ee"
-uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d"
-version = "1.8.1"
-
-[[deps.Pkg]]
-deps = ["Artifacts", "Dates", "Downloads", "FileWatching", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "Random", "SHA", "TOML", "Tar", "UUIDs", "p7zip_jll"]
-uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
-version = "1.11.0"
-weakdeps = ["REPL"]
-
- [deps.Pkg.extensions]
- REPLExt = "REPL"
-
-[[deps.PrecompileTools]]
-deps = ["Preferences"]
-git-tree-sha1 = "5aa36f7049a63a1528fe8f7c3f2113413ffd4e1f"
-uuid = "aea7be01-6a6a-4083-8856-8a6e6704d82a"
-version = "1.2.1"
-
-[[deps.Preferences]]
-deps = ["TOML"]
-git-tree-sha1 = "0f27480397253da18fe2c12a4ba4eb9eb208bf3d"
-uuid = "21216c6a-2e73-6563-6e65-726566657250"
-version = "1.5.0"
-
-[[deps.Printf]]
-deps = ["Unicode"]
-uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7"
-version = "1.11.0"
-
-[[deps.REPL]]
-deps = ["InteractiveUtils", "Markdown", "Sockets", "StyledStrings", "Unicode"]
-uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb"
-version = "1.11.0"
-
-[[deps.Random]]
-deps = ["SHA"]
-uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
-version = "1.11.0"
-
-[[deps.Reexport]]
-git-tree-sha1 = "45e428421666073eab6f2da5c9d310d99bb12f9b"
-uuid = "189a3867-3050-52da-a836-e630ba90ab69"
-version = "1.2.2"
-
-[[deps.SHA]]
-uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce"
-version = "0.7.0"
-
-[[deps.Serialization]]
-uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
-version = "1.11.0"
-
-[[deps.SimpleBufferStream]]
-git-tree-sha1 = "f305871d2f381d21527c770d4788c06c097c9bc1"
-uuid = "777ac1f9-54b0-4bf8-805c-2214025038e7"
-version = "1.2.0"
-
-[[deps.Sockets]]
-uuid = "6462fe0b-24de-5631-8697-dd941f90decc"
-version = "1.11.0"
-
-[[deps.StyledStrings]]
-uuid = "f489334b-da3d-4c2e-b8f0-e476e12c162b"
-version = "1.11.0"
-
-[[deps.TOML]]
-deps = ["Dates"]
-uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76"
-version = "1.0.3"
-
-[[deps.Tar]]
-deps = ["ArgTools", "SHA"]
-uuid = "a4e569a6-e804-4fa4-b0f3-eef7a1d5b13e"
-version = "1.10.0"
-
-[[deps.Test]]
-deps = ["InteractiveUtils", "Logging", "Random", "Serialization"]
-uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
-version = "1.11.0"
-
-[[deps.TranscodingStreams]]
-git-tree-sha1 = "0c45878dcfdcfa8480052b6ab162cdd138781742"
-uuid = "3bb67fe8-82b1-5028-8e26-92a6c54297fa"
-version = "0.11.3"
-
-[[deps.URIs]]
-git-tree-sha1 = "bef26fb046d031353ef97a82e3fdb6afe7f21b1a"
-uuid = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4"
-version = "1.6.1"
-
-[[deps.UUIDs]]
-deps = ["Random", "SHA"]
-uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
-version = "1.11.0"
-
-[[deps.Unicode]]
-uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5"
-version = "1.11.0"
-
-[[deps.Xranklin]]
-deps = ["ANSIColoredPrinters", "CRC32c", "Dates", "FranklinParser", "IOCapture", "LiveServer", "Logging", "Markdown", "OrderedCollections", "Pkg", "REPL", "Reexport", "Serialization", "TOML", "URIs"]
-git-tree-sha1 = "00c83bd65338e9cf1ca1b8a183bd27f5df60767e"
-repo-rev = "main"
-repo-url = "https://github.com/tlienart/Xranklin.jl/"
-uuid = "558449b0-171e-4e1f-900f-d076a5ddf486"
-version = "0.1.0"
-
-[[deps.Zlib_jll]]
-deps = ["Libdl"]
-uuid = "83775a58-1f1d-513f-b197-d71354ab007a"
-version = "1.2.13+1"
-
-[[deps.nghttp2_jll]]
-deps = ["Artifacts", "Libdl"]
-uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d"
-version = "1.59.0+0"
-
-[[deps.p7zip_jll]]
-deps = ["Artifacts", "Libdl"]
-uuid = "3f19e933-33d8-53b3-aaab-bd5110c3b7a0"
-version = "17.4.0+2"
diff --git a/MoJuWoPreprocessor/Manifest.toml b/MoJuWoPreprocessor/Manifest.toml
new file mode 100644
index 0000000..97bb750
--- /dev/null
+++ b/MoJuWoPreprocessor/Manifest.toml
@@ -0,0 +1,211 @@
+# This file is machine-generated - editing it directly is not advised
+
+julia_version = "1.12.6"
+manifest_format = "2.0"
+project_hash = "421107290f5ec5605f25b73639704f3a5e537674"
+
+[[deps.ANSIColoredPrinters]]
+git-tree-sha1 = "574baf8110975760d391c710b6341da1afa48d8c"
+uuid = "a4c015fc-c6ff-483c-b24f-f7ea428134e9"
+version = "0.0.1"
+
+[[deps.ArgTools]]
+uuid = "0dad84c5-d112-42e6-8d28-ef12dabb789f"
+version = "1.1.2"
+
+[[deps.Artifacts]]
+uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33"
+version = "1.11.0"
+
+[[deps.Base64]]
+uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
+version = "1.11.0"
+
+[[deps.CompilerSupportLibraries_jll]]
+deps = ["Artifacts", "Libdl"]
+uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae"
+version = "1.3.0+1"
+
+[[deps.Dates]]
+deps = ["Printf"]
+uuid = "ade2ca70-3891-5945-98fb-dc099432e06a"
+version = "1.11.0"
+
+[[deps.Distributed]]
+deps = ["Random", "Serialization", "Sockets"]
+uuid = "8ba89e20-285c-5b6f-9357-94700520ee1b"
+version = "1.11.0"
+
+[[deps.Downloads]]
+deps = ["ArgTools", "FileWatching", "LibCURL", "NetworkOptions"]
+uuid = "f43a241f-c20a-4ad4-852c-f6b1247861c6"
+version = "1.7.0"
+
+[[deps.FileWatching]]
+uuid = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee"
+version = "1.11.0"
+
+[[deps.IOCapture]]
+deps = ["Logging", "Random"]
+git-tree-sha1 = "0ee181ec08df7d7c911901ea38baf16f755114dc"
+uuid = "b5f81e59-6552-4d32-b1f0-c071b021bf89"
+version = "1.0.0"
+
+[[deps.InteractiveUtils]]
+deps = ["Markdown"]
+uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240"
+version = "1.11.0"
+
+[[deps.JuliaSyntaxHighlighting]]
+deps = ["StyledStrings"]
+uuid = "ac6e5ff7-fb65-4e79-a425-ec3bc9c03011"
+version = "1.12.0"
+
+[[deps.LibCURL]]
+deps = ["LibCURL_jll", "MozillaCACerts_jll"]
+uuid = "b27032c2-a3e7-50c8-80cd-2d36dbcbfd21"
+version = "0.6.4"
+
+[[deps.LibCURL_jll]]
+deps = ["Artifacts", "LibSSH2_jll", "Libdl", "OpenSSL_jll", "Zlib_jll", "nghttp2_jll"]
+uuid = "deac9b47-8bc7-5906-a0fe-35ac56dc84c0"
+version = "8.15.0+0"
+
+[[deps.LibGit2]]
+deps = ["LibGit2_jll", "NetworkOptions", "Printf", "SHA"]
+uuid = "76f85450-5226-5b5a-8eaa-529ad045b433"
+version = "1.11.0"
+
+[[deps.LibGit2_jll]]
+deps = ["Artifacts", "LibSSH2_jll", "Libdl", "OpenSSL_jll"]
+uuid = "e37daf67-58a4-590a-8e99-b0245dd2ffc5"
+version = "1.9.0+0"
+
+[[deps.LibSSH2_jll]]
+deps = ["Artifacts", "Libdl", "OpenSSL_jll"]
+uuid = "29816b5a-b9ab-546f-933c-edad1886dfa8"
+version = "1.11.3+1"
+
+[[deps.Libdl]]
+uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb"
+version = "1.11.0"
+
+[[deps.Logging]]
+uuid = "56ddb016-857b-54e1-b83d-db4d58db5568"
+version = "1.11.0"
+
+[[deps.Malt]]
+deps = ["Distributed", "Logging", "RelocatableFolders", "Serialization", "Sockets"]
+git-tree-sha1 = "c2335b4e291f2422e2be8abf8936ccad58a98992"
+uuid = "36869731-bdee-424d-aa32-cab38c994e3b"
+version = "1.4.1"
+
+[[deps.Markdown]]
+deps = ["Base64", "JuliaSyntaxHighlighting", "StyledStrings"]
+uuid = "d6f4376e-aef5-505a-96c1-9c027394607a"
+version = "1.11.0"
+
+[[deps.MoJuWoPreprocessor]]
+deps = ["ANSIColoredPrinters", "IOCapture", "Logging", "Malt", "Pkg", "REPL"]
+path = "."
+uuid = "6baad647-a534-4edf-86c6-a371ed53b1b6"
+version = "0.1.0"
+
+[[deps.MozillaCACerts_jll]]
+uuid = "14a3606d-f60d-562e-9121-12d972cd8159"
+version = "2025.11.4"
+
+[[deps.NetworkOptions]]
+uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908"
+version = "1.3.0"
+
+[[deps.OpenSSL_jll]]
+deps = ["Artifacts", "Libdl"]
+uuid = "458c3c95-2e84-50aa-8efc-19380b2a3a95"
+version = "3.5.4+0"
+
+[[deps.Pkg]]
+deps = ["Artifacts", "Dates", "Downloads", "FileWatching", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "Random", "SHA", "TOML", "Tar", "UUIDs", "p7zip_jll"]
+uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
+version = "1.12.1"
+weakdeps = ["REPL"]
+
+ [deps.Pkg.extensions]
+ REPLExt = "REPL"
+
+[[deps.Printf]]
+deps = ["Unicode"]
+uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7"
+version = "1.11.0"
+
+[[deps.REPL]]
+deps = ["InteractiveUtils", "JuliaSyntaxHighlighting", "Markdown", "Sockets", "StyledStrings", "Unicode"]
+uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb"
+version = "1.11.0"
+
+[[deps.Random]]
+deps = ["SHA"]
+uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
+version = "1.11.0"
+
+[[deps.RelocatableFolders]]
+deps = ["SHA", "Scratch"]
+git-tree-sha1 = "ffdaf70d81cf6ff22c2b6e733c900c3321cab864"
+uuid = "05181044-ff0b-4ac5-8273-598c1e38db00"
+version = "1.0.1"
+
+[[deps.SHA]]
+uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce"
+version = "0.7.0"
+
+[[deps.Scratch]]
+deps = ["Dates"]
+git-tree-sha1 = "9b81b8393e50b7d4e6d0a9f14e192294d3b7c109"
+uuid = "6c6a2e73-6563-6170-7368-637461726353"
+version = "1.3.0"
+
+[[deps.Serialization]]
+uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
+version = "1.11.0"
+
+[[deps.Sockets]]
+uuid = "6462fe0b-24de-5631-8697-dd941f90decc"
+version = "1.11.0"
+
+[[deps.StyledStrings]]
+uuid = "f489334b-da3d-4c2e-b8f0-e476e12c162b"
+version = "1.11.0"
+
+[[deps.TOML]]
+deps = ["Dates"]
+uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76"
+version = "1.0.3"
+
+[[deps.Tar]]
+deps = ["ArgTools", "SHA"]
+uuid = "a4e569a6-e804-4fa4-b0f3-eef7a1d5b13e"
+version = "1.10.0"
+
+[[deps.UUIDs]]
+deps = ["Random", "SHA"]
+uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
+version = "1.11.0"
+
+[[deps.Unicode]]
+uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5"
+version = "1.11.0"
+
+[[deps.Zlib_jll]]
+deps = ["Libdl"]
+uuid = "83775a58-1f1d-513f-b197-d71354ab007a"
+version = "1.3.1+2"
+
+[[deps.nghttp2_jll]]
+deps = ["Artifacts", "Libdl"]
+uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d"
+version = "1.64.0+1"
+
+[[deps.p7zip_jll]]
+deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl"]
+uuid = "3f19e933-33d8-53b3-aaab-bd5110c3b7a0"
+version = "17.7.0+0"
diff --git a/MoJuWoPreprocessor/Project.toml b/MoJuWoPreprocessor/Project.toml
new file mode 100644
index 0000000..70d4ff0
--- /dev/null
+++ b/MoJuWoPreprocessor/Project.toml
@@ -0,0 +1,36 @@
+name = "MoJuWoPreprocessor"
+uuid = "6baad647-a534-4edf-86c6-a371ed53b1b6"
+version = "0.1.0"
+authors = ["Adrian Hill "]
+
+[deps]
+ANSIColoredPrinters = "a4c015fc-c6ff-483c-b24f-f7ea428134e9"
+IOCapture = "b5f81e59-6552-4d32-b1f0-c071b021bf89"
+Logging = "56ddb016-857b-54e1-b83d-db4d58db5568"
+Malt = "36869731-bdee-424d-aa32-cab38c994e3b"
+Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
+REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb"
+
+[compat]
+ANSIColoredPrinters = "0.0.1"
+Aqua = "0.8"
+ExplicitImports = "1.9"
+IOCapture = "1.0.0"
+JET = "0.12"
+Logging = "1.11"
+Malt = "1.3"
+Pkg = "1.12"
+REPL = "1.11"
+Test = "1.11"
+julia = "1.12"
+
+[apps.zola-preprocess]
+
+[extras]
+Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
+ExplicitImports = "7d51a73a-1435-4ff3-83d9-f097790105c7"
+JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+
+[targets]
+test = ["Aqua", "ExplicitImports", "JET", "Test"]
diff --git a/MoJuWoPreprocessor/README.md b/MoJuWoPreprocessor/README.md
new file mode 100644
index 0000000..b1aa4a8
--- /dev/null
+++ b/MoJuWoPreprocessor/README.md
@@ -0,0 +1,102 @@
+# MoJuWoPreprocessor
+
+Executes [Xranklin](https://github.com/tlienart/Xranklin.jl)-style code fences
+and emits Zola-ready markdown with ANSI-colored `
` blocks.
+Everything that is not an executable fence passes through untouched.
+
+The authoring syntax extends Xranklin's:
+
+- ` ```>name ` julia mode: expressions are echoed and evaluated one by one,
+ like the REPL, with `ans` support and trailing-`;` result suppression;
+- ` ```?name ` help mode (`REPL.helpmode`);
+- ` ```]name ` pkg mode (`Pkg.REPLMode.pkgstr`);
+- ` ```;name ` shell mode (`sh -c`);
+- ` ```!name ` plain mode: code is included silently and shown as a regular
+ ` ```julia ` block; `# hideall` hides the whole block, a trailing `# hide`
+ hides single lines.
+
+One extension over Xranklin: a fence whose code is *supposed* to error must
+say so with an ` allow-error` flag, e.g. ` ```>name allow-error `. The error
+then renders REPL-style like any other output (and the build warns if the
+fence stops erroring, so stale flags cannot linger). Without the flag, an
+erroring fence still renders REPL-style but fails the build β see
+"Strictness" below.
+
+Named fences share one sandbox module per page.
+Every page executes on its own persistent [Malt.jl](https://github.com/JuliaPluto/Malt.jl) worker process,
+so pages cannot leak loaded packages, package-extension triggers, or global state into each other.
+Because pages are isolated, they are also preprocessed concurrently,
+so a full build takes about as long as its slowest page.
+The worker's load path is fixed at spawn:
+the page's own environment first (if the page's directory contains a `Project.toml`),
+then the preprocessor's environment, then the standard library β
+each page sees exactly the environment its `Project.toml` declares.
+Fences execute with the working directory set to a per-page scratch directory under the (gitignored) `_workdir/`,
+so relative paths in fences never touch the repository.
+
+## Usage
+
+The package follows the [Julia app](https://pkgdocs.julialang.org/v1/apps/) conventions;
+run it from the repository root:
+
+```bash
+julia --project=MoJuWoPreprocessor -m MoJuWoPreprocessor
+```
+
+(On Julia 1.11, which lacks `-m`, substitute `MoJuWoPreprocessor/main.jl` for `-m MoJuWoPreprocessor`.)
+
+Commands:
+
+- `preprocess ` executes every `*.md` page under ``
+ and writes it to the same relative path under ``.
+ For this site that is `src` (the authored pages) and `content`
+ (the generated, gitignored directory that Zola builds).
+- `serve` preprocesses `src/` into `content/`, starts `zola serve`,
+ and then watches the pages under `src/`:
+ saving one re-preprocesses just that page, which Zola's own watcher picks up for live reload.
+ Because each page's worker process stays warm across re-preprocesses,
+ packages loaded on the first pass stay loaded,
+ so re-processing a page takes seconds instead of a cold start.
+ Note that warm re-runs share the worker's global state with earlier runs of the same page;
+ the cold build remains the source of truth.
+- `build` / `check` preprocess `src/` into `content/`, then run the corresponding Zola command.
+ `build` additionally indexes the rendered site for full-text search with
+ [Pagefind](https://pagefind.app) when `pagefind` is on the PATH, and only
+ warns when it is not.
+- `clean` removes `content/`, `public/` and the workdir.
+
+Options:
+
+- `--only ` (repeatable) restricts preprocessing to pages whose
+ source path ends with the given path β fast iteration on a single page:
+
+ ```bash
+ julia --project=MoJuWoPreprocessor -m MoJuWoPreprocessor preprocess src content --only src/writing/index.md
+ ```
+
+- `--workdir ` sets the scratch directory fences run in
+ (default: `./_workdir`).
+- Everything after `--` is passed to the Zola command, e.g.
+ `serve -- --port 1112 --open`.
+
+The Zola-backed commands (`serve`, `build`, `check`) expect `zola` on the
+PATH and must run from the repository root (next to `zola.toml`). CI runs
+`preprocess` and then calls `zola build`/`zola check`/`pagefind` directly so
+the pages are only executed once.
+
+## Strictness
+
+Two kinds of broken fence are distinguished:
+
+- **Broken markdown** β an unclosed fence, or an executable fence with
+ trailing junk (say, a misspelled flag) β always aborts with an error
+ naming the page and line. There is no legitimate page that contains one.
+- **Fence code that errors** renders the error REPL-style in the output and
+ is summarized at the end of the run. `preprocess`, `build` and `check`
+ then exit non-zero unless the fence is marked ` allow-error`; `serve` only
+ reports and keeps the dev server running, since broken intermediate saves
+ are normal while editing.
+ This covers thrown exceptions and error-level log messages alike:
+ fence code that emits an `@error` without throwing β
+ as Base does when a package extension fails to load β
+ fails the build just the same.
diff --git a/MoJuWoPreprocessor/src/MoJuWoPreprocessor.jl b/MoJuWoPreprocessor/src/MoJuWoPreprocessor.jl
new file mode 100644
index 0000000..638e795
--- /dev/null
+++ b/MoJuWoPreprocessor/src/MoJuWoPreprocessor.jl
@@ -0,0 +1,25 @@
+"""
+ MoJuWoPreprocessor
+
+Preprocessor for modernjuliaworkflows: replays Xranklin's executable code
+fences and emits ANSI-colored HTML, turning the authored markdown in `src/`
+into plain markdown that Zola can build. See `MoJuWoPreprocessor/README.md`
+for the fence modes and the CLI.
+"""
+module MoJuWoPreprocessor
+
+using ANSIColoredPrinters: HTMLPrinter
+using IOCapture: IOCapture
+using Logging: Logging, current_logger, with_logger
+using Malt: Malt
+using Pkg: Pkg
+using REPL: REPL
+
+export process_page, process_tree
+
+include("render.jl")
+include("modes.jl")
+include("page.jl")
+include("cli.jl")
+
+end # module MoJuWoPreprocessor
diff --git a/MoJuWoPreprocessor/src/cli.jl b/MoJuWoPreprocessor/src/cli.jl
new file mode 100644
index 0000000..5bd421a
--- /dev/null
+++ b/MoJuWoPreprocessor/src/cli.jl
@@ -0,0 +1,388 @@
+# CLI: `preprocess` walks the source tree, processes every markdown page,
+# mirrors the result into the output directory, and summarizes fence errors
+# at the end. `serve`/`build`/`check` preprocess and then drive Zola;
+# `serve` additionally watches the source pages and re-preprocesses on change,
+# and `build` finishes by indexing the rendered site for search.
+
+# Each page executes on its own persistent worker process, so nothing β
+# loaded packages, extension triggers, redefined methods, global state β
+# leaks between pages. Keyed by the page's absolute source path (not its
+# relative path, which distinct trees like the site and the test fixtures
+# could collide on).
+const PAGE_WORKERS = Dict{String, Malt.Worker}()
+
+# The worker's load path is a static environment stack, fixed at spawn:
+# the page environment first (only if the page directory declares one),
+# then the preprocessor's own environment so the worker can load
+# MoJuWoPreprocessor, then the standard library. The page environment being
+# the primary project also makes the `pkg>` prompt carry the page's name.
+function spawn_page_worker(pagedir::AbstractString)
+ # The leading `@` expands to nothing in the worker itself (no project is
+ # ever explicitly activated there), but julia subprocesses spawned by
+ # fence code inherit `JULIA_LOAD_PATH`, and without `@` their
+ # `--project` would not be on their own load path (Aqua's
+ # persistent_tasks check precompiles a wrapper package that way).
+ stack = String["@"]
+ isfile(joinpath(pagedir, "Project.toml")) && push!(stack, abspath(pagedir))
+ host_project = Base.active_project()
+ host_project === nothing || push!(stack, dirname(host_project))
+ push!(stack, "@stdlib")
+ w = Malt.Worker(;
+ env = [
+ "JULIA_LOAD_PATH=" * join(stack, Sys.iswindows() ? ";" : ":"),
+ # Keep Pkg from precompiling mid-page; CI precompiles the
+ # environments up front and locally it only causes noise in the
+ # captured fence output.
+ "JULIA_PKG_PRECOMPILE_AUTO=0",
+ ]
+ )
+ Malt.remote_eval_wait(
+ Main, w, quote
+ import Pkg
+ Pkg.instantiate(; io = devnull)
+ import MoJuWoPreprocessor
+ end
+ )
+ return w
+end
+
+function page_worker(src::AbstractString)
+ w = get(PAGE_WORKERS, src, nothing)
+ w !== nothing && Malt.isrunning(w) && return w
+ return PAGE_WORKERS[src] = spawn_page_worker(dirname(src))
+end
+
+"""
+ stop_page_workers()
+
+Stop every page worker and empty the registry. Workers also die with the
+host process; the explicit stop keeps command exits tidy.
+"""
+function stop_page_workers()
+ for w in values(PAGE_WORKERS)
+ Malt.isrunning(w) && Malt.stop(w)
+ end
+ empty!(PAGE_WORKERS)
+ return nothing
+end
+
+# Worker-side entry point: Malt transports exceptions as printed messages,
+# so a `FenceSyntaxError` must travel as a value for `process_tree` to
+# rethrow it typed on the host side.
+function worker_process_page(
+ text::AbstractString, relpath::AbstractString,
+ pagedir::AbstractString, workdir::AbstractString
+ )
+ try
+ output, errors = process_page(text, relpath; pagedir, workdir)
+ return (:ok, output, errors)
+ catch err
+ err isa FenceSyntaxError || rethrow()
+ return (:fence_syntax_error, err.page, err.line, err.message)
+ end
+end
+
+"""
+ process_tree(srcdir, outdir; workdir, only = String[]) -> failures
+
+Process every `*.md` under `srcdir` into the same relative path under
+`outdir`, each page on its own persistent worker process (spawned lazily,
+reused while it lives) so pages cannot leak loaded packages or global
+state into each other. That isolation also lets all pages run
+concurrently; outputs are written in sorted page order once every page
+has finished. `only` restricts the run to pages whose source path ends
+with one of the given paths. Returns a `Dict` mapping page paths to
+their unsanctioned fence errors (errors in fences not marked
+`allow-error`); the CLI build commands fail on those, `serve` only
+reports them. A structurally broken fence throws a
+[`FenceSyntaxError`](@ref) for the first broken page in sorted order.
+"""
+function process_tree(
+ srcdir::AbstractString, outdir::AbstractString;
+ workdir::AbstractString = joinpath(pwd(), "_workdir"),
+ only::Vector{String} = String[]
+ )
+ pages = String[]
+ for (root, _dirs, files) in walkdir(srcdir), f in files
+ endswith(f, ".md") && push!(pages, relpath(joinpath(root, f), srcdir))
+ end
+ sort!(pages)
+ if !isempty(only)
+ wanted = normpath.(only)
+ pages = [
+ p for p in pages if
+ any(w -> endswith(normpath(joinpath(srcdir, p)), w), wanted)
+ ]
+ isempty(pages) && error("--only matched no pages: ", join(only, ", "))
+ end
+ mkpath(workdir)
+ failures = Dict{String, Vector{FenceError}}()
+ @info "β±οΈ Preprocessing Julia code blocks. \nThis may take a minute (subsequent evaluations will be faster)."
+ start = time()
+ # Worker isolation makes the pages independent, so they run
+ # concurrently: each task only blocks on its worker's IO, the workers
+ # do the actual work in parallel.
+ results = Vector{Any}(undef, length(pages))
+ @sync for (i, rel) in enumerate(pages)
+ @async begin
+ src = abspath(joinpath(srcdir, rel))
+ w = page_worker(src)
+ # `remote_eval_fetch` rather than `remote_call_fetch`:
+ # evaluation runs in the worker's latest world age, which the
+ # entry point β imported after the worker's serve loop
+ # started β requires.
+ results[i] = Malt.remote_eval_fetch(
+ Main, w,
+ :(
+ $worker_process_page(
+ $(read(src, String)), $rel, $(dirname(src)), $(abspath(workdir))
+ )
+ )
+ )
+ # The pid matches the `[Worker ]:` prefix Malt puts on
+ # forwarded worker output.
+ @info "...completed $rel [Worker $(w.proc_pid)]"
+ end
+ end
+ for (i, rel) in enumerate(pages)
+ result = results[i]
+ if first(result) === :fence_syntax_error
+ _, page, line, message = result
+ throw(FenceSyntaxError(page, line, message))
+ end
+ _, output, errors = result
+ dst = joinpath(outdir, rel)
+ mkpath(dirname(dst))
+ write(dst, output)
+ isempty(errors) || (failures[rel] = errors)
+ end
+ n = length(pages)
+ @info "β Preprocessing completed in $(round(time() - start; digits = 1))s"
+ for (rel, errors) in sort!(collect(failures); by = first)
+ for e in errors
+ @error "fence errored" page = rel fence = e.label e.message
+ end
+ end
+ return failures
+end
+
+# Strict pass for the build commands: a `FenceSyntaxError` aborts with a
+# clean message and unsanctioned fence errors fail the run, both as exit
+# code 1.
+function preprocess_strict(
+ srcdir::AbstractString, outdir::AbstractString;
+ workdir::AbstractString, only::Vector{String}
+ )
+ failures = try
+ process_tree(srcdir, outdir; workdir, only)
+ catch err
+ err isa FenceSyntaxError || rethrow()
+ println(stderr, sprint(showerror, err))
+ return 1
+ end
+ isempty(failures) && return 0
+ n = sum(length, values(failures))
+ println(
+ stderr,
+ "$n fence error(s); fix them or mark intentional error demos with `allow-error`"
+ )
+ return 1
+end
+
+"""
+ page_mtimes(srcdir) -> Dict{String,Float64}
+
+Map every `*.md` page under `srcdir` (as a path relative to it) to its mtime.
+"""
+function page_mtimes(srcdir::AbstractString)
+ times = Dict{String, Float64}()
+ for (root, _dirs, files) in walkdir(srcdir), f in files
+ endswith(f, ".md") || continue
+ path = joinpath(root, f)
+ times[relpath(path, srcdir)] = mtime(path)
+ end
+ return times
+end
+
+"""
+ serve(srcdir, outdir; workdir, only = String[], zola_args = String[]) -> exit code
+
+Preprocess the tree, start `zola serve`, then poll the source pages and
+re-preprocess any page whose mtime changes. Zola's own watcher sees the
+updated output and live-reloads the browser. Page workers stay warm across
+re-preprocesses, so saving a page re-runs it in seconds instead of a cold
+start. Runs until `zola serve` exits (propagating its exit code) or Ctrl-C
+stops both processes. Unlike the build commands, fence errors only get
+reported here β a dev server should survive broken intermediate states.
+"""
+function serve(
+ srcdir::AbstractString, outdir::AbstractString;
+ workdir::AbstractString, only::Vector{String} = String[],
+ zola_args::Vector{String} = String[], interval::Real = 0.5
+ )
+ # Snapshot before the initial pass so pages edited while it runs are
+ # caught by the first poll rather than silently absorbed.
+ mtimes = page_mtimes(srcdir)
+ process_tree(srcdir, outdir; workdir, only)
+ zola = run(pipeline(`zola serve $zola_args`; stdout, stderr); wait = false)
+ @info "watching $srcdir; saving a page re-preprocesses it (Ctrl-C stops)"
+ # Without this, SIGINT kills Julia before the finally can reap Zola.
+ Base.exit_on_sigint(false)
+ interrupted = false
+ try
+ while process_running(zola)
+ sleep(interval)
+ current = page_mtimes(srcdir)
+ changed = sort!([page for (page, t) in current if get(mtimes, page, 0.0) != t])
+ mtimes = current
+ isempty(changed) && continue
+ try
+ process_tree(srcdir, outdir; workdir, only = changed)
+ catch err
+ # Keep the server alive: broken intermediate saves are normal.
+ @error "re-preprocess failed" pages = changed exception = (err, catch_backtrace())
+ end
+ end
+ catch err
+ err isa InterruptException || rethrow()
+ interrupted = true
+ finally
+ Base.exit_on_sigint(true)
+ process_running(zola) && kill(zola)
+ stop_page_workers()
+ end
+ return interrupted ? 0 : zola.exitcode
+end
+
+"""
+ index_search() -> exit code
+
+Build the Pagefind search bundle into `public/pagefind/` by crawling the
+rendered site; `js/search.js` loads it for the full-text result tier.
+`pagefind` is optional locally (CI installs a pinned version), so a missing
+binary only warns: the site works without it, minus full-text search.
+"""
+function index_search()
+ if isnothing(Sys.which("pagefind"))
+ @warn "`pagefind` not found on PATH; skipping the search index (https://pagefind.app)"
+ return 0
+ end
+ return success(run(ignorestatus(`pagefind --site public`))) ? 0 : 1
+end
+
+const USAGE = """
+usage: julia --project=MoJuWoPreprocessor -m MoJuWoPreprocessor [options]
+ (on Julia 1.11, use `MoJuWoPreprocessor/main.jl` instead of `-m MoJuWoPreprocessor`)
+
+commands:
+ preprocess execute every markdown page under
+ into the same relative path under
+ serve preprocess src/ into content/, run `zola serve`,
+ and re-preprocess pages as they change
+ build preprocess src/ into content/, then `zola build`
+ and index the site for search with `pagefind`
+ check preprocess src/ into content/, then `zola check`
+ clean remove content/, public/ and the workdir
+
+options:
+ --only (repeatable) restrict preprocessing to pages whose source
+ path ends with the given path
+ --workdir scratch directory fences run in (default: ./_workdir)
+ -- ... pass everything after `--` to the Zola command,
+ e.g. `serve -- --port 1112 --open`
+
+The Zola-backed commands must run from the repository root (next to zola.toml).
+See MoJuWoPreprocessor/README.md for details."""
+
+# Entry point following the Julia app conventions
+# (https://pkgdocs.julialang.org/v1/apps/): on Julia 1.12+ this runs via
+# `julia -m MoJuWoPreprocessor`; on 1.11 use main.jl. Deliberately not exported β
+# an exported `@main` would also run after `Pkg.test`, with an empty ARGS.
+function (@main)(args::Vector{String})
+ positional = String[]
+ only = String[]
+ zola_args = String[]
+ workdir = joinpath(pwd(), "_workdir")
+ i = 1
+ while i <= length(args)
+ a = args[i]
+ if a == "--"
+ append!(zola_args, args[(i + 1):end])
+ break
+ elseif a == "--only" && i < length(args)
+ only = push!(only, args[i += 1])
+ elseif startswith(a, "--only=")
+ push!(only, chopprefix(a, "--only="))
+ elseif a == "--workdir" && i < length(args)
+ workdir = args[i += 1]
+ elseif startswith(a, "--workdir=")
+ workdir = chopprefix(a, "--workdir=")
+ elseif a in ("-h", "--help")
+ println(USAGE)
+ return 0
+ elseif startswith(a, "-")
+ println(stderr, "unknown option: $a\n\n$USAGE")
+ return 2
+ else
+ push!(positional, a)
+ end
+ i += 1
+ end
+ workdir = String(workdir)
+ if isempty(positional)
+ println(stderr, USAGE)
+ return 2
+ end
+ command = popfirst!(positional)
+ if command in ("preprocess", "clean") && !isempty(zola_args)
+ println(stderr, "`$command` does not take `--` arguments\n\n$USAGE")
+ return 2
+ end
+ if command == "preprocess"
+ if length(positional) != 2
+ println(stderr, "`preprocess` expects \n\n$USAGE")
+ return 2
+ end
+ code = preprocess_strict(positional[1], positional[2]; workdir, only)
+ stop_page_workers()
+ return code
+ end
+ if !(command in ("serve", "build", "check", "clean"))
+ println(stderr, "unknown command: $command\n\n$USAGE")
+ return 2
+ end
+ if !isempty(positional)
+ println(stderr, "unexpected argument for `$command`: $(positional[1])\n\n$USAGE")
+ return 2
+ end
+ if command == "clean"
+ for dir in ("content", "public", workdir)
+ rm(dir; force = true, recursive = true)
+ end
+ return 0
+ end
+ if !isfile("zola.toml")
+ println(stderr, "`$command` must run from the repository root (no zola.toml in $(pwd()))")
+ return 2
+ end
+ if isnothing(Sys.which("zola"))
+ println(stderr, "`zola` not found on PATH; see https://www.getzola.org/documentation/getting-started/installation/")
+ return 2
+ end
+ if command == "serve"
+ try
+ return serve("src", "content"; workdir, only, zola_args)
+ catch err
+ # Only the initial pass throws; while watching, syntax errors in
+ # intermediate saves are caught and reported without stopping.
+ err isa FenceSyntaxError || rethrow()
+ println(stderr, sprint(showerror, err))
+ return 1
+ end
+ end
+ code = preprocess_strict("src", "content"; workdir, only)
+ stop_page_workers()
+ code == 0 || return code
+ success(run(ignorestatus(`zola $command $zola_args`))) || return 1
+ return command == "build" ? index_search() : 0
+end
diff --git a/MoJuWoPreprocessor/src/modes.jl b/MoJuWoPreprocessor/src/modes.jl
new file mode 100644
index 0000000..b479aaa
--- /dev/null
+++ b/MoJuWoPreprocessor/src/modes.jl
@@ -0,0 +1,286 @@
+# Fence execution: one sandbox module per page, four REPL modes plus plain
+# `!` blocks, rendered as the REPL would show them.
+
+const HIDEALL_RE = r"^\s*#\s*hideall\s*$"i
+const HIDE_RE = r"#\s*hide\s*$"i
+
+struct FenceError
+ label::String
+ message::String
+end
+
+mutable struct PageContext
+ mod::Module
+ relpath::String
+ pagedir::String
+ errors::Vector{FenceError}
+end
+
+function make_sandbox(relpath::AbstractString)
+ name = Symbol("MJW_", replace(first(splitext(relpath)), r"[^A-Za-z0-9]+" => "_"))
+ # Evaluating a `module` expression (as SafeTestsets does) gives the sandbox
+ # the standard `eval`/`include` definitions, which a raw `Module()` lacks.
+ return Core.eval(
+ Main, Expr(
+ :module, true, name,
+ Expr(:block, :(ans = nothing))
+ )
+ )::Module
+end
+
+# Pkg warns when its REPL mode is driven programmatically; drop that noise
+# but let everything else (e.g. `@warn` in page code) through.
+struct PkgWarningFilter <: Logging.AbstractLogger
+ parent::Logging.AbstractLogger
+end
+Logging.min_enabled_level(l::PkgWarningFilter) = Logging.min_enabled_level(l.parent)
+Logging.shouldlog(l::PkgWarningFilter, args...) = Logging.shouldlog(l.parent, args...)
+Logging.catch_exceptions(l::PkgWarningFilter) = Logging.catch_exceptions(l.parent)
+function Logging.handle_message(
+ l::PkgWarningFilter, level, message, _module, group, id,
+ file, line; kwargs...
+ )
+ occursin("intended for interactive use", string(message)) && return nothing
+ return Logging.handle_message(
+ l.parent, level, message, _module, group, id,
+ file, line; kwargs...
+ )
+end
+
+# Fence code can log an error without throwing one β
+# Base does exactly that when a package extension fails to load β
+# and IOCapture reports no error for those.
+# Recording error-level log messages lets such fences fail the build
+# like thrown errors do (and `allow-error` sanction them alike).
+struct ErrorLogRecorder <: Logging.AbstractLogger
+ parent::Logging.AbstractLogger
+ messages::Vector{String}
+end
+Logging.min_enabled_level(l::ErrorLogRecorder) = Logging.min_enabled_level(l.parent)
+Logging.shouldlog(l::ErrorLogRecorder, args...) = Logging.shouldlog(l.parent, args...)
+Logging.catch_exceptions(l::ErrorLogRecorder) = Logging.catch_exceptions(l.parent)
+function Logging.handle_message(
+ l::ErrorLogRecorder, level, message, _module, group, id,
+ file, line; kwargs...
+ )
+ level >= Logging.Error && push!(l.messages, string(message))
+ return Logging.handle_message(
+ l.parent, level, message, _module, group, id,
+ file, line; kwargs...
+ )
+end
+
+# IOCapture merges stdout/stderr and installs a ConsoleLogger on the captured
+# stream; io_context forces :color so the output carries ANSI codes even in
+# non-interactive builds.
+function capture(f)
+ logged = String[]
+ c = IOCapture.capture(;
+ rethrow = InterruptException, color = true,
+ io_context = [:color => true]
+ ) do
+ with_logger(ErrorLogRecorder(PkgWarningFilter(current_logger()), logged)) do
+ f()
+ end
+ end
+ return (; c.value, c.output, c.error, c.backtrace, logged_errors = logged)
+end
+
+# The context the REPL displays results with: truncated arrays, unqualified
+# names for things defined in the page's own module.
+displayctx(io::IO, mod::Module) =
+ IOContext(io, :color => true, :limit => true, :displaysize => (24, 80), :module => mod)
+
+unwrap_load_error(err) = err isa LoadError ? err.error : err
+
+# REPL-style error line, without the (path-dependent) stacktrace.
+function print_repl_error(io::IO, err, mod::Module)
+ ctx = displayctx(io, mod)
+ printstyled(ctx, "ERROR: "; color = Base.error_color(), bold = true)
+ showerror(ctx, err)
+ println(io)
+ return nothing
+end
+
+function record_error!(ctx::PageContext, label::AbstractString, err)
+ message = first(split(sprint(showerror, err), '\n'))
+ push!(ctx.errors, FenceError(String(label), message))
+ return nothing
+end
+
+function record_logged_errors!(
+ ctx::PageContext, label::AbstractString, messages::Vector{String}
+ )
+ for msg in messages
+ message = string("error-level log: ", first(split(msg, '\n')))
+ push!(ctx.errors, FenceError(String(label), message))
+ end
+ return nothing
+end
+
+# Captured output, normalized to end on a line boundary.
+function print_captured(io::IO, output::AbstractString)
+ isempty(output) && return nothing
+ print(io, output)
+ endswith(output, '\n') || println(io)
+ return nothing
+end
+
+"""
+ split_toplevel(code; filename) -> Vector{(source, exprs)}
+
+Split fence code into REPL inputs: one per top-level expression, grouped by
+starting line so `a = 1; b = 2` stays a single input, with the verbatim
+source lines attached for the prompt echo.
+"""
+function split_toplevel(code::AbstractString; filename::AbstractString = "REPL")
+ ex = Meta.parseall(String(code); filename = String(filename))
+ if !(ex isa Expr && ex.head === :toplevel)
+ return [(source = String(strip(code)), exprs = Any[ex])]
+ end
+ lines = split(code, '\n')
+ starts = Int[]
+ groups = Vector{Any}[]
+ cur = 1
+ for arg in ex.args
+ if arg isa LineNumberNode
+ cur = arg.line
+ elseif !isempty(groups) && starts[end] == cur
+ push!(groups[end], arg)
+ else
+ push!(starts, cur)
+ push!(groups, Any[arg])
+ end
+ end
+ out = @NamedTuple{source::String, exprs::Vector{Any}}[]
+ for k in eachindex(groups)
+ lo = starts[k]
+ hi = k < lastindex(groups) ? starts[k + 1] - 1 : length(lines)
+ seg = lines[lo:min(hi, length(lines))]
+ while !isempty(seg) && isempty(strip(last(seg)))
+ pop!(seg)
+ end
+ push!(out, (source = join(seg, '\n'), exprs = groups[k]))
+ end
+ return out
+end
+
+# `>` mode: echo and evaluate expression by expression; update `ans`; a
+# trailing `;` or a `nothing` result suppresses the display, stdout does not.
+function exec_julia(ctx::PageContext, code::AbstractString, label::AbstractString)
+ io = IOBuffer()
+ for (k, group) in enumerate(split_toplevel(code))
+ k > 1 && println(io)
+ print(io, julia_prompt(), group.source, '\n')
+ suppress = REPL.ends_with_semicolon(group.source)
+ for (j, ex) in enumerate(group.exprs)
+ c = capture(() -> Core.eval(ctx.mod, ex))
+ print_captured(io, c.output)
+ record_logged_errors!(ctx, label, c.logged_errors)
+ if c.error
+ err = unwrap_load_error(c.value)
+ print_repl_error(io, err, ctx.mod)
+ record_error!(ctx, label, err)
+ break
+ end
+ Core.eval(ctx.mod, Expr(:(=), :ans, QuoteNode(c.value)))
+ if j == lastindex(group.exprs) && !suppress && c.value !== nothing
+ show(displayctx(io, ctx.mod), MIME"text/plain"(), c.value)
+ println(io)
+ end
+ end
+ end
+ return String(take!(io))
+end
+
+# `?` mode: the docstring is looked up in the page module (so packages loaded
+# by the page and functions defined on it are found) and rendered as the REPL
+# renders it, ANSI colors included.
+function exec_help(ctx::PageContext, code::AbstractString, label::AbstractString)
+ io = IOBuffer()
+ mod = ctx.mod
+ for (k, query) in enumerate(filter(!isempty, map(strip, split(code, '\n'))))
+ k > 1 && println(io)
+ print(io, help_prompt(), query, '\n')
+ c = capture() do
+ expr = if hasmethod(REPL.helpmode, Tuple{IO, String, Module})
+ REPL.helpmode(devnull, String(query), mod)
+ else
+ REPL.helpmode(devnull, String(query))
+ end
+ doc = Core.eval(mod, expr)
+ show(displayctx(stdout, mod), MIME"text/plain"(), doc)
+ println()
+ end
+ print_captured(io, c.output)
+ record_logged_errors!(ctx, label, c.logged_errors)
+ if c.error
+ err = unwrap_load_error(c.value)
+ print_repl_error(io, err, mod)
+ record_error!(ctx, label, err)
+ end
+ end
+ return String(take!(io))
+end
+
+# `]` mode: one command per line, echoed behind the environment-aware prompt.
+function exec_pkg(ctx::PageContext, code::AbstractString, label::AbstractString)
+ io = IOBuffer()
+ for (k, cmd) in enumerate(filter(!isempty, map(strip, split(code, '\n'))))
+ k > 1 && println(io)
+ print(io, pkg_prompt(), cmd, '\n')
+ c = capture(() -> Pkg.REPLMode.pkgstr(String(cmd)))
+ print_captured(io, c.output)
+ record_logged_errors!(ctx, label, c.logged_errors)
+ if c.error
+ err = unwrap_load_error(c.value)
+ print_repl_error(io, err, ctx.mod)
+ record_error!(ctx, label, err)
+ end
+ end
+ return String(take!(io))
+end
+
+# `;` mode: run through `sh -c` from the preprocessor's working directory,
+# stdout and stderr merged, non-zero exit codes ignored like in the REPL.
+function exec_shell(ctx::PageContext, code::AbstractString, label::AbstractString)
+ io = IOBuffer()
+ for (k, cmd) in enumerate(filter(!isempty, map(strip, split(code, '\n'))))
+ k > 1 && println(io)
+ print(io, shell_prompt(), cmd, '\n')
+ buf = IOBuffer()
+ try
+ run(pipeline(ignorestatus(`sh -c $cmd`); stdout = buf, stderr = buf))
+ catch err
+ print_repl_error(buf, err, ctx.mod)
+ record_error!(ctx, label, err)
+ end
+ print_captured(io, String(take!(buf)))
+ end
+ return String(take!(io))
+end
+
+# `!` mode: include silently, show the code as a plain ```julia block (minus
+# `# hide` lines; `# hideall` hides the block entirely) and stdout, if any,
+# as an output block. Results are never displayed, matching Xranklin.
+function exec_plain(ctx::PageContext, code::AbstractString, label::AbstractString)
+ lines = split(code, '\n')
+ hideall = any(l -> occursin(HIDEALL_RE, l), lines)
+ c = capture(() -> include_string(ctx.mod, code, String(label)))
+ record_logged_errors!(ctx, label, c.logged_errors)
+ err = c.error ? unwrap_load_error(c.value) : nothing
+ err === nothing || record_error!(ctx, label, err)
+ hideall && return ("", "")
+ visible = [l for l in lines if !occursin(HIDE_RE, l)]
+ while !isempty(visible) && isempty(strip(first(visible)))
+ popfirst!(visible)
+ end
+ while !isempty(visible) && isempty(strip(last(visible)))
+ pop!(visible)
+ end
+ code_md = isempty(visible) ? "" : string("```julia\n", join(visible, '\n'), "\n```")
+ out = IOBuffer()
+ print_captured(out, c.output)
+ err === nothing || print_repl_error(out, err, ctx.mod)
+ return (code_md, String(take!(out)))
+end
diff --git a/MoJuWoPreprocessor/src/page.jl b/MoJuWoPreprocessor/src/page.jl
new file mode 100644
index 0000000..a178988
--- /dev/null
+++ b/MoJuWoPreprocessor/src/page.jl
@@ -0,0 +1,178 @@
+# Page processing: scan the authored markdown, execute fences, and pass
+# everything else through untouched.
+
+# A structurally broken fence: unclosed, or an executable fence with trailing
+# junk. These always abort the page (and thereby fail the build) β unlike
+# fence *code* that errors, which is reported via `PageContext.errors`.
+struct FenceSyntaxError <: Exception
+ page::String
+ line::Int
+ message::String
+end
+Base.showerror(io::IO, e::FenceSyntaxError) =
+ print(io, e.page, ":", e.line, ": ", e.message)
+
+# Executable fences: exactly three backticks, a mode character, and a name
+# that may contain any non-space characters (`$`, `-`, ...) or be empty.
+# An ` allow-error` flag marks a fence whose code is expected to error: the
+# error renders REPL-style instead of failing the build.
+const EXEC_FENCE_RE = r"^```([!>?\];])(\S*)(?:\s+(allow-error))?\s*$"
+const FENCE_OPEN_RE = r"^(`{3,})(.*)$"
+const FENCE_CLOSE_RE = r"^(`{3,})\s*$"
+
+# Franklin-era build directives; handled by Xranklin, not by us. They are
+# dropped from the output (they were never content) with a warning so the
+# migration can't silently leave one behind.
+const LEGACY_LINE_RE = r"^\\(toc|activate\{\})\s*$"
+
+# Extent of the fence opened at `lines[i]`: index of its closing line and
+# whether one was found (CommonMark: the closer needs at least as many ticks).
+function fence_extent(lines::Vector{<:AbstractString}, i::Int)
+ opener = match(FENCE_OPEN_RE, lines[i])::RegexMatch
+ ticks = length(opener.captures[1]::SubString)
+ for j in (i + 1):length(lines)
+ m = match(FENCE_CLOSE_RE, lines[j])
+ m !== nothing && length(m.captures[1]::SubString) >= ticks && return (j, true)
+ end
+ return (length(lines), false)
+end
+
+# Raw-HTML blocks are preceded by a blank line so they stay standalone HTML
+# blocks even where a fence directly follows a paragraph. Content markdown is
+# Tera-templated in Zola 0.23, and fence output can print `{{`/`{%`/`{#`, so
+# every emitted block is wrapped in `{% raw %}` to keep Tera out of it.
+function emit_html_block!(out::IOBuffer, html::AbstractString)
+ isempty(html) && return nothing
+ println(out)
+ println(out, "{% raw %}")
+ println(out, html)
+ println(out, "{% endraw %}")
+ return nothing
+end
+
+function emit_exec_fence!(
+ out::IOBuffer, ctx::PageContext, mode::Char, name::String,
+ code::String, allow_error::Bool
+ )
+ label = isempty(name) ? string(mode) : name
+ nerrors = length(ctx.errors)
+ if mode == '!'
+ code_md, output = exec_plain(ctx, code, label)
+ if !isempty(code_md)
+ println(out)
+ println(out, code_md)
+ end
+ emit_html_block!(out, repl_block_html(output; class = "code-output ansi"))
+ else
+ ansi = mode == '>' ? exec_julia(ctx, code, label) :
+ mode == '?' ? exec_help(ctx, code, label) :
+ mode == ']' ? exec_pkg(ctx, code, label) :
+ exec_shell(ctx, code, label)
+ emit_html_block!(out, repl_block_html(ansi))
+ end
+ if allow_error
+ if length(ctx.errors) > nerrors
+ resize!(ctx.errors, nerrors)
+ else
+ @warn "fence is marked `allow-error` but did not error" page = ctx.relpath fence = label
+ end
+ end
+ return nothing
+end
+
+"""
+ process_page(text, relpath; pagedir, workdir) -> (output, errors)
+
+Execute the fences of one authored page in the current process and return
+the markdown Zola should build, plus the list of fence errors (rendered
+REPL-style in the output, and reported so the build can fail on them).
+Errors in fences marked `allow-error` are sanctioned and not reported.
+Structurally broken fences β unclosed, or an executable fence with trailing
+junk β throw a [`FenceSyntaxError`](@ref) instead.
+
+Fences run with the working directory set to the page's subdirectory of
+`workdir`, so anything they create β generated demo packages, files written
+by shell commands β lands in scratch space. The page's environment is not
+managed here: [`process_tree`](@ref) runs each page on a worker process
+whose load path is fixed to the page's environment at spawn.
+"""
+# The scan loop lives in its own function rather than a closure inside `cd`:
+# the loop counter would be boxed by the closure, hiding every type from
+# static analysis.
+function emit_page!(out::IOBuffer, ctx::PageContext, lines::Vector{SubString{String}})
+ i = 1
+ n = length(lines)
+ # Front matter passes through untouched.
+ if n >= 1 && strip(lines[1]) == "+++"
+ println(out, lines[1])
+ i = 2
+ while i <= n
+ println(out, lines[i])
+ i += 1
+ strip(lines[i - 1]) == "+++" && break
+ end
+ end
+ while i <= n
+ line = lines[i]
+ mexec = match(EXEC_FENCE_RE, line)
+ mfence = match(FENCE_OPEN_RE, line)
+ if mexec !== nothing
+ j, closed = fence_extent(lines, i)
+ closed || throw(FenceSyntaxError(ctx.relpath, i, "unclosed fence `$line`"))
+ body = lines[(i + 1):(j - 1)]
+ emit_exec_fence!(
+ out, ctx, (mexec.captures[1]::SubString)[1],
+ String(mexec.captures[2]::SubString), join(body, '\n'),
+ mexec.captures[3] !== nothing
+ )
+ i = j + 1
+ elseif mfence !== nothing
+ j, closed = fence_extent(lines, i)
+ closed || throw(FenceSyntaxError(ctx.relpath, i, "unclosed fence `$line`"))
+ info = strip(mfence.captures[2]::SubString)
+ exact = length(mfence.captures[1]::SubString) == 3
+ # A three-backtick fence whose info string starts with a mode
+ # character but did not parse as an executable fence is a typo
+ # (bad flag, stray space), not content.
+ if exact && !isempty(info) && info[1] in "!>?];"
+ throw(
+ FenceSyntaxError(
+ ctx.relpath, i,
+ "malformed executable fence `$line`; expected ``` with an optional ` allow-error` flag"
+ )
+ )
+ end
+ if exact && info == "julia-repl"
+ body = lines[(i + 1):(j - 1)]
+ emit_html_block!(out, render_static_repl(body))
+ else
+ foreach(l -> println(out, l), lines[i:j])
+ end
+ i = j + 1
+ elseif match(LEGACY_LINE_RE, strip(line)) !== nothing
+ @warn "dropping leftover Franklin directive" page = ctx.relpath line
+ i += 1
+ else
+ println(out, line)
+ i += 1
+ end
+ end
+ return nothing
+end
+
+function process_page(
+ text::AbstractString, relpath::AbstractString;
+ pagedir::AbstractString, workdir::AbstractString
+ )
+ lines = split(String(text), '\n')
+ ctx = PageContext(
+ make_sandbox(relpath), String(relpath), abspath(pagedir),
+ FenceError[]
+ )
+ out = IOBuffer()
+ pagework = normpath(joinpath(abspath(workdir), dirname(relpath)))
+ mkpath(pagework)
+ cd(() -> emit_page!(out, ctx, lines), pagework)
+ output = string(rstrip(String(take!(out)), '\n'), '\n')
+ return (output, ctx.errors)
+end
diff --git a/MoJuWoPreprocessor/src/render.jl b/MoJuWoPreprocessor/src/render.jl
new file mode 100644
index 0000000..7c04290
--- /dev/null
+++ b/MoJuWoPreprocessor/src/render.jl
@@ -0,0 +1,69 @@
+# ANSI rendering: REPL-style prompts, and conversion of ANSI text into
+# `
` blocks with the same nested .sgrNN spans Xranklin emits today.
+
+# Prompt text is wrapped in color + bold, closed by a full reset, which
+# ANSIColoredPrinters turns into ``.
+ansi_prompt(text::AbstractString, color::Union{Int, String}) =
+ string("\e[", color, "m\e[1m", text, "\e[0m ")
+
+julia_prompt() = ansi_prompt("julia>", 32)
+help_prompt() = ansi_prompt("help?>", 33)
+shell_prompt() = ansi_prompt("shell>", 31)
+
+# The pkg prompt carries the active environment's name, e.g. "(writing) pkg> ".
+# `promptf` lives in Pkg's REPL extension on Julia β₯ 1.11.
+function pkg_prompt()
+ prompt = try
+ ext = Base.get_extension(Pkg, :REPLExt)
+ ext === nothing ? Pkg.REPLMode.promptf() : ext.promptf()
+ catch
+ proj = Base.active_project()
+ string("(", proj === nothing ? "?" : basename(dirname(proj)), ") pkg> ")
+ end
+ return ansi_prompt(rstrip(prompt), 34)
+end
+
+function ansi_to_html(ansi::AbstractString, class::AbstractString)
+ printer = HTMLPrinter(IOBuffer(String(ansi)); root_class = class)
+ return sprint(show, MIME"text/html"(), printer)
+end
+
+# Wrap finished fence output; returns "" for empty output so callers skip it.
+function repl_block_html(ansi::AbstractString; class::AbstractString = "julia-repl ansi")
+ isempty(strip(ansi)) && return ""
+ endswith(ansi, '\n') || (ansi *= '\n')
+ return ansi_to_html(ansi, class)
+end
+
+# Hand-written ```julia-repl fences get their prompts colorized so they look
+# identical to executed fences (and need no `julia-repl` grammar in Zola).
+# Debugging prompts (Infiltrator, Debugger) have no slot in the 16-color
+# terminal palette; a truecolor escape renders them in the orange the
+# highlight.js setup used (#ffa657).
+const DEBUG_ORANGE = "38;2;255;166;87"
+
+const STATIC_PROMPTS = [
+ (r"^julia> ", 32),
+ (r"^help\?> ", 33),
+ (r"^shell> ", 31),
+ (r"^(?:\([^)]+\) )?pkg> ", 34),
+ (r"^infil> ", DEBUG_ORANGE),
+ (r"^\d+\|debug> ", DEBUG_ORANGE),
+ (r"^\d+\|julia> ", DEBUG_ORANGE),
+]
+
+function colorize_repl_line(line::AbstractString)
+ line = String(line)
+ for (re, color) in STATIC_PROMPTS
+ m = match(re, line)
+ m === nothing && continue
+ rest = SubString(line, 1 + ncodeunits(m.match))
+ return string(ansi_prompt(rstrip(m.match), color), rest)
+ end
+ return line
+end
+
+function render_static_repl(body::Vector{<:AbstractString})
+ ansi = join((colorize_repl_line(l) for l in body), '\n')
+ return repl_block_html(ansi)
+end
diff --git a/MoJuWoPreprocessor/test/common.jl b/MoJuWoPreprocessor/test/common.jl
new file mode 100644
index 0000000..4af51dc
--- /dev/null
+++ b/MoJuWoPreprocessor/test/common.jl
@@ -0,0 +1,16 @@
+using MoJuWoPreprocessor
+
+# Render the fixture tree into a temp directory and normalize machine-specific
+# paths so the result is comparable across machines.
+function render_fixture()
+ tmp = mktempdir()
+ srcdir = joinpath(tmp, "src")
+ outdir = joinpath(tmp, "content")
+ cp(joinpath(@__DIR__, "fixtures", "src"), srcdir)
+ failures = process_tree(srcdir, outdir; workdir = joinpath(tmp, "_workdir"))
+ got = read(joinpath(outdir, "page", "index.md"), String)
+ for p in unique([realpath(tmp), tmp])
+ got = replace(got, p => "")
+ end
+ return got, failures
+end
diff --git a/writing/Project.toml b/MoJuWoPreprocessor/test/fixtures/src/page/Project.toml
similarity index 100%
rename from writing/Project.toml
rename to MoJuWoPreprocessor/test/fixtures/src/page/Project.toml
diff --git a/MoJuWoPreprocessor/test/fixtures/src/page/index.md b/MoJuWoPreprocessor/test/fixtures/src/page/index.md
new file mode 100644
index 0000000..4b2c6d9
--- /dev/null
+++ b/MoJuWoPreprocessor/test/fixtures/src/page/index.md
@@ -0,0 +1,152 @@
++++
+title = "Fixture page"
++++
+
+Intro text with \tldr{a Franklin command left untouched}.
+
+## Julia mode
+
+```>state
+x = 21
+2x
+```
+
+State and `ans` persist across named fences:
+
+```>state-2
+ans - x
+```
+
+```>multiline
+function double(n)
+ return 2n
+end;
+
+double(4)
+
+println("printed, not returned")
+```
+
+```>display
+collect(1:8)
+```
+
+Fences marked `allow-error` render their error REPL-style instead of
+failing the build:
+
+```>error-example allow-error
+sqrt(-1)
+```
+
+The next fence has no blank line above it:
+```>tight
+1 + 1
+```
+
+## Logging
+
+```!warn-func
+function warn_func(n)
+ @warn "This is bad" n
+end
+```
+
+```>warn-repl
+warn_func(3)
+```
+
+## Help mode
+
+```!
+# hideall
+"""
+ greet(name)
+
+Print a greeting to `name`.
+"""
+greet(name) = println("Hello, ", name)
+```
+
+```?help-example
+greet
+```
+
+## Package mode
+
+```]pkg-example
+status
+```
+
+## Shell mode
+
+```;shell-example
+echo hello from the shell
+printf 'two\nlines\n'
+```
+
+Shell and julia fences share the page's scratch working directory:
+
+```;shell-write
+echo scratch > created.txt
+```
+
+```>cwd-example
+read("created.txt", String)
+```
+
+## Plain fences
+
+```!plain-output
+println("plain fence output")
+```
+
+```!hide-line
+hidden_setup = 1 # hide
+visible_line = hidden_setup + 1;
+```
+
+```!hidden
+#hideall
+hidden_value = 123
+```
+
+## Static REPL blocks
+
+```julia-repl
+julia> 1 + 1
+2
+
+(demo) pkg> st
+
+infil> @locals
+
+1|debug> n
+
+1|julia> k
+```
+
+## Passthrough
+
+```julia
+unexecuted() = "not run"
+```
+
+````markdown
+A fence inside a fence:
+
+```julia
+inner() = 1
+```
+````
+
+```bash
+echo untouched
+```
+
+Legacy Franklin directives are dropped with a warning:
+
+\toc
+
+\activate{}
+
+The end.
diff --git a/MoJuWoPreprocessor/test/linting.jl b/MoJuWoPreprocessor/test/linting.jl
new file mode 100644
index 0000000..e976187
--- /dev/null
+++ b/MoJuWoPreprocessor/test/linting.jl
@@ -0,0 +1,23 @@
+# Static analysis: package hygiene (Aqua), import hygiene (ExplicitImports),
+# and type-level error detection (JET).
+using Aqua
+using ExplicitImports
+using JET
+using Test
+using MoJuWoPreprocessor
+
+@testset "linting" begin
+ @testset "Aqua" begin
+ Aqua.test_all(MoJuWoPreprocessor)
+ end
+ @testset "ExplicitImports" begin
+ @test check_no_implicit_imports(MoJuWoPreprocessor) === nothing
+ @test check_no_stale_explicit_imports(MoJuWoPreprocessor) === nothing
+ @test check_all_explicit_imports_via_owners(MoJuWoPreprocessor) === nothing
+ @test check_all_qualified_accesses_via_owners(MoJuWoPreprocessor) === nothing
+ @test check_no_self_qualified_accesses(MoJuWoPreprocessor) === nothing
+ end
+ @testset "JET" begin
+ JET.test_package(MoJuWoPreprocessor; target_modules = (MoJuWoPreprocessor,))
+ end
+end
diff --git a/MoJuWoPreprocessor/test/references/page.md b/MoJuWoPreprocessor/test/references/page.md
new file mode 100644
index 0000000..dfc039f
--- /dev/null
+++ b/MoJuWoPreprocessor/test/references/page.md
@@ -0,0 +1,200 @@
++++
+title = "Fixture page"
++++
+
+Intro text with \tldr{a Franklin command left untouched}.
+
+## Julia mode
+
+
+{% raw %}
+
julia> x = 21
+21
+
+julia> 2x
+42
+
+{% endraw %}
+
+State and `ans` persist across named fences:
+
+
+{% raw %}
+
julia> ans - x
+21
+
+{% endraw %}
+
+
+{% raw %}
+
julia> function double(n)
+ return 2n
+end;
+
+julia> double(4)
+8
+
+julia> println("printed, not returned")
+printed, not returned
+
+{% endraw %}
+
+Fences marked `allow-error` render their error REPL-style instead of
+failing the build:
+
+
+{% raw %}
+
julia> sqrt(-1)
+ERROR: DomainError with -1.0:
+sqrt was called with a negative real argument but will only return a complex result if called with a complex argument. Try sqrt(Complex(x)).
+
+{% endraw %}
+
+The next fence has no blank line above it:
+
+{% raw %}
+
julia> 1 + 1
+2
+
+{% endraw %}
+
+## Logging
+
+
+```julia
+function warn_func(n)
+ @warn "This is bad" n
+end
+```
+
+
+{% raw %}
+
julia> warn_func(3)
+β Warning: This is bad
+β n = 3
+β @ Main.MJW_page_index warn-func:2
+
+{% endraw %}
+
+## Help mode
+
+
+
+{% raw %}
+
help?> greet
+ greet(name)
+
+ Print a greeting to name.
+
+{% endraw %}
+
+## Package mode
+
+
+{% raw %}
+
(page) pkg> status
+Status `/src/page/Project.toml` (empty project)
+
+{% endraw %}
+
+## Shell mode
+
+
+{% raw %}
+
shell> echo hello from the shell
+hello from the shell
+
+shell> printf 'two\nlines\n'
+two
+lines
+
+{% endraw %}
+
+Shell and julia fences share the page's scratch working directory:
+
+
+{% raw %}
+