Skip to content
Open
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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ jobs:
# Builds the candidate theme from this branch into .deploy/quantecon-theme.
- name: Build theme template
run: make build-theme
# Node-level e2e of the build-time plugin: builds a throwaway MyST
# project with the CLI installed above and asserts the git history
# injected into the page AST.
- name: Plugin tests (git-metadata)
run: npm run test:plugin
env:
THEME_TEMPLATE: ${{ github.workspace }}/.deploy/quantecon-theme
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium
- name: Run visual regression tests
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The notebook-launch toolbar trigger now has an accessible name (`aria-label="Launch notebook"`); previously it exposed no name to assistive technology. Added alongside the first regression coverage of the launch popover (Colab URL + default selection — BinderHub was deliberately not added, see [#26](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/26)).

### Added
- Git history in page headers (Phase 1 of [`PLAN.md`](./PLAN.md)): a "Last
changed" control on the page-header author line opens a centred changelog
modal with GitHub-linked commit hashes and a full-history link, mirroring the
`quantecon-book-theme` header. Data is injected at build time by a new MyST
transform plugin (`plugins/git-metadata.mjs`, `git log --follow` per page →
`mdast.data.git_metadata`), with a `site.git_metadata` page-frontmatter
override for manual pinning. mystmd has no built-in last-modified support
(jupyter-book/mystmd#2213), and plugin transforms cannot modify page
frontmatter, hence the AST channel.
- Per-PR rendered previews on GitHub Pages (`.github/workflows/preview.yml`): each PR statically builds the real `lecture-python-programming` lectures with the candidate theme (`myst build --html`, including the build-time Jupyter Book → MyST upgrade) and publishes to `gh-pages` at `pr-preview/pr-<n>/` with a sticky link comment and teardown on close. Self-contained on `GITHUB_TOKEN` — no external preview service.

### Removed
Expand Down
45 changes: 29 additions & 16 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,26 +233,39 @@ and a "full history" link.
`docs/user/git-metadata.md`.

**Upstream (build-time) deliverable — required:**
- [ ] Investigate existing `mystmd` support for last-modified / git metadata in
- [x] Investigate existing `mystmd` support for last-modified / git metadata in
frontmatter before writing anything custom.
- [ ] If absent, build a **MyST plugin** (option A) that, during `myst build`, runs
`git log --follow` per source file and injects into frontmatter, e.g.
`frontmatter.last_modified` (ISO + formatted) and
`frontmatter.changelog: [{hash, author, date, relative_time, message}]`.
Mirror the book-theme's git logic (timeouts, `--follow`, graceful no-git
fallback, configurable `max_entries` and date format).
*Finding (2026-06-12):* none built in — jupyter-book/mystmd#2213 is open with no
traction. Plugin transforms can't inject **frontmatter** either (page frontmatter
is extracted before `document`-stage transforms run), so the plugin attaches to
the page AST (`mdast.data.git_metadata`), which flows into the page JSON intact.
Per-page `site:` frontmatter also passes through (validated as a plain object) —
used as the manual override / deterministic-fixture channel.
- [x] If absent, build a **MyST plugin** (option A) that, during `myst build`, runs
`git log --follow` per source file and injects
`{ last_modified, changelog: [{hash, short_hash, author, date, message}] }`.
Mirrors the book-theme's git logic (5s timeout, `--follow`, graceful no-git/
untracked fallback, `QE_GIT_METADATA_MAX` for max entries; relative time is
computed at render so it can't go stale). → `plugins/git-metadata.mjs`, with a
node-level e2e (`npm run test:plugin`) that builds a throwaway project with the
real `myst` CLI.
- [ ] Decide where the plugin lives (a shared `quantecon-myst-plugins` package reused
across lecture repos is preferable to per-repo copies).
across lecture repos is preferable to per-repo copies). *Interim:* lives in this
repo (`plugins/`) — single self-contained `.mjs`, copy or reference from lecture
repos; revisit when a second shared plugin appears (the QuantEcon `mystmd` fork
is another candidate home, but a plugin stays usable on stock mystmd).

