diff --git a/releasenotes/notes/stable-section-anchors-d99258b6df39c0fa.yaml b/releasenotes/notes/stable-section-anchors-d99258b6df39c0fa.yaml new file mode 100644 index 0000000..52a27e1 --- /dev/null +++ b/releasenotes/notes/stable-section-anchors-d99258b6df39c0fa.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Added explicitly calculated anchors to ensure section links are both + unique and stable over time. diff --git a/reno/formatter.py b/reno/formatter.py index 3834142..dd93fa7 100644 --- a/reno/formatter.py +++ b/reno/formatter.py @@ -25,6 +25,21 @@ def _indent_for_list(text, prefix=' '): ]) + '\n' +def _anchor(version_title, title): + title = title or 'relnotes' + return '.. _{title}_{version_title}:'.format( + title=title, + version_title=version_title) + + +def _section_anchor(section_title, version_title, title): + # Get the title and remove the trailing : + title = _anchor(version_title, title)[:-1] + return "{title}_{section_title}:".format( + title=title, + section_title=section_title) + + def format_report(loader, config, versions_to_include, title=None, show_source=True): report = [] @@ -44,11 +59,13 @@ def format_report(loader, config, versions_to_include, title=None, for version in versions_to_include: if '-' in version: # This looks like an "unreleased version". - title = config.unreleased_version_title or version + version_title = config.unreleased_version_title or version else: - title = version - report.append(title) - report.append('=' * len(title)) + version_title = version + report.append(_anchor(version_title, title)) + report.append('') + report.append(version_title) + report.append('=' * len(version_title)) report.append('') # Add the preludes. @@ -57,7 +74,11 @@ def format_report(loader, config, versions_to_include, title=None, notefiles_with_prelude = [(n, sha) for n, sha in notefiles if prelude_name in file_contents[n]] if notefiles_with_prelude: - report.append(prelude_name.replace('_', ' ').title()) + prelude_title = prelude_name.replace('_', ' ').title() + report.append(_section_anchor( + prelude_title, version_title, title)) + report.append('') + report.append(prelude_title) report.append('-' * len(prelude_name)) report.append('') @@ -76,6 +97,9 @@ def format_report(loader, config, versions_to_include, title=None, for n in file_contents[fn].get(section_name, []) ] if notes: + report.append(_section_anchor( + section_title, version_title, title)) + report.append('') report.append(section_title) report.append('-' * len(section_title)) report.append('') diff --git a/reno/tests/test_formatter.py b/reno/tests/test_formatter.py index 0d223a4..4aecf18 100644 --- a/reno/tests/test_formatter.py +++ b/reno/tests/test_formatter.py @@ -156,6 +156,7 @@ class TestFormatterCustomSections(TestFormatterBase): expected = [prelude_pos, api_pos, features_pos] actual = list(sorted([prelude_pos, features_pos, api_pos])) self.assertEqual(expected, actual) + self.assertIn('.. _relnotes_1.0.0_API Changes:', result) class TestFormatterCustomUnreleaseTitle(TestFormatterBase): @@ -182,6 +183,7 @@ class TestFormatterCustomUnreleaseTitle(TestFormatterBase): ) self.assertIn('Not Released', result) self.assertNotIn('0.1.0-1', result) + self.assertIn('.. _This is the title_Not Released:', result) def test_without_title(self): result = formatter.format_report( @@ -191,3 +193,49 @@ class TestFormatterCustomUnreleaseTitle(TestFormatterBase): title='This is the title', ) self.assertIn('0.1.0-1', result) + self.assertIn('.. _This is the title_0.1.0-1:', result) + + +class TestFormatterAnchors(TestFormatterBase): + + note_bodies = { + 'note1': { + 'prelude': 'This is the prelude.', + }, + 'note2': { + 'issues': [ + 'This is the first issue.', + 'This is the second issue.', + ], + }, + 'note3': { + 'features': [ + 'We added a feature!', + ], + 'upgrade': None, + }, + } + + def test_with_title(self): + self.c.override(unreleased_version_title='Not Released') + result = formatter.format_report( + loader=self.ldr, + config=self.c, + versions_to_include=self.versions, + title='This is the title', + ) + self.assertIn('.. _This is the title_0.0.0:', result) + self.assertIn('.. _This is the title_0.0.0_Prelude:', result) + self.assertIn('.. _This is the title_1.0.0:', result) + self.assertIn('.. _This is the title_1.0.0_Known Issues:', result) + + def test_without_title(self): + result = formatter.format_report( + loader=self.ldr, + config=self.c, + versions_to_include=self.versions, + ) + self.assertIn('.. _relnotes_0.0.0:', result) + self.assertIn('.. _relnotes_0.0.0_Prelude:', result) + self.assertIn('.. _relnotes_1.0.0:', result) + self.assertIn('.. _relnotes_1.0.0_Known Issues:', result)