Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Git history in page headers (Phase 1 of [`PLAN.md`](./PLAN.md)): a "Last
changed" control in the page header expands into a changelog dropdown 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.

### Removed
- Retire the `make deploy` / `deploy-theme` targets — releases ship exclusively via the tag-triggered GitHub Release workflow, and the legacy build repo (`QuantEcon/quantecon-theme`) is archived. `make build-theme` now assembles the bundle locally instead of cloning the archived repo (so the CI test harness no longer depends on it), and a new `make build-zip` produces the release-equivalent zip for local artifact testing ([#81](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/81)).

Expand Down
42 changes: 27 additions & 15 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,26 +224,38 @@ 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,
- [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 + 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.
via the Radix popover used by the existing toolbar components.
- [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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,40 @@ downloads:

The launch notebooks capability has been developed to mirror capabilities in the previous QuantEcon theme. It is assumed that the `.notebooks` suffix convention for repository naming is used when launching both Google Colab and Private Jupyter Hub sessions.

### Git history in page headers

The page header shows a "Last changed: ⟨date⟩" control that expands into a
changelog of 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
170 changes: 170 additions & 0 deletions app/components/PageHeaderHistory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import * as Popover from '@radix-ui/react-popover';
import { ChevronDown } 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 with a changelog dropdown, mirroring the
* quantecon-book-theme header.
*
* 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 already computes `source_url` as {repo}/blob/{branch}/{path}.
const historyUrl = (frontmatter?.source_url as string | undefined)?.replace(
'/blob/',
'/commits/'
);
Comment thread
mmcky marked this conversation as resolved.
Outdated

return (
<Popover.Root>
<Popover.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"
>
Comment thread
mmcky marked this conversation as resolved.
Outdated
Last changed: {formatDate(lastModified)}
{changelog.length > 0 && (
<ChevronDown
size={14}
aria-hidden
className="transition-transform group-data-[state=open]:rotate-180"
/>
)}
</Popover.Trigger>
{changelog.length > 0 && (
<Popover.Portal>
<Popover.Content
align="start"
sideOffset={3}
className={`
z-10 w-[420px] max-w-[90vw] rounded bg-white dark:bg-qepage-dark p-4
text-qetext-light dark:text-qetext-dark
will-change-[transform,opacity]
shadow-md
dark:shadow-sm
dark:shadow-white/20
`}
>
<Popover.Arrow className="shadow-md stroke-2 fill-white dark:fill-qepage-dark" />
<div className="flex items-baseline justify-between pb-2 border-b border-qetoolbar-border">
<span className="text-base font-medium">Changelog</span>
{historyUrl && (
<a
href={historyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-qeborder-blue hover:underline"
>
full history
</a>
)}
</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>
</Popover.Content>
</Popover.Portal>
)}
</Popover.Root>
);
}
2 changes: 2 additions & 0 deletions app/components/ProjectFrontmatter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Author } from '@myst-theme/frontmatter';
import type { Affiliation, Contributor } from 'myst-frontmatter';
import React from 'react';
import { useBaseurl, useLinkProvider } from '@myst-theme/providers';
import { PageHeaderHistory } from './PageHeaderHistory';

export function ProjectFrontmatter({
className,
Expand Down Expand Up @@ -69,6 +70,7 @@ export function ProjectFrontmatter({
}, '')}
</div>
)}
<PageHeaderHistory />
</div>
);
}
8 changes: 8 additions & 0 deletions app/types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import type { GitMetadata } from './components/PageHeaderHistory';

export interface TemplateOptions {
hide_toc?: boolean;
hide_outline?: boolean;
hide_search?: boolean;
hide_footer_links?: boolean;
outline_maxdepth?: number;
hide_title_block?: boolean;
/**
* Page-level override for the "Last changed" header control, normally
* injected at build time by plugins/git-metadata.mjs (set under `site:` in
* page frontmatter).
*/
git_metadata?: GitMetadata;
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"start": "npm run build:css && remix dev",
"test:visual": "playwright test",
"test:visual:update": "playwright test --update-snapshots",
"test:plugin": "node --test \"tests/plugin/**/*.test.mjs\"",
"test:fouc": "playwright test --project=webkit-fouc"
},
"dependencies": {
Expand Down
Loading
Loading