**Theme (render) deliverable:**
- [ ] New `app/components/PageHeaderHistory.tsx` (or fold into `ProjectFrontmatter.tsx`)
that reads the injected frontmatter and renders the button + collapsible dropdown,
with the QuantEcon blue accent, dark mode, keyboard (Esc to close), and ARIA
matching the existing toolbar components. Use Radix (already a dependency) for the
disclosure.
- [ ] Build commit/history URLs from the project `github` field (reuse the `.myst`-suffix
handling already in `LaunchButton.tsx`).
- [ ] Graceful no-op when the frontmatter fields are absent.
- [x] New `app/components/PageHeaderHistory.tsx` (rendered from `ProjectFrontmatter.tsx`)
that reads the injected AST data (or the `site.git_metadata` page-frontmatter
override) and renders the button (aligned right of the author line) + a
centred changelog modal, with the QuantEcon blue accent, dark mode, keyboard
(Esc to close), and ARIA via Radix Dialog. A modal (not an anchored popover)
keeps the changelog clear of the left/right page menus and centres on mobile.
- [x] Build commit/history URLs from the project `github` field — commit links keep the
`.myst` suffix (they target the source repo, unlike `LaunchButton.tsx`'s notebook
URLs), and the full-history link derives from the mystmd-computed `source_url`.
- [x] Graceful no-op when the metadata is absent.

**Effort:** M–L (plugin is the bulk). **Risk:** medium (new upstream component).
**Deps:** Phase 0 preview harness helps validate against a real repo.
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,41 @@ need it). BinderHub is deliberately not offered — it proved flaky in practice;
see issue [#26](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/26),
kept open as a demand-driven future request.

### Git history in page headers

The page header shows a "Last changed: ⟨date⟩" control (aligned to the right of
the author line) that opens a centred changelog modal listing the most recent
commits touching that page — commit hashes link to GitHub, and a "full history"
link opens the file's complete commit log (mirroring the `quantecon-book-theme`
header).

The data is injected at build time by [`plugins/git-metadata.mjs`](./plugins/git-metadata.mjs),
a MyST transform that runs `git log --follow` per source file and attaches
`{ last_modified, changelog: [{hash, short_hash, author, date, message}] }`
to the page AST. Copy the plugin into a lecture repo (or reference a checkout)
and register it:

```yaml
# myst.yml
project:
github: https://github.com/QuantEcon/lecture-python.myst # commit links target this repo
plugins:
- git-metadata.mjs
```

Notes:

- The header control renders nothing when no metadata is present, so projects
without the plugin are unaffected.
- The plugin is a silent no-op for untracked files, non-git checkouts, missing
`git`, or a `git log` timeout (5s). Shallow CI clones (`fetch-depth: 1`)
produce truncated history — use `fetch-depth: 0` when building for deploy.
- `QE_GIT_METADATA_MAX` caps changelog entries per page (default 10; myst-cli
does not pass options to transform plugins, hence the environment variable).
- A page can pin or correct its history manually — set the same shape under
`site.git_metadata` in the page frontmatter, which takes precedence over the
injected data (this is how the visual fixture keeps snapshots deterministic).

## Usage with MyST

Point your project's `site.template` at a **pinned release** zip:
Expand Down
206 changes: 206 additions & 0 deletions app/components/PageHeaderHistory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import * as Dialog from "@radix-ui/react-dialog";
import { ChevronDown, X } from "lucide-react";
import React from "react";
import { usePage } from "./PageProvider";

export interface GitChangelogEntry {
hash: string;
short_hash: string;
author: string;
date: string;
message: string;
}

export interface GitMetadata {
last_modified?: string;
changelog?: GitChangelogEntry[];
}

const DATE_FORMAT = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
// Pin the timezone so the server render, client hydration, and visual
// snapshots agree on the date regardless of host timezone.
timeZone: "UTC",
});

function formatDate(iso: string) {
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? iso : DATE_FORMAT.format(date);
}

function relativeTime(iso: string, now: number) {
const seconds = (now - new Date(iso).getTime()) / 1000;
if (Number.isNaN(seconds) || seconds < 60) return "just now";
const units: [number, string][] = [
[3600, "minute"],
[86400, "hour"],
[604800, "day"],
[2592000, "week"],
[31536000, "month"],
[Infinity, "year"],
];
let size = 60;
for (const [limit, label] of units) {
if (seconds < limit) {
const count = Math.floor(seconds / size);
return `${count} ${label}${count !== 1 ? "s" : ""} ago`;
}
size = limit;
}
}

