Skip to content

feat(seo): emit a canonical link, and base-URL-aware head links - #227

Merged
mmcky merged 4 commits into
mainfrom
claude/funny-meitner-i34zme-207-canonical
Sep 12, 2026
Merged

mmcky merged 4 commits into
mainfrom
claude/funny-meitner-i34zme-207-canonical

Conversation

@quantecon-services

Copy link
Copy Markdown
Collaborator

Closes #207, implementing the 2026-09-11 decision with the widened scope.

Problem, re-verified on main @ d7ec6cd

No page emitted a <link rel="canonical"> at all, and on a BASE_URL build the favicon and /myst-theme.css hrefs resolved to the domain root — which is why lecture-wasm's pages link to https://quantecon.github.io/favicon.ico (404) while the icon is served at /lecture-wasm/favicon.ico.

Change

  • Canonical link on every page, from site_url. og:url is built by the same function (pageUrl), so the two cannot drift apart.
  • The home page resolves to the served root. With a base URL the export renders the root index.html by requesting the index slug, so the render-time path is that slug — and the slug's own URL is not served.
  • Trailing-slash form throughout, so no canonical names a redirect.
  • Nothing without site_url, as Sphinx emits nothing without html.baseurl.
  • Favicon and /myst-theme.css carry the base URL. Neither can come from a route's links() (no arguments in Remix 1.17, and BASE_URL reaches the app only via the root loader), so the local Document emits them.
  • og:image absolute against the site origin.

Verified on real static builds

BASE_URL=/lecture-wasm myst build --html, inspecting the generated HTML:

