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 + + + + + +
+
%(kicker)s
+

%(title)s

+
%(meta)s
+ +
+
+ +
+

This browser will not display a PDF here — most phones will not.

+

Open the PDF or + read the article on the site.

+
+
+
+ + +""" + + +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("", at) + if end < 0: + return html, False + return html[:start] + html[end + len(""):], 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 "" 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("", SCRIPT + "", 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() diff --git a/scripts/preview_build.py b/scripts/preview_build.py index 1c80eaf..789bd67 100644 --- a/scripts/preview_build.py +++ b/scripts/preview_build.py @@ -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: diff --git a/scripts/stage_downloads.py b/scripts/stage_downloads.py index a00a4d8..8ef8cc5 100644 --- a/scripts/stage_downloads.py +++ b/scripts/stage_downloads.py @@ -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 @@ -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 //.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)) diff --git a/static/uwtn.css b/static/uwtn.css index 4c5e476..63106de 100644 --- a/static/uwtn.css +++ b/static/uwtn.css @@ -617,3 +617,27 @@ a.uwtn-tag:hover { color: var(--uwtn-accent); border-color: var(--uwtn-accent); .uwtn-landing-banner, .uwtn-landing-banner img { max-height: 180px; } } + +/* The archival PDF, as a visible link in the article's frontmatter badge row. + The theme's own route to it is an entry inside the Downloads menu, which a + reader has to know is there; this sits beside the licence badge and goes to + //read/, where the PDF is embedded with the file and the markdown + source as buttons. Sized and faded to sit with the badges rather than + compete with them. */ +.uwtn-pdf-link { + font-family: var(--uwtn-sans); + font-size: 0.72rem; + letter-spacing: 0.06em; + text-decoration: none; + color: var(--uwtn-muted); + border: 1px solid var(--uwtn-rule); + border-radius: 3px; + padding: 0.05rem 0.4rem; + margin-right: 0.55rem; + opacity: 0.75; +} +.uwtn-pdf-link:hover { + opacity: 1; + color: var(--uwtn-accent); + border-color: var(--uwtn-accent); +} diff --git a/tests/test_migration.py b/tests/test_migration.py index 07a64a7..b0fe894 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -2081,3 +2081,104 @@ def test_examples_carry_no_absolute_paths(): if hit not in allowed: offenders.append(f"{path.relative_to(root)}:{number}: {hit}") assert not offenders, "absolute home paths in shipped code:\n" + "\n".join(offenders) + +# --------------------------------------------------------------------------- # +# the reader page: //read/ +# --------------------------------------------------------------------------- # +def test_reader_page_is_built_after_the_files_it_links_to(): + """`build_reader_pages` only writes where the PDF is already staged. + + Order matters and is easy to lose in a one-line task: run it before + `stage_downloads.py` and every reader page silently disappears, because the + generator skips any article whose PDF is not in the build yet. + """ + root = pathlib.Path(__file__).resolve().parent.parent + task = (root / "pixi.toml").read_text(encoding="utf-8") + line = next(l for l in task.splitlines() if l.startswith("build-html =")) + assert line.index("stage_downloads.py") < line.index("build_reader_pages.py") + # the retarget script rewrites links in built pages, so it comes after them + assert line.index("build_reader_pages.py") < line.index("inject_reader_link.py") + + preview = (root / "scripts" / "preview_build.py").read_text(encoding="utf-8") + for step in ("build_reader_pages.py", "inject_reader_link.py"): + assert step in preview, f"the preview path does not run {step}" + + +def test_reader_page_offers_the_pdf_and_the_source(): + """The page a reader lands on carries both downloads and a way back.""" + reader = load("build_reader_pages") + page = reader.PAGE % { + "slug": "a-note", "title": "A Note", "kicker": "UWTN 2026-001", + "meta": "Someone", "style": "", + } + assert 'href="../a-note.pdf" download' in page + assert 'href="../a-note.md" download' in page + assert 'href="../"' in page # back to the article + assert 'type="application/pdf"' in page # embedded, not linked + assert 'name="robots" content="noindex"' in page # the article is canonical + # RELATIVE throughout: the preview site serves everything from a hashed + # subdirectory, and an absolute path walks out of it to the domain root. + assert 'href="/a-note' not in page and 'data="/a-note' not in page + + +def test_reader_meta_line_links_the_doi(): + reader = load("build_reader_pages") + line = reader.meta_line({ + "authors": [{"name": "A Person"}, {"name": "B Person"}], + "publication_date": "2026-08-11", + "archive_doi": "10.6084/m9.figshare.1", + }) + assert "A Person and B Person" in line + assert 'href="https://doi.org/10.6084/m9.figshare.1"' in line + assert reader.meta_line({}) == "" + + +def test_theme_downloads_menu_is_dropped(): + """One route to the PDF, not two. + + The reader page carries both files, so the theme's Downloads menu is a + quieter second way to the same two things. It is removed at both ends: the + `exports` array the theme renders it from, and the button MyST already put + in the static HTML. + """ + inject = load("inject_reader_link") + payload = '{"title":"x","exports":[{"format":"typst","url":"/build/a.pdf"}],"y":1}' + assert inject.EXPORTS.sub('"exports":[]', payload) == \ + '{"title":"x","exports":[],"y":1}' + # an already-empty array is left alone rather than matched again + assert inject.EXPORTS.sub('"exports":[]', '{"exports":[],"y":1}') == \ + '{"exports":[],"y":1}' + + button = ('
') + stripped, dropped = inject.drop_downloads_button(button) + assert dropped and stripped == '
' + assert inject.drop_downloads_button("

no menu

") == ("

no menu

", False) + + +def test_reader_link_is_visible_and_click_safe(): + """A reader must be able to see the way to the PDF, and clicking must work. + + The theme's only route is an entry inside the Downloads menu, which it + renders from its hydration payload when the menu OPENS -- so there is no + anchor to rewrite beforehand, and a rewrite that waits for one races the + reader's next click. The injected script therefore does two things: adds a + visible link to the frontmatter badge row, and catches the click in the + capture phase. + """ + inject = load("inject_reader_link") + script = inject.SCRIPT + assert ".myst-fm-block-badges" in script # somewhere visible to put it + assert "uwtn-pdf-link" in script + assert 'addEventListener("click"' in script and ", true)" in script # capture + assert "preventDefault" in script + # Relative, so the preview subdirectory survives: the href is built from the + # current path. (Asserting the absence of an absolute form matched the + # COMMENT explaining why we do not use one, which is why this asserts the + # positive instead.) + assert "window.location.pathname" in script + + style = (pathlib.Path(__file__).resolve().parent.parent + / "static" / "uwtn.css").read_text(encoding="utf-8") + assert ".uwtn-pdf-link" in style, "the injected link has no style"