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: "

x

" -/ #guard_msgs in #eval Html.asString {{

"x"

}} (breakLines := false) + +/-! ## Tests for URL rewriting -/ + +private def urlCases : Array String := + #["/x", "./x", "../x", "-verso-data/x", "#frag", "https://x", "//cdn/x", "mailto:x", ""] + +private def urlDoc : Array Html := + urlCases.map (fun u => {{"link"}}) ++ + #[{{}}, + {{"remote"}}, + {{}}, + {{}}, + {{}}, + {{}}, + {{}}, + {{
}}, + {{}}, + {{
}}, + {{"ping"}}, + {{"kept"}}] + +/-- +info: | +link +link +link +link +link +link +link +link +link + +remote + + + + + + +
+ +
+ping +kept +-/ +#guard_msgs in +#eval do + IO.println "|" + for html in (urlDoc.map <| rewriteUrls ("[" ++ · ++ "]")) do + IO.println html.asString + +/-! ## Tests for the URL-list attribute parsers -/ + +private def mark (url : String) : String := "<" ++ url ++ ">" + +/-- +Cases for `rewriteSrcset`. A candidate's URL ends at whitespace or a trailing comma, never at a +comma inside the URL, and every separator, descriptor, and piece of whitespace survives untouched. +-/ +private def srcsetCases : Array String := #[ + -- ordinary lists + "a.png", + "a.png 1x", + "a.png 1x, b.png 2x", + "a.png 480w, b.png 800w, c.png", + -- a URL containing a comma is one URL, because only a trailing comma ends a candidate + "a,b.png 1x, c.png", + "data:image/png;base64,AAAA 1x", + -- commas as the only separator, with no space after them + "a.png,b.png", + "a.png 1x,b.png 2x", + -- odd but legal whitespace and separators + " a.png 1x , b.png 2x ", + ",,, a.png 1x ,,, b.png 2x ,,,", + "\na.png\t1x,\nb.png\t2x\n", + -- degenerate inputs + "", + " ", + ",", + ",,,", + -- a descriptor holding a comma inside parentheses + "a.png (min-width, 100px), b.png 2x", + -- trailing comma with no candidate after it + "a.png 1x,", + "a.png," +] + +/-- +info: | +"a.png" => "" +"a.png 1x" => " 1x" +"a.png 1x, b.png 2x" => " 1x, 2x" +"a.png 480w, b.png 800w, c.png" => " 480w, 800w, " +"a,b.png 1x, c.png" => " 1x, " +"data:image/png;base64,AAAA 1x" => " 1x" +"a.png,b.png" => "" +"a.png 1x,b.png 2x" => " 1x, 2x" +" a.png 1x , b.png 2x " => " 1x , 2x " +",,, a.png 1x ,,, b.png 2x ,,," => ",,, 1x ,,, 2x ,,," +"\na.png\t1x,\nb.png\t2x\n" => "\n\t1x,\n\t2x\n" +"" => "" +" " => " " +"," => "," +",,," => ",,," +"a.png (min-width, 100px), b.png 2x" => " (min-width, 100px), 2x" +"a.png 1x," => " 1x," +"a.png," => "," +-/ +#guard_msgs in + #eval IO.println <| "|\n" ++ String.join + (srcsetCases.toList.map fun c => s!"{repr c} => {repr (Html.rewriteSrcset mark c)}\n") + +/-- Cases for `rewriteUrlList`, which `ping` uses. -/ +private def urlListCases : Array String := + #["a", "a b", " a b ", "", " ", "\ta\nb\t"] + +/-- +info: | +"a" => "" +"a b" => " " +" a b " => " " +"" => "" +" " => " " +"\ta\nb\t" => "\t\n\t" +-/ +#guard_msgs in + #eval IO.println <| "|\n" ++ String.join + (urlListCases.toList.map fun c => s!"{repr c} => {repr (Html.rewriteUrlList mark c)}\n") diff --git a/src/tests/Tests/RenderedHtml.lean b/src/tests/Tests/RenderedHtml.lean new file mode 100644 index 000000000..3fbf73bef --- /dev/null +++ b/src/tests/Tests/RenderedHtml.lean @@ -0,0 +1,351 @@ +/- +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 VersoRenderedHtml + +public section + +open Lean (Json ToJson FromJson toJson fromJson?) +open Verso Multi RenderedHtml + +namespace Tests.RenderedHtml + +/-- 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" + +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 checkEq [BEq α] [ToString α] (actual expected : α) (what : String) : TestM Unit := + unless actual == expected do + fail s!"{what}: expected '{expected}', got '{actual}'" + +private def attempt (act : IO α) : IO (Except String α) := do + try + return .ok (← act) + catch e => + return .error (toString e) + +private def mentions (message : String) (fragment : String) : Bool := + (message.find? fragment).isSome + +private def expectRejected (what : String) (expected : List String) (forbidden : 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}" + for f in forbidden do + if mentions message f then + fail s!"{what} was rejected, and the message wrongly mentioned '{f}': {message}" + +private def expectAccepted (what : String) (act : IO α) : TestM (Option α) := do + match ← attempt act with + | .ok v => return some v + | .error message => + fail s!"{what} was rejected: {message}" + return none + +/-- A manifest with fields that this version of Verso does not know, at both levels. -/ +private def manifestWithUnknownFields : String := " +{\"format\": \"verso-rendered-html\", + \"formatVersion\": 1, + \"futureField\": {\"anything\": [1, 2, 3]}, + \"generator\": {\"tool\": \"t\", \"version\": \"v\", \"toolchain\": \"tc\", \"futureField\": 1}, + \"title\": \"Round Trip\", + \"titleHtml\": \"Round Trip\", + \"stylesheets\": [{\"path\": \"static/a.css\", \"role\": \"variables\"}, + {\"path\": \"static/b.css\", \"role\": \"content\"}, + {\"path\": \"static/c.css\", \"role\": \"someday\"}], + \"scripts\": [{\"path\": \"static/a.js\", \"defer\": true, + \"provides\": {\"name\": \"katex\", \"version\": \"0.16.22\"}}], + \"pages\": {\"\": {\"title\": \"Root\", \"titleHtml\": \"Root\", \"futureField\": 7, + \"fragments\": {\"content\": {\"file\": \"fragments/content.html\", + \"rootToken\": \"%verso:root%\", + \"futureField\": \"x\"}}}, + \"one\": {\"title\": \"One\", \"titleHtml\": \"One\", + \"fragments\": {\"content\": {\"file\": \"fragments/one/content.html\", + \"rootToken\": \"%verso:root%\"}}}}} +" + +private def parseManifest (text : String) : IO RenderedHtmlContent := do + let json ← + match Json.parse text with + | .ok json => pure json + | .error e => throw <| .userError s!"Failed to parse: {e}" + match fromJson? json with + | .ok (manifest : RenderedHtmlContent) => pure manifest + | .error e => throw <| .userError s!"Failed to read: {e}" + +private def testRoundTrip : TestM Unit := do + let some manifest ← expectAccepted "A manifest with unknown fields" + (parseManifest manifestWithUnknownFields) + | return + checkEq manifest.title "Round Trip" "The title of the round-tripped manifest" + checkEq manifest.pages.size 2 "The page count of the round-tripped manifest" + checkEq manifest.stylesheets.size 3 "The stylesheet count of the round-tripped manifest" + check (manifest.stylesheets[2]!.role == .other "someday") + "An unrecognized stylesheet role is kept" + check (!manifest.stylesheets[2]!.role.placesAsVariables) + "An unrecognized stylesheet role is placed as content" + check (manifest.stylesheets[0]!.role.placesAsVariables) + "The variables role is placed ahead of the rest" + checkEq manifest.stylesheets[0]!.mountPath "a.css" "The mount path of a stylesheet" + checkEq manifest.scripts[0]!.mountPath "a.js" "The mount path of a script" + checkEq (manifest.scripts[0]!.provides.map (·.name)) (some "katex") + "The library that a script is a copy of" + checkEq (manifest.stylesheets[0]!.provides.map (·.name)) none + "A stylesheet that is no library names none" + let again ← parseManifest (toJson manifest).compress + checkEq (toJson again).compress (toJson manifest).compress + "Serializing and reading a manifest again is the identity" + +/-- A manifest that leaves out every field that carries a default. -/ +private def manifestWithoutOptionalFields : String := " +{\"format\": \"verso-rendered-html\", + \"formatVersion\": 1, + \"generator\": {\"tool\": \"t\", \"version\": \"v\", \"toolchain\": \"tc\"}, + \"title\": \"Sparse\", + \"titleHtml\": \"Sparse\"} +" + +private def testOptionalFields : TestM Unit := do + let some manifest ← expectAccepted "A manifest that leaves out the fields carrying defaults" + (parseManifest manifestWithoutOptionalFields) + | return + checkEq manifest.stylesheets.size 0 "An absent stylesheet list reads as empty" + checkEq manifest.scripts.size 0 "An absent script list reads as empty" + checkEq manifest.pages.size 0 "An absent page map reads as empty" + -- A field that is present but of the wrong type is still an error. + expectRejected "A manifest whose stylesheet list is not a list" [] (act := parseManifest <| + "{\"format\": \"verso-rendered-html\", \"formatVersion\": 1, + \"generator\": {\"tool\": \"t\", \"version\": \"v\", \"toolchain\": \"tc\"}, + \"title\": \"T\", \"titleHtml\": \"T\", \"stylesheets\": 7}") + +private def testFixture : TestM Unit := do + let some loaded ← expectAccepted "The checked-in fixture" (load fixtureDir) + | return + let manifest := loaded.manifest + checkEq manifest.formatVersion 1 "The fixture's format version" + checkEq manifest.title "Fixture Content" "The fixture's title" + checkEq manifest.titleHtml "Fixture Content" "The fixture's HTML title" + let paths := manifest.pages.toArray.map (pathToString ·.fst) + checkEq (", ".intercalate paths.toList) ", guide, guide/first, guide/step-1" + "The fixture's page paths, in order" + check (manifest.stylesheets.any (·.role == .other "decoration")) + "The fixture carries a stylesheet with an unrecognized role" + + let some rootPage := manifest.pages[(#[] : Multi.Path)]? + | fail "The fixture has no root page" + checkEq rootPage.title "Fixture Content" "The fixture root page's title" + let some rootFragment := rootPage.fragments["content".sluggify]? + | fail "The fixture root page has no content fragment" + checkEq rootFragment.rootToken defaultRootToken "The fixture root fragment's token" + let rootText ← loaded.readFragment rootFragment "../.." + check (mentions rootText "href=\"../../guide/\"") + s!"Substituting the fixture root fragment's token: {rootText}" + check (mentions rootText "href=\"/\"") + "A URL that begins with a slash carries no token" + check (!mentions rootText defaultRootToken) + "Substitution replaces every occurrence of the token" + + let some deepPage := manifest.pages[(#["guide", "first"] : Multi.Path)]? + | fail "The fixture has no page at 'guide/first'" + checkEq deepPage.titleHtml "First Steps" "The fixture deep page's HTML title" + let some deepFragment := deepPage.fragments["content".sluggify]? + | fail "The fixture page at 'guide/first' has no content fragment" + check (deepFragment.rootToken != defaultRootToken) + "A fragment whose prose spells the default token declares a uniquified one" + let deepText ← loaded.readFragment deepFragment "../../.." + check (mentions deepText defaultRootToken) + "Uniquifying a fragment's token leaves the prose that spells the default token alone" + check (mentions deepText "href=\"../../../files/example.txt\"") + s!"Substituting a uniquified token: {deepText}" + +private def testTokens : TestM Unit := do + check (hasToken defaultRootToken s!"a {defaultRootToken} b") "A token in the text is found" + check (!hasToken defaultRootToken "a b") "A token that is absent is not found" + checkEq (chooseToken "nothing to see here") defaultRootToken + "Text without the default token gets the default token" + let chosen := chooseToken s!"prose that spells {defaultRootToken}" + check (chosen != defaultRootToken) "Text with the default token gets a uniquified token" + check (!hasToken chosen s!"prose that spells {defaultRootToken}") + "The uniquified token does not occur in the text" + checkEq (substitute defaultRootToken "x" s!"{defaultRootToken}/a {defaultRootToken}/b") + "x/a x/b" "Substitution replaces every occurrence" + +private def testRejections : TestM Unit := do + expectRejected "A sparse page path set" ["guide"] (act := load sparseFixtureDir) + expectRejected "A directory whose static files claim page destinations" + ["index.html", "guide"] (forbidden := ["'ab'", "static/ab"]) + (act := load conflictFixtureDir) + + IO.FS.withTempDir fun tmp => do + let dir := tmp / "newer" + IO.FS.createDirAll dir + IO.FS.writeFile (manifestFile dir) + "{\"format\": \"verso-rendered-html\", \"formatVersion\": 99, + \"generator\": {\"tool\": \"t\", \"version\": \"v\", \"toolchain\": \"tc\"}, + \"title\": \"T\", \"titleHtml\": \"T\", \"stylesheets\": [], \"scripts\": [], + \"pages\": {}}" + expectRejected "A newer format version" ["newer Verso"] (act := load dir) + + IO.FS.withTempDir fun tmp => do + let dir := tmp / "missing-asset" + IO.FS.createDirAll (dir / "fragments") + IO.FS.writeFile (dir / "fragments" / "content.html") "
%verso:root%
" + IO.FS.writeFile (manifestFile dir) + "{\"format\": \"verso-rendered-html\", \"formatVersion\": 1, + \"generator\": {\"tool\": \"t\", \"version\": \"v\", \"toolchain\": \"tc\"}, + \"title\": \"T\", \"titleHtml\": \"T\", + \"stylesheets\": [{\"path\": \"static/gone.css\", \"role\": \"content\"}], + \"pages\": {\"\": {\"title\": \"T\", \"titleHtml\": \"T\", + \"fragments\": {\"content\": {\"file\": \"fragments/content.html\", + \"rootToken\": \"%verso:root%\"}}}}}" + expectRejected "A stylesheet that is not there" ["static/gone.css"] (act := load dir) + + IO.FS.withTempDir fun tmp => do + let dir := tmp / "no-pages" + IO.FS.createDirAll dir + IO.FS.writeFile (manifestFile dir) + "{\"format\": \"verso-rendered-html\", \"formatVersion\": 1, + \"generator\": {\"tool\": \"t\", \"version\": \"v\", \"toolchain\": \"tc\"}, + \"title\": \"T\", \"titleHtml\": \"T\", \"pages\": {}}" + expectRejected "A manifest with no pages" ["dense"] (act := load dir) + + for (what, path) in [("absolute", "/etc/passwd"), ("dotted", "fragments/../../secret.html")] do + IO.FS.withTempDir fun tmp => do + let dir := tmp / what + IO.FS.createDirAll dir + IO.FS.writeFile (manifestFile dir) <| + "{\"format\": \"verso-rendered-html\", \"formatVersion\": 1, + \"generator\": {\"tool\": \"t\", \"version\": \"v\", \"toolchain\": \"tc\"}, + \"title\": \"T\", \"titleHtml\": \"T\", \"stylesheets\": [], \"scripts\": [], + \"pages\": {\"\": {\"title\": \"T\", \"titleHtml\": \"T\", + \"fragments\": {\"content\": {\"file\": \"" ++ path ++ + "\", \"rootToken\": \"%verso:root%\"}}}}}" + expectRejected s!"A fragment file with an {what} path" [path] (act := load dir) + + for badPath in ["a/b c", "a//b", "a/./b", "a/../b"] do + expectRejected s!"A page path '{badPath}'" [] (act := parseManifest <| + "{\"format\": \"verso-rendered-html\", \"formatVersion\": 1, + \"generator\": {\"tool\": \"t\", \"version\": \"v\", \"toolchain\": \"tc\"}, + \"title\": \"T\", \"titleHtml\": \"T\", \"stylesheets\": [], \"scripts\": [], + \"pages\": {\"" ++ badPath ++ + "\": {\"title\": \"T\", \"titleHtml\": \"T\", \"fragments\": {}}}}") + +private def testSlugs : TestM Unit := do + for good in ["abc", "a-b_c", "ABC123", "-", "_"] do + check (Slug.isSlug? good |>.isSome) s!"'{good}' is a slug" + for bad in ["a b", "a.b", "a/b", "a.isNone) s!"'{bad}' is not a slug" + check (bad.sluggify.toString != bad) s!"Sluggifying '{bad}' changes it" + +private def sampleOutput : Output where + generator := { tool := "verso-tests", version := "1", toolchain := "none" } + title := "Written Content" + titleHtml := "Written Content" + stylesheets := #[{ path := "static/-verso-data/x.css", role := .variables }] + scripts := #[{ path := "static/-verso-data/x.js", defer := true }] + pages := #[ + { path := #[], title := "Root", titleHtml := "Root", + fragments := #[{ + name := "content".sluggify, rootToken := defaultRootToken, + content := s!"
" + }] }, + { path := #["one"], title := "One", titleHtml := "One", + fragments := #[{ + name := "content".sluggify, rootToken := defaultRootToken, + content := "

One.

" + }] } + ] + +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 := {{ +
+ + "remote" + "here" +
+ }} + 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 "