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
3 changes: 2 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ Sphinx extension to redirect files

This Sphinx extension redirects non-existent pages to working pages.
Rediraffe can also check that deleted or renamed files in your git repo
are redirected.
are redirected, and client-side redirects for removed HTML anchors can
be declared with the ``anchormap`` directive.

Rediraffe creates a graph of all specified redirects and traverses it
to point all internal urls to leaf urls.
Expand Down
23 changes: 23 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,29 @@ Note: The auto redirect builder only works with a configuration file.

Note: Deleted files cannot be added to your redirects file automatically.

Anchor redirects
----------------

Rediraffe's redirects work at the page level.
When a section is moved or removed instead, old ``#anchor`` links out in the
wild break silently. The ``anchormap`` directive lets you declare redirects for
removed HTML anchors next to the content. They are resolved at build time,
embedded in the page as JSON, and a small script redirects visitors from the
stale anchor to its new location.

Declare redirects in the document the old anchors used to live on:

.. code-block:: rst

.. anchormap::

removed-anchor: :ref:`new-target`
other-removed-anchor: :doc:`elsewhere`

Each entry is ``old-html-fragment: target``, where the target is inline
reStructuredText that must resolve to exactly one internal link.
The directive produces no output. Visiting the page with ``#removed-anchor`` in
the URL redirects to the new target instead.

Options
=======
Expand Down
136 changes: 136 additions & 0 deletions sphinxext/rediraffe.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
import subprocess
from os.path import relpath
from pathlib import Path, PurePosixPath, PureWindowsPath
from urllib.parse import urlsplit

from docutils import nodes
from jinja2 import Environment, FileSystemLoader, Template
from sphinx.builders import Builder
from sphinx.builders.dirhtml import DirectoryHTMLBuilder
Expand All @@ -14,6 +16,7 @@
from sphinx.errors import ExtensionError
from sphinx.util import logging
from sphinx.util.console import green, red, yellow # pylint: disable=no-name-in-module
from sphinx.util.docutils import SphinxDirective

TYPE_CHECKING = False
if TYPE_CHECKING:
Expand Down Expand Up @@ -47,6 +50,36 @@
REDIRECT_JSON_NAME = '_rediraffe_redirected.json'
RE_OBJ = re.compile(r"(?:(\"|')(.*?)\1|(\S+))\s+(?:(\"|')(.*?)\4|(\S+))")

ANCHORMAP_JSON_ID = 'rediraffe-anchormap'
ANCHORMAP_JS_NAME = 'rediraffe_anchormap.js'
ANCHORMAP_JS = """\
const script = document.getElementById("rediraffe-anchormap");
const redirects = JSON.parse(script.textContent);

function redirectAnchor() {
const anchor = window.location.hash.slice(1);
if (!anchor) {
return;
}

if (document.getElementById(anchor)) {
return;
}

const target = redirects[anchor];
if (!target) {
return;
}
const targetUrl = new URL(target, window.location.href).href;
if (targetUrl !== window.location.href) {
window.location.replace(targetUrl);
}
}

window.addEventListener("hashchange", redirectAnchor);
redirectAnchor();
"""

READTHEDOCS_BUILDERS = ['readthedocs', 'readthedocsdirhtml']


Expand Down Expand Up @@ -303,6 +336,103 @@ def build_redirects(app: Sphinx, exception: Exception | None) -> None:
redirect_json_file.write_text(json.dumps(redirect_record), encoding='utf8')


class AnchorMapEntryNode(nodes.Element):
pass


class AnchorMap(SphinxDirective):
"""Collect client-side redirects for HTML anchors removed from this page."""

has_content = True

def run(self) -> list[nodes.Node]:
self.assert_has_content()

entries = []
messages = []

for index, line in enumerate(self.content):
if not (line := line.strip()):
continue

old_anchor, sep, target = line.partition(': ')
old_anchor, target = old_anchor.strip(), target.strip()

if not sep or not old_anchor or not target:
raise self.error(
"anchormap entries should be like: 'old-html-fragment: target'"
)

children, parse_messages = self.state.inline_text(
target, self.content_offset + index
)
entry = AnchorMapEntryNode('', *children, old_anchor=old_anchor)
self.set_source_info(entry)
entries.append(entry)
messages.extend(parse_messages)

if not entries:
raise self.error('anchormap must contain at least one entry')

return entries + messages


def process_anchor_maps(
app: Sphinx,
doctree: nodes.document,
_docname: str,
) -> None:
redirects = {}

for entry in list(doctree.findall(AnchorMapEntryNode)):
target = None
references = list(entry.findall(nodes.reference))

if len(references) == 1:
if refuri := references[0].get('refuri'):
parts = urlsplit(refuri)
if not parts.scheme and not parts.netloc: # Check it's internal
target = refuri
elif refid := references[0].get('refid'):
target = f'#{refid}'

