diff --git a/pixi.toml b/pixi.toml
index 8f31ecb..d56df04 100644
--- a/pixi.toml
+++ b/pixi.toml
@@ -43,7 +43,7 @@ pages = "python3 scripts/build_pages.py"
# duration of the Typst run -- and pixi runs independent tasks concurrently. Run
# in parallel they raced: the HTML was built from banner-stripped sources, and
# the blocks were missing from the tree afterwards.
-build-html = { cmd = "myst build --html && python3 scripts/fix_slugs.py && python3 scripts/inject_style.py && python3 scripts/stage_downloads.py && python3 scripts/inject_comments.py && python3 scripts/build_feed.py", depends-on = ["build-pdf"] }
+build-html = { cmd = "myst build --html && python3 scripts/fix_slugs.py && python3 scripts/inject_style.py && python3 scripts/stage_downloads.py && python3 scripts/build_reader_pages.py && python3 scripts/inject_comments.py && python3 scripts/inject_reader_link.py && python3 scripts/build_feed.py", depends-on = ["build-pdf"] }
# The banner belongs on the web, not on page one of an archival PDF. It is
# derived from the `banner:` front matter, so it can be taken out for the Typst
# build and put back after; both directions are idempotent.
diff --git a/scripts/build_index.py b/scripts/build_index.py
index 3d8511a..4117778 100644
--- a/scripts/build_index.py
+++ b/scripts/build_index.py
@@ -173,7 +173,11 @@ def entry_html(meta, description, has_pdf, banner=None, lead=False):
links = ['Read' % slug]
if has_pdf:
- links.append('PDF' % (slug, slug))
+ # The reader page rather than the file: it shows the PDF embedded and
+ # offers the download and the markdown source as buttons. A reader who
+ # wants the bytes is one click away; a reader who wanted to look at it
+ # is no clicks away.
+ links.append('PDF' % slug)
# The archival DOI is the one to circulate; until a note is deposited, the
# legacy DOI is all there is.
doi = meta.get("archive_doi") or meta.get("legacy_doi")
diff --git a/scripts/build_reader_pages.py b/scripts/build_reader_pages.py
new file mode 100644
index 0000000..bd3d771
--- /dev/null
+++ b/scripts/build_reader_pages.py
@@ -0,0 +1,164 @@
+#!/usr/bin/env python3
+"""Give every archival PDF a page of its own to be read on.
+
+A DOI click lands a reader on the repository's item page, which is a file
+browser: it shows a PDF and a zip and asks which one you wanted. The PDF is the
+publication, so the site serves it embedded on a page that says what it is and
+offers the two things a reader actually asks for next -- the file itself, and the
+markdown the article is written from.
+
+Written at ``//read/``, so it sits under the article's own URL and travels
+with it. Every link on it is RELATIVE (``../.pdf``, ``../``): the preview
+site serves the whole thing from a hashed subdirectory, and an absolute path
+would walk out of it to the domain root. Standalone HTML rather than a MyST page: it carries an embedded PDF and
+nothing else, it must not enter the toc, and generating it here keeps it out of
+the theme's client-side router (where a hydrated document would reconcile the
+embed away).
+
+Run after ``stage_downloads.py``, which is what puts the PDF and the markdown
+where this page links to.
+
+Usage:
+ python3 scripts/build_reader_pages.py [--build _build/html]
+"""
+
+import argparse
+import html
+import pathlib
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parent.parent
+ARTICLES = ROOT / "articles"
+
+sys.path.insert(0, str(ROOT / "scripts"))
+
+# The site's own tokens, inlined. The stylesheet is injected into MyST pages by
+# `inject_style.py` rather than published as a file, so there is nothing to link
+# to; keeping a small copy here is the price of a standalone page, and it is only
+# the half-dozen values that make it look like the rest of the site.
+STYLE = """
+:root {
+ --uwtn-ink: #16202b; --uwtn-muted: #5d6b7a; --uwtn-rule: #dfe4ea;
+ --uwtn-accent: #1a4f80; --uwtn-paper: #ffffff;
+ --uwtn-sans: "Helvetica Neue", Helvetica, Arial, sans-serif;
+}
+@media (prefers-color-scheme: dark) {
+ :root {
+ --uwtn-ink: #e6e9ec; --uwtn-muted: #9aa7b4; --uwtn-rule: #2b3541;
+ --uwtn-accent: #7cb3e0; --uwtn-paper: #131a21;
+ }
+}
+* { box-sizing: border-box; }
+body { margin: 0; background: var(--uwtn-paper); color: var(--uwtn-ink);
+ font-family: var(--uwtn-sans); font-size: 16px; line-height: 1.5;
+ display: flex; flex-direction: column; min-height: 100vh; }
+header { border-bottom: 1px solid var(--uwtn-rule); padding: 1.1rem 1.4rem; }
+.wrap { max-width: 68rem; margin: 0 auto; width: 100%; }
+.kicker { font-size: .78rem; letter-spacing: .09em; text-transform: uppercase;
+ color: var(--uwtn-muted); }
+h1 { font-size: 1.25rem; margin: .25rem 0 .35rem; font-weight: 600; }
+.meta { color: var(--uwtn-muted); font-size: .9rem; }
+.meta a { color: inherit; }
+.actions { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .9rem; }
+.actions a { display: inline-block; text-decoration: none; font-size: .88rem;
+ padding: .4rem .8rem; border-radius: 5px;
+ border: 1px solid var(--uwtn-rule); color: var(--uwtn-ink); }
+.actions a:hover { border-color: var(--uwtn-accent); color: var(--uwtn-accent); }
+.actions a.primary { background: var(--uwtn-accent); border-color: var(--uwtn-accent);
+ color: #fff; }
+.reader { flex: 1 1 auto; min-height: 32rem; }
+.reader object, .reader iframe { display: block; width: 100%; height: 100%;
+ min-height: 32rem; border: 0; }
+.fallback { padding: 2rem 1.4rem; color: var(--uwtn-muted); }
+"""
+
+PAGE = """
+
+
+
+
+%(title)s — Underworld Geodynamics
+
+
+
+
+
+
+
+
+"""
+
+
+def meta_line(meta):
+ """Authors, date and DOI, as one line of plain text with the DOI linked."""
+ authors = [str(a.get("name") or "") for a in (meta.get("authors") or [])]
+ if len(authors) > 2:
+ byline = "%s and %d others" % (authors[0], len(authors) - 1)
+ else:
+ byline = " and ".join(a for a in authors if a)
+ bits = [html.escape(byline)] if byline else []
+ date = str(meta.get("publication_date") or "")
+ if date:
+ bits.append(html.escape(date))
+ doi = meta.get("archive_doi") or meta.get("legacy_doi")
+ if doi:
+ bits.append('doi:%s'
+ % (html.escape(str(doi)), html.escape(str(doi))))
+ return " · ".join(bits)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--build", default="_build/html")
+ args = parser.parse_args()
+
+ import build_index
+
+ build = ROOT / args.build
+ if not build.exists():
+ sys.exit("no build at %s -- run `myst build --html` first" % build)
+
+ written = 0
+ for path in sorted(ARTICLES.glob("*/metadata.yml")):
+ meta = build_index.read_yaml(path)
+ slug = str(meta.get("slug") or path.parent.name)
+ target = build / slug
+ # Only where the reader has something to read: the PDF must be staged.
+ if not (target / ("%s.pdf" % slug)).exists():
+ continue
+ kind = str(meta.get("id") or meta.get("article_type") or "").strip()
+ page = PAGE % {
+ "slug": html.escape(slug),
+ "title": html.escape(str(meta.get("title") or slug)),
+ "kicker": html.escape(kind or "Underworld Geodynamics"),
+ "meta": meta_line(meta),
+ "style": STYLE,
+ }
+ reader = target / "read"
+ reader.mkdir(exist_ok=True)
+ (reader / "index.html").write_text(page, encoding="utf-8")
+ written += 1
+
+ print("%d reader page(s) written at //read/" % written)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/inject_reader_link.py b/scripts/inject_reader_link.py
new file mode 100644
index 0000000..59e0a5f
--- /dev/null
+++ b/scripts/inject_reader_link.py
@@ -0,0 +1,157 @@
+#!/usr/bin/env python3
+"""Give an article one visible route to its PDF, and remove the theme's.
+
+The theme's own route is an entry inside a Downloads menu, behind an icon in the
+frontmatter row: a reader has to know it is there. It is replaced by a visible
+"PDF" link in that row, which goes to ``//read/`` -- the PDF embedded, with
+the file and the markdown source as buttons. Both of the things the menu offered
+are on that page, so the menu is removed rather than left as a second, quieter
+way to the same two files.
+
+* **The menu is dropped at its source.** The theme renders it from the
+ ``exports`` array in the page's hydration payload; emptying that array means
+ there is nothing to render after hydration. The button MyST already rendered
+ into the static HTML is deleted with it -- React would reconcile it away, but
+ not before it had been on screen, and never at all for a reader without
+ Javascript.
+* **A "PDF" link is added to the frontmatter badge row**, beside the licence
+ badge, after hydration -- markup added before it is reconciled away.
+* **A click on any surviving export link is caught** in the capture phase and
+ sent to the reader page. Belt and braces: the payload edit should leave none,
+ and if a theme upgrade renders one from somewhere else it still leads to the
+ right place rather than to a bare file.
+
+Rewritten in the browser rather than in the HTML, for the same reason as the
+comments (see ``inject_comments.py``): the theme calls ``hydrateRoot(document,
+...)``, so React owns the document and reconciles away markup it did not render.
+Anything changed before hydration is changed back.
+
+Only anchors pointing INTO ``/build/`` are touched. That is the theme's export
+path and nothing else uses it, so a PDF linked from an article's own prose --
+which points at ``//.pdf`` or off-site -- is left alone.
+
+Usage:
+ python3 scripts/inject_reader_link.py [--build _build/html]
+"""
+
+import argparse
+import pathlib
+import re
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parent.parent
+MARKER = "uwtn-reader-link"
+# The theme's Downloads menu is rendered from this array in the hydration
+# payload. Emptied rather than deleted: the key is what the theme reads.
+EXPORTS = re.compile(r'"exports":\[(?!\])(?:[^][]|\[[^]]*\])*?\](?=[,}])')
+
+SCRIPT = """""" % MARKER
+
+
+def drop_downloads_button(html):
+ """Remove the server-rendered Downloads button from the frontmatter row.
+
+ Located by its own accessible label rather than by a class: the theme's
+ classes are utility soup and its element ids are generated per render, but
+ the button carries ``Downloads`` because a
+ screen reader needs it to. Buttons do not nest, so the first closing tag
+ after the opening one is the right one.
+ """
+ label = 'Downloads'
+ at = html.find(label)
+ if at < 0:
+ return html, False
+ start = html.rfind(""):], True
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--build", default="_build/html")
+ args = parser.parse_args()
+
+ build = ROOT / args.build
+ if not build.exists():
+ sys.exit("no build at %s -- run `myst build --html` first" % build)
+
+ touched, emptied, buttons = 0, 0, 0
+ for page in build.rglob("index.html"):
+ html = page.read_text(encoding="utf-8")
+ if MARKER in html or "