Check Result
every canonical carries the base once, trailing slash
home page canonical is the site root https://example.org/lecture-wasm/
index slug's own URL is not served ✅ no intro/ directory exists
favicon href includes the base, file exists at that path
myst-theme.css href includes the base, file exists
og:url equals the canonical
site_url unset → no canonical, no og:url
error pages still declare an icon link ✅ (via the root route's fallback)

Adversarial review, and what it found

Four lenses (correctness, static builds/BASE_URL, error pages/hydration, SEO semantics), each finding refuted by two independent skeptics. 4 findings survived, reducing to three distinct defects — all fixed in the second commit:

  1. Serious, and I had missed the case entirely. On a site whose projects carry a slug:, every project home page was canonicalised onto a URL the build never writes. My home test compared the bare index slug against the whole path, which can only match a project at the site root. I reproduced it: a two-project site built with BASE_URL=/lecture-wasm writes alpha/index.html but emitted https://example.org/lecture-wasm/alpha/index/ — and /lecture-wasm/alpha/index/ 404s. The project slug is now part of the test, and such a page resolves to the project root. Re-verified on the same fixture: alpha/ and beta/, both of which the build writes.
  2. A page whose slug equals the base segment was collapsed onto the home page. The base was stripped on an exact match as well as a <base>/ prefix, and at render time the path carries no base — so /notebook under BASE_URL=/notebook looked like the site root. Only a real prefix is stripped now; verified that the page gets /notebook/notebook/ while the home still gets /notebook/, and that the client path (which does carry the base) resolves to the same URL.
  3. absoluteImage corrupted a protocol-relative URL: //assets.example.org/og.png begins with a slash but names its own host, and the origin was prefixed to it.

Six findings were verified and refuted — including four claiming the hreflang alternates now contradict the canonical. Worth noting since it was raised repeatedly and rejected each time: hreflangLinks builds per-edition URLs from the languages option, which is a separate concern from the canonical's own origin, and the skeptics could not show a real disagreement on a built page.

Not silently capped: 11 further findings were low severity or over the five-per-lens verification cap and were not put through refutation.

Tests

tests/unit/seo.test.mjs — 42 green, including one test per defect above: the project-home case, the base-equals-slug collision in both path forms, and the protocol-relative image. Plus social-meta now asserts the canonical link and the home page's root canonical, and a new no-canonical-without-site-url covers the unset case.

No pixels move

Only <head> content changes. The full suite shows the same locally-drifting baselines that drift on unmodified main, and nothing else — no baseline refresh needed, -darwin included.

One nuance worth your eye

On a BASE_URL site an error page's icon link is still the root-absolute /favicon.ico, so it still 404s there. The criterion is only that error pages declare an icon link, and they do. Fixing it properly means the root ErrorBoundary — which is upstream's AppErrorBoundary, rendering upstream's own Document with no base URL — so it is an upstream change, not one this PR can make. Say the word and I'll file it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UZLpDVYu1YBZHQfwkRRJj7


Generated by Claude Code

Every page now carries `<link rel="canonical">`, built from `site_url`
as the Sphinx lecture sites build theirs from `html.baseurl`. Two rules
apply, and `og:url` is built by the same function so the two cannot
disagree:

- the home page's canonical is the site root. With a base URL, the
  export 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, so naming it would point every home page at a 404;
- every URL takes the trailing-slash form, which is what the export
  writes and what a host redirects the slashless form to, so no
  canonical names a redirect.

Nothing is emitted without `site_url`, as Sphinx emits nothing without
`html_baseurl`. The page path is stripped of the base before the base is
re-applied: the browser router has no basename, so on the client the
location already carries it.

Two head links assumed the domain root and 404ed on a site served under
a sub-path, which is how lecture-wasm is deployed. The favicon and
`/myst-theme.css` now carry the base URL. Neither can 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, so the local Document
emits them; the root route keeps a root-absolute icon as the fallback
for the error boundary upstream renders with its own Document.

`og:image` is made absolute against the site origin, which a social
scraper needs to fetch it at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZLpDVYu1YBZHQfwkRRJj7
- On a site whose projects carry a slug, every project home page was
  canonicalised onto a URL the build never writes. The home test
  compared the bare index slug against the whole path, which can only
  match a project at the site root; with a base URL the export renders a
  project's index.html by requesting `<projectSlug>/<indexSlug>`, so the
  test failed and the page named `<projectSlug>/<indexSlug>/` -- a 404 --
  as its canonical. The project slug is now part of the test, and such a
  page resolves to the project root, which is where the build serves it.

- A page whose own slug equalled the base-URL segment was canonicalised
  onto the home page. The base was stripped on an exact match as well as
  on a `<base>/` prefix, and at render time the path carries no base at
  all, so `/notebook` under `BASE_URL=/notebook` looked like the site
  root. Only a real prefix is stripped now, which leaves the client path
  (which does carry the base) resolving to the same URL.

- `absoluteImage` corrupted a protocol-relative image URL: `//host/x.png`
  begins with a slash but names its own host, and the site origin was
  prefixed to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZLpDVYu1YBZHQfwkRRJj7
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-12 03:26 UTC

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

🎭 Visual regression results

passed  48 passed
skipped  15 skipped

Details

stats  63 tests across 2 suites
duration  1 minute, 37 seconds
commit  8f8e889

Skipped tests

mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › without JavaScript › drawer-opens-without-javascript
mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › drawer-closes-when-search-opens
mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › launch-colab
mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › live-compute-toggle
mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › live-compute-toggle-absent-without-thebe
mobile-chrome › theme.spec.ts › On this page outline › outline-pinned-and-nested
mobile-chrome › theme.spec.ts › On this page outline › outline-tracks-scroll
mobile-chrome › theme.spec.ts › On this page outline › outline-unnumbered
mobile-chrome › theme.spec.ts › On this page outline › outline-within-viewport
mobile-chrome › theme.spec.ts › Meta/SEO and notebook output › social-meta
mobile-chrome › theme.spec.ts › Meta/SEO and notebook output › no-canonical-without-site-url
mobile-chrome › theme.spec.ts › Meta/SEO and notebook output › stderr-collapsed
mobile-chrome › theme.spec.ts › Site options reach the theme › site-options
mobile-chrome › theme.spec.ts › Site footer › default-footer-without-part
mobile-chrome › theme.spec.ts › Site footer › declared-part-replaces-default

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Document.tsx uses baseurl verbatim in href construction, which can generate double slashes when BASE_URL has a trailing slash (e.g. /lecture-wasm/).

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This PR implements issue #207 by making SEO head output consistent and correct across normal and BASE_URL (sub-path) static builds, adding a canonical URL signal and ensuring critical head links resolve under sub-path hosting.

Changes:

  • Add a pageUrl() builder and emit <link rel="canonical"> on pages when site_url is set, keeping it aligned with og:url.
  • Make og:image absolute (when possible) and harden image URL handling (including protocol-relative URLs).
  • Move base-URL-sensitive head links (favicon + /myst-theme.css) into the local Document where BASE_URL is available, and extend unit/visual tests plus docs/changelog accordingly.
File summaries
File Description
tests/visual/theme.spec.ts Adds visual assertions for trailing-slash og:url, canonical link presence, and the no-site_url behavior.
tests/unit/seo.test.mjs Adds unit coverage for canonical/page URL construction, base URL edge cases, and absolute image handling.
template.yml Updates site_url option description to include canonical + absolute og:image behavior.
README.md Documents canonical/og:url coupling, trailing-slash canonical form, and unset site_url behavior.
docs/configuration.md Updates site_url docs to reflect canonical link + og:url.
CHANGELOG.md Records Added/Fixed entries for canonical link and base-URL-aware head links.
app/seo.ts Introduces pageUrl, canonicalLink, absoluteImage; updates socialMetaTags to use url and absolute images.
app/routes/$.tsx Computes a public url via pageUrl, uses it for og:url, and emits canonical link.
app/routes/_index.tsx Same as article route: derives url, uses it for og:url, and emits canonical link.
app/root.tsx Removes /myst-theme.css from links() (now emitted by Document); documents error-boundary icon fallback rationale.
app/components/Document.tsx Emits base-URL-aware favicon and /myst-theme.css from the local Document where baseurl is available.
Review details
  • Files reviewed: 11/11 changed files
  • Comments generated: 1
  • Review effort level: Lite (auto)

Note

Copilot is running an experiment and ran this review at Lite.


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread app/components/Document.tsx
mmcky added a commit that referenced this pull request Sep 12, 2026
The Site URL section read as though `site_url` is what makes the theme
emit a canonical link. `site_url` has existed since 2.7.0, but no page
carries a canonical link on main at all: that is #207, implemented by
#227, which is still open. Without this note the section describes a
feature the theme does not have, and it would only become true if this
branch merged after that one -- a doc should not depend on merge order to
be accurate.

The wording follows the two callouts already in the file, which flag
QuantEcon/mystmd#108 for the notebook header setting and QuantEcon/mystmd#113
for build-time redirects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mmcky added a commit that referenced this pull request Sep 12, 2026
…#226)

* docs: add a migration checklist for repos moving off the Sphinx theme

A lecture repository leaving `quantecon-book-theme` has to set up
several things its Sphinx build handled another way, and no page listed
them as steps. Three came with the Sphinx theme and needed no per-repo
setup at all; the rest are configured today under `_config.yml` keys
this theme does not read.

`myst init` carries none of them across: it reads the analytics ID only
from the `html` section, takes `project.github` only from a top-level
`repository.url`, and never reads `sphinx.config`, which is where the
lecture configs keep most of these.

The page also carries the table of Sphinx theme options with no
counterpart here, each with its reason, and `docs/index.md` now points
its "where a Sphinx option has no counterpart" promise at that table
rather than at the individual pages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZLpDVYu1YBZHQfwkRRJj7

* docs: leave the no-counterpart sentence to the docs review

The migration page's table is where that promise now points, but the
sentence also carries the PLAN.md reference the docs review rewrites in
the same paragraph. Changing it in both places would only collide, so
this keeps to the Pages-table row its own acceptance criteria name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZLpDVYu1YBZHQfwkRRJj7

* docs(migrating): mark the canonical link as not shipped yet

The Site URL section read as though `site_url` is what makes the theme
emit a canonical link. `site_url` has existed since 2.7.0, but no page
carries a canonical link on main at all: that is #207, implemented by
#227, which is still open. Without this note the section describes a
feature the theme does not have, and it would only become true if this
branch merged after that one -- a doc should not depend on merge order to
be accurate.

The wording follows the two callouts already in the file, which flag
QuantEcon/mystmd#108 for the notebook header setting and QuantEcon/mystmd#113
for build-time redirects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Matt McKay <mmcky@users.noreply.github.com>
mmcky and others added 2 commits September 12, 2026 12:37
#221, #222, #224, #225 and #226 all landed entries under `## [Unreleased]`
while this was open. This branch adds one `### Added` entry and one
`### Fixed` entry, so each folds into the matching section rather than
opening a second: Added (default site footer, `{raw}` rewriter, migration
checklist, this canonical link), Changed (the breaking launch change),
Fixed (output image centring, these base-URL head links).

Both entries also gain their PR link beside the issue link, which is what
CONTRIBUTING.md:225-227 asks for.

`README.md`, `docs/configuration.md`, `template.yml` and
`tests/visual/theme.spec.ts` auto-merged. Each was checked rather than
assumed: both sides' content is present in all four.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A trailing slash on `BASE_URL` doubled the separator in every href built
from it. `Document` builds `${baseurl}/favicon.ico` and
`${baseurl}/myst-theme.css`, so `/lecture-wasm/` produced
`/lecture-wasm//favicon.ico` -- and the same unnormalised value reaches
`BaseUrlProvider`, so every link `withBaseurl()` builds carried it too.
Whoever deploys the site writes `BASE_URL` by hand, so both spellings
arrive.

`normalizeBaseurl` trims it and strips trailing slashes once, in the root
loader where the value enters, and `pageUrl` calls it instead of
repeating the rule -- so the canonical URL and the head links cannot
disagree about the shape of the base.

The helper lives in `app/seo.ts` rather than `root.tsx` because a `.tsx`
module cannot be imported by the unit suite: Node strips types but not
JSX. Mutation-tested: with the trailing-slash strip removed the new test
fails, and it passes again with the strip restored.

Also drops the migration doc's "Not shipped yet" note for the canonical
link, which this branch is what ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mmcky
mmcky merged commit 2ac66a3 into main Sep 12, 2026
4 checks passed
@mmcky
mmcky deleted the claude/funny-meitner-i34zme-207-canonical branch September 12, 2026 03:26
mmcky added a commit that referenced this pull request Sep 12, 2026
#221, #222, #224, #225, #226 and #227 all landed entries under
`## [Unreleased]` while this was open. This branch adds one `### Added`
entry, so it folds into that list rather than opening a second section,
and it gains its PR link beside the issue link, which is what
CONTRIBUTING.md:225-227 asks for.

`docs/layout.md`, `styles/quantecon.css` and `tests/visual/theme.spec.ts`
auto-merged. Each was checked rather than assumed: the branch's h4 indent
rule sits beside main's output-image centring rule, the branch's outline
assertions beside main's footer, launch, centring and canonical tests, and
the branch's h2-to-h4 documentation beside main's default-footer section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mmcky added a commit that referenced this pull request Sep 12, 2026
#221, #222, #224, #225, #226, #227 and #228 all landed `[Unreleased]`
entries while this was open. This branch adds a `### Fixed` entry, so it
folds into main's existing Fixed section (output image centring, base-URL
head links) rather than opening a second one, and it gains its PR link
beside the issue link per CONTRIBUTING.md:225-227.

`docs/configuration.md`, `docs/index.md` and `docs/layout.md` auto-merged,
and each was checked rather than assumed: layout.md holds this branch's
Header and Widths rewrites beside #228's h2-to-h4 outline text and #221's
default-footer section; index.md holds #226's Pages-table row beside this
branch's rewritten closing paragraph; configuration.md holds the rows from
all four PRs that touched its table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pages emit no rel=canonical, and the favicon link 404s on sites served under a sub-path

4 participants