diff --git a/CHANGELOG.md b/CHANGELOG.md index ea8409385..f0a70869f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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 @@ -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 diff --git a/README.md b/README.md index 666a6a98e..8ac554389 100644 --- a/README.md +++ b/README.md @@ -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 ``, 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" @@ -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` | diff --git a/app/components/Document.tsx b/app/components/Document.tsx index ed82689c1..2c807c3d0 100644 --- a/app/components/Document.tsx +++ b/app/components/Document.tsx @@ -145,6 +145,21 @@ export function DocumentWithoutProviders({ {title && {title}} + {/* 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 `` + and a later `rel="icon"` wins. */} + {baseurl && } + { 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', @@ -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 @@ -189,7 +199,10 @@ export const links: LinksFunction = () => { }; export const loader: LoaderFunction = async ({ request }): Promise => { - 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), diff --git a/app/routes/$.tsx b/app/routes/$.tsx index bdff93ce8..df02f7fb8 100644 --- a/app/routes/$.tsx +++ b/app/routes/$.tsx @@ -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). @@ -38,13 +38,24 @@ export const meta: V2_MetaFunction = ({ 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 [ @@ -62,6 +73,7 @@ export const meta: V2_MetaFunction = ({ data, matches, location } }), social), // hreflang alternates for the translated editions. ...hreflangLinks(config?.options, location.pathname, baseurl), + ...canonicalLink(url), ]; }; diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index 5b3a0d45b..9fc7da219 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -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). @@ -28,13 +28,23 @@ export const meta: V2_MetaFunction = ({ 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 [ @@ -49,6 +59,7 @@ export const meta: V2_MetaFunction = ({ data, matches, location } }), social), // hreflang alternates for the translated editions. ...hreflangLinks(config?.options, location.pathname, baseurl), + ...canonicalLink(url), ]; }; diff --git a/app/seo.ts b/app/seo.ts index 4b15883ad..b81dc8c1c 100644 --- a/app/seo.ts +++ b/app/seo.ts @@ -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 ``, 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 @@ -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 + * (`/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 -- `/...` -- 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; +} + +/** + * `` 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://` -- the CLI validates @@ -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; @@ -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); diff --git a/docs/configuration.md b/docs/configuration.md index a659a1cd2..3e32dbe37 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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` | diff --git a/docs/migrating.md b/docs/migrating.md index e5071defe..618a89cfd 100644 --- a/docs/migrating.md +++ b/docs/migrating.md @@ -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 diff --git a/template.yml b/template.yml index 725205c69..9eb5e69ed 100644 --- a/template.yml +++ b/template.yml @@ -72,8 +72,10 @@ options: type: string description: > The site's public URL (the Sphinx sites' `html_baseurl`), for the - absolute `og:url` on every page. `site.domains` would be the natural - source but does not reach the theme. + `` and the absolute `og:url` on every page, and + for making `og:image` absolute. Neither the canonical link nor `og:url` + is emitted without it. `site.domains` would be the natural source but + does not reach the theme. - id: og_logo_url type: string description: > diff --git a/tests/unit/seo.test.mjs b/tests/unit/seo.test.mjs index 2a9849b52..2f7764c17 100644 --- a/tests/unit/seo.test.mjs +++ b/tests/unit/seo.test.mjs @@ -5,7 +5,16 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { mergeMeta, ogLocale, siteOrigin, socialMetaTags } from '../../app/seo.ts'; +import { + absoluteImage, + canonicalLink, + mergeMeta, + normalizeBaseurl, + ogLocale, + pageUrl, + siteOrigin, + socialMetaTags, +} from '../../app/seo.ts'; const byKey = (tags) => Object.fromEntries(tags.map((t) => [t.property ?? t.name, t.content])); @@ -20,6 +29,24 @@ test('siteOrigin: site_url wins and is reduced to an origin; domains are the fal assert.equal(siteOrigin(' ', []), undefined); }); +test('normalizeBaseurl: no trailing slash survives, and empty means absent', () => { + // Whoever deploys the site writes BASE_URL by hand, so both spellings arrive. + // Every consumer joins the result to a path that already starts with a slash + // -- the head links build `${baseurl}/favicon.ico` and `${baseurl}/myst-theme.css`, + // and the base-URL provider builds every in-site link -- so a surviving + // trailing slash would double the separator in all of them. + assert.equal(normalizeBaseurl('/lecture-wasm'), '/lecture-wasm'); + assert.equal(normalizeBaseurl('/lecture-wasm/'), '/lecture-wasm'); + assert.equal(normalizeBaseurl('/lecture-wasm//'), '/lecture-wasm'); + assert.equal(normalizeBaseurl(' /quantecon-theme.mystmd/pr-preview/pr-9/ '), '/quantecon-theme.mystmd/pr-preview/pr-9'); + // Undefined rather than '' for an absent value, so `baseurl && ...` guards in + // the Document still tell absent from present. + assert.equal(normalizeBaseurl(undefined), undefined); + assert.equal(normalizeBaseurl(''), undefined); + assert.equal(normalizeBaseurl(' '), undefined); + assert.equal(normalizeBaseurl('/'), undefined); +}); + test('ogLocale: BCP 47 to OpenGraph', () => { assert.equal(ogLocale('en'), 'en_US'); assert.equal(ogLocale('zh-cn'), 'zh_CN'); @@ -33,7 +60,7 @@ test('the full set, on a lecture page with the site-level images', () => { const tags = byKey( socialMetaTags({ siteTitle: 'Python Programming for Economics and Finance', - pathname: '/python-by-example/', + url: 'https://python-programming.quantecon.org/python-by-example/', options: { site_url: 'https://python-programming.quantecon.org', twitter: 'quantecon', @@ -58,18 +85,24 @@ test('the full set, on a lecture page with the site-level images', () => { test('a page thumbnail beats the site image for og:image; twitter:image follows the Twitter logo', () => { const tags = byKey( socialMetaTags({ - pathname: '/p', + url: 'https://example.org/p/', pageImage: '/thumb.png', - options: { og_logo_url: 'https://x/og.png', twitter_logo_url: 'https://x/tw.png', twitter: '@qe' }, + options: { + site_url: 'https://example.org', + og_logo_url: 'https://x/og.png', + twitter_logo_url: 'https://x/tw.png', + twitter: '@qe', + }, }), ); - assert.equal(tags['og:image'], '/thumb.png'); + // Made absolute: a scraper cannot resolve a root-relative path. + assert.equal(tags['og:image'], 'https://example.org/thumb.png'); assert.equal(tags['twitter:image'], 'https://x/tw.png'); assert.equal(tags['twitter:site'], '@qe'); }); test('nothing configured: only og:type, and no dangling twitter tags', () => { - const tags = socialMetaTags({ pathname: '/p' }); + const tags = socialMetaTags({}); assert.deepEqual(tags, [{ property: 'og:type', content: 'website' }]); }); @@ -93,3 +126,128 @@ test('mergeMeta replaces same-key upstream tags and keeps the rest in order', () { property: 'og:type', content: 'website' }, ]); }); + +const QE = 'https://quantecon.github.io'; + +test('pageUrl: the trailing-slash form, with and without a base URL', () => { + const origin = 'https://python-programming.quantecon.org'; + assert.equal(pageUrl({ origin, pathname: '/about-py' }), `${origin}/about-py/`); + assert.equal(pageUrl({ origin, pathname: '/about-py/' }), `${origin}/about-py/`); + assert.equal( + pageUrl({ origin: QE, pathname: '/short-path', baseurl: '/lecture-wasm' }), + `${QE}/lecture-wasm/short-path/`, + ); + // A trailing slash on the base is not doubled. + assert.equal( + pageUrl({ origin: QE, pathname: '/short-path', baseurl: '/lecture-wasm/' }), + `${QE}/lecture-wasm/short-path/`, + ); +}); + +test('pageUrl: the base appears exactly once, whether or not the path carries it', () => { + // At render time the path has no base; on the client the router has no + // basename so it does. Both must give the same URL. + for (const pathname of ['/short-path', '/lecture-wasm/short-path']) { + const url = pageUrl({ origin: QE, pathname, baseurl: '/lecture-wasm' }); + assert.equal(url, `${QE}/lecture-wasm/short-path/`); + assert.equal((url.match(/lecture-wasm/g) ?? []).length, 1); + } +}); + +test('pageUrl: a page whose slug equals the base segment is not mistaken for the root', () => { + // The base is stripped only as a `/` prefix. At render time the path + // is the bare page path, so an exact match is the page, not the site root. + assert.equal( + pageUrl({ origin: QE, pathname: '/notebook', baseurl: '/notebook' }), + `${QE}/notebook/notebook/`, + ); + // ...and the client path for that same page agrees. + assert.equal( + pageUrl({ origin: QE, pathname: '/notebook/notebook', baseurl: '/notebook' }), + `${QE}/notebook/notebook/`, + ); + // The home page of that site still resolves to the root. + assert.equal(pageUrl({ origin: QE, pathname: '/notebook/', baseurl: '/notebook' }), `${QE}/notebook/`); +}); + +test('pageUrl: the home page is the site root, named by path or by index slug', () => { + assert.equal(pageUrl({ origin: QE, pathname: '/', baseurl: '/lecture-wasm' }), `${QE}/lecture-wasm/`); + // With a base URL the export renders the root index.html by requesting the + // index slug, so that is the home page's render-time path -- and the slug's + // own URL is not served. + assert.equal( + pageUrl({ origin: QE, pathname: '/intro', baseurl: '/lecture-wasm', indexSlug: 'intro' }), + `${QE}/lecture-wasm/`, + ); + assert.equal(pageUrl({ origin: QE, pathname: '/intro', indexSlug: 'intro' }), `${QE}/`); + // A different page is unaffected by the index slug. + assert.equal( + pageUrl({ origin: QE, pathname: '/introduction', indexSlug: 'intro' }), + `${QE}/introduction/`, + ); +}); + +test('pageUrl: a project home page resolves to the project root, not the site root', () => { + // On a site whose projects carry a slug, the export renders the project's + // own index.html by requesting `/` and writes it as + // `/index.html`, so the served URL is the project root. + assert.equal( + pageUrl({ + origin: QE, + pathname: '/alpha/index', + baseurl: '/lecture-wasm', + projectSlug: 'alpha', + indexSlug: 'index', + }), + `${QE}/lecture-wasm/alpha/`, + ); + // Without a base URL the same project home is requested at its slug. + assert.equal( + pageUrl({ origin: QE, pathname: '/alpha', projectSlug: 'alpha', indexSlug: 'index' }), + `${QE}/alpha/`, + ); + // Any other page of that project keeps its own path. + assert.equal( + pageUrl({ + origin: QE, + pathname: '/alpha/page1', + baseurl: '/lecture-wasm', + projectSlug: 'alpha', + indexSlug: 'index', + }), + `${QE}/lecture-wasm/alpha/page1/`, + ); +}); + +test('pageUrl: nothing without an origin, as Sphinx emits nothing without html_baseurl', () => { + assert.equal(pageUrl({ pathname: '/about-py' }), undefined); + assert.equal(pageUrl({ origin: undefined, pathname: '/', baseurl: '/x' }), undefined); +}); + +test('canonicalLink: a link descriptor, or nothing', () => { + assert.deepEqual(canonicalLink('https://example.org/p/'), [ + { tagName: 'link', rel: 'canonical', href: 'https://example.org/p/' }, + ]); + assert.deepEqual(canonicalLink(undefined), []); +}); + +test('absoluteImage: only root-relative paths take the origin', () => { + assert.equal(absoluteImage('/build/graph.png', 'https://example.org'), 'https://example.org/build/graph.png'); + assert.equal(absoluteImage('https://cdn.example/og.png', 'https://example.org'), 'https://cdn.example/og.png'); + assert.equal(absoluteImage('/build/graph.png', undefined), '/build/graph.png'); + assert.equal(absoluteImage(undefined, 'https://example.org'), undefined); + // A protocol-relative URL names its own host: it starts with a slash but is + // not a path on this site, so prefixing the origin would break it. + assert.equal(absoluteImage('//assets.example.org/og.png', 'https://example.org'), '//assets.example.org/og.png'); +}); + +test('og:url comes from the same URL the canonical link uses', () => { + const url = pageUrl({ + origin: 'https://quantecon.github.io', + pathname: '/short-path', + baseurl: '/lecture-wasm', + }); + const tags = byKey(socialMetaTags({ url, options: { site_url: 'https://quantecon.github.io' } })); + assert.equal(tags['og:url'], url); + assert.equal(canonicalLink(url)[0].href, url); +}); diff --git a/tests/visual/theme.spec.ts b/tests/visual/theme.spec.ts index c283038d1..7e759792d 100644 --- a/tests/visual/theme.spec.ts +++ b/tests/visual/theme.spec.ts @@ -491,7 +491,8 @@ test.describe("Meta/SEO and notebook output", () => { expect(meta(page, sel)).toHaveAttribute("content", content); await expectTag('property="og:type"', "website"); await expectTag('property="og:site_name"', "QE Theme No-Thebe Fixture"); - await expectTag('property="og:url"', "https://example.org/notebook"); + // The trailing-slash form, which is the URL the build actually serves. + await expectTag('property="og:url"', "https://example.org/notebook/"); await expectTag('property="og:image"', "https://assets.example.org/qe-og-logo.png"); await expectTag('property="og:locale"', "en_US"); await expectTag('name="twitter:site"', "@quantecon"); @@ -501,6 +502,27 @@ test.describe("Meta/SEO and notebook output", () => { // Replaced, not duplicated: one og:image, one twitter:card. await expect(meta(page, 'property="og:image"')).toHaveCount(1); await expect(meta(page, 'name="twitter:card"')).toHaveCount(1); + + // The canonical link, from the same `site_url` and the same builder as + // og:url, so the two agree on every page. + const canonical = page.locator('head link[rel="canonical"]'); + await expect(canonical).toHaveCount(1); + await expect(canonical).toHaveAttribute("href", "https://example.org/notebook/"); + // The home page's canonical is the site root, not the index slug's URL. + await page.goto(`${noThebeBase}/`, { waitUntil: "domcontentloaded" }); + await expect(page.locator('head link[rel="canonical"]')).toHaveAttribute( + "href", + "https://example.org/" + ); + }); + + // A site that sets no `site_url` emits neither, as Sphinx emits nothing + // without `html_baseurl`. The main fixture sets none. + test("no-canonical-without-site-url", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-chrome", "not viewport-dependent"); + await page.goto("/features", { waitUntil: "domcontentloaded" }); + await expect(page.locator('head link[rel="canonical"]')).toHaveCount(0); + await expect(meta(page, 'property="og:url"')).toHaveCount(0); }); // A cell's stderr stream is folded behind a "Code warnings" disclosure,