Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion scripts/build_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,11 @@ def entry_html(meta, description, has_pdf, banner=None, lead=False):

links = ['<a class="uwtn-read" href="/%s/">Read</a>' % slug]
if has_pdf:
links.append('<a href="/%s/%s.pdf">PDF</a>' % (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('<a href="/%s/read/">PDF</a>' % 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")
Expand Down
164 changes: 164 additions & 0 deletions scripts/build_reader_pages.py
Original file line number Diff line number Diff line change
@@ -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 ``/<slug>/read/``, so it sits under the article's own URL and travels
with it. Every link on it is RELATIVE (``../<slug>.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 = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>%(title)s — Underworld Geodynamics</title>
<meta name="robots" content="noindex">
<link rel="canonical" href="../">
<style>%(style)s</style>
</head>
<body>
<header><div class="wrap">
<div class="kicker">%(kicker)s</div>
<h1>%(title)s</h1>
<div class="meta">%(meta)s</div>
<div class="actions">
<a class="primary" href="../%(slug)s.pdf" download>Download PDF</a>
<a href="../%(slug)s.md" download>Markdown source</a>
<a href="../">Read on the site</a>
</div>
</div></header>
<div class="reader">
<object data="../%(slug)s.pdf#view=FitH" type="application/pdf">
<div class="fallback">
<p>This browser will not display a PDF here — most phones will not.</p>
<p><a href="../%(slug)s.pdf">Open the PDF</a> or
<a href="../">read the article on the site</a>.</p>
</div>
</object>
</div>
</body>
</html>
"""


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('<a href="https://doi.org/%s">doi:%s</a>'
% (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 /<slug>/read/" % written)


if __name__ == "__main__":
main()
157 changes: 157 additions & 0 deletions scripts/inject_reader_link.py
Original file line number Diff line number Diff line change
@@ -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 ``/<slug>/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 ``/<slug>/<slug>.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 = """<script id="%s">
(function () {
var CLASS = "uwtn-pdf-link";

function readerHref() {
// Built from the CURRENT path, not from "/" + slug: the preview site serves
// the whole site from a hashed subdirectory, and an absolute path would walk
// out of it to the domain root.
var path = window.location.pathname.replace(/\/+$/, "");
if (!path || /\/read$/.test(path)) return null;
return path + "/read/";
}

// 1. A visible link in the frontmatter badge row, beside the licence badge.
function addBadge() {
var href = readerHref();
if (!href) return;
var row = document.querySelector(".myst-fm-block-badges");
if (!row) return; // not an article page
var existing = row.querySelector("." + CLASS);
if (existing) { existing.setAttribute("href", href); return; }
var link = document.createElement("a");
link.className = CLASS;
link.setAttribute("href", href);
link.setAttribute("aria-label", "Read the archival PDF");
link.textContent = "PDF";
row.insertBefore(link, row.firstChild);
}

// 2. The theme's Downloads menu. Its entries are rendered from the hydration
// payload only when the menu opens, so there is nothing to rewrite until
// then -- and a rewrite that waits for them races the reader's next click.
// Catching the click is exact.
document.addEventListener("click", function (event) {
var anchor = event.target && event.target.closest
? event.target.closest('a[href*="/build/"][href$=".pdf"]') : null;
if (!anchor) return;
var href = readerHref();
if (!href) return;
event.preventDefault();
window.location.href = href;
}, true); // capture: before the theme

// After hydration, like the comments bootstrap: anything done earlier is undone.
function start() { window.setTimeout(addBadge, 0); }
if (document.readyState === "complete") start();
else window.addEventListener("load", start);

// The theme routes on the client, so a new article never reloads the page.
var pending = null;
new MutationObserver(function () {
if (pending) return;
pending = window.setTimeout(function () { pending = null; addBadge(); }, 120);
}).observe(document.body, { childList: true, subtree: true });
})();
</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 ``<span class="sr-only">Downloads</span>`` 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 = '<span class="sr-only">Downloads</span>'
at = html.find(label)
if at < 0:
return html, False
start = html.rfind("<button", 0, at)
if start < 0:
return html, False
end = html.find("</button>", at)
if end < 0:
return html, False
return html[:start] + html[end + len("</button>"):], 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 "</body>" not in html:
continue
# Empty the exports the Downloads menu is rendered from. Non-greedy to
# the closing bracket that is followed by a comma or a brace, so it
# stops at the array and not at the end of the payload.
html, count = EXPORTS.subn('"exports":[]', html)
emptied += 1 if count else 0
html, dropped = drop_downloads_button(html)
buttons += 1 if dropped else 0
page.write_text(html.replace("</body>", SCRIPT + "</body>", 1),
encoding="utf-8")
touched += 1
print("reader link wired into %d page(s); theme downloads dropped from %d "
"payload(s) and %d button(s)" % (touched, emptied, buttons))


if __name__ == "__main__":
main()
2 changes: 2 additions & 0 deletions scripts/preview_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ def build(slugs, base_url, whole_site=False):
[sys.executable, "scripts/fix_slugs.py"],
[sys.executable, "scripts/inject_style.py"],
[sys.executable, "scripts/stage_downloads.py"],
[sys.executable, "scripts/build_reader_pages.py"],
[sys.executable, "scripts/inject_reader_link.py"],
]
for step in steps:
if subprocess.call(step, cwd=ROOT, env=env) != 0:
Expand Down
8 changes: 7 additions & 1 deletion scripts/stage_downloads.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Place each article's archival PDF beside its page in the built site.
"""Place each article's archival PDF and markdown source beside its page.

MyST writes the PDF next to the article source, not into the site, so without
this the download link on every page and on the front page is a 404. The PDF is
Expand Down Expand Up @@ -44,6 +44,12 @@ def main():
continue
shutil.copy2(pdf, target_dir / pdf.name)
staged.append((slug, pdf.stat().st_size))
# The markdown the article is written from, at /<slug>/<slug>.md. The
# reader page offers it beside the PDF: it is what someone reusing a
# figure, a table or an equation actually wants, and it is already here.
source = directory / ("%s.md" % slug)
if source.exists():
shutil.copy2(source, target_dir / source.name)

for slug, size in staged:
print(" staged %-56s %5dKB" % (slug[:56], size / 1024))
Expand Down
Loading
Loading