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

## [Unreleased]

### Added
- The full OpenGraph / Twitter card set the Sphinx lecture sites emit, on every
page: `og:type`, `og:site_name`, `og:url` (from a new `site_url` option; myst's `site.domains` never reaches the theme), a site-level
`og:image` / `twitter:image` when the page has no thumbnail (new
`og_logo_url` / `twitter_logo_url` options, named as in the book theme),
`twitter:site`, and `og:locale` from `current_language`. `twitter:site` had
never rendered: upstream puts it in the root route's meta, which the article
route's replaces under Remix v2 semantics. Phase 6 of the book-theme parity
plan ([#92](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/92)) ([#197](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/197)).
- Collapsible stderr: a notebook cell's stderr stream is folded behind a
"⚠ Code warnings" disclosure, closed by default, as the Sphinx build's
`stderr-warnings.js` does. A native `<details>` around the stream at render
time rather than DOM surgery after load, so it holds in server-rendered HTML
without a script; stdout in the same cell stays visible
([#92](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/92)) ([#197](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/197)).
- A `docs/` feature reference mirroring the book theme's `docs/user/*` pages
(configuration, layout, authors, launch, notebooks, git metadata, code
highlighting, text colour schemes, dark mode, RTL and editions,
announcements), each naming its `myst.yml` keys and what has no counterpart
([#92](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/92)) ([#197](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/197)).

### Fixed
- The "On this page" panel is pinned, tracks scrolling and lists subsections,
as the Sphinx sites' `sticky_contents` panel does. It scrolled off with the
Expand Down
16 changes: 10 additions & 6 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -484,16 +484,20 @@ for the same translated sites).

**Goal:** close the smaller gaps and verify assumptions.

- [ ] **OpenGraph/Twitter parity:** the book-theme emits a full OG + Twitter card set;
- [x] **OpenGraph/Twitter parity:** the book-theme emits a full OG + Twitter card set;
`root.tsx` currently uses `getMetaTagsForSite` (title/description/twitter). Add
`og:image`/`twitter:image` (logo), `og:type`, `og:site_name`, etc., driven from
site config.
- [ ] **Collapsible stderr warnings:** confirm whether `@myst-theme/jupyter` already
site config. *Done (#92): `app/seo.ts` adds `og:type`, `og:site_name`, `og:url`
(from a `site_url` option), site-level images (`og_logo_url` / `twitter_logo_url`),
`twitter:site` and `og:locale` on top of upstream's article tags.*
- [x] **Collapsible stderr warnings:** confirm whether `@myst-theme/jupyter` already
renders notebook stderr in a collapsible/styled way (it may — verify before
porting). If not, add an output transform/renderer.
- [ ] **Docs:** add a `docs/`-style feature reference for the MyST theme mirroring the
porting). If not, add an output transform/renderer. *Verified (#92): upstream
renders stderr as a plain `<pre class="jupyter-error">`; ported as a native
`<details>` around the stderr `output` node (`STDERR_RENDERERS`).*
- [x] **Docs:** add a `docs/`-style feature reference for the MyST theme mirroring the
book-theme's `docs/user/*` set, so downstream lecture maintainers have parity
documentation.
documentation. *Done (#92): `docs/` with one page per book-theme user page.*

**Effort:** S–M. **Risk:** low. **Deps:** none.

Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ QuantEcon lectures and books, distributed as a zip attached to each
- Notebook launch (Google Colab) with configurable repo, branch and paths
- In-page live compute via Thebe (JupyterLite / Pyodide by default)
- Git history in page headers ("Last changed" plus an inline changelog)
- Full OpenGraph / Twitter card meta tags, and collapsible stderr on notebook cells
- A `docs/` feature reference mirroring the book theme's user guide
- Page Footer based on MyST Content
- Bundled QuantEcon brand assets

Expand Down Expand Up @@ -205,6 +207,24 @@ One trap: a page whose `site:` frontmatter sets any of these keys has its whole
`site:` block replaced by the validated keys, so an undeclared `site.git_metadata`
on the same page is lost. Keep the two on different pages.

### Meta tags

Every page carries the OpenGraph and Twitter card set the Sphinx lecture sites
emit: `og:title`, `og:description`, `og:type` (`website`), `og:site_name`,
`og:url`, `og:image`, `og:locale`, and `twitter:card`, `twitter:site`,
`twitter:creator`, `twitter:title`, `twitter:description`, `twitter:image`.
Four `site.options` keys feed them: `site_url` (the public URL, for
`og:url`; myst's `site.domains` does not reach the theme), `twitter`, and
the two image URLs `og_logo_url` / `twitter_logo_url`, named as in the book
theme so a lecture repo copies its values across. A page's own thumbnail takes precedence for
`og:image`.

### Collapsible stderr

A notebook cell's stderr stream is folded behind a "⚠ Code warnings"
disclosure, closed by default, as on the Sphinx sites; stdout in the same cell
stays visible. It is a native `<details>`, so it works without JavaScript.

## Site options

Everything the theme reads from `site.options` is declared in
Expand All @@ -218,6 +238,8 @@ block inside a string (`key: |`), which the theme parses.
| Option | Scope | Purpose |
| ------ | ----- | ------- |
| `twitter` | site | Handle for the `twitter:site` / `twitter:creator` card meta tags |
| `site_url` | site | The site's public URL, for `og:url` ([Meta tags](#meta-tags)) |
| `og_logo_url`, `twitter_logo_url` | site | Site-level images for `og:image` / `twitter:image` when a page has no thumbnail ([Meta tags](#meta-tags)) |
| `favicon` | site | Favicon file, relative to `myst.yml`; served at `/favicon.ico` (the QuantEcon lectures favicon when unset) |
| `analytics_google`, `analytics_plausible` | site | Analytics IDs, rendered by `@myst-theme/site` |
| `hide_toc`, `hide_search` | site or page | Hide the contents drawer / the search control |
Expand Down
36 changes: 36 additions & 0 deletions app/renderers.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { GenericNode } from 'myst-common';
import type { NodeRenderers } from '@myst-theme/providers';
import { MyST } from 'myst-to-react';
import { OUTPUT_RENDERERS } from '@myst-theme/jupyter';

/**
* Fancy ordered lists (QuantEcon/mystmd#50): `list` nodes carry `style`
Expand Down Expand Up @@ -65,3 +66,38 @@ export const LIST_RENDERERS: NodeRenderers = {
);
},
};

/**
* Collapsible stderr (Phase 6, #92). The Sphinx build's `stderr-warnings.js`
* folds a cell's stderr streams behind a "Code warnings" button after page
* load; upstream @myst-theme/jupyter renders them as a plain `<pre
* class="jupyter-error">`. Same fold, done at render: a stderr stream
* `output` node is wrapped in a native `<details>`, closed by default, so it
* works in the server-rendered HTML with no script and no DOM surgery. Every
* other output goes to upstream's renderer untouched. Styled by the
* `.qe-stderr` block in styles/quantecon.css.
*/
const UpstreamOutput = OUTPUT_RENDERERS.output as (props: {
node: GenericNode;
className?: string;
}) => JSX.Element | null;

export const STDERR_RENDERERS: NodeRenderers = {
output(props: { node: GenericNode; className?: string }) {
const data = props.node.jupyter_data as { output_type?: string; name?: string } | undefined;
if (data?.output_type === 'stream' && data?.name === 'stderr') {
return (
<details className="qe-stderr">
<summary>
<span className="qe-stderr__icon" aria-hidden="true">
</span>{' '}
Code warnings
</summary>
<UpstreamOutput {...props} />
</details>
);
}
return <UpstreamOutput {...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 } from './renderers';
import { LIST_RENDERERS, STDERR_RENDERERS } from './renderers';
import { Document } from './components/Document';
import { htmlDir, htmlLang } from './i18n';
import type { TemplateOptions } from './types';
Expand All @@ -33,6 +33,8 @@ const RENDERERS: NodeRenderers = mergeRenderers([
defaultRenderers,
JUPYTER_RENDERERS,
LIST_RENDERERS,
// After JUPYTER_RENDERERS: wraps upstream's `output` renderer (#92).
STDERR_RENDERERS,
]);

export const meta: V2_MetaFunction<typeof loader> = ({ data }) => {
Expand Down
14 changes: 12 additions & 2 deletions app/routes/$.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { SiteManifest } from 'myst-config';
import { ErrorPage } from '~/components/ErrorPage';
import { Page } from '~/components/Page';
import { hreflangLinks } from '~/i18n';
import { mergeMeta, socialMetaTags } from '~/seo';

// Never re-run the loader on a navigation that changes neither pathname nor
// search (Back off an in-page anchor on a static build) -- #186.
Expand All @@ -37,8 +38,17 @@ export const meta: V2_MetaFunction<typeof loader> = ({ data, matches, location }
);
const baseurl = rootMatch?.data?.BASE_URL;

// Phase 6 (#92): the Sphinx sites' full OpenGraph / Twitter set on top of
// upstream's article tags -- see app/seo.ts for what upstream leaves out.
const social = socialMetaTags({
domains: config?.domains,
siteTitle: config?.title ?? project?.title,
pageImage: (page?.thumbnailOptimized || page?.thumbnail) ?? (project?.thumbnailOptimized || project?.thumbnail) ?? undefined,
pathname: `${baseurl ?? ''}${location.pathname}`,
options: config?.options as any,
});
return [
...getMetaTagsForArticle({
...mergeMeta(getMetaTagsForArticle({
origin: '',
url: location.pathname,
title: page?.title ? `${page.title}${siteTitle ? ` - ${siteTitle}` : ''}` : siteTitle,
Expand All @@ -49,7 +59,7 @@ export const meta: V2_MetaFunction<typeof loader> = ({ data, matches, location }
undefined,
twitter: config?.options?.twitter,
keywords: page?.keywords ?? project?.keywords ?? config?.keywords ?? [],
}),
}), social),
// hreflang alternates for the translated editions (Phase 4, #90).
...hreflangLinks(config?.options, location.pathname, baseurl),
];
Expand Down
14 changes: 12 additions & 2 deletions app/routes/_index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getProject } from '@myst-theme/common';

import { Page } from '~/components/Page';
import { hreflangLinks } from '~/i18n';
import { mergeMeta, socialMetaTags } from '~/seo';

// Never re-run the loader on a navigation that changes neither pathname nor
// search (Back off an in-page anchor on a static build) -- #186.
Expand All @@ -27,16 +28,25 @@ export const meta: V2_MetaFunction<typeof loader> = ({ data, matches, location }
);
const baseurl = rootMatch?.data?.BASE_URL;

// Phase 6 (#92): the Sphinx sites' full OpenGraph / Twitter set on top of
// upstream's article tags -- see app/seo.ts for what upstream leaves out.
const social = socialMetaTags({
domains: config?.domains,
siteTitle: config?.title ?? project?.title,
pageImage: (project.thumbnailOptimized || project.thumbnail) ?? undefined,
pathname: `${baseurl ?? ''}${location.pathname}`,
options: config?.options as any,
});
return [
...getMetaTagsForArticle({
...mergeMeta(getMetaTagsForArticle({
origin: '',
url: location.pathname,
title: config?.title ?? project.title,
description: config.description ?? project.description ?? undefined,
image: (project.thumbnailOptimized || project.thumbnail) ?? undefined,
keywords: config.keywords ?? project.keywords ?? [],
twitter: config?.options?.twitter,
}),
}), social),
// hreflang alternates for the translated editions (Phase 4, #90).
...hreflangLinks(config?.options, location.pathname, baseurl),
];
Expand Down
123 changes: 123 additions & 0 deletions app/seo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* Social / SEO meta tags (Phase 6, #92) -- the full OpenGraph and Twitter
* card set the Sphinx lecture sites emit, on top of what @myst-theme/site's
* `getMetaTagsForArticle` already produces (title, description, keywords,
* og:title/description/url/image, twitter:card/creator/title/description/
* image/alt).
*
* What upstream leaves out, and the Sphinx sites ship on every page:
*
* og:type "website" (Sphinx: every lecture page)
* og:site_name the site title
* og:url upstream needs an `origin`, which the routes never had.
* It comes from the `site_url` option (the Sphinx sites'
* `html_baseurl`); `site.domains` would be the natural
* source, but the CLI's site manifest does not carry it, so
* it is only a fallback should that change
* og:image / a site-level image when the page has no thumbnail --
* twitter:image `og_logo_url` and `twitter_logo_url`, the book theme's
* option names, so a lecture repo copies its values across
* twitter:site upstream puts it in the root route's meta, which Remix v2
* replaces with the article route's, so it never rendered
* og:locale from `current_language` (Phase 4), when set
*
* Pure TypeScript with no React; tests/unit/seo.test.mjs runs it under
* `node --test` with type stripping like the other helpers.
*/
import type { V2_MetaDescriptor } from '@remix-run/react';

export interface SeoSiteOptions {
site_url?: string;
twitter?: string;
og_logo_url?: string;
twitter_logo_url?: string;
current_language?: string;
}

export interface SeoInput {
/** `site.domains` from myst.yml, if the manifest ever carries it; `options.site_url` is the real source. */
domains?: string[];
/** Site title, for og:site_name. */
siteTitle?: string;
/** The page's own image, if any (thumbnail); site-level images fill in. */
pageImage?: string;
/** Path of the page, including the static build's base URL. */
pathname: string;
options?: SeoSiteOptions;
}

/**
* The canonical origin: `site_url` (an absolute URL, trailing slash and path
* dropped to the origin), else `https://<first domain>` -- the CLI validates
* domains as bare hosts.
*/
export function siteOrigin(siteUrl?: string, domains?: string[]): string | undefined {
const fromOption = siteUrl?.trim();
if (fromOption) {
try {
return new URL(/^https?:\/\//i.test(fromOption) ? fromOption : `https://${fromOption}`).origin;
} catch {
// fall through to domains
}
}
const host = domains?.find((d) => typeof d === 'string' && d.trim());
if (!host) return undefined;
const h = host.trim().replace(/\/+$/, '');
return /^https?:\/\//i.test(h) ? h : `https://${h}`;
}

function handle(twitter?: string): string | undefined {
const t = twitter?.trim().replace(/^@/, '');
return t ? `@${t}` : undefined;
}

/** BCP 47 code to an OpenGraph locale (`en` -> `en_US`, `zh-cn` -> `zh_CN`). */
export function ogLocale(code?: string): string | undefined {
const c = code?.trim();
if (!c) return undefined;
const [lang, region] = c.split(/[-_]/);
if (!lang) return undefined;
if (region) return `${lang.toLowerCase()}_${region.toUpperCase()}`;
const defaults: Record<string, string> = { en: 'en_US', fa: 'fa_IR', fr: 'fr_FR', zh: 'zh_CN', es: 'es_ES', ja: 'ja_JP' };
return defaults[lang.toLowerCase()] ?? lang.toLowerCase();
}

/**
* The tags to add to (or replace in) upstream's article set. Applied by
* `mergeMeta`, which drops an upstream tag with the same name/property first,
* so a site-level image does not sit beside a missing page image and
* twitter:image follows the site's Twitter logo when one is configured.
*/
export function socialMetaTags({ domains, siteTitle, pageImage, pathname, options }: SeoInput): V2_MetaDescriptor[] {
const origin = siteOrigin(options?.site_url, domains);
const image = pageImage || options?.og_logo_url;
const twitterImage = options?.twitter_logo_url || image;
const tags: V2_MetaDescriptor[] = [{ property: 'og:type', content: 'website' }];
if (siteTitle) tags.push({ property: 'og:site_name', content: siteTitle });
if (origin) tags.push({ property: 'og:url', content: `${origin}${pathname}` });
if (image) tags.push({ property: 'og:image', content: image });
const site = handle(options?.twitter);
if (site) {
tags.push({ name: 'twitter:site', content: site });
tags.push({ name: 'twitter:card', content: 'summary' });
}
if (twitterImage && (site || twitterImage !== pageImage)) {
tags.push({ name: 'twitter:image', content: twitterImage });
}
const locale = ogLocale(options?.current_language);
if (locale) tags.push({ property: 'og:locale', content: locale });
return tags;
}

function key(tag: V2_MetaDescriptor): string | undefined {
const t = tag as Record<string, unknown>;
if (typeof t.property === 'string') return `property:${t.property}`;
if (typeof t.name === 'string') return `name:${t.name}`;
return undefined;
}

/** `additions` win over `base` on the same name/property; order otherwise kept. */
export function mergeMeta(base: V2_MetaDescriptor[], additions: V2_MetaDescriptor[]): V2_MetaDescriptor[] {
const replaced = new Set(additions.map(key).filter((k): k is string => !!k));
return [...base.filter((t) => !replaced.has(key(t) ?? '')), ...additions];
}
3 changes: 3 additions & 0 deletions app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ export interface TemplateOptions {

// Meta / SEO and analytics, passed through to @myst-theme/site.
twitter?: string; // handle for twitter:site / twitter:creator, `@` optional
site_url?: string; // public URL, for og:url (#92); site.domains never reaches the manifest
og_logo_url?: string; // og:image when the page has no thumbnail (#92)
twitter_logo_url?: string; // twitter:image; falls back to og_logo_url (#92)
favicon?: string; // declared `file`: the CLI copies it and rewrites this to its served path
analytics_google?: string;
analytics_plausible?: string;
Expand Down
7 changes: 7 additions & 0 deletions docs/announcements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Announcement banner

The Sphinx theme can show a dismissible site-wide banner (`announcement` in
`html_theme_options`). This theme does not offer one: no deployed lecture site
configures it, and the parity work covers the book-theme feature set as the
lecture sites actually use it. If a series needs a banner, open a request on
the theme repository with the use case; the header is the natural place for it.
Loading
Loading