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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
for each. `myst init` carries none of these across: it never reads
`sphinx.config`, where the lecture configs keep them
([#209](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/209)) ([#226](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/226)).
- A `<link rel="canonical">` on every page, from `site_url`, as the Sphinx
lecture sites emit from `html.baseurl`. The home page's canonical is the site
root, and every URL takes the trailing-slash form the build actually serves,
so no canonical names a redirect. `og:url` is built by the same function, so
the two cannot disagree. Nothing is emitted without `site_url`
([#207](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/207)) ([#227](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/227)).

### Changed
- **Breaking: the Launch control is now opt-in and explicitly configured.** It
Expand Down Expand Up @@ -76,6 +82,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
800px column. Both the stored outputs and the ones re-rendered when a reader
starts live compute are covered. Tables and text outputs stay left-aligned
([#206](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/206)) ([#225](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/225)).
- Head links that assumed the domain root now carry the static build's base
URL, so they resolve on a site served under a sub-path instead of 404ing at
the domain root: the favicon and `/myst-theme.css`. `og:image` is made
absolute against `site_url`, which a social scraper needs
([#207](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/207)) ([#227](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/227)).

## [2.7.0] - 2026-09-11

Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,12 @@ 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`.

Every page also carries a `<link rel="canonical">`, from the same `site_url`
and built by the same function as `og:url`, so the two always agree. The home
page's canonical is the site root, and every URL takes the trailing-slash form
the build actually serves. With `site_url` unset neither is emitted — what
Sphinx does without `html_baseurl`.

### Collapsible stderr

A notebook cell's stderr stream is folded behind a "⚠ Code warnings"
Expand All @@ -257,7 +263,7 @@ 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)) |
| `site_url` | site | The site's public URL, for the canonical link and `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` |
Expand Down
15 changes: 15 additions & 0 deletions app/components/Document.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,21 @@ export function DocumentWithoutProviders({
{title && <title>{title}</title>}
<Meta />
<Links />
{/* Head links whose href has to carry the static build's base URL.
They cannot come from a route's `links()`, which takes no arguments
in Remix 1.17 while BASE_URL reaches the app only through the root
loader -- but this Document is handed it directly.

The export writes both files at the build root, and rewrites only
`/myst_assets_folder/` URLs for the base, so a root-absolute href
resolves to the domain root and 404s on a sub-path site.

The icon is emitted only when there is a base to add: without one
the root route's own `/favicon.ico` is already right, and a second
identical link would be noise. With one, this comes after `<Links/>`
and a later `rel="icon"` wins. */}
{baseurl && <link rel="icon" href={`${baseurl}/favicon.ico`} />}
<link rel="stylesheet" href={`${baseurl ?? ''}/myst-theme.css`} />
Comment thread
mmcky marked this conversation as resolved.
<Analytics
analytics_google={config?.options?.analytics_google}
analytics_plausible={config?.options?.analytics_plausible}
Expand Down
17 changes: 15 additions & 2 deletions app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { JUPYTER_RENDERERS } from '@myst-theme/jupyter';
import { LIST_RENDERERS, STDERR_RENDERERS } from './renderers';
import { Document } from './components/Document';
import { htmlDir, htmlLang } from './i18n';
import { normalizeBaseurl } from './seo';
import type { TemplateOptions } from './types';
export { AppErrorBoundary as ErrorBoundary } from '@myst-theme/site';
// Never re-run the loader on a navigation that changes neither pathname nor
Expand Down Expand Up @@ -163,6 +164,12 @@ const CRITICAL_CSS = `

export const links: LinksFunction = () => {
return [
// The root-absolute fallback. It is wrong on a site served under a
// sub-path, where it resolves to the domain root, but it is the only icon
// link that also applies when the root ErrorBoundary renders -- that
// boundary is upstream's, with upstream's own Document and no base URL.
// The local Document emits the base-aware one after this, and a later
// `rel="icon"` wins, so only error pages fall back to this.
{
rel: 'icon',
href: '/favicon.ico',
Expand All @@ -178,7 +185,10 @@ export const links: LinksFunction = () => {
...PTSerifCSS,
{ rel: 'stylesheet', href: tailwind },
{ rel: 'stylesheet', href: thebeCoreCss },
{ rel: 'stylesheet', href: '/myst-theme.css' },
// `/myst-theme.css` (the consumer's own stylesheet slot) is NOT declared
// here: its href has to carry the static build's base URL, and `links()`
// takes no arguments in Remix 1.17 while BASE_URL reaches the app only
// through the root loader. The local Document emits it instead.
// jupyter-matplotlib's stylesheet is vendored into the Tailwind bundle
// (styles/mpl-widget.css) rather than linked from jsdelivr here: that
// would be a render-blocking request to a third-party CDN that is
Expand All @@ -189,7 +199,10 @@ export const links: LinksFunction = () => {
};

export const loader: LoaderFunction = async ({ request }): Promise<SiteLoader> => {
const baseURL = process.env.BASE_URL || undefined;
// Normalised here, at the one place the value enters the app: it reaches the
// Document's head links and the base-URL provider unchanged, and both join it
// to a path that already starts with a slash.
const baseURL = normalizeBaseurl(process.env.BASE_URL);
const [config, themeSession] = await Promise.all([
getConfig().catch(() => null),
getThemeSession(request),
Expand Down
16 changes: 14 additions & 2 deletions app/routes/$.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +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';
import { canonicalLink, mergeMeta, pageUrl, siteOrigin, 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).
Expand All @@ -38,13 +38,24 @@ export const meta: V2_MetaFunction<typeof loader> = ({ data, matches, location }
);
const baseurl = rootMatch?.data?.BASE_URL;

// The page's public URL, shared by og:url and the canonical link. `pageUrl`
// strips the base before re-applying it: the browser router has no basename,
// so on the client `location.pathname` already carries it.
const url = pageUrl({
origin: siteOrigin((config?.options as any)?.site_url, config?.domains),
pathname: location.pathname,
baseurl,
projectSlug: project?.slug,
indexSlug: project?.index,
});

// The OpenGraph / Twitter tags this theme adds to (or replaces in) upstream's
// article set -- see app/seo.ts.
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}`,
url,
options: config?.options as any,
});
return [
Expand All @@ -62,6 +73,7 @@ export const meta: V2_MetaFunction<typeof loader> = ({ data, matches, location }
}), social),
// hreflang alternates for the translated editions.
...hreflangLinks(config?.options, location.pathname, baseurl),
...canonicalLink(url),
];
};

Expand Down
15 changes: 13 additions & 2 deletions app/routes/_index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { getProject } from '@myst-theme/common';

import { Page } from '~/components/Page';
import { hreflangLinks } from '~/i18n';
import { mergeMeta, socialMetaTags } from '~/seo';
import { canonicalLink, mergeMeta, pageUrl, siteOrigin, 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).
Expand All @@ -28,13 +28,23 @@ export const meta: V2_MetaFunction<typeof loader> = ({ data, matches, location }
);
const baseurl = rootMatch?.data?.BASE_URL;

// The page's public URL, shared by og:url and the canonical link -- see the
// article route for why the base is stripped before it is re-applied.
const url = pageUrl({
origin: siteOrigin((config?.options as any)?.site_url, config?.domains),
pathname: location.pathname,
baseurl,
projectSlug: project?.slug,
indexSlug: project?.index,
});

// The OpenGraph / Twitter tags this theme adds to (or replaces in) upstream's
// article set -- see app/seo.ts.
const social = socialMetaTags({
domains: config?.domains,
siteTitle: config?.title ?? project?.title,
pageImage: (project.thumbnailOptimized || project.thumbnail) ?? undefined,
pathname: `${baseurl ?? ''}${location.pathname}`,
url,
options: config?.options as any,
});
return [
Expand All @@ -49,6 +59,7 @@ export const meta: V2_MetaFunction<typeof loader> = ({ data, matches, location }
}), social),
// hreflang alternates for the translated editions.
...hreflangLinks(config?.options, location.pathname, baseurl),
...canonicalLink(url),
];
};

Expand Down
134 changes: 125 additions & 9 deletions app/seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,13 @@
* og:url upstream needs an `origin`, which the routes never had.
* It comes from the `site_url` option; `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 `og_logo_url` when the page has no thumbnail
* not carry it, so it is only a fallback should that change.
* Built by `pageUrl`, the same function as the canonical
* link, so the two cannot disagree
* canonical a `<link>`, not a meta tag, but built from the same URL
* og:image `og_logo_url` when the page has no thumbnail, made absolute
* against the site origin: a social scraper cannot resolve a
* root-relative path
* twitter:image `twitter_logo_url` when set, even over a page thumbnail;
* otherwise the og:image
* twitter:card "summary" whenever `twitter` is set, in place of upstream's
Expand Down Expand Up @@ -46,11 +51,120 @@ export interface SeoInput {
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;
/** The page's public URL, from `pageUrl`; og:url is omitted without one. */
url?: string;
options?: SeoSiteOptions;
}

export interface PageUrlInput {
/** Site origin, from `siteOrigin`. Without it there is no public URL. */
origin?: string;
/** The page's render-time path, which may or may not carry the base URL. */
pathname: string;
/** The static build's base URL, if the site has one. */
baseurl?: string;
/** `project.slug`, on a site whose projects are not at the site root. */
projectSlug?: string;
/** `project.index`: the slug whose page the project's root serves. */
indexSlug?: string;
}

/**
* The page's public URL. The canonical link and og:url are both built here, so
* that the two rules below apply to both and they cannot drift apart.
*
* The home page resolves to the site root. With a base URL, mystmd renders the
* root `index.html` by requesting the index slug, so the page's render-time
* path is that slug -- and the slug's own URL is not served at all, so naming
* it would point every home page at a 404.
*
* Every URL takes the trailing-slash form, which is what the export writes
* (`<slug>/index.html`) and what a host redirects the slashless form to; a URL
* taken straight from the render-time path would name a redirect.
*
* Returns undefined when the site sets no `site_url`, as Sphinx emits nothing
* without `html_baseurl`.
*/
/**
* The base URL in the one shape the rest of the app can append to: trimmed,
* with any trailing slashes removed, and undefined when there is nothing left.
*
* `BASE_URL` is written by whoever deploys the site, so `/lecture-wasm/` is as
* likely as `/lecture-wasm`, and every consumer joins it to a path that already
* starts with a slash. Normalising once here keeps the doubled separator out of
* a head link's href and out of every link the base-URL provider builds.
* Undefined rather than `''` for an empty value, so `baseurl && ...` guards
* still tell absent from present.
*/
export function normalizeBaseurl(value?: string): string | undefined {
const base = (value ?? '').trim().replace(/\/+$/, '');
return base || undefined;
}

export function pageUrl({
origin,
pathname,
baseurl,
projectSlug,
indexSlug,
}: PageUrlInput): string | undefined {
if (!origin) return undefined;
const base = normalizeBaseurl(baseurl) ?? '';

// The base is stripped only where it is a real prefix -- `<base>/...` -- and
// never on an exact match. The browser router has no basename, so on the
// client the path carries the base and has to lose it before it is
// re-applied; at render time it does not carry it at all, and a page whose
// own slug happens to equal the base segment would otherwise be mistaken
// for the site root and canonicalised onto it.
let path = pathname || '/';
if (base && path.startsWith(`${base}/`)) path = path.slice(base.length);
const slug = trim(path);

// The project's root, which serves the index page. With a base URL the
// export renders that page by requesting the index slug -- under the
// project's own slug when it has one -- and writes it as the root's
// index.html, so the render-time path is that slug and its own URL is not
// served at all.
const project = trim(projectSlug ?? '');
const home = [project, indexSlug].filter(Boolean).join('/');
let tail: string;
if (slug === '') tail = '/';
else if (indexSlug && slug === home) tail = project ? `/${project}/` : '/';
else tail = `/${slug}/`;
return `${origin}${base}${tail}`;
}

export interface CanonicalLink {
tagName: 'link';
rel: 'canonical';
href: string;
// Remix's meta descriptor type is an open record; the index signature lets
// this spread into a route's `meta` return without a cast, as the hreflang
// alternates do.
[key: string]: unknown;
}

/**
* `<link rel="canonical">` for the page, in the shape Remix's v2 `meta`
* renders. Empty without a URL, so a site that sets no `site_url` emits
* nothing -- what Sphinx does without `html_baseurl`.
*/
export function canonicalLink(url?: string): CanonicalLink[] {
return url ? [{ tagName: 'link', rel: 'canonical', href: url }] : [];
}

/**
* An image URL a social scraper can fetch: root-relative paths take the origin.
* A protocol-relative URL already names its own host, so it is left alone --
* it begins with a slash but is not a path on this site.
*/
export function absoluteImage(image?: string, origin?: string): string | undefined {
if (!image) return undefined;
if (!origin || !image.startsWith('/') || image.startsWith('//')) return image;
return `${origin}${image}`;
}

/**
* The canonical origin: `site_url` (an absolute URL, trailing slash and path
* dropped to the origin), else `https://<first domain>` -- the CLI validates
Expand All @@ -71,6 +185,8 @@ export function siteOrigin(siteUrl?: string, domains?: string[]): string | undef
return /^https?:\/\//i.test(h) ? h : `https://${h}`;
}

const trim = (value: string): string => value.replace(/^\/+|\/+$/g, '');

function handle(twitter?: string): string | undefined {
const t = twitter?.trim().replace(/^@/, '');
return t ? `@${t}` : undefined;
Expand All @@ -93,20 +209,20 @@ export function ogLocale(code?: string): string | undefined {
* 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[] {
export function socialMetaTags({ domains, siteTitle, pageImage, url, 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 image = absoluteImage(pageImage || options?.og_logo_url, origin);
const twitterImage = absoluteImage(options?.twitter_logo_url, origin) || 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 (url) tags.push({ property: 'og:url', content: url });
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)) {
if (twitterImage && (site || twitterImage !== absoluteImage(pageImage, origin))) {
tags.push({ name: 'twitter:image', content: twitterImage });
}
const locale = ogLocale(options?.current_language);
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ below.

| Option | Type | Scope | Default | Purpose |
| --- | --- | --- | --- | --- |
| `site_url` | string | site | — | the site's public URL, for `og:url` |
| `site_url` | string | site | — | the site's public URL, for the canonical link and `og:url` |
| `twitter` | string | site | — | handle for `twitter:site` / `twitter:creator`; `@` optional |
| `og_logo_url` | string | site | — | `og:image` when a page has no thumbnail |
| `twitter_logo_url` | string | site | — | `twitter:image`; falls back to `og_logo_url` |
Expand Down
4 changes: 0 additions & 4 deletions docs/migrating.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,6 @@ site:
Without it the theme emits **neither** the canonical link nor `og:url`, and
`og:image` stays root-relative. See [configuration](configuration.md).

> **Not shipped yet.** The canonical link is #207, implemented by #227. Until
> that merges `site_url` feeds `og:url` and `og:image` only, and no page carries
> a canonical link whether or not the option is set.

## Old URLs

Every page URL changes at cutover: `about_py.html` becomes `about-py/`. Inbound
Expand Down
Loading
Loading