/**
* "Last changed" page-header control that opens a centred changelog modal,
* mirroring the quantecon-book-theme header. A modal (rather than an anchored
* popover) keeps the changelog clear of the left/right page menus and centres
* cleanly on mobile.
*
* Data sources, in order of precedence:
* 1. `site.git_metadata` in the page frontmatter (manual override, and how
* the visual fixture pins deterministic data), then
* 2. `mdast.data.git_metadata` injected at build time by
* plugins/git-metadata.mjs.
*
* Renders nothing when neither is present.
*/
export function PageHeaderHistory() {
const page = usePage();
// Relative times depend on the clock; render absolute dates on the server
// and only switch after mount so statically exported HTML hydrates cleanly.
const [now, setNow] = React.useState<number | null>(null);
React.useEffect(() => setNow(Date.now()), []);

const frontmatter = page?.frontmatter as any;
const meta: GitMetadata | undefined =
frontmatter?.site?.git_metadata ?? (page?.mdast as any)?.data?.git_metadata;
const changelog = meta?.changelog ?? [];
const lastModified = meta?.last_modified ?? changelog[0]?.date;
if (!lastModified) return null;

// Commit links target the source repository itself, so unlike
// LaunchButton's notebook URLs the `.myst` suffix must be kept.
const github: string | undefined = frontmatter?.github;
const repoUrl = github?.startsWith("https://github.com/")
? github.replace(/\/$/, "")
: undefined;
// mystmd computes `source_url` as {repo}/blob/{branch}/{path}; only a URL of
// that shape can be rewritten into a commits view.
const sourceUrl = frontmatter?.source_url as string | undefined;
const historyUrl = sourceUrl?.includes("/blob/")
? sourceUrl.replace("/blob/", "/commits/")
: undefined;

// Without changelog entries there is nothing to expand — render plain text,
// not a non-functional interactive control.
if (changelog.length === 0) {
return (
<div className="text-sm text-qetext-light/70 dark:text-qetext-dark-muted">
Last changed: {formatDate(lastModified)}
</div>
);
}

return (
<Dialog.Root>
<Dialog.Trigger
className="group flex items-center gap-1 text-sm cursor-pointer
text-qetext-light/70 dark:text-qetext-dark-muted
hover:text-qeborder-blue dark:hover:text-qeborder-blue"
>
Last changed: {formatDate(lastModified)}
<ChevronDown
size={14}
aria-hidden
className="transition-transform group-data-[state=open]:rotate-180"
/>
</Dialog.Trigger>
<Dialog.Portal>
{/* Dim backdrop so the centred modal stands out across browsers
(Chrome lacks Safari's default dialog outline) and reads clearly
over the left/right page menus. */}
<Dialog.Overlay className="fixed inset-0 z-40 bg-black/40" />
<Dialog.Content
aria-describedby={undefined}
className={`
fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2
w-[440px] max-w-[90vw] rounded
border border-qeborder-blue
bg-white dark:bg-qepage-dark p-4
text-qetext-light dark:text-qetext-dark
shadow-lg focus:outline-none
`}
>
<div className="flex items-center justify-between gap-3 pb-2 border-b border-qetoolbar-border">
<Dialog.Title className="text-base font-medium">
Changelog
</Dialog.Title>
<div className="flex items-center gap-3">
{historyUrl && (
<a
href={historyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-qeborder-blue hover:underline"
>
full history
</a>
)}
<Dialog.Close
aria-label="Close"
className="text-qetext-light/60 dark:text-qetext-dark-muted
hover:text-qeborder-blue cursor-pointer"
>
<X size={16} aria-hidden />
</Dialog.Close>
</div>
</div>
<ol
className="m-0 p-0 list-none max-h-80 overflow-y-auto"
aria-label="Recent changes"
>
{changelog.map((entry) => (
<li
key={entry.hash}
className="py-1.5 border-b border-qetoolbar-border/50 last:border-b-0"
>
<div className="flex items-baseline gap-2">
{repoUrl ? (
<a
href={`${repoUrl}/commit/${entry.hash}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-xs text-qeborder-blue hover:underline"
>
{entry.short_hash}
</a>
) : (
<span className="font-mono text-xs opacity-70">
{entry.short_hash}
</span>
)}
<span
className="flex-1 text-sm truncate"
title={entry.message}
>
{entry.message}
</span>
</div>
<div className="flex gap-1 text-xs opacity-70">
<span>{entry.author}</span>
<span aria-hidden>·</span>
<span>
{now
? relativeTime(entry.date, now)
: formatDate(entry.date)}
</span>
</div>
</li>
))}
</ol>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
Loading
Loading