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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- The landing-page `{tableofcontents}` now renders like the lecture builds'
toctree instead of a plain bulleted list: section titles as real `<h2>`
headings (each with a stable anchor id, so they join the "On this page"
outline and can be deep-linked) over bulletless lists of the section's
lectures, with every entry labelled "1. Title" — enumerator, period, title.
The theme rebuilds the TOC from the site manifest (where the enumerator is
a separate field) rather than re-parsing the CLI's baked "1 Title" text,
inside a `nav` labelled "Table of contents"
(`app/components/ProjectTOC.tsx`, `styles/front-toc.css`)
([#240](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/240)).

### Changed
- Code blocks render their source at 16px with a 20px line, up from the 13px
/ 17px they inherited, against the 18px prose. The 13px was JupyterLab's
Expand Down
24 changes: 24 additions & 0 deletions UPSTREAM-CANDIDATES.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,27 @@ upstream_candidates:
route to `shouldRevalidate: () => false`; React Router 7 keeps the same
hash-removal semantics, so the migration tracked in #28 does not repair
it. No upstream issue found on jupyter-book/myst-theme at filing time.

- id: project-toc-renderer
title: Landing-page {tableofcontents} renderer (sections, enumerators, no bullets)
description: |
A theme-side renderer for the `toc:project` block: rebuilds the TOC
from the site manifest (separate `enumerator` field) instead of the
CLI's baked "1 Title" list, rendering section titles as real `<h2>`
headings with anchor ids inside a labelled `<nav>`, entries as
"1. Title", bulletless (app/components/ProjectTOC.tsx + app/tocTree.ts).
status: pending
target: jupyter-book/myst-theme
provenance:
- local_pr: 240
note: Landing-page TOC parity with the Sphinx lecture sites.
upstream:
pr: null
notes: |
Upstream has no TOC renderer at all — myst-transforms' buildTocTransform
flattens the directive into a plain list, baking the enumerator into the
text with no period, and every myst-theme template renders it as prose.
Two possible upstream shapes: keep the enumerator as a field on the
generated list AST (myst-transforms), or a `toc:*`-aware block renderer
in @myst-theme/site like this one. The section-title anchor ids and
the `nav` landmark are worth carrying either way.
94 changes: 94 additions & 0 deletions app/components/ProjectTOC.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Landing-page table of contents: renders the `{tableofcontents}`
* directive's `toc:project` block to match the existing lecture sites —
* section titles as headings with the section's lectures listed under them,
* entries as "1. About These Lectures", no bullets (styles/front-toc.css).
*
* The CLI's toc transform has already flattened the directive into a plain
* nested list whose link text bakes the enumerator in without a period
* ("1 About These Lectures") and whose section titles are bare text nodes.
* Instead of re-parsing that, the component rebuilds the TOC from the site
* manifest — the same source ContentsSidebar.tsx uses — where `enumerator`
* is a separate field and sections are headings without a slug.
*
* Semantics deliberately improve on the old quantecon-book-theme markup:
* real `<h2>` elements (the old theme faked them with
* `<p role="heading" aria-level="2">`) inside a labelled `<nav>`, each with
* a stable id, so they land in the document outline and are deep-linkable.
*/
import classNames from 'classnames';
import type { GenericNode } from 'myst-common';
import { slugToUrl } from 'myst-common';
import { getProjectHeadings } from '@myst-theme/common';
import {
useBaseurl,
useLinkProvider,
useProjectManifest,
useSiteManifest,
withBaseurl,
} from '@myst-theme/providers';
import { Block } from 'myst-to-react';
import type { TocEntry } from '../tocTree';
import { buildTocTree, tocLabel, tocSegments } from '../tocTree';

// Upstream types `Block`'s node as `GenericParent`; renderer props carry
// `GenericNode`. Same cast STDERR_RENDERERS (app/renderers.tsx) uses.
const UpstreamBlock = Block as (props: { node: GenericNode; className?: string }) => JSX.Element;

function TocList({ entries }: { entries: TocEntry[] }) {
const baseurl = useBaseurl();
const Link = useLinkProvider();
return (
<ul>
{entries.map((entry) => (
<li key={entry.slug ?? entry.htmlId ?? entry.title}>
{entry.slug ? (
<Link to={withBaseurl(`/${slugToUrl(entry.slug)}`, baseurl)}>{tocLabel(entry)}</Link>
) : (
// A nested captionless group (depth ≥ 2 in the project toc).
// Only top-level sections get real headings; promoting deeper
// groups would need level bookkeeping no lecture repo exercises.
tocLabel(entry)
)}
{entry.children.length > 0 && <TocList entries={entry.children} />}
</li>
))}
</ul>
);
}

export function ProjectTOCBlock({ node, className }: { node: GenericNode; className?: string }) {
const config = useSiteManifest();
const project = useProjectManifest();
const headings = config
? getProjectHeadings(config, project?.slug, { addGroups: false })
: undefined;
const segments = tocSegments(buildTocTree(headings ?? []));
// No manifest in this render context, or nothing to list: fall back to the
// CLI's baked list so the directive never renders as a hole in the page.
if (segments.length === 0) return <UpstreamBlock node={node} className={className} />;
return (
<nav
aria-label="Table of contents"
id={node.html_id}
className={classNames('qe-front-toc', className)}
>
{segments.map((segment) =>
segment.kind === 'section' ? (
<section key={segment.entry.htmlId} aria-labelledby={segment.entry.htmlId}>
{/* The `.heading-text` span is upstream's outline contract:
useHeaders drops any heading without one (DocumentOutline's
`.filter((h) => !!h.text)`), and myst's own heading renderer
always emits it. */}
<h2 id={segment.entry.htmlId}>
<span className="heading-text">{tocLabel(segment.entry)}</span>
</h2>
{segment.entry.children.length > 0 && <TocList entries={segment.entry.children} />}
</section>
) : (
<TocList key={segment.entries[0].slug} entries={segment.entries} />
)
)}
</nav>
);
}
26 changes: 25 additions & 1 deletion app/renderers.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { GenericNode } from 'myst-common';
import type { NodeRenderers } from '@myst-theme/providers';
import { MyST } from 'myst-to-react';
import { Block, MyST } from 'myst-to-react';
import { OUTPUT_RENDERERS } from '@myst-theme/jupyter';
import { ProjectTOCBlock } from './components/ProjectTOC';

/**
* Fancy ordered lists (QuantEcon/mystmd#50): `list` nodes carry `style`
Expand Down Expand Up @@ -100,3 +101,26 @@ export const STDERR_RENDERERS: NodeRenderers = {
return <UpstreamOutput {...props} />;
},
};

/**
* Landing-page `{tableofcontents}`: the CLI's toc transform turns the
* directive into a `block` whose only marker is `data.part === 'toc:project'`
* — unreachable by `selectRenderer`'s unist-util-select keys, which match
* top-level props only. So this wraps the base `block` renderer instead:
* project TOCs go to ProjectTOCBlock (app/components/ProjectTOC.tsx), every
* other block — including `toc:children`/`toc:page`/`toc:section` — falls
* through to upstream's Block untouched.
*/
const UpstreamBlock = Block as (props: {
node: GenericNode;
className?: string;
}) => JSX.Element;

export const TOC_RENDERERS: NodeRenderers = {
block(props: { node: GenericNode; className?: string }) {
if ((props.node.data as { part?: string } | undefined)?.part === 'toc:project') {
return <ProjectTOCBlock {...props} />;
}
return <UpstreamBlock {...props} />;
},
};
4 changes: 3 additions & 1 deletion app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type { ISearch, MystSearchIndex } from '@myst-theme/search';
import { SEARCH_ATTRIBUTES_ORDERED } from '@myst-theme/search';
import { useCallback } from 'react';
import { JUPYTER_RENDERERS } from '@myst-theme/jupyter';
import { LIST_RENDERERS, STDERR_RENDERERS } from './renderers';
import { LIST_RENDERERS, STDERR_RENDERERS, TOC_RENDERERS } from './renderers';
import { Document } from './components/Document';
import { htmlDir, htmlLang } from './i18n';
import { normalizeBaseurl } from './seo';
Expand All @@ -36,6 +36,8 @@ const RENDERERS: NodeRenderers = mergeRenderers([
LIST_RENDERERS,
// After JUPYTER_RENDERERS: wraps upstream's `output` renderer.
STDERR_RENDERERS,
// Wraps the base `block` renderer: `toc:project` blocks only.
TOC_RENDERERS,
]);

export const meta: V2_MetaFunction<typeof loader> = ({ data }) => {
Expand Down
98 changes: 98 additions & 0 deletions app/tocTree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* Pure helpers behind the landing-page `{tableofcontents}` renderer
* (app/components/ProjectTOC.tsx). Split out of the component so the tree
* building and label formatting run under `node --test` with type stripping,
* like app/i18n.ts and app/seo.ts.
*
* The site manifest's project headings (`getProjectHeadings` from
* `@myst-theme/common`) are the data source, not the directive's baked AST:
* the CLI's toc transform flattens `"1 About These Lectures"` into plain text
* with no separate enumerator, while the manifest keeps `enumerator` as its
* own field — which is what lets `tocLabel` emit the `1.` the lecture sites
* use without guessing at strings that merely start with a number.
*/
import { createHtmlId } from 'myst-common';

/** The subset of `@myst-theme/common`'s `Heading` this module consumes. */
export type TocHeading = {
slug?: string;
title: string;
level: number | 'index';
enumerator?: string;
};

export type TocEntry = {
title: string;
slug?: string;
enumerator?: string;
/** Anchor id, present only on captionless section titles (no slug). */
htmlId?: string;
children: TocEntry[];
};

/** "1. About These Lectures" — enumerator, period, space, title. */
export function tocLabel(entry: { title: string; enumerator?: string }): string {
return entry.enumerator ? `${entry.enumerator}. ${entry.title}` : entry.title;
}

/**
* Nest the flat, level-annotated manifest headings into a tree, dropping the
* `level: 'index'` entry (the landing page must not list itself). Section
* titles — headings without a slug — get a stable, deduplicated anchor id so
* the document outline and deep links can target them.
*/
export function buildTocTree(headings: TocHeading[]): TocEntry[] {
const root: TocEntry[] = [];
const stack: { level: number; entry: TocEntry }[] = [];
const idCounts = new Map<string, number>();

for (const heading of headings) {
if (heading.level === 'index') continue;
const entry: TocEntry = {
title: heading.title,
slug: heading.slug,
enumerator: heading.enumerator,
children: [],
};
if (!heading.slug) {
const base = createHtmlId(heading.title) || 'section';
const seen = idCounts.get(base) ?? 0;
idCounts.set(base, seen + 1);
entry.htmlId = seen === 0 ? base : `${base}-${seen + 1}`;
}
while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) {
stack.pop();
}
const parent = stack[stack.length - 1];
(parent ? parent.entry.children : root).push(entry);
stack.push({ level: heading.level, entry });
}
return root;
}

/**
* Split the top level into render segments: each captionless section title
* becomes a heading with its children as the sibling list, and runs of
* directly-linked pages (a flat TOC, or stray top-level pages between
* sections) collapse into one list.
*/
export type TocSegment =
| { kind: 'section'; entry: TocEntry }
| { kind: 'list'; entries: TocEntry[] };

export function tocSegments(tree: TocEntry[]): TocSegment[] {
const segments: TocSegment[] = [];
for (const entry of tree) {
if (!entry.slug) {
segments.push({ kind: 'section', entry });
continue;
}
const last = segments[segments.length - 1];
if (last?.kind === 'list') {
last.entries.push(entry);
} else {
segments.push({ kind: 'list', entries: [entry] });
}
}
return segments;
}
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Every theme option lives under `site.options` and is listed in
| --- | --- |
| [migrating](migrating.md) | moving a lecture repo off `quantecon-book-theme`, step by step |
| [configuration](configuration.md) | every `site.options` key, its scope and its default |
| [layout](layout.md) | header, contents drawer, "On this page" panel, back-to-top, footer |
| [layout](layout.md) | header, contents drawer, "On this page" panel, landing-page table of contents, back-to-top, footer |
| [authors](authors.md) | author line and translator credit |
| [launch](launch.md) | notebook launch buttons (Colab) and the notebook repo conventions |
| [notebooks](notebooks.md) | notebook output rendering, live compute, collapsible stderr |
Expand Down
15 changes: 15 additions & 0 deletions docs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,21 @@ into one, its subsections then expand, and an h3's h4s expand once that h3 or
one of those h4s is current. Ancestors of the current entry are expanded but
not marked — exactly one entry is marked at a time.

On a landing page that carries a `{tableofcontents}` directive, the TOC's
section titles are headings too, so they are listed here and can be linked to
by fragment.

## Landing-page table of contents

The `{tableofcontents}` directive renders as the Sphinx lecture sites render
their toctree: each section title from `project.toc` as a heading over a
bulletless list of its lectures, every entry labelled with its number and a
period ("1. About These Lectures") when `numbering.titles` is on, and the bare
title otherwise. The landing page itself is never listed. The theme builds the
list from the site manifest rather than the directive's output, so a lecture
whose title starts with a number is left alone. The whole block is a `nav`
labelled "Table of contents".

## Back to top

A "↑ Top" link in the margin column, visible after 80px of scrolling, as a plain
Expand Down
1 change: 1 addition & 0 deletions styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
font paths would resolve relative to this file's *output* and 404. */
@import '@myst-theme/styles';
@import './lists.css';
@import './front-toc.css';
@import './mpl-widget.css';
/* QuantEcon content styles. Last, so it has the final say among the
imports -- see the file's header for the specificity rules. */
Expand Down
Loading
Loading