From 050ca9e354421530ba4282d95dead90e31d6dc2a Mon Sep 17 00:00:00 2001 From: David Steele Date: Tue, 13 Oct 2026 18:41:57 +0200 Subject: [PATCH] Add expandable details to the release notes. Release items from 2.60 on put the paragraphs after the first and the links to their issues, pull requests, and commits in a details element that is closed by default. The toggle is drawn in place of the bullet: a plus when the details have text and a chevron when they have only links. An item with more than one commit has a line for each commit with its subject. A button on each release opens or closes all of its details. Older releases still join extra paragraphs into one line. The version pattern for bug fix releases also matched x.y.0, so the commits of a release reached back to v2.53, and 2.54.0 through 2.59.0 were only checked while each was the most recent release. A bug fix release is now x.y.z with z > 0, so a release keeps its commit links after a newer release is added. The 2.55.0 PostgreSQL 18 item now has a commit element because the note was reworded after the commit. The GCS Workload Identity Federation item now has a commit element because its text does not match the commit subject. --- doc/lib/command/render/release.py | 133 +++++++++-- doc/lib/command/render/render.py | 9 +- doc/resource/html/default.css | 152 ++++++++++++ doc/resource/html/default.js | 80 ++++++- doc/xml/release/2020s/2025/2.55.0.xml | 4 +- doc/xml/release/2020s/2026/2.60.0.xml | 7 +- .../module/doc/command/render/release_test.py | 222 ++++++++++++++++++ 7 files changed, 584 insertions(+), 23 deletions(-) diff --git a/doc/lib/command/render/release.py b/doc/lib/command/render/release.py index 7f30fe600..4bec882a4 100644 --- a/doc/lib/command/render/release.py +++ b/doc/lib/command/render/release.py @@ -7,6 +7,7 @@ Each item is checked against the git history: a release item with no commit behi commit whose subject no longer matches what the release reports it did.""" #################################################################################################################################### +import html import json import os import re @@ -59,7 +60,21 @@ _CONTRIBUTOR_LIST = ( ("release-item-ideator", "Reported", "Suggested"), ) -_VERSION_BUG_FIX_EXP = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") +# Links to an issue and to a pull request, in the order they are listed +_REF_LIST = ( + ("github-issue", "Issue", "{[github-url-issues]}/%s"), + ("github-pull-request", "Pull request", "{[github-url-base]}/pull/%s"), +) + +# Link to a commit and the length of the hash it is named by +_COMMIT_URL = "{[github-url-base]}/commit/%s" +_COMMIT_SIZE = 7 + +# Releases with version >= this have the paragraphs after the first and the links of an item in details +_DETAIL_VERSION_MIN = "2.60" + +# A bug fix release is x.y.z with z > 0 +_VERSION_BUG_FIX_EXP = re.compile(r"^[0-9]+\.[0-9]+\.[1-9][0-9]*$") _VERSION_DATE_EXP = re.compile(r"^(XXXX-XX-XX)|([0-9]{4}-[0-9]{2}-[0-9]{2})$") @@ -317,34 +332,110 @@ class _Release: return commit_list, remaining_list, commit_map, check ################################################################################################################################ - def _item_check(self, item, text, version, commit_list, remaining_list, commit_map): - """Check that every commit a release item claims is in the history of the release.""" + def _item_commit(self, item, text, version, commit_list, remaining_list, commit_map, check): + """Find every commit a release item claims in the history of the release. + Each commit is returned with the commit element that names it. The commit element is None when the item names no commit and + the commit is None when it is not found. A commit that is not found is an error when check is true.""" + + result = [] commit_xml_list = xml_node_child_list(item, "commit") # An item with no commit named uses what it says as the subject to look for if len(commit_xml_list) == 0: subject = self.render.process_text(text) + commit = self._commit_find(commit_list, subject, False) - if self._commit_find(commit_list, subject, False) is None: + if commit is None and check: self._commit_error( "unable to find commit or no subject match for release %s item '%s'" % (version, subject), remaining_list, commit_map, ) + result.append((None, commit)) + for commit_xml in commit_xml_list: subject = xml_node_attribute(commit_xml, "subject", True) commit = self._commit_find(commit_list, subject, False) if commit is None: - self._commit_error( - "unable to find release %s commit subject '%s' in list" % (version, subject), remaining_list, commit_map - ) - - if commit["commit"] in remaining_list: + if check: + self._commit_error( + "unable to find release %s commit subject '%s' in list" % (version, subject), remaining_list, commit_map + ) + elif commit["commit"] in remaining_list: remaining_list.remove(commit["commit"]) + result.append((commit_xml, commit)) + + return result + + ################################################################################################################################ + @staticmethod + def _ref_add(ref, label, url, value, code=False): + """Add a link to an issue, a pull request, or a commit.""" + + ref_item = xml_node_add(ref, "list-detail-ref-item") + + if label is not None: + xml_node_content_add(ref_item, "%s " % label) + + link = xml_node_add(ref_item, "link", {"url": url}) + xml_node_content_add(xml_node_add(link, "code") if code else link, value) + + return ref_item + + ################################################################################################################################ + def _ref_list_add(self, ref, node): + """Add a link to each issue and pull request a node names.""" + + for type, label, url in _REF_LIST: + for ref_xml in xml_node_child_list(node, type): + id = xml_node_attribute(ref_xml, "id", True) + self._ref_add(ref, label, url % id, "#%s" % id) + + ################################################################################################################################ + def _detail_add(self, list_item, item, item_commit_list): + """Add the paragraphs after the first and the issues, pull requests, and commits of an item as details. + + An item with one commit has the commit on the same line as its issues and pull requests, after them. An item with more than + one commit has a line for each commit, with the subject of the commit and the issues and pull requests the commit element + names.""" + + detail = xml_node_add(list_item, "list-detail") + + for paragraph in xml_node_child_list(item, "p")[1:]: + xml_node_child_add(xml_node_add(detail, "list-detail-text"), paragraph) + + ref = xml_node_add(detail, "list-detail-ref") + self._ref_list_add(ref, item) + + for commit_xml, commit in item_commit_list: + url = None if commit is None else _COMMIT_URL % commit["commit"] + hash = None if commit is None else commit["commit"][:_COMMIT_SIZE] + + if len(item_commit_list) > 1: + ref = xml_node_add(detail, "list-detail-ref") + + # Escape the subject for html + if commit is not None: + xml_node_content_add(self._ref_add(ref, None, url, hash, True), " %s" % html.escape(commit["subject"])) + + if commit_xml is not None: + self._ref_list_add(ref, commit_xml) + + if len(item_commit_list) == 1 and commit is not None: + self._ref_add(ref, "Commit", url, hash, True) + + # Remove lines with no links and details with nothing in them + for ref in xml_node_child_list(detail, "list-detail-ref"): + if len(ref) == 0: + detail.remove(ref) + + if len(detail) == 0: + list_item.remove(detail) + ################################################################################################################################ def render_document(self): """Build the release document.""" @@ -436,6 +527,7 @@ class _Release: """Render what one release changed.""" note = False + detail = version >= _DETAIL_VERSION_MIN for section_type, section_title in _SECTION_LIST: type_node = xml_node_child(release, section_type) @@ -470,14 +562,19 @@ class _Release: for item in xml_node_child_list(item_node, "release-item"): text = xml_node_child_list(item, "p")[0] + item_check = check and item_type != "release-development-list" + item_commit_list = None - if check and item_type != "release-development-list": - self._item_check(item, text, version, commit_list, remaining_list, commit_map) + if item_check or detail: + item_commit_list = self._item_commit( + item, text, version, commit_list, remaining_list, commit_map, item_check + ) - # An item written as more than one paragraph reads as one line in the release notes - for extra in xml_node_child_list(item, "p")[1:]: - xml_node_content_add(text, " ") - xml_node_child_add(text, extra) + # An item written as more than one paragraph reads as one line in the release notes when version < 2.60 + if not detail: + for extra in xml_node_child_list(item, "p")[1:]: + xml_node_content_add(text, " ") + xml_node_child_add(text, extra) contributor_text = self._contributor_text(item, item_name) @@ -486,7 +583,11 @@ class _Release: xml_node_content_add(xml_node_add(text, "i"), contributor_text) xml_node_content_add(text, ")") - xml_node_child_add(xml_node_text_add(xml_node_add(list_node, "list-item")), text) + list_item = xml_node_text_add(xml_node_add(list_node, "list-item")) + xml_node_child_add(list_item, text) + + if detail: + self._detail_add(list_item, item, item_commit_list) #################################################################################################################################### diff --git a/doc/lib/command/render/render.py b/doc/lib/command/render/render.py index decdf5c1a..1ebbf2b4a 100644 --- a/doc/lib/command/render/render.py +++ b/doc/lib/command/render/render.py @@ -30,7 +30,7 @@ SECTION_ANCHOR = "anchor" SECTION_ANCHOR_NO_INHERIT = "no-inherit" # Tags whose content is text and markup rather than markup alone, so what they hold is rendered as text -_TAG_TEXT = ("admonition", "code-block", "list-item", "p", "summary", "title") +_TAG_TEXT = ("admonition", "code-block", "list-detail-ref-item", "list-detail-text", "list-item", "p", "summary", "title") # Tags that are content however little they hold, so they are never read as a property of the node holding them _TAG_CONTENT = _TAG_TEXT + ("table-cell", "table-column") @@ -102,6 +102,13 @@ _TAG_RENDER = { "i": ("", ""), "id": ('', ""), "list": ('"), + "list-detail": ( + '\n
', + "
", + ), + "list-detail-ref": ('\n
', "
"), + "list-detail-ref-item": ('', ""), + "list-detail-text": ('\n
', "
"), "list-item": ('
  • ', "
  • "), "p": ("", ""), "param": ('', ""), diff --git a/doc/resource/html/default.css b/doc/resource/html/default.css index 46c0cd99e..6a1ff2a88 100644 --- a/doc/resource/html/default.css +++ b/doc/resource/html/default.css @@ -747,6 +747,158 @@ li.list-unordered::marker color: var(--accent); } +/*********************************************************************************************************************************** +List Details + +An item with details has a toggle in place of the bullet. The toggle is a plus when the details have text and a chevron when they +have only links. +***********************************************************************************************************************************/ +li.list-unordered:has(> .list-detail) +{ + position: relative; + list-style-type: none; +} + +/* The toggle fills the space the bullet is drawn in, level with the first line of the item */ +.list-detail-toggle +{ + position: absolute; + top: 0; + left: -1.4rem; + display: flex; + align-items: center; + justify-content: center; + width: 1.3rem; + height: 1.7em; + border-radius: var(--radius); + color: var(--text-muted); + cursor: pointer; + list-style: none; +} + +.list-detail-toggle::-webkit-details-marker +{ + display: none; +} + +.list-detail-toggle:hover +{ + background-color: var(--accent-soft); + color: var(--accent-hover); +} + +.list-detail-toggle:focus-visible +{ + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +/* A chevron that points right when closed and down when open */ +.list-detail-toggle::before +{ + content: ""; + width: 6px; + height: 6px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: translateX(-1px) rotate(-45deg); +} + +.list-detail[open] > .list-detail-toggle::before +{ + transform: translateY(-2px) rotate(45deg); +} + +/* A plus that is a minus when open */ +.list-detail:has(> .list-detail-text) > .list-detail-toggle +{ + color: var(--accent); +} + +.list-detail:has(> .list-detail-text) > .list-detail-toggle::before +{ + width: 10px; + height: 10px; + border: none; + background: + linear-gradient(currentColor, currentColor) center / 100% 2px no-repeat, + linear-gradient(currentColor, currentColor) center / 2px 100% no-repeat; + transform: none; +} + +.list-detail[open]:has(> .list-detail-text) > .list-detail-toggle::before +{ + background: linear-gradient(currentColor, currentColor) center / 100% 2px no-repeat; +} + +.list-detail[open] +{ + margin: .35rem 0 .25rem 0; + border-left: 2px solid var(--border); + padding-left: .75rem; +} + +.list-detail-text + .list-detail-text +{ + margin-top: .5rem; +} + +.list-detail-text + .list-detail-ref +{ + margin-top: .4rem; +} + +.list-detail-ref +{ + color: var(--text-muted); + font-size: .875rem; + line-height: 1.6; +} + +/* A dot between the links on a line */ +.list-detail-ref-item + .list-detail-ref-item::before +{ + content: "\00b7"; + margin: 0 .45em; +} + +/* The hash of a commit is drawn in the code font without the box of a keyword */ +.list-detail-ref .id +{ + border: none; + background: none; + padding: 0; + font-size: .9375em; +} + +/* The button that opens or closes every details of a section, which the script adds */ +.list-detail-all +{ + display: block; + margin-top: .5rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background-color: var(--bg-surface); + padding: .1rem .65rem; + color: var(--text-muted); + font-family: inherit; + font-size: .8125rem; + line-height: 1.6; + cursor: pointer; +} + +.list-detail-all:hover +{ + border-color: var(--border-strong); + color: var(--text); +} + +.list-detail-all:focus-visible +{ + outline: 2px solid var(--accent); + outline-offset: 1px; +} + /*********************************************************************************************************************************** Table Elements ***********************************************************************************************************************************/ diff --git a/doc/resource/html/default.js b/doc/resource/html/default.js index f1178f62e..779defa53 100644 --- a/doc/resource/html/default.js +++ b/doc/resource/html/default.js @@ -1,10 +1,10 @@ /*********************************************************************************************************************************** Documentation Script -Four things that a page reads better with and works without. A browser that runs no script loses them and loses nothing it needs: +Five things that a page reads better with and works without. A browser that runs no script loses them and loses nothing it needs: the contents are links and work as links, every block that can scroll is already reachable from the keyboard, a menu too wide for -the window scrolls whether or not it says so, and the button that copies a command or a config file is not shown until there is a -script to make it work. +the window scrolls whether or not it says so, the button that copies a command or a config file is not shown until there is a +script to make it work, and each details of a list still opens on its own. The first marks the section being read in the contents beside the text, so a long page says where the reader is rather than only where they can go. The section being read is the last one that begins above the menu bar, which is what a reader means by the @@ -381,3 +381,77 @@ cannot copy never shows it at all, since a button that does nothing is worse tha for (var buttonIdx = 0; buttonIdx < buttonList.length; buttonIdx++) ready(buttonList[buttonIdx]); })(); + +/*********************************************************************************************************************************** +Opening every details of a section + +A section with details in its lists has a button after its subtitles that opens every details of the section and then closes them +when pressed again. Opening or closing one details does not change the button. The button is added here, so a page with no script +has no button. +***********************************************************************************************************************************/ +(function() +{ + var SECTION = ".section1, .section2, .section3"; + + var sectionList = document.querySelectorAll(SECTION); + + /******************************************************************************************************************************* + Add the button to a section + *******************************************************************************************************************************/ + function ready(section, detailList) + { + var button = document.createElement("button"); + + // Did the button last open the details? + var open = false; + + button.type = "button"; + button.className = "list-detail-all"; + + // The label is what pressing the button does + function label() + { + button.textContent = open ? "Hide details" : "Show details"; + } + + function toggle() + { + open = !open; + + for (var idx = 0; idx < detailList.length; idx++) + detailList[idx].open = open; + + label(); + } + + button.addEventListener("click", toggle); + + label(); + + // After the subtitles of the section, or first in its body when it has none + var body = section.querySelector(":scope > .section-body"); + var subtitleList = body.querySelectorAll(":scope > [class$='subtitle']"); + + if (subtitleList.length > 0) + subtitleList[subtitleList.length - 1].after(button); + else + body.prepend(button); + } + + for (var sectionIdx = 0; sectionIdx < sectionList.length; sectionIdx++) + { + var section = sectionList[sectionIdx]; + var detailAll = section.querySelectorAll(".list-detail"); + var detailList = []; + + // Details in a section below this one belong to that section + for (var detailIdx = 0; detailIdx < detailAll.length; detailIdx++) + { + if (detailAll[detailIdx].closest(SECTION) === section) + detailList.push(detailAll[detailIdx]); + } + + if (detailList.length > 0) + ready(section, detailList); + } +})(); diff --git a/doc/xml/release/2020s/2025/2.55.0.xml b/doc/xml/release/2020s/2025/2.55.0.xml index 9cdb6366b..5b14af295 100644 --- a/doc/xml/release/2020s/2025/2.55.0.xml +++ b/doc/xml/release/2020s/2025/2.55.0.xml @@ -93,7 +93,9 @@ - + + + diff --git a/doc/xml/release/2020s/2026/2.60.0.xml b/doc/xml/release/2020s/2026/2.60.0.xml index be2789263..85ca215df 100644 --- a/doc/xml/release/2020s/2026/2.60.0.xml +++ b/doc/xml/release/2020s/2026/2.60.0.xml @@ -22,8 +22,10 @@ - - + + + + @@ -249,6 +251,7 @@ + diff --git a/test/src/module/doc/command/render/release_test.py b/test/src/module/doc/command/render/release_test.py index 5b284a10b..1f6ded653 100644 --- a/test/src/module/doc/command/render/release_test.py +++ b/test/src/module/doc/command/render/release_test.py @@ -15,6 +15,7 @@ from common.exec import exec_one from common.storage import file_read, file_write from common.xml import xml_node_attribute, xml_node_child, xml_node_child_list, xml_node_content, xml_parse from command.render.release import release_current_stable, release_history_update, release_last, release_list, release_render +from command.render.render import DocRender # The history a release is checked against, newest first, which is the order git writes it in HISTORY = [ @@ -135,6 +136,85 @@ RELEASE_LIST = """ """ +# Releases with details and the history they are checked against +DETAIL_HISTORY = [ + ("d0", "Improve speed."), + ("d1", "Add a feature."), + ("d2", "Fix a bug."), + ("d3", 'Fix a "quoted" .'), + ("d4", "Begin v2.60.0 development."), + ("d5", "v2.59.0: Prior."), + ("d6", "Improve a thing."), + ("d7", "v1.00: Initial."), +] + +DETAIL_RELEASE_LIST = """ + + + + + + + +

    Add a feature.

    + +

    Say more about it.

    + +

    And more.

    +
    + + + + + + + +

    Fix bugs.

    +
    + + + + + + + +

    Speed things up.

    +
    +
    +
    +
    + + + + + +

    Fix a bug on a branch.

    +
    +
    +
    +
    + + + + + + + +

    Improve a thing.

    + +

    Say more.

    +
    +
    +
    +
    + + +""" + +# Html of the details up to the first paragraph or line of links +DETAIL_HTML = '\n
    ' + + #################################################################################################################################### def _doc(release_list=RELEASE_LIST, contributor=CONTRIBUTOR): """The release document, which is the list of releases and who worked on them.""" @@ -178,6 +258,19 @@ def _section_list(root): return [xml_node_attribute(section, "id") for section in xml_node_child_list(root, "section")] +#################################################################################################################################### +def _item_html(release): + """The html of each item of a release.""" + + render = DocRender("html") + + return [ + render.process_text(item) + for list_node in xml_node_child_list(release, "list") + for item in xml_node_child_list(list_node, "list-item") + ] + + #################################################################################################################################### def test_release_list(): """The releases are listed newest first, which is the order they are read in.""" @@ -435,6 +528,135 @@ def test_release_render_commit_ambiguous(): assert_in("subject prefix 'Fix a bug.' already found in commit", str(raised.exception)) +#################################################################################################################################### +def test_release_render_detail(): + """Releases with version >= 2.60 have the paragraphs after the first and the links of an item in details.""" + + root = _render(_doc(DETAIL_RELEASE_LIST), history=DETAIL_HISTORY) + + assert_equal(_section_list(root), ["introduction", "current", "supported"]) + + release = xml_node_child_list(xml_node_child_list(root, "section")[1], "section")[0] + + assert_equal( + _item_html(release), + [ + "Add a feature." + + DETAIL_HTML + + '\n
    Say more about it.
    ' + + '\n
    And more.
    ' + + '\n
    ' + + 'Issue #1' + + 'Pull request #2' + + 'Commit ' + + 'd1' + + "
    ", + # An item with more than one commit has a line for each commit + "Fix bugs." + + DETAIL_HTML + + '\n
    ' + + 'd2' + + " Fix a bug." + + 'Pull request #3
    ' + + '\n
    ' + + 'd3' + + " Fix a "quoted" <bug>.
    ", + # An item with one commit has the commit after the issues and pull requests the commit element names + "Speed things up." + + DETAIL_HTML + + '\n
    ' + + 'Issue #5' + + 'Pull request #6' + + 'Commit ' + + 'd0' + + "
    ", + ], + ) + + # Releases with version < 2.60 have no details + supported = xml_node_child_list(root, "section")[2] + + assert_equal(_item_html(xml_node_child_list(supported, "section")[1]), ["Improve a thing. Say more."]) + + +#################################################################################################################################### +def test_release_render_detail_dev(): + """A development release has links for the commits that are found and no error for the commits that are not.""" + + root = _render( + _doc( + DETAIL_RELEASE_LIST.replace('date="2026-10-01" version="2.60.0"', 'date="XXXX-XX-XX" version="2.60.0dev"').replace( + "", + """ +

    Do a thing.

    +
    + + + + + + +

    Do another thing.

    +
    + + + + + + + +

    Do a third thing.

    +
    + """, + 1, + ) + ), + history=DETAIL_HISTORY, + ) + + development = xml_node_child_list(xml_node_child_list(root, "section")[1], "section")[0] + item_list = _item_html(development) + + assert_equal(len(item_list), 6) + assert_equal(item_list[3], "Do a thing.") + assert_equal( + item_list[4], + "Do another thing." + + DETAIL_HTML + + '\n
    ' + + 'Pull request #4
    ', + ) + + # A line for a commit that is not found has only the links the commit element names + assert_equal( + item_list[5], + "Do a third thing." + + DETAIL_HTML + + '\n
    ' + + 'Pull request #7
    ' + + '\n
    ' + + 'd0' + + " Improve speed.
    ", + ) + + +#################################################################################################################################### +def test_release_render_bug_fix(): + """A bug fix release x.y.z with z > 0 is skipped when it is not the most recent and a release x.y.0 is checked.""" + + # The commits of 2.60.0 end at the commit that closed 2.59.0 + with assert_raises(ToolError) as raised: + _render(_doc(DETAIL_RELEASE_LIST.replace("

    Add a feature.

    ", "

    Improve a thing.

    ")), history=DETAIL_HISTORY) + + assert_in("unable to find commit or no subject match for release 2.60.0 item 'Improve a thing.'", str(raised.exception)) + + # 2.59.0 is checked + with assert_raises(ToolError) as raised: + _render(_doc(DETAIL_RELEASE_LIST.replace("

    Improve a thing.

    ", "

    Improve two things.

    ")), history=DETAIL_HISTORY) + + assert_in("unable to find commit or no subject match for release 2.59.0 item 'Improve two things.'", str(raised.exception)) + + #################################################################################################################################### def test_release_history_update(): """The history is written from git when HEAD is not the newest commit in it."""