if target is not None:
redirects[entry['old_anchor']] = target

entry.parent.remove(entry)

if app.builder.format == 'html' and not app.builder.embedded:
doctree['anchor_redirects'] = redirects


def add_anchor_redirects_to_context(
app: Sphinx,
_pagename: str,
_templatename: str,
_context: dict[str, object],
doctree: nodes.document | None,
) -> None:
if doctree is None:
return

if redirects := doctree.get('anchor_redirects'):
# Called during html-page-context, these only apply to this page.
app.add_js_file(
None,
body=json.dumps(redirects),
id=ANCHORMAP_JSON_ID,
type='application/json',
)
app.add_js_file(ANCHORMAP_JS_NAME, type='module')


def write_anchormap_js(app: Sphinx, exc: Exception | None) -> None:
if app.builder.format == 'html' and not app.builder.embedded and exc is None:
static_dir = Path(app.outdir) / '_static'
static_dir.mkdir(parents=True, exist_ok=True)
(static_dir / ANCHORMAP_JS_NAME).write_text(ANCHORMAP_JS, encoding='utf-8')


class CheckRedirectsDiffBuilder(Builder):
name = 'rediraffecheckdiff'

Expand Down Expand Up @@ -477,6 +607,12 @@ def setup(app: Sphinx) -> ExtensionMetadata:
app.add_builder(WriteRedirectsDiffBuilder)
app.connect('build-finished', build_redirects)

app.add_directive('anchormap', AnchorMap)
app.add_node(AnchorMapEntryNode)
app.connect('doctree-resolved', process_anchor_maps)
app.connect('html-page-context', add_anchor_redirects_to_context)
app.connect('build-finished', write_anchormap_js)

return {
'version': __version__,
'env_version': 1,
Expand Down
10 changes: 10 additions & 0 deletions tests/roots/ext/test-anchormap/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from __future__ import annotations

extensions = ['sphinxext.rediraffe']

master_doc = 'index'
exclude_patterns = ['_build']

html_theme = 'basic'

rediraffe_redirects = {}
13 changes: 13 additions & 0 deletions tests/roots/ext/test-anchormap/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Index
=====

.. toctree::

old

.. _new-home:

New home
--------

Content lives here now.
13 changes: 13 additions & 0 deletions tests/roots/ext/test-anchormap/old.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Old page
========

.. anchormap::

removed-anchor: :ref:`new-home`
some-content: :ref:`new-home`
unresolvable-anchor: plain text without a link

Some content
------------

This section still exists, so its anchor must not be redirected.
70 changes: 70 additions & 0 deletions tests/test_anchormap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from __future__ import annotations

import time
from pathlib import Path

import pytest
from conftest import TESTS_ROOT, rel2url

TYPE_CHECKING = False
if TYPE_CHECKING:
from sphinx.application import Sphinx


@pytest.fixture(scope='module')
def rootdir():
return TESTS_ROOT / 'roots' / 'ext'


def wait_for_url(sb, expected: str, timeout: float = 5.0) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if Path(sb.get_current_url()) == Path(expected):
return
time.sleep(0.1)
assert Path(sb.get_current_url()) == Path(expected)


class TestAnchorMap:
@pytest.mark.sphinx('html', testroot='anchormap')
def test_page_output(self, app: Sphinx):
app.build()
assert app.statuscode == 0

outdir = Path(app.outdir)
old_page = (outdir / 'old.html').read_text(encoding='utf-8')
assert (
'<script id="rediraffe-anchormap" type="application/json">'
'{"removed-anchor": "index.html#new-home",'
' "some-content": "index.html#new-home"}</script>'
) in old_page
assert 'src="_static/rediraffe_anchormap.js"' in old_page
assert (outdir / '_static' / 'rediraffe_anchormap.js').is_file()

# The directive itself produces no visible output.
assert 'plain text without a link' not in old_page

# Pages without an anchormap should not get the scripts.
index_page = (outdir / 'index.html').read_text(encoding='utf-8')
assert 'rediraffe-anchormap' not in index_page
assert 'rediraffe_anchormap' not in index_page

@pytest.mark.sphinx('html', testroot='anchormap')
def test_anchor_is_redirected(self, app: Sphinx, sb_):
app.build()
assert app.statuscode == 0

sb_.open(rel2url(app.outdir, 'old.html') + '#removed-anchor')
wait_for_url(sb_, rel2url(app.outdir, 'index.html') + '#new-home')

@pytest.mark.sphinx('html', testroot='anchormap')
def test_existing_anchor_is_not_redirected(self, app: Sphinx, sb_):
app.build()
assert app.statuscode == 0

# 'some-content' is in the anchormap but its anchor still exists,
# so the existing anchor must win.
before = rel2url(app.outdir, 'old.html') + '#some-content'
sb_.open(before)
time.sleep(0.5)
assert Path(sb_.get_current_url()) == Path(before)
Loading