diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d68f78520..9aa1c6c75 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -145,6 +145,12 @@ jobs:
run: |
inkscape --version
+ # The tests check that the tutorial emitter's own rendered HTML content loads through
+ # the same reader that a consuming site uses, so it is generated first.
+ - name: Generate the test tutorial site as rendered HTML content
+ run: |
+ lake exe tutorial-example-rendered-html
+
- name: Run tests
run: |
lake test -- --verbose --check-tex
@@ -181,6 +187,22 @@ jobs:
run: |
linkchecker --config=.linkchecker/linkcheckerrc --no-status ./_out/tut/
+ - name: Generate the site that mounts rendered HTML content
+ run: |
+ lake exe mount-site --output _out/test-projects/mount-site
+
+ - name: Check internal links on the mounting site
+ run: |
+ linkchecker --config=.linkchecker/linkcheckerrc --no-status ./_out/test-projects/mount-site/
+
+ # A site is served from wherever its root is mounted, so nothing that Verso emits may be
+ # root-relative. The fixture holds one on purpose, to pin that content may.
+ - name: Check that the generated sites work under a URL prefix
+ run: |
+ ./scripts/check-url-prefix.sh _out/test-projects/demosite
+ ./scripts/check-url-prefix.sh _out/tut
+ ./scripts/check-url-prefix.sh _out/test-projects/mount-site --allow 'href="/"$'
+
- name: Generate the manual
run: |
./generate.sh
@@ -268,8 +290,15 @@ jobs:
browser-tests/test_search_page.py \
browser-tests/test_toc_resize.py \
browser-tests/test_redirect.py \
+ browser-tests/test_search_path_prefix.py \
browser-tests/test_katex.py -v
+ - name: Run the mounted content browser tests
+ run: |
+ uv run --project browser-tests --extra test pytest \
+ browser-tests/mount-site -v \
+ --site-dir "$(pwd)/_out/test-projects/mount-site"
+
- name: Build the VersoHtml site for browser tests
run: |
# The verso-html genre renders literate JSON into a standalone HTML site.
diff --git a/.gitignore b/.gitignore
index 88efe9519..ebf56d547 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,11 @@ multi.json
single.json
*.html
!/doc/stats.html
+# Rendered HTML content fixtures are checked in and never regenerated. A new fixture directory
+# needs its own line here, or its fragments are silently left out of the repository.
+!/test-projects/rendered-html-fixture/**/*.html
+!/test-projects/rendered-html-sparse-fixture/**/*.html
+!/test-projects/rendered-html-conflict-fixture/**/*.html
*.produced.out
__pycache__
test-projects/literate-config/lake-manifest.json
diff --git a/.prettierignore b/.prettierignore
index 3346a754e..3473c583d 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -8,6 +8,11 @@ html-multi
htmlout
.playwright-browsers
+# Hand-written fixtures, whose exact markup and JSON are what the tests read
+test-projects/rendered-html-fixture
+test-projects/rendered-html-sparse-fixture
+test-projects/rendered-html-conflict-fixture
+
# Vendored files
vendored-js
*.min.js
diff --git a/browser-tests/mount-site/test_mounted_content.py b/browser-tests/mount-site/test_mounted_content.py
new file mode 100644
index 000000000..8addefd75
--- /dev/null
+++ b/browser-tests/mount-site/test_mounted_content.py
@@ -0,0 +1,61 @@
+"""Tests for a page that mounts rendered HTML content.
+
+The document carries the scripts of two Verso releases at once: the site's own, which reach the
+markup that the site rendered, and the mounted content's, which reach the markup that shipped with
+it. Nothing static shows how they behave together, so these tests drive a browser.
+"""
+
+from playwright.sync_api import expect, Page
+
+MOUNTED_PAGE = "/tutorials/v4.30.0/hashmap/"
+
+# The site's own content on a mounted page.
+SITE_MATH = "#site-math"
+SITE_TOKEN = "#site-token"
+
+# The mounted content's own markup.
+MOUNTED = ".verso-content.content"
+
+
+class TestMountedContent:
+ def test_math_is_rendered_once(self, server: str, page: Page):
+ page.goto(f"{server}{MOUNTED_PAGE}")
+ page.wait_for_load_state("networkidle")
+
+ math = page.locator(".math.inline, .math.display")
+ expect(math).not_to_have_count(0)
+ for i in range(math.count()):
+ expect(math.nth(i).locator(".katex")).to_have_count(1)
+
+ # Both the site's own math and the mounted content's math are rendered.
+ expect(page.locator(f"{SITE_MATH} .katex")).to_have_count(1)
+ expect(page.locator(f"{MOUNTED} .math.inline .katex")).not_to_have_count(0)
+
+ def test_hovers_work_on_the_sites_own_code(self, server: str, page: Page):
+ page.goto(f"{server}{MOUNTED_PAGE}")
+ page.wait_for_load_state("networkidle")
+
+ page.locator(SITE_TOKEN).hover()
+ expect(page.locator("[data-tippy-root]")).not_to_have_count(0)
+
+ def test_hovers_work_on_the_mounted_code(self, server: str, page: Page):
+ page.goto(f"{server}{MOUNTED_PAGE}")
+ page.wait_for_load_state("networkidle")
+
+ token = page.locator(f"{MOUNTED} .hl.lean .const.token").first
+ expect(token).to_be_visible()
+ token.hover()
+ expect(page.locator("[data-tippy-root]")).not_to_have_count(0)
+
+ def test_the_wrappers_are_separate(self, server: str, page: Page):
+ page.goto(f"{server}{MOUNTED_PAGE}")
+ page.wait_for_load_state("networkidle")
+
+ # The mounted content is marked as Verso content, and the site's own code on the same
+ # page is not, which is what keeps the two releases' scripts off each other's markup.
+ expect(page.locator(f"{MOUNTED}[data-verso-docs]")).to_have_count(1)
+ expect(
+ page.locator(f"{SITE_TOKEN}").locator(
+ "xpath=ancestor::*[contains(@class,'verso-content')]"
+ )
+ ).to_have_count(0)
diff --git a/doc/UsersGuide/Basic.lean b/doc/UsersGuide/Basic.lean
index b22cab944..6f20ba549 100644
--- a/doc/UsersGuide/Basic.lean
+++ b/doc/UsersGuide/Basic.lean
@@ -6,6 +6,7 @@ Author: David Thrane Christiansen
import VersoManual
import UsersGuide.Markup
import UsersGuide.Websites
+import UsersGuide.RenderedHtml
import UsersGuide.Manuals
import UsersGuide.Elab
import UsersGuide.Extensions
@@ -110,6 +111,8 @@ Mixing incompatible features results in an ordinary Lean type error.
{include 0 UsersGuide.Websites}
+{include 0 UsersGuide.RenderedHtml}
+
{include 0 UsersGuide.Manuals}
{include 0 UsersGuide.Literate}
diff --git a/doc/UsersGuide/Output/HTML.lean b/doc/UsersGuide/Output/HTML.lean
index adb7d31ec..8943cc427 100644
--- a/doc/UsersGuide/Output/HTML.lean
+++ b/doc/UsersGuide/Output/HTML.lean
@@ -41,6 +41,8 @@ They are typically produced using an embedded DSL that is available when the nam
{docstring Html.visitM}
+{docstring Html.rewriteUrls}
+
{docstring Html.format}
{docstring Html.asString}
@@ -151,7 +153,7 @@ The element's text content is the TeX code, which is not processed while generat
For example, `` $`\frac{1}{2}` `` is represented in HTML as `\frac{1}{2}`.
Math is typeset in the browser using the bundled KaTeX library.
-When a page has loaded, the script in {name}`Html.math.js` renders every element with these classes.
+When a page has loaded, the script produced by {name}`Html.mathJs` renders every element with these classes.
Pages that contain mathematical notation should include this script together with KaTeX itself: its stylesheet ({name}`Html.katex.css`), its code ({name}`Html.katex.js`), and its fonts ({name}`Html.katexFonts`).
The stylesheet refers to the fonts by relative paths, so the file layout described in their docstrings should be preserved.
@@ -161,4 +163,4 @@ The stylesheet refers to the fonts by relative paths, so the file layout describ
{docstring Html.katexFonts}
-{docstring Html.math.js}
+{docstring Html.mathJs}
diff --git a/doc/UsersGuide/Releases/Entries.lean b/doc/UsersGuide/Releases/Entries.lean
index d2dd7cd0d..f1a887760 100644
--- a/doc/UsersGuide/Releases/Entries.lean
+++ b/doc/UsersGuide/Releases/Entries.lean
@@ -22,6 +22,7 @@ public import UsersGuide.Releases.Entries.LiterateHtmlKatex
public import UsersGuide.Releases.Entries.LiterateProgramming
public import UsersGuide.Releases.Entries.MethodInMultiVerso
public import UsersGuide.Releases.Entries.ReleaseNotesChapter
+public import UsersGuide.Releases.Entries.RenderedHtmlContent
public import UsersGuide.Releases.Entries.RoleDiagnostics
public import UsersGuide.Releases.Entries.SearchPriority
public import UsersGuide.Releases.Entries.VersionedReleaseNotes
diff --git a/doc/UsersGuide/Releases/Entries/RenderedHtmlContent.lean b/doc/UsersGuide/Releases/Entries/RenderedHtmlContent.lean
new file mode 100644
index 000000000..6b64d85c6
--- /dev/null
+++ b/doc/UsersGuide/Releases/Entries/RenderedHtmlContent.lean
@@ -0,0 +1,58 @@
+/-
+Copyright (c) 2026 Lean FRO LLC. All rights reserved.
+Released under Apache 2.0 license as described in the file LICENSE.
+Author: David Thrane Christiansen
+-/
+module
+
+public import UsersGuide.Releases.Entry
+import VersoBlog
+
+open Verso.Genre Manual InlineLean UsersGuide.Releases
+
+release_note
+ version := ⟨4, 34, 0⟩
+ breaking := true
+ tag := "rendered-html-content"
+ prs := [964]
+
+open Verso.Output.Html.Files
+
+open Verso.Genre
+
+#doc (Manual) "Rendered HTML Content" =>
+
+A Verso document can now render to a directory of HTML fragments, and the website genre supports integrating these fragments into a particular theme.
+
+:::paragraph
+Exported content can be rendered to a directory of fragments, and Verso websites can mount this exported content into their own URL structure, integrating it into their navigation structure and theme.
+This feature is described in a {ref "rendered-html"}[dedicated section].
+To make it possible for HTML fragments to be reliably bundled with required CSS and JavaScript, several changes were made that affect all rendered HTML:
+
+* Hover text is fetched from the path named by the nearest enclosing element carrying `data-verso-docs`, rather than from a fixed path, so one document holds as many sources of hover text as it holds regions of Verso content.
+ The prior behavior is a fallback when no `data-verso-docs` element is found.
+* Verso's scripts confine their queries, their listeners, and the data they fetch to the elements they were given, and register their listeners additively, so a page may include scripts from several Verso releases without them interfering with each others' markup.
+ The math script marks what it has rendered, preventing double rendering.
+* In the blog genre, KaTeX and `marked` are served from the site itself rather than from a CDN, so a page depends on nothing on the network.
+* The stylesheets and scripts that traversal accumulates are emitted as file references rather than as inline text, and the assets that have no name of their own are named by a hash of their contents.
+* A site's own scripts now skip any subtree marked with `verso-content`, which is what a mounted directory's markup carries, so they reach the site's own pages and nothing that a mount contributed.
+:::
+
+# Breaking Changes
+%%%
+tag := none
+%%%
+* The types that describe a genre's stylesheets and scripts were moved from the `Verso.Genre.Manual.Files` namespace to `Verso.Output.Html.Files`, where every genre that emits HTML can reach them.
+ In particular, {name}`CSS`, {name}`JS`, {name}`StaticCssFile`, {name}`CssFile`, {name}`StaticJsFile`, {name}`JsSourceMap`, and {name}`JsFile` were moved, and the modules `VersoManual.Html.Basic`, `VersoManual.Html.CssFile`, and `VersoManual.Html.JsFile` were replaced by `Verso.Output.Html.Files`.
+ The files should be sorted using the helper {name}`Verso.Output.Html.Files.sortByAfter`, which ensures that the ordering constraints between scripts are respected.
+
+* {name}`Blog.Theme.cssFiles` and {name}`Blog.Theme.jsFiles` contain {name}`CssFile` and {name}`JsFile` rather than tuples, as do {name}`Blog.TraverseState.cssFiles` and {name}`Blog.TraverseState.jsFiles`.
+
+* The `path` parameter that the website genre passes to the `post` and `archiveEntry` templates now includes a trailing slash.
+ `Blog.dirPathToString` has been replaced by `Verso.Multi.Path.relativeLink`.
+ Links to posts and to categories from these templates end in `/`, as the links from the post list already did.
+
+* A theme invokes {name}`Blog.Template.builtinHeader` before defining its own custom properties, so that its definitions override the ones that the header emits.
+ {name}`Blog.Theme.default` has been changed accordingly.
+ `Verso.Genre.Blog.Traverse.renderMathJs` and `Verso.Output.Html.math.js` have been consolidated to {name}`Verso.Output.Html.mathJs`.
+ {name Verso.Output.Html.mathJs}`mathJs` and {name}`Verso.Code.highlightingJs` take the selector for the elements their script belongs to, which is {lean}`"body"` for a whole page.
diff --git a/doc/UsersGuide/RenderedHtml.lean b/doc/UsersGuide/RenderedHtml.lean
new file mode 100644
index 000000000..ffdb2860e
--- /dev/null
+++ b/doc/UsersGuide/RenderedHtml.lean
@@ -0,0 +1,313 @@
+/-
+Copyright (c) 2026 Lean FRO LLC. All rights reserved.
+Released under Apache 2.0 license as described in the file LICENSE.
+Author: David Thrane Christiansen
+-/
+import Lean.DocString.Syntax
+import VersoManual
+import VersoBlog
+import VersoRenderedHtml
+import VersoTutorial
+
+open Verso Genre Manual
+
+open Verso.Genre.Blog (Page Post)
+
+open InlineLean
+open Verso.Doc
+
+#doc (Manual) "Rendered HTML Content" =>
+%%%
+tag := "rendered-html"
+htmlSplit := .never
+%%%
+
+To make it easier to combine documents written in multiple genres, as well as to host historical content such as prior versions of documentation, Verso provides a format for pre-rendered HTML snippets with their associated CSS and JavaScript that can be incorporated into another document.
+In particular, this archived content can be placed into the URL hierarchy and site design {ref "website"}[of a Verso-based website].
+Pre-rendered content is saved in a directory with a specific structure.
+Within a Verso-based website, it can be {deftech}[mounted] at a specific path in the site.
+
+For each page, pre-rendered content provides a set of named {deftech}[fragments] of HTML.
+The site in which the content is displayed should place these fragments in the proper places for its design.
+
+# The Directory
+%%%
+tag := "rendered-html-directory"
+%%%
+
+:::paragraph
+Saved rendered content is in a directory with the following contents:
+
+: `verso-rendered-html.json`
+
+ This manifest maps page paths to their titles and HTML fragments, and it lists the stylesheets and scripts that the pages need.
+
+: `fragments/`
+
+ This directory contains the HTML fragments that the manifest references.
+
+: `static/`
+
+ This directory contains files that are referenced from the fragments, to be served as-is.
+:::
+
+
+A consumer copies the static directory to its mount point, so, for example, `static/images/screenshot.png` is served at the
+mount point followed by `images/screenshot.png`.
+It also renders one page per manifest entry.
+
+The static directory includes Verso's CSS and JavaScript along with bundled versions of third-party dependencies, so pages continue to render correctly as Verso changes.
+The scripts are confined to the content's own markup, so a page may carry the scripts of several Verso releases alongside those of the consuming site.
+
+{docstring Verso.RenderedHtmlContent}
+
+{docstring Verso.RenderedHtmlContent.Page}
+
+{docstring Verso.RenderedHtmlContent.Fragment}
+
+{docstring Verso.RenderedHtmlContent.Generator}
+
+{docstring Verso.RenderedHtmlContent.Stylesheet}
+
+{docstring Verso.RenderedHtmlContent.Stylesheet.mountPath}
+
+{docstring Verso.RenderedHtmlContent.StylesheetRole}
+
+{docstring Verso.RenderedHtmlContent.Script}
+
+{docstring Verso.RenderedHtmlContent.Library}
+
+{docstring Verso.RenderedHtmlContent.Script.mountPath}
+
+# Page Paths
+%%%
+tag := "rendered-html-page-paths"
+%%%
+
+Page paths are {deftech}_dense_: every proper prefix of a page path is itself a page path, so a directory
+always has an index and the root page is always present.
+A page keyed `a/b` is served at `a/b/` under the mount point.
+
+{docstring Verso.RenderedHtml.pathToString}
+
+{docstring Verso.RenderedHtml.pathOfString}
+
+{docstring Verso.RenderedHtml.checkDense}
+
+{docstring Verso.RenderedHtml.checkDestinations}
+
+# Fragments
+%%%
+tag := "rendered-html-fragments"
+%%%
+
+Fragments are separate files, and the consuming template places the ones that it knows by name, so neither the producer nor the consumer parses HTML.
+Every fragment belongs in the document body.
+A fragment that the template does not reference is not rendered, and a consumer should warn about any fragments that it does not place.
+
+:::paragraph
+Every fragment is wrapped in a `
` with the following classes:
+ * `verso-content`
+ * The fragment's name
+ * The wrapper identifier that the content's scripts use to target their effects
+
+For example:
+```
+
+ ...
+
+```
+
+`verso-content` marks a subtree as Verso content and is stable across Verso releases.
+The class names inside a fragment's markup are those that the content's own stylesheets style, and they may change from one release to the next.
+A site that styles a fragment's markup itself, such as one that places the local table of contents and styles it against `nav.local-toc`, writes its rules against the markup of the release that produced the content, and content from another release may need adaptor rules that bridge the two.
+:::
+
+## Conventions
+%%%
+tag := "fragment-conventions"
+%%%
+
+The fragment named `content` holds the page's body and is always present.
+A tutorial page also has `localNav` when it has a local table of contents, a code download, or a live editor link.
+
+
+# The URL Token
+%%%
+tag := "rendered-html-token"
+%%%
+
+Fragments are stored as text, so URLs in them are written relative to a unique token rather than to a fixed
+root.
+The consumer can relocate the markup in the URL hierarchy via a string replacement operation, rather than by parsing HTML and other languages.
+
+A fragment's token stands for the root of the mounted content.
+A consumer replaces it with a prefix such that the token followed by `/x` resolves to `x` under the mount point in the document that the consumer produces.
+The token expands with no trailing slash, and the content writes the separator.
+Each fragment specifies its own token in the manifest, and the producer chooses a token that does not occur in that fragment's content.
+
+{docstring Verso.RenderedHtml.defaultRootToken}
+
+{docstring Verso.RenderedHtml.chooseToken}
+
+{docstring Verso.RenderedHtml.hasToken}
+
+{docstring Verso.RenderedHtml.substitute}
+
+# Stylesheets, Scripts, and Theming
+%%%
+tag := "rendered-html-theming"
+%%%
+
+A Verso-based website places a mount's stylesheets and scripts through
+{name Verso.Genre.Blog.Template.builtinHeader}`builtinHeader`.
+A consumer of another kind places them as described here.
+
+Each stylesheet has an associated role.
+Stylesheet roles are:
+
+: `variables`
+
+ Defines default values for `--verso-*` custom properties on `:root`.
+ Consumers should emit these prior to Verso's defaults and their own themes, so that those will override the included content.
+
+: `content`
+
+ Styles the markup and reads the properties.
+ Consumers should emit these last, so that they take precedence over others.
+ A role that a consumer does not recognize is placed as `content`.
+
+
+When a Verso release renames a `--verso-*` property, archived content that reads the earlier name
+continues to render, because it carries its own value for that name.
+A site that wants such content to follow its theme defines the earlier name in terms of the current
+one in an adaptor stylesheet of its own, which applies to every mount, and the archived directory itself is
+untouched.
+
+A script under the static directory touches only nodes inside the wrapper that it belongs to.
+Each wrapper carries the identifier that its scripts select on, and each script confines its
+queries, its listeners, and the data that it fetches to that subtree.
+A page therefore carries the scripts of several Verso releases, and of the consuming site, without
+any of them affecting each other's markup.
+
+A directory also ships the libraries that its pages need, such as KaTeX, so a site that places
+several directories holds several copies of one library, under file names that need not match.
+These are plain scripts that assign globals, so the copy placed last owns the global, and their
+stylesheets follow the cascade in the same way.
+A stylesheet or script that is a copy of a library says which one in `provides`, which is what a
+site reads if it decides to place a single copy rather than all of them.
+
+{docstring Verso.Genre.Blog.Template.MountedAssets}
+
+# The Stability Contract
+%%%
+tag := "rendered-html-contract"
+%%%
+
+To enable the display of archived content without rebuilding it, producers and consumers may rely on the following properties:
+
+* Within a format version, the manifest only gains fields, and the format version is incremented only for a change that cannot be expressed as an additional field.
+ A consumer reads every format version up to and including its own, ignores unknown fields, and accepts an unrecognized value of an enumerated
+ field.
+
+* Fragments are rooted at a `
` and carry no ``, ``, ``, ``, or page navigation features.
+ Fragments reach a consumer as text, so a consumer relies on this without checking it.
+
+* A title contains only text nodes and the following tags: `em`, `strong`, `code`, `sub`, `sup`, `span` and `br`.
+ Additionally, it contains no URLs.
+ This makes it safe to be emitted in a heading, link, or list item without being rewritten.
+ The title is the one piece of content that a consumer renders on pages other than the content's own, where the content's stylesheets and scripts are absent.
+
+* A fragment's declared token is the only token in its text, and no further substitutions are expected.
+ Files under the static directory are served verbatim and are never substituted, so nothing in them refers to a path outside the static
+ directory, CSS `url()` included.
+
+* Verso's content stylesheets read `--verso-*` custom properties and hardcode no colors or fonts, and the content ships its own definitions of those properties on `:root`.
+ Third-party stylesheets that ship alongside, KaTeX in particular, may set their own colors and fonts.
+
+* A `--verso-*` property keeps its meaning across Verso releases.
+
+* A page keyed `a/b` is served at `a/b/` under the mount point, and `static/foo` at `foo`.
+ Conflicts are with respect to the tree that the mount writes, so they involve nothing outside the directory.
+
+* Pages depend on nothing outside the static directory and nothing on the network.
+
+* The format describes one directory.
+ How directories are named, where they are found, and what order a site presents them in are decisions of the consumer.
+
+# Producing a Directory
+%%%
+tag := "rendered-html-producing"
+%%%
+
+{docstring Verso.Genre.Blog.Site.toRenderedHtml}
+
+{docstring Verso.Genre.Blog.Site.writeRenderedHtml}
+
+{docstring Verso.Genre.Blog.RenderedHtmlOptions}
+
+{docstring Verso.Genre.Blog.RenderedHtmlOptions.wrapperClass}
+
+{docstring Verso.Genre.Tutorial.tutorialsRenderedHtmlMain}
+
+A producer that writes files into the static directory itself uses these:
+
+{docstring Verso.RenderedHtml.write}
+
+{docstring Verso.RenderedHtml.writeStaticFile}
+
+{docstring Verso.RenderedHtml.Output}
+
+{docstring Verso.RenderedHtml.OutputPage}
+
+{docstring Verso.RenderedHtml.OutputFragment}
+
+# Mounting a Directory
+%%%
+tag := "rendered-html-mounting"
+%%%
+
+In the {ref "website"}[website genre], a site mounts a directory with the `mount` form of the site configuration language.
+For example, this site mounts a content directory under `/page/` and another under `/guides/archive/`:
+```lean -show
+open Verso.Genre.Blog Site Syntax
+opaque MySite.Front : Part Page
+opaque MySite.Guides : Part Page
+```
+```lean
+def mountingSite : Site := site MySite.Front /
+ mount "page" ← "path/to/content"
+ "guides" MySite.Guides /
+ mount "archive" ← "path/to/older/content" with {
+ showInNav := false
+ }
+```
+
+A mount may appear wherever a directory may: beneath any page, whether at the top level of the site or nested.
+
+{docstring Verso.Genre.Blog.MountSettings}
+
+{docstring Verso.Genre.Blog.Site.resolveMounts}
+
+{docstring Verso.Genre.Blog.Site.insertDir}
+
+{docstring Verso.Genre.Blog.Site.insertMount}
+
+{docstring Verso.RenderedHtml.load}
+
+{docstring Verso.RenderedHtml.Loaded}
+
+Page IDs are namespaced by the mount, because a site that mounts several versions of the same content holds every internal page path once per version.
+An author links to a mounted page with the `page_link` role, writing the segments that are not valid Lean identifier components in guillemets, as in `{page_link tutorials.«getting-started»}`.
+
+{docstring Verso.Genre.Blog.mountPageId}
+
+A mount's fragments are provided to the site's template as ordinary template parameters, prefixed by {lean}`"fragments."`.
+A per-path override may be used if a special template is required.
+The main content of the page is in the `content` fragment, as the parameter `fragments.content`.
+{name Verso.Genre.Blog.Template.builtinHeader}`builtinHeader` places the stylesheets and scripts of a mount in the ``.
+All themes should use this; if they do, no further support is required in the `` element.
diff --git a/doc/UsersGuide/Websites.lean b/doc/UsersGuide/Websites.lean
index 116214076..23e4c5902 100644
--- a/doc/UsersGuide/Websites.lean
+++ b/doc/UsersGuide/Websites.lean
@@ -61,12 +61,32 @@ The URL layout of a site is specified via a {name Blog.Site}`Site`:
{docstring Blog.Dir}
These are usually constructed using a small embedded configuration language.
+A page is written as its URL segment followed by the name of the document that it renders, further
+pages are indented beneath a `/`, a blog is introduced by `with`, a directory of files that are
+served verbatim by `static`, and a directory of {ref "rendered-html"}[rendered HTML content] by
+`mount`:
+
+```
+def mySite : Site := site MySite.Front /
+ static "static" ← "static_files"
+ "about" MySite.About
+ "blog" MySite.Blog with
+ MySite.Blog.FirstPost
+ "tutorials" MySite.Tutorials /
+ mount "v1" ← "content/v1"
+ mount "v0" ← "content/v0" with {showInNav := false}
+```
+
+The settings after `with` in a `mount` form are a {name Blog.MountSettings}`MountSettings`.
A blog is rendered using a theme, which is a collection of templates.
Templates are monadic functions that construct {name Verso.Output.Html}`Html` from a set of dynamically-typed parameters.
{docstring Blog.Theme}
+A theme that is used to produce {ref "rendered-html"}[rendered HTML content] keeps its chrome in its
+primary template, because the export renders the page template alone.
+
{docstring Blog.Template}
{docstring Blog.TemplateM}
diff --git a/lakefile.lean b/lakefile.lean
index ade9bbabd..da9f31ba6 100644
--- a/lakefile.lean
+++ b/lakefile.lean
@@ -39,6 +39,11 @@ lean_lib VersoSearch where
-- Rebuild search when JS on disk changes
needs := #[staticWeb]
+@[default_target]
+lean_lib VersoRenderedHtml where
+ srcDir := "src/verso-rendered-html"
+ roots := #[`VersoRenderedHtml]
+
@[default_target]
lean_lib VersoBlog where
srcDir := "src/verso-blog"
@@ -215,6 +220,23 @@ lean_exe «tutorial-example» where
root := `TutorialExampleMain
supportInterpreter := true
+-- A site that mounts directories of rendered HTML content and renders them with its own theme
+lean_lib MountSite where
+ srcDir := "test-projects/mount-site"
+ roots := #[`MountSite]
+
+@[default_target]
+lean_exe «mount-site» where
+ srcDir := "test-projects/mount-site"
+ root := `MountSiteMain
+ supportInterpreter := true
+
+@[default_target]
+lean_exe «tutorial-example-rendered-html» where
+ srcDir := "test-projects/tutorial-test"
+ root := `TutorialRenderedHtmlMain
+ supportInterpreter := true
+
private def leanOptionArgs (m : Module) : Array String := Id.run do
let opts := Module.leanOptions m
let vals := Lean.LeanOptions.values opts
diff --git a/scripts/check-url-prefix.sh b/scripts/check-url-prefix.sh
new file mode 100755
index 000000000..ca83847ae
--- /dev/null
+++ b/scripts/check-url-prefix.sh
@@ -0,0 +1,85 @@
+#!/usr/bin/env bash
+#
+# Copies a generated site under a URL prefix, rejects the URLs that break there, and link-checks the
+# copy.
+#
+# A site is served from wherever its root is mounted, so every URL that Verso emits is relative to
+# the document rather than to the origin. A URL that begins with a single slash addresses the origin
+# instead, so it breaks as soon as the site is served under a prefix. Content may hold one on
+# purpose, which is what `--allow` is for.
+#
+# usage: scripts/check-url-prefix.sh [--allow ]
+
+set -euo pipefail
+
+usage() {
+ echo "usage: $0 [--allow ]" >&2
+ exit 2
+}
+
+site=""
+allow=""
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --allow)
+ [ $# -ge 2 ] || usage
+ allow="$2"
+ shift 2
+ ;;
+ -*) usage ;;
+ *)
+ [ -z "$site" ] || usage
+ site="$1"
+ shift
+ ;;
+ esac
+done
+
+[ -n "$site" ] || usage
+[ -d "$site" ] || {
+ echo "No such directory: $site" >&2
+ exit 2
+}
+
+prefix="a/url/prefix"
+work="$(mktemp -d)"
+trap 'rm -rf "$work"' EXIT
+mkdir -p "$work/$prefix"
+cp -R "$site/." "$work/$prefix/"
+
+# The attributes are the ones that `Verso.Output.Html.rewriteUrls` rewrites. An attribute name is
+# taken whole, so `metadata` is not read as `data`. A candidate list is checked at its first URL,
+# which is where a root-relative one in emitted content appears.
+#
+# A protocol-relative URL begins with two slashes and is left alone.
+attrs='href|src|data|poster|action|formaction|cite|ping|srcset|imagesrcset'
+pattern="(^|[[:space:]])($attrs)=\"/([^/\"]|\")|url\\([\"']?/[^/]"
+
+rc=0
+hits="$(grep -REon --include='*.html' --include='*.css' "$pattern" "$work/$prefix")" || rc=$?
+if [ "$rc" -gt 1 ]; then
+ echo "Failed to scan '$site' for root-relative URLs." >&2
+ exit 2
+fi
+if [ -n "$allow" ] && [ -n "$hits" ]; then
+ rc=0
+ hits="$(printf '%s\n' "$hits" | grep -Ev "$allow")" || rc=$?
+ if [ "$rc" -gt 1 ]; then
+ echo "Failed to apply --allow to the scan of '$site'." >&2
+ exit 2
+ fi
+fi
+
+if [ -n "$hits" ]; then
+ echo "Root-relative URLs in '$site' break when the site is served under a URL prefix:" >&2
+ printf '%s\n' "$hits" | sed "s|^$work/$prefix/||" >&2
+ exit 1
+fi
+
+if command -v linkchecker >/dev/null 2>&1; then
+ linkchecker --config=.linkchecker/linkcheckerrc --no-status "$work/$prefix/"
+else
+ echo "linkchecker is not installed, so only the URL check ran." >&2
+fi
+
+echo "'$site' works under a URL prefix."
diff --git a/src/multi-verso/MultiVerso/Slug.lean b/src/multi-verso/MultiVerso/Slug.lean
index 429994065..629390b25 100644
--- a/src/multi-verso/MultiVerso/Slug.lean
+++ b/src/multi-verso/MultiVerso/Slug.lean
@@ -110,16 +110,16 @@ where
/--
Converts a string to a valid slug, mangling as appropriate.
-/
-def asSlug (str : String) : String :=
- let rec loop (iter : String.Legacy.Iterator) (acc : String) : String :=
- if iter.atEnd then acc
- else
- let c := iter.curr
- loop iter.next <|
- if c ∈ Slug.validChars then acc.push c
- else if c.isWhitespace then acc.push '-'
- else acc ++ mangle c
- loop (String.Legacy.iter str) ""
+def asSlug (str : String) : String := Id.run do
+ let mut iter := str.startPos
+ let mut out := ""
+ while h : iter ≠ str.endPos do
+ let c := iter.get h
+ if c ∈ Slug.validChars then out := out.push c
+ else if c.isWhitespace then out := out.push '-'
+ else out := out ++ mangle c
+ iter := iter.next h
+ return out
/--
A slug is a well-formed string.
@@ -138,12 +138,6 @@ instance : ToString Slug := ⟨Slug.toString⟩
instance : ToJson Slug where
toJson s := ToJson.toJson s.toString
-instance : FromJson Slug where
- fromJson? v := private do
- let s : String ← FromJson.fromJson? v
- if asSlug s = s then pure ⟨s⟩
- else throw s!"String {s} contains invalid characters"
-
namespace Slug
instance : LT Slug where
@@ -163,6 +157,19 @@ instance : DecidableRel (@LE.le Slug _) := fun s1 s2 =>
defmethod String.sluggify (str : String) : Slug :=
⟨asSlug str⟩
+/--
+Returns {lean}`some str` as a slug if it is already a valid slug, or {name}`none` otherwise.
+-/
+def isSlug? (str : String) : Option Slug :=
+ if wf str then some str.sluggify else none
+
+instance : FromJson Slug where
+ fromJson? v := do
+ let s : String ← FromJson.fromJson? v
+ match isSlug? s with
+ | some slug => pure slug
+ | none => throw s!"String {s} contains invalid characters"
+
/--
Appends two slugs by appending their underlying strings.
-/
diff --git a/src/tests/TestMain.lean b/src/tests/TestMain.lean
index 28314873f..44eba9534 100644
--- a/src/tests/TestMain.lean
+++ b/src/tests/TestMain.lean
@@ -163,6 +163,24 @@ def testBlog (_ : Config) : IO Unit := do
if fails > 0 then
throw <| IO.userError s!"{fails} blog tests failed"
+def testRenderedHtml (_ : Config) : IO Unit := do
+ IO.println "Running rendered HTML content format tests..."
+ let fails ← Tests.RenderedHtml.runRenderedHtmlTests
+ if fails > 0 then
+ throw <| IO.userError s!"{fails} rendered HTML content format tests failed"
+
+def testRenderedHtmlExport (_ : Config) : IO Unit := do
+ IO.println "Running rendered HTML content export tests..."
+ let fails ← Tests.RenderedHtmlExport.runRenderedHtmlExportTests
+ if fails > 0 then
+ throw <| IO.userError s!"{fails} rendered HTML content export tests failed"
+
+def testRenderedHtmlMount (_ : Config) : IO Unit := do
+ IO.println "Running rendered HTML content mounting tests..."
+ let fails ← Tests.RenderedHtmlMount.runRenderedHtmlMountTests
+ if fails > 0 then
+ throw <| IO.userError s!"{fails} rendered HTML content mounting tests failed"
+
def testServe (_ : Config) : IO Unit := do
IO.println "Running serve tests..."
let fails ← Verso.Tests.Serve.runServeTests
@@ -363,6 +381,9 @@ def tests := [
testSerialization,
testSearchJs,
testBlog,
+ testRenderedHtml,
+ testRenderedHtmlExport,
+ testRenderedHtmlMount,
testServe,
testStemmer,
testTexOutput "sample-doc" SampleDoc.doc,
diff --git a/src/tests/Tests.lean b/src/tests/Tests.lean
index 5bcdd422c..9b5ca24be 100644
--- a/src/tests/Tests.lean
+++ b/src/tests/Tests.lean
@@ -38,6 +38,9 @@ import Tests.ParserRegression
import Tests.Paths
import Tests.PorterStemmer
import Tests.Refs
+import Tests.RenderedHtml
+import Tests.RenderedHtmlExport
+import Tests.RenderedHtmlMount
import Tests.SearchJs
import Tests.ExtensionResolution
import Tests.Serialization
diff --git a/src/tests/Tests/Arbitrary.lean b/src/tests/Tests/Arbitrary.lean
index bfe69e9a8..9be16145b 100644
--- a/src/tests/Tests/Arbitrary.lean
+++ b/src/tests/Tests/Arbitrary.lean
@@ -11,8 +11,7 @@ import Lean.Data.Json.FromToJson
import all MultiVerso.InternalId
public meta import MultiVerso.NameMap
public meta import MultiVerso
-public meta import VersoManual.Html.JsFile
-public meta import VersoManual.Html.CssFile
+public meta import Verso.Output.Html.Files
public meta import VersoManual.Html.Features
public meta import VersoManual.LicenseInfo
public meta import VersoSearch
@@ -21,13 +20,14 @@ public meta import Verso.Output.Html
public meta import MultiVerso.Manifest
public meta import VersoManual.Basic
import all VersoManual.Basic
-import VersoManual.Html.CssFile
+import Verso.Output.Html.Files
open Lean
open Plausible Gen Arbitrary
open Verso Multi
open Shrinkable
open Std
+open Verso.Output.Html.Files
/-!
This module contains Plausible generators for most of the types that Verso regularly serializes or
diff --git a/src/tests/Tests/Html.lean b/src/tests/Tests/Html.lean
index 515cd0150..102af8de9 100644
--- a/src/tests/Tests/Html.lean
+++ b/src/tests/Tests/Html.lean
@@ -123,3 +123,132 @@ info: |
/-- info: "
"
+ }] }
+ ]
+
+private def testWrite : TestM Unit := do
+ IO.FS.withTempDir fun tmp => do
+ let first := tmp / "first"
+ let second := tmp / "second"
+ for dir in [first, second] do
+ IO.FS.createDirAll dir
+ writeStaticFile dir "-verso-data/x.css" (.text ":root { --verso-text-color: black; }\n")
+ writeStaticFile dir "-verso-data/x.js" (.text "// nothing\n")
+ let _ ← expectAccepted "Writing a directory" (write dir sampleOutput)
+ let firstManifest ← IO.FS.readBinFile (manifestFile first)
+ let secondManifest ← IO.FS.readBinFile (manifestFile second)
+ check (firstManifest == secondManifest)
+ "Writing unchanged content twice produces identical manifests"
+ let some loaded ← expectAccepted "A directory that was just written" (load first)
+ | return
+ checkEq loaded.manifest.pages.size 2 "The page count of a directory that was just written"
+
+ let third := tmp / "third"
+ IO.FS.createDirAll third
+ writeStaticFile third "-verso-data/x.js" (.text "// nothing\n")
+ expectRejected "Writing a directory that names a stylesheet it did not write"
+ ["x.css"] (act := write third sampleOutput)
+
+ let fourth := tmp / "fourth"
+ IO.FS.createDirAll fourth
+ writeStaticFile fourth "-verso-data/x.css" (.text ":root {}\n")
+ writeStaticFile fourth "-verso-data/x.js" (.text "// nothing\n")
+ expectRejected "Writing a directory with no pages"
+ ["dense"] (act := write fourth { sampleOutput with pages := #[] })
+
+ IO.FS.withTempDir fun tmp => do
+ let dir := tmp / "conflict"
+ IO.FS.createDirAll dir
+ writeStaticFile dir "index.html" (.text "
Claims the root page's destination.
\n")
+ writeStaticFile dir "-verso-data/x.css" (.text ":root { }\n")
+ writeStaticFile dir "-verso-data/x.js" (.text "// nothing\n")
+ expectRejected "Writing a directory whose static files claim a page destination"
+ ["index.html"] (act := write dir sampleOutput)
+
+ IO.FS.withTempDir fun tmp => do
+ let dir := tmp / "sparse"
+ IO.FS.createDirAll dir
+ expectRejected "Writing a sparse page path set" ["deep"] (act := write dir { sampleOutput with
+ pages := #[
+ { path := #[], title := "Root", titleHtml := "Root", fragments := #[] },
+ { path := #["deep", "page"], title := "Deep", titleHtml := "Deep", fragments := #[] }
+ ] })
+
+ IO.FS.withTempDir fun tmp => do
+ let dir := tmp / "asset"
+ IO.FS.createDirAll dir
+ expectRejected "Writing an asset path outside the static directory" ["elsewhere"]
+ (act := write dir { sampleOutput with
+ stylesheets := #[{ path := "elsewhere/x.css", role := .content }] })
+
+/--
+Runs the rendered HTML content format tests, returning the number of failures.
+-/
+def runRenderedHtmlTests : IO Nat := do
+ let ((), failures) ←
+ (do
+ testRoundTrip
+ testOptionalFields
+ testFixture
+ testTokens
+ testRejections
+ testSlugs
+ testWrite : TestM Unit).run 0
+ return failures
+
+end Tests.RenderedHtml
diff --git a/src/tests/Tests/RenderedHtmlExport.lean b/src/tests/Tests/RenderedHtmlExport.lean
new file mode 100644
index 000000000..ef84d4a22
--- /dev/null
+++ b/src/tests/Tests/RenderedHtmlExport.lean
@@ -0,0 +1,244 @@
+/-
+Copyright (c) 2026 Lean FRO LLC. All rights reserved.
+Released under Apache 2.0 license as described in the file LICENSE.
+Author: David Thrane Christiansen
+-/
+module
+
+public import VersoBlog
+public import VersoRenderedHtml
+
+public section
+
+open Lean
+open Verso Genre Blog
+open Verso.Output (Html)
+open Verso.Output.Html
+open Verso.RenderedHtml (load manifestFile staticDir)
+
+namespace Tests.RenderedHtmlExport
+
+private abbrev TestM := StateRefT Nat IO
+
+private def fail (message : String) : TestM Unit := do
+ IO.eprintln s!" FAIL: {message}"
+ modify (· + 1)
+
+private def check (condition : Bool) (message : String) : TestM Unit :=
+ unless condition do fail message
+
+private def mentions (text : String) (fragment : String) : Bool :=
+ (text.find? fragment).isSome
+
+/-- A logger that accumulates messages without printing them. -/
+def quietLogger : IO (Verso.Logger IO) := do
+ let errorsRef ← IO.mkRef #[]
+ let warningsRef ← IO.mkRef #[]
+ return {
+ log severity text loc := do
+ let msg : Verso.LogMessage := { severity, text, loc }
+ match severity with
+ | .error => errorsRef.modify (·.push msg)
+ | .warning => warningsRef.modify (·.push msg)
+ errors := errorsRef.get
+ warnings := warningsRef.get
+ }
+
+/-- A part with a plain-text title. -/
+def part (title : String) (content : Array (Verso.Doc.Block Page))
+ (subParts : Array (Verso.Doc.Part Page) := #[]) : Verso.Doc.Part Page :=
+ Verso.Doc.Part.mk #[Verso.Doc.Inline.text title] title none content subParts
+
+/-- A site whose links cover the cases that URL rewriting distinguishes. -/
+def sampleSite : Site :=
+ .page `sample
+ (part "Sample Content" #[
+ Verso.Doc.Block.para #[
+ .link #[.text "the guide"] "guide/", .text " ",
+ .link #[.text "elsewhere"] "https://lean-lang.org", .text " ",
+ .link #[.text "the serving site"] "/", .text " ",
+ .link #[.text "mail"] "mailto:nobody@example.com", .text " ",
+ .math .inline "x^2"]])
+ #[.page "guide" `sample.guide
+ (part "The Guide" #[Verso.Doc.Block.para #[.text "Guide text."]]) #[]]
+
+/-- A theme with a stylesheet of its own. -/
+def sampleTheme : Theme :=
+ { Theme.default with
+ cssFiles := #[
+ { filename := "sample.css",
+ contents := ".sample { color: var(--verso-text-color); }\n" }] }
+
+/-- A theme whose page template carries page chrome. -/
+def chromeTheme : Theme :=
+ { Theme.default with
+ pageTemplate := do
+ return {{
"Chrome where a fragment belongs."
}} }
+
+/-- A theme whose page template is replaced, for one path, by one that carries page chrome. -/
+def overriddenChromeTheme : Theme :=
+ sampleTheme.override #["guide"]
+ ⟨(do return {{
"Chrome from an ad hoc template."
}}), id⟩
+
+/-- A site whose title needs an element that a title may not hold. -/
+def imageTitleSite : Site :=
+ .page `image
+ (Verso.Doc.Part.mk #[Verso.Doc.Inline.image "alt" "picture.png"] "A picture" none #[] #[])
+ #[]
+
+private def exportOptions : RenderedHtmlOptions where
+ generator := { tool := "verso-tests", version := "1", toolchain := "none" }
+
+/--
+Exports a site to `dir`, returning the manifest and the errors that were reported.
+-/
+def exportSite (dir : System.FilePath) (site : Site) (theme : Theme := sampleTheme) :
+ IO (Verso.RenderedHtmlContent × Array Verso.LogMessage) := do
+ let logger ← quietLogger
+ let cfg : Config := {}
+ let wrapper := exportOptions.wrapperClass site
+ let (site, xref) ← site.traverse cfg {} |>.run logger
+ let ctxt : Generate.Context := {
+ theme, site,
+ ctxt := { path := .root, config := cfg, components := {} },
+ xref, dir, config := cfg, header := Html.doctype,
+ linkTargets := {}, components := {}
+ }
+ let (((manifest, _), _)) ←
+ Site.writeRenderedHtml dir theme site exportOptions wrapper |>.run ctxt .empty {} |>.run logger
+ return (manifest, ← logger.errors)
+
+private def testExport : TestM Unit := do
+ IO.FS.withTempDir fun tmp => do
+ let first := tmp / "first"
+ let second := tmp / "second"
+ let (manifest, errors) ← exportSite first sampleSite
+ check errors.isEmpty s!"Exporting a site reported errors: {errors.map (·.text)}"
+ check (manifest.pages.size == 2) s!"An exported site has 2 pages, got {manifest.pages.size}"
+ check (manifest.title == "Sample Content") s!"The exported title is '{manifest.title}'"
+ check (manifest.stylesheets[0]!.role == .variables)
+ "The variables stylesheet comes first"
+ check (manifest.stylesheets.any (·.path.endsWith "verso-vars.css"))
+ "An exported directory ships the custom property definitions"
+ check (manifest.scripts.any (·.path.endsWith "math.js"))
+ "An exported directory ships the math script"
+
+ let some loaded ← (do
+ try
+ return some (← load first)
+ catch e =>
+ fail s!"An exported directory did not load: {e}"
+ return none)
+ | return
+ let some rootPage := loaded.manifest.pages[(#[] : Multi.Path)]?
+ | fail "An exported directory has no root page"
+ let some fragment := rootPage.fragments["content".sluggify]?
+ | fail "An exported page has no content fragment"
+ let text ← loaded.readFragmentText fragment
+ check (mentions text s!"class=\"verso-content content verso-content-")
+ s!"A fragment is wrapped: {text}"
+ check (mentions text "data-verso-docs=")
+ s!"The content fragment names its hover data: {text}"
+ check (mentions text s!"href=\"{fragment.rootToken}/guide/\"")
+ s!"A site-relative URL becomes relative to the token: {text}"
+ check (mentions text "href=\"https://lean-lang.org\"")
+ s!"An absolute URL is left alone: {text}"
+ check (mentions text "href=\"/\"")
+ s!"A root-relative URL is left alone: {text}"
+ check (mentions text "href=\"mailto:nobody@example.com\"")
+ s!"A URL with a scheme is left alone: {text}"
+ check (!mentions text " do
+ let dir := tmp / "chrome"
+ let (_, errors) ← exportSite dir sampleSite chromeTheme
+ check (errors.any (mentions ·.text "page chrome"))
+ s!"A theme whose page template carries chrome is reported: {errors.map (·.text)}"
+
+private def testAdHocChromeIsRejected : TestM Unit := do
+ IO.FS.withTempDir fun tmp => do
+ let dir := tmp / "adhoc"
+ let (_, errors) ← exportSite dir sampleSite overriddenChromeTheme
+ check (errors.any fun e => mentions e.text "page chrome" && mentions e.text "guide")
+ s!"Chrome from a replaced page template is reported: {errors.map (·.text)}"
+
+private def testAssetOrderIsStable : TestM Unit := do
+ let two := Template.hashNamedCss "component" (Std.HashSet.ofList [".a {}", ".b {}"])
+ let three := Template.hashNamedCss "component" (Std.HashSet.ofList [".a {}", ".b {}", ".c {}"])
+ check (two.size == 2 && three.size == 3) "Naming assets by a hash of their contents keeps them all"
+ let kept := three.filter fun css => two.any (·.filename == css.filename)
+ check (kept.map (·.filename) == two.map (·.filename))
+ s!"Adding an asset leaves the order of the others unchanged: {two.map (·.filename)} then {kept.map (·.filename)}"
+
+/-- The URLs that rewriting distinguishes, and what the export makes of each. -/
+private def urlTable : List (String × String) := [
+ ("/x", "/x"),
+ ("./x", "TOKEN/./x"),
+ ("../x", "TOKEN/../x"),
+ ("-verso-data/x", "TOKEN/-verso-data/x"),
+ ("guide/", "TOKEN/guide/"),
+ ("#frag", "#frag"),
+ ("https://x", "https://x"),
+ ("//cdn/x", "//cdn/x"),
+ ("mailto:x", "mailto:x"),
+ ("", "")
+]
+
+private def testUrlTable : TestM Unit := do
+ for (url, expected) in urlTable do
+ let actual := RenderedHtml.relocateUrl "TOKEN" url
+ check (actual == expected) s!"Rewriting '{url}': expected '{expected}', got '{actual}'"
+
+ -- The walk itself leaves `` and remote content alone.
+ let doc : Html := {{
+
+ }}
+ let rewritten := (doc.rewriteUrls ("[" ++ · ++ "]")).asString
+ check ((rewritten.find? "").isSome)
+ s!"A base element keeps its own URL: {rewritten}"
+ check ((rewritten.find? "href=\"/remote/x\"").isSome)
+ s!"An element carrying data-verso-remote keeps its own URL: {rewritten}"
+ check ((rewritten.find? "href=\"[x]\"").isSome)
+ s!"Every other URL-valued attribute is rewritten: {rewritten}"
+ check ((rewritten.find? "title=\"/not-a-url\"").isSome)
+ s!"An attribute that holds no URL is left alone: {rewritten}"
+
+private def testTitleIsChecked : TestM Unit := do
+ IO.FS.withTempDir fun tmp => do
+ let dir := tmp / "title"
+ let (_, errors) ← exportSite dir imageTitleSite
+ check (errors.any (mentions ·.text "title"))
+ s!"A title that needs more than inline markup is reported: {errors.map (·.text)}"
+
+/--
+Runs the rendered HTML content export tests, returning the number of failures.
+-/
+def runRenderedHtmlExportTests : IO Nat := do
+ let ((), failures) ←
+ (do
+ testExport
+ testChromeIsRejected
+ testAdHocChromeIsRejected
+ testAssetOrderIsStable
+ testUrlTable
+ testTitleIsChecked : TestM Unit).run 0
+ return failures
+
+end Tests.RenderedHtmlExport
diff --git a/src/tests/Tests/RenderedHtmlMount.lean b/src/tests/Tests/RenderedHtmlMount.lean
new file mode 100644
index 000000000..428259450
--- /dev/null
+++ b/src/tests/Tests/RenderedHtmlMount.lean
@@ -0,0 +1,268 @@
+/-
+Copyright (c) 2026 Lean FRO LLC. All rights reserved.
+Released under Apache 2.0 license as described in the file LICENSE.
+Author: David Thrane Christiansen
+-/
+module
+
+public import VersoBlog
+public import VersoRenderedHtml
+public import Tests.RenderedHtmlExport
+
+public section
+
+open Lean
+open Verso Genre Blog
+open Verso.Output (Html)
+open Verso.Output.Html
+open Verso.RenderedHtml (load)
+open Tests.RenderedHtmlExport (quietLogger part)
+
+namespace Tests.RenderedHtmlMount
+
+private abbrev TestM := StateRefT Nat IO
+
+private def fail (message : String) : TestM Unit := do
+ IO.eprintln s!" FAIL: {message}"
+ modify (· + 1)
+
+private def check (condition : Bool) (message : String) : TestM Unit :=
+ unless condition do fail message
+
+private def mentions (text : String) (fragment : String) : Bool :=
+ (text.find? fragment).isSome
+
+/-- The hand-written format version 1 directory that guards the format contract. -/
+def fixtureDir : System.FilePath := "test-projects/rendered-html-fixture"
+
+/-- A directory whose page paths have a gap. -/
+def sparseFixtureDir : System.FilePath := "test-projects/rendered-html-sparse-fixture"
+
+/-- A directory whose static files claim page destinations. -/
+def conflictFixtureDir : System.FilePath := "test-projects/rendered-html-conflict-fixture"
+
+/-- The directory that `tutorial-example-rendered-html` writes. -/
+def tutorialContent : System.FilePath := "_out/tutorial-content/v4.30.0"
+
+/-- A page that a mount can sit under. -/
+private def holder (title : String) : Verso.Doc.Part Page :=
+ part title #[Verso.Doc.Block.para #[.text "A page that holds mounts."]]
+
+/-- A site that mounts `dirs`, each under the name it is paired with, at the top level. -/
+def mountingSite (dirs : Array (String × System.FilePath)) : Site :=
+ .page `mounting (holder "Mounting") <|
+ dirs.map fun (name, dir) => .mount name dir {} none
+
+/-- A site whose root is a blog, so it holds no directories. -/
+def blogRootedSite : Site :=
+ .blog `blogRoot (holder "A Blog") #[]
+
+/-- The custom property definitions of one theme. -/
+private def themeVars (color : String) : String :=
+ ":root { --verso-text-color: " ++ color ++ "; }"
+
+/-- A theme that places the fragments that a mount contributes. -/
+def mountingTheme (color : String) (withLocalNav : Bool := true) : Theme :=
+ { Theme.default with
+ primaryTemplate := do
+ return {{
+
+
+ {{← Template.param (α := String) "title"}}
+ {{← Template.builtinHeader}}
+
+
+
+
+ {{← Template.param "content"}}
+
+
+ }},
+ pageTemplate := do
+ match (← Template.param? (α := Html) "fragments.content") with
+ | some content =>
+ let localNav ←
+ if withLocalNav then
+ pure ((← Template.param? (α := Html) "fragments.localNav").getD .empty)
+ else pure .empty
+ return {{{{localNav}}{{content}}}}
+ | none =>
+ return {{
{{← Template.param "title"}}
{{← Template.param "content"}}}} }
+
+/--
+Traverses and generates a site into `dir`, returning the errors and warnings that were reported.
+-/
+def generate (dir : System.FilePath) (site : Site) (theme : Theme) :
+ IO (Array Verso.LogMessage × Array Verso.LogMessage) := do
+ let logger ← quietLogger
+ let cfg : Config := { destination := dir }
+ let (site, xref) ← site.traverse cfg {} |>.run logger
+ let ctxt : Generate.Context := {
+ theme, site,
+ ctxt := { path := .root, config := cfg, components := {} },
+ xref, dir, config := cfg, header := Html.doctype,
+ linkTargets := {}, components := {}
+ }
+ let (((), _), components) ← site.generate theme |>.run ctxt .empty {} |>.run logger
+ Template.writeBuiltinAssets dir "body"
+ Template.writeHeadAssets dir (theme.headAssets xref components)
+ return (← logger.errors, ← logger.warnings)
+
+/--
+Traverses a site, returning its traversal state and the errors that were reported.
+
+Traversal reports a conflict through the logger, not through the state, so a test that asserts no
+conflict was reported has to read the logger.
+-/
+def traverseOnly (site : Site) : IO (TraverseState × Array Verso.LogMessage) := do
+ let logger ← quietLogger
+ let (_, xref) ← site.traverse {} {} |>.run logger
+ return (xref, ← logger.errors)
+
+private def attempt (act : IO α) : IO (Except String α) := do
+ try
+ return .ok (← act)
+ catch e =>
+ return .error (toString e)
+
+private def expectRejected (what : String) (expected : List String) (act : IO α) : TestM Unit := do
+ match ← attempt act with
+ | .ok _ => fail s!"{what} was accepted"
+ | .error message =>
+ for e in expected do
+ unless mentions message e do
+ fail s!"{what} was rejected, but the message did not mention '{e}': {message}"
+
+private def testPageIds : TestM Unit := do
+ let site :=
+ mountingSite #[("fixture", fixtureDir), ("fixture-again", fixtureDir), ("v4.30.0", fixtureDir)]
+ let (xref, errors) ← traverseOnly site
+ for name in ["fixture", "fixture.guide", "fixture.guide.first", "fixture.guide.«step-1»",
+ "«fixture-again»", "«fixture-again».guide", "«fixture-again».guide.«step-1»",
+ "«v4.30.0»", "«v4.30.0».guide", "«v4.30.0».guide.«step-1»"] do
+ let id := (Syntax.decodeNameLit s!"`{name}").getD .anonymous
+ check (xref.pageIds.find? id |>.isSome)
+ s!"Mounting registers the page ID '{name}': {xref.pageIds.toList.map (·.fst)}"
+ check errors.isEmpty
+ s!"Mounting the same directory twice reports no conflict: {errors.map (·.text)}"
+
+private def testRejections : TestM Unit := do
+ expectRejected "A mount of a sparse directory" ["guide"] <|
+ traverseOnly (mountingSite #[("sparse", sparseFixtureDir)])
+ expectRejected "A mount whose static files claim a page destination" ["index.html"] <|
+ traverseOnly (mountingSite #[("conflicting", conflictFixtureDir)])
+ expectRejected "Inserting a mount under a blog-rooted path" ["blog"] <|
+ blogRootedSite.insertMount [] "fixture" fixtureDir
+ expectRejected "Inserting a mount under a static directory" ["not a page"] <|
+ (Site.page `s (holder "S") #[.static "files" "test-projects"]).insertMount
+ ["files"] "fixture" fixtureDir
+ expectRejected "Inserting a mount under a mount" ["not a page"] <|
+ (mountingSite #[("fixture", fixtureDir)]).insertMount ["fixture"] "inner" fixtureDir
+ -- An empty name would put the mount's root page where its holder's own page goes.
+ expectRejected "Inserting a mount whose name is empty" ["empty"] <|
+ (Site.page `s (holder "S") #[]).insertMount [] "" fixtureDir
+ -- Two directories of the same name would generate into the same place.
+ expectRejected "Inserting a mount whose name is taken" ["already holds"] <|
+ (mountingSite #[("fixture", fixtureDir)]).insertMount [] "fixture" fixtureDir
+ expectRejected "Inserting a mount whose name a static directory holds" ["already holds"] <|
+ (Site.page `s (holder "S") #[.static "files" "test-projects"]).insertMount
+ [] "files" fixtureDir
+
+private def testExportRejectsMounts : TestM Unit := do
+ IO.FS.withTempDir fun tmp => do
+ let exported := Tests.RenderedHtmlExport.exportSite (tmp / "mounts")
+ (mountingSite #[("fixture", fixtureDir)]) (mountingTheme "black")
+ match ← attempt exported with
+ | .error message =>
+ check (mentions message "mount")
+ s!"Exporting a site that holds a mount is rejected: {message}"
+ | .ok (_, errors) =>
+ check (errors.any fun e => mentions e.text "mount" && mentions e.text "fixture")
+ s!"Exporting a site that holds a mount is rejected: {errors.map (·.text)}"
+
+private def testFragmentReports : TestM Unit := do
+ IO.FS.withTempDir fun tmp => do
+ let site := mountingSite #[("fixture", fixtureDir)]
+ let (errors, warnings) ←
+ generate (tmp / "dropped") site (mountingTheme "black" (withLocalNav := false))
+ check errors.isEmpty s!"Generating a mounting site reported errors: {errors.map (·.text)}"
+ check (warnings.any fun w => mentions w.text "localNav" && mentions w.text "fixture")
+ s!"A fragment that no template placed is reported: {warnings.map (·.text)}"
+
+ let dropping :=
+ Site.page `mounting (holder "Mounting")
+ #[.mount "fixture" fixtureDir
+ { droppedFragments := #["localNav".sluggify] } none]
+ let (_, warnings) ←
+ generate (tmp / "silent") dropping (mountingTheme "black" (withLocalNav := false))
+ check (!warnings.any (mentions ·.text "localNav"))
+ s!"A mount that drops a fragment on purpose is silent: {warnings.map (·.text)}"
+
+/-- The text between the first occurrence of `before` and the next occurrence of `after`. -/
+private def between (before after : String) (text : String) : String :=
+ match text.splitOn before with
+ | _ :: rest :: _ => (rest.splitOn after).headD ""
+ | _ => ""
+
+private def testRetheming : TestM Unit := do
+ IO.FS.withTempDir fun tmp => do
+ let site := mountingSite #[("fixture", fixtureDir)]
+ let dark := tmp / "dark"
+ let light := tmp / "light"
+ let _ ← generate dark site (mountingTheme "white")
+ let _ ← generate light site (mountingTheme "black")
+ let darkPage ← IO.FS.readFile (dark / "fixture" / "guide" / "index.html")
+ let lightPage ← IO.FS.readFile (light / "fixture" / "guide" / "index.html")
+ check (darkPage != lightPage)
+ "Two themes whose custom properties differ produce different output"
+ check (between "" "" darkPage == between "" "" lightPage)
+ "Re-theming leaves the mounted markup alone"
+ check (mentions darkPage "--verso-text-color: white" &&
+ mentions lightPage "--verso-text-color: black")
+ "Each theme's own custom properties reach the page"
+
+ -- The site's own chrome renders on a page that mounts as it does on a page that does not.
+ let mountingPage ← IO.FS.readFile (dark / "fixture" / "index.html")
+ let plainPage ← IO.FS.readFile (dark / "index.html")
+ check (mentions mountingPage "