diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..41478a022
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,3 @@
+* text=auto !eol
+*.sh eol=lf
+*.bat eol=crlf
\ No newline at end of file
diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml
index 6cac9cdd6..705edc3ec 100644
--- a/.github/workflows/ci_cd.yml
+++ b/.github/workflows/ci_cd.yml
@@ -45,6 +45,19 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
+ - name: "Setup python"
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ env.MAIN_PYTHON_VERSION }}
+
+ - name: "Checkout repo"
+ uses: actions/checkout@v4
+
+ - name: "Install dependencies"
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e .[changelog]
+
- uses: ansys/actions/doc-changelog@v8
if: ${{ github.event_name == 'pull_request' }}
with:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 8f4848de9..1e625e60a 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -33,7 +33,7 @@ repos:
- id: check-github-workflows
- repo: https://github.com/ansys/pre-commit-hooks
- rev: v0.4.4
+ rev: v0.5.1
hooks:
- id: add-license-headers
files: '(src|doc/source/examples)/.*\.(py)'
diff --git a/doc/changelog.d/583.maintenance.md b/doc/changelog.d/583.maintenance.md
new file mode 100644
index 000000000..ab73b83e0
--- /dev/null
+++ b/doc/changelog.d/583.maintenance.md
@@ -0,0 +1 @@
+feat: add whatsnew options
\ No newline at end of file
diff --git a/doc/changelog.d/changelog_template.jinja b/doc/changelog.d/changelog_template.jinja
index c5fe4e7da..0e90e56c3 100644
--- a/doc/changelog.d/changelog_template.jinja
+++ b/doc/changelog.d/changelog_template.jinja
@@ -1,17 +1,17 @@
-{% if sections[""] %}
-{% for category, val in definitions.items() if category in sections[""] %}
-
-{{ definitions[category]['name'] }}
-{% set underline = '^' * definitions[category]['name']|length %}
-{{ underline }}
-
-{% for text, values in sections[""][category].items() %}
-- {{ text }} {{ values|join(', ') }}
-{% endfor %}
-
-{% endfor %}
-{% else %}
-No significant changes.
-
-
-{% endif %}
+{% if sections[""] %}
+{% for category, val in definitions.items() if category in sections[""] %}
+
+{{ definitions[category]['name'] }}
+{% set underline = '^' * definitions[category]['name']|length %}
+{{ underline }}
+
+{% for text, values in sections[""][category].items() %}
+- {{ text }} {{ values|join(', ') }}
+{% endfor %}
+
+{% endfor %}
+{% else %}
+No significant changes.
+
+
+{% endif %}
diff --git a/doc/source/_static/whatsnew_section.png b/doc/source/_static/whatsnew_section.png
new file mode 100644
index 000000000..13d9269e8
Binary files /dev/null and b/doc/source/_static/whatsnew_section.png differ
diff --git a/doc/source/_static/whatsnew_sidebar.png b/doc/source/_static/whatsnew_sidebar.png
new file mode 100644
index 000000000..e90273b8b
Binary files /dev/null and b/doc/source/_static/whatsnew_sidebar.png differ
diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst
index 401dbfee9..0304d9a1a 100644
--- a/doc/source/changelog.rst
+++ b/doc/source/changelog.rst
@@ -1,10 +1,9 @@
+
.. _ref_release_notes:
Release notes
#############
-This document contains the release notes for the project.
-
.. vale off
.. towncrier release notes start
diff --git a/doc/source/user-guide/options.rst b/doc/source/user-guide/options.rst
index 551dbfed0..878b9f637 100644
--- a/doc/source/user-guide/options.rst
+++ b/doc/source/user-guide/options.rst
@@ -256,3 +256,91 @@ main ``index.rst`` file and the ``learning.rst`` file in its "Getting started" s
To use this feature, you must have the `quarto ` package installed. To create thumbnails of generated PDF files,
the theme is using `pdf2image`. So you should have the ``poppler`` package installed in your system.
For more information, see the `pdf2image documentation `_.
+
+What's new section
+------------------
+
+The "What's new" section is an option that allows you to highlight new features in your library
+for each minor version within the changelog file.
+
+To get started, create a YAML file named ``whatsnew.yml`` in the ``doc/source`` directory. The
+YAML file should contain the following structure:
+
+.. code-block:: yaml
+
+ fragments:
+ - title: Feature title
+ version: 0.2.0 # The version the feature is introduced
+ content: |
+ Feature description in RST format.
+
+ - title: Another feature title
+ version: 0.1.2
+ content: |
+ Feature description in RST format.
+
+The dropdown generation only supports the following RST formats in the "content" field:
+
+- Bold: Use double asterisks to wrap the text.
+- Italics: Use single asterisks to wrap the text.
+- Code samples: Use single or double backticks to wrap the text.
+- Links: Use the following format to include links:
+
+ .. code-block:: rst
+
+ `link text `_
+
+- Code blocks: Use the following format to include code blocks:
+
+ .. code-block:: rst
+
+ .. code:: python
+
+ print("hello world")
+
+If a format is used in the "content" field that does not fall into the categories above, it will not
+be rendered correctly.
+
+To enable the "What's new" sections and sidebar in the changelog file, add the following dictionary
+to the ``html_theme_options`` dictionary:
+
+.. code-block:: python
+
+ html_theme_options = (
+ {
+ "whatsnew": {
+ "whatsnew_file_path": "changelog.d/whatsnew.yml",
+ "changelog_file_path": "changelog.rst",
+ "sidebar_pages": ["changelog"],
+ "sidebar_no_of_headers": 3, # Optional
+ "sidebar_no_of_contents": 3, # Optional
+ },
+ },
+ )
+
+The dictionary contains the following keys:
+
+- ``whatsnew_file_path``: The path to the YAML file containing what's new content local to the
+ ``doc/source`` directory. If not provided, the what's new section will not be generated.
+- ``changelog_file_path``: The path to the changelog.rst file local to the ``doc/source``
+ directory. If not provided, the what's new section will not be generated.
+- ``sidebar_pages``: List of names for the pages to include the what's new sidebar on. If not
+ provided, the what's new sidebar is not displayed.
+- ``sidebar_no_of_headers``: Number of minor version sections to display in the what's new sidebar.
+ By default, it displays three version sections in the sidebar.
+- ``sidebar_no_of_contents``: Number of what's new content to display under each minor version in the
+ what's new sidebar. If not provided, it displays all dropdowns by default.
+
+The following images show a sample "What's new" section and sidebar in the changelog file:
+
+.. tab-set::
+
+ .. tab-item:: What's new section
+
+ .. image:: ../_static/whatsnew_section.png
+ :alt: What's new section
+
+ .. tab-item:: What's new sidebar
+
+ .. image:: ../_static/whatsnew_sidebar.png
+ :alt: What's new sidebar
diff --git a/pyproject.toml b/pyproject.toml
index 6d6ced348..0b1de9636 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -45,6 +45,7 @@ doc = [
"Pillow>=9.0",
"PyGitHub==2.5.0",
"pyvista[jupyter]==0.44.2",
+ "PyYAML==6.0.2",
"requests==2.32.3",
"Sphinx==8.1.3",
"sphinx-autoapi==3.4.0",
@@ -54,6 +55,10 @@ doc = [
"sphinx-jinja==2.0.2",
"sphinx-notfound-page==1.0.4",
]
+changelog = [
+ "PyYAML==6.0.2",
+ "sphinx-design==0.6.1",
+]
[project.entry-points."sphinx.html_themes"]
ansys_sphinx_theme = "ansys_sphinx_theme"
@@ -121,7 +126,7 @@ directory = "doc/changelog.d"
filename = "doc/source/changelog.rst"
template = "doc/changelog.d/changelog_template.jinja"
start_string = ".. towncrier release notes start\n"
-title_format = "`{version} `_ - {project_date}"
+title_format = "`{version} `_ ({project_date})"
issue_format = "`#{issue} `_"
[[tool.towncrier.type]]
diff --git a/src/ansys_sphinx_theme/__init__.py b/src/ansys_sphinx_theme/__init__.py
index c5851f624..f5d6d9d2d 100644
--- a/src/ansys_sphinx_theme/__init__.py
+++ b/src/ansys_sphinx_theme/__init__.py
@@ -22,16 +22,19 @@
"""Module for the Ansys Sphinx theme."""
+from itertools import islice, tee
import logging
import os
import pathlib
+import re
import subprocess
-from typing import Any, Dict
+from typing import Any, Dict, Iterable
import warnings
from docutils import nodes
from sphinx import addnodes
from sphinx.application import Sphinx
+import yaml
from ansys_sphinx_theme.extension.linkcode import DOMAIN_KEYS, sphinx_linkcode_resolve
from ansys_sphinx_theme.latex import generate_404
@@ -65,6 +68,15 @@
ANSYS_LOGO_LINK = "https://www.ansys.com/"
PYANSYS_LOGO_LINK = "https://docs.pyansys.com/"
+"""Semantic version regex as found on semver.org:
+https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string"""
+SEMVER_REGEX = (
+ r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
+ r"(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)"
+ r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
+ r"(?:\+(>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?"
+)
+
# make logo paths available
ansys_favicon = str((LOGOS_PATH / "ansys-favicon.png").absolute())
ansys_logo_black = str((LOGOS_PATH / "ansys_logo_black_cropped.jpg").absolute())
@@ -540,6 +552,556 @@ def check_for_depreciated_theme_options(app: Sphinx):
)
+def get_whatsnew_options(app: Sphinx) -> tuple:
+ """Get the whatsnew options from the configuration file.
+
+ Parameters
+ ----------
+ app : sphinx.application.Sphinx
+ Application instance for rendering the documentation.
+
+ Returns
+ -------
+ tuple
+ Tuple containing the paths to the whatsnew file, changelog file, and the sidebar pages.
+ If the whatsnew options are not found, return None for each of those fields.
+ """
+ # Get the html_theme_options from conf.py
+ config_options = app.config.html_theme_options
+
+ # Get the whatsnew key from the html_theme_options
+ whatsnew_options = config_options.get("whatsnew", None)
+
+ if whatsnew_options is None:
+ return None, None, None
+
+ # Get the names of the whatsnew.yml and changelog.rst files
+ whatsnew_file = whatsnew_options.get("whatsnew_file_name", None)
+ changelog_file = whatsnew_options.get("changelog_file_name", None)
+
+ # The source directory of the documentation: {repository_root}/doc/source
+ doc_src_dir = app.env.srcdir
+
+ if whatsnew_file is not None:
+ whatsnew_file = pathlib.Path(doc_src_dir) / whatsnew_file
+ if changelog_file is not None:
+ changelog_file = pathlib.Path(doc_src_dir) / changelog_file
+
+ # Get the pages the whatsnew sidebar should be displayed on
+ sidebar_pages = whatsnew_options.get("sidebar_pages", None)
+
+ return whatsnew_file, changelog_file, sidebar_pages
+
+
+def add_whatsnew_changelog(app: Sphinx, doctree: nodes.document) -> None:
+ """Add the what's new section to each minor version if applicable.
+
+ Parameters
+ ----------
+ app : sphinx.application.Sphinx
+ Application instance for rendering the documentation.
+ doctree : docutils.nodes.document
+ Document tree for the page.
+ """
+ # Get the html_theme_options from conf.py
+ config_options = app.config.html_theme_options
+
+ # Get the whatsnew key from the html_theme_options
+ whatsnew_options = config_options.get("whatsnew")
+
+ # The source directory of the documentation: {repository_root}/doc/source
+ doc_src_dir = pathlib.Path(app.env.srcdir)
+
+ # Full paths to the whatsnew.yml and changelog.rst files from the doc/source directory
+ whatsnew_file = doc_src_dir / whatsnew_options.get("whatsnew_file_name")
+ changelog_file = doc_src_dir / whatsnew_options.get("changelog_file_name")
+
+ # Read the file and get the sections from the file as a list. For example,
+ # sections = []
+ sections = doctree.traverse(nodes.document)
+ if not sections:
+ return
+
+ # Get the file name of the section using section.get("source") and return the section
+ # if section.get("source") is equal to the changelog_file
+ changelog_doctree_sections = [
+ section for section in sections if section.get("source") == str(changelog_file)
+ ]
+
+ # Return if the changelog file sections are not found
+ if not changelog_doctree_sections:
+ return
+
+ # Get the what's new data from the whatsnew.yml file
+ whatsnew_data = get_whatsnew_data(whatsnew_file)
+
+ existing_minor_versions = []
+ docs_content = doctree.traverse(nodes.section)
+
+ # Get each section that contains a semantic version number
+ version_sections = [node for node in docs_content if re.search(SEMVER_REGEX, node[0].astext())]
+
+ for node in version_sections:
+ # Get the semantic version number from the section title link
+ next_node = node.next_node(nodes.reference)
+ # Get the name of the section title link
+ version = next_node.get("name", None)
+
+ if version:
+ # Create the minor version from the patch version
+ minor_version = ".".join(version.split(".")[:-1])
+
+ if minor_version not in existing_minor_versions:
+ # Add minor version to list of existing minor versions
+ existing_minor_versions.append(minor_version)
+
+ # Create a section for the minor version
+ minor_version_section = nodes.section(
+ ids=[f"version-{minor_version}"], names=[f"Version {minor_version}"]
+ )
+ # Add the title to the section for the minor version
+ minor_version_section += nodes.title("", f"Version {minor_version}")
+
+ # Add "What's New" section under the minor version if the minor version is in
+ # the what's new data
+ if whatsnew_file.exists() and (minor_version in list(whatsnew_data.keys())):
+ minor_version_whatsnew = add_whatsnew_section(minor_version, whatsnew_data)
+ minor_version_section.append(minor_version_whatsnew)
+
+ # Add the title at the beginning of a section with a patch version
+ node.insert(0, minor_version_section)
+
+
+def get_whatsnew_data(whatsnew_file: pathlib.Path) -> dict:
+ """Get the what's new data from the whatsnew.yml file.
+
+ Parameters
+ ----------
+ whatsnew_file : pathlib.Path
+ Path to the whatsnew.yml file.
+
+ Returns
+ -------
+ dict
+ Dictionary containing the what's new data from the whatsnew.yml file.
+ """
+ if whatsnew_file.exists():
+ # Open and read the whatsnew.yml file
+ with pathlib.Path.open(whatsnew_file, "r", encoding="utf-8") as file:
+ whatsnew_data = yaml.safe_load(file)
+
+ # Create a dictionary containing the what's new data for each minor version
+ # For example: { minor_version: [fragment1_dict, fragment2_dict, ...] }
+ minor_version_whatsnew_data = {}
+ for fragment in whatsnew_data["fragments"]:
+ # Get the minor version from the fragment version
+ whatsnew_minor_version = ".".join(fragment["version"].split(".")[:2])
+
+ # Create an empty list for the minor version if it does not exist
+ if whatsnew_minor_version not in minor_version_whatsnew_data:
+ minor_version_whatsnew_data[whatsnew_minor_version] = []
+ # Append the fragment to the minor version in the whatsnew_data
+ minor_version_whatsnew_data[whatsnew_minor_version].append(fragment)
+
+ return minor_version_whatsnew_data
+
+
+def add_whatsnew_section(minor_version: str, whatsnew_data: dict) -> nodes.section:
+ """Add the what's new section and dropdowns for each fragment in the whatsnew.yml file.
+
+ Parameters
+ ----------
+ minor_version : str
+ Minor version number.
+ whatsnew_data : dict
+ Dictionary containing the what's new data from the whatsnew.yml file.
+
+ Returns
+ -------
+ nodes.section
+ Section containing the what's new title and dropdowns for each fragment in the
+ whatsnew.yml file.
+ """
+ # Add the what's new section and title
+ minor_version_whatsnew = nodes.section(ids=[f"version-{minor_version}-whatsnew"])
+ minor_version_whatsnew += nodes.title("", "What's New")
+
+ # Add a dropdown under the "What's New" section for each fragment in the whatsnew.yml file
+ for fragment in whatsnew_data[minor_version]:
+ # Create a dropdown for the fragment
+ whatsnew_dropdown = nodes.container(
+ body_classes=[""],
+ chevron=True,
+ container_classes=["sd-mb-3 sd-fade-in-slide-down"],
+ design_component="dropdown",
+ has_title=True,
+ icon="",
+ is_div=True,
+ opened=False,
+ title_classes=[""],
+ type="dropdown",
+ )
+
+ # Set the title_id for the dropdown
+ title_id = fragment["title"].replace(" ", "-").lower()
+
+ # Add the title of the fragment to the dropdown
+ whatsnew_dropdown += nodes.rubric(ids=[title_id], text=fragment["title"])
+
+ # Add a line specifying which version the fragment is available in
+ version_paragraph = nodes.paragraph("sd-card-text")
+ version_paragraph.append(
+ nodes.emphasis("", f"Available in v{fragment['version']} and later")
+ )
+ whatsnew_dropdown += version_paragraph
+
+ # Split content from YAML file into list
+ content_lines = fragment["content"].split("\n")
+
+ # Create iterator for the content_lines
+ content_iterator = iter(content_lines)
+
+ # Navigate to first line in the iterator
+ line = next(content_iterator, None)
+
+ while line is not None:
+ if ".. code" in line or ".. sourcecode" in line:
+ # Get language after "code::"
+ language = line.split("::")[1].strip()
+ # Create the code block container node with the language if it exists
+ code_block = (
+ nodes.container(classes=[f"highlight-{language} notranslate"])
+ if language
+ else nodes.container()
+ )
+
+ # Fill the code block with the following lines until it reaches the end or an
+ # unindented line
+ code_block, line = fill_code_block(content_iterator, code_block)
+ whatsnew_dropdown += code_block
+ else:
+ # Create the paragraph node
+ paragraph = nodes.paragraph("sd-card-text")
+
+ # Fill the paragraph node with the following lines until it reaches
+ # the end or a code block
+ paragraph, line = fill_paragraph(content_iterator, paragraph, line)
+ whatsnew_dropdown += paragraph
+
+ # Append the fragment dropdown to the minor_version_whatsnew section
+ minor_version_whatsnew.append(whatsnew_dropdown)
+
+ return minor_version_whatsnew
+
+
+def fill_code_block(content_iterator: Iterable, code_block: nodes.container) -> nodes.container:
+ """Fill the code block.
+
+ Parameters
+ ----------
+ content_iterator : Iterable
+ Iterator for the content lines from the fragments in the whatsnew.yml file.
+ code_block : nodes.container
+ Container node for the code block.
+
+ Returns
+ -------
+ nodes.container, str
+ Container node for the code block and the next line in the content iterator.
+ """
+ # classes=["highlight"] is required for the copy button to show up in the literal_block
+ highlight_container = nodes.container(classes=["highlight"])
+
+ # Create literal block with copy button
+ literal_block = nodes.literal_block(
+ classes=["sd-button sd-button--icon sd-button--icon-only sd-button--icon-small"],
+ icon="copy",
+ label="Copy",
+ title="Copy",
+ )
+
+ # Move to the first line in the code block (the line after ".. code::")
+ next_line = next(content_iterator, None)
+
+ # Boolean to check if the line in code block is within a dictionary
+ in_dictionary = False
+
+ # While the next_line is indented or blank, add it to the code block
+ while next_line is not None and (next_line.startswith(" ") or (next_line == "")):
+ if in_dictionary:
+ if next_line.lstrip().startswith("}"):
+ in_dictionary = False
+ formatted_line = next_line.lstrip() + "\n"
+ else:
+ formatted_line = next_line + "\n"
+ else:
+ if next_line.lstrip().startswith("{"):
+ in_dictionary = True
+ formatted_line = next_line.lstrip() + "\n"
+
+ # Add the formatted line to the literal block
+ literal_block += nodes.inline(text=formatted_line)
+
+ # Break the loop if the end of the content is reached
+ if next_line is not None:
+ # Move to the next line in the content
+ next_line = next(content_iterator, None)
+ else:
+ break
+
+ # Add the literal block to the highlight container
+ highlight_container += literal_block
+
+ # Add the highlight container to the code block
+ code_block += highlight_container
+
+ return code_block, next_line
+
+
+def fill_paragraph(
+ content_iterator: Iterable, paragraph: nodes.paragraph, next_line: str
+) -> nodes.paragraph:
+ """Fill the paragraph node.
+
+ Parameters
+ ----------
+ content_iterator : Iterable
+ Iterator for the content lines from the fragments in the whatsnew.yml file.
+ paragraph : nodes.paragraph
+ Paragraph node.
+ next_line : str
+ Next line in the content iterator.
+
+ Returns
+ -------
+ nodes.paragraph, str
+ Paragraph node and the next line in the content iterator.
+ """
+ # While the next_line is not None and is not a code block, add it to the paragraph
+ while next_line is not None and not next_line.startswith(".. "):
+ # Regular expressions to find rst links, and single & double backticks/asterisks
+ rst_link_regex = r"(`[^<`]+? <[^>`]+?>`_)"
+ single_backtick_regex = r"(`[^`]+?`)"
+ double_backtick_regex = r"(``.*?``)"
+ bold_text_regex = r"(\*\*.*?\*\*)"
+ italic_text_regex = r"(\*[^\*]+?\*)"
+
+ # Check if there are rst links, single & double backticks/asterisks in the line
+ link_backtick_regex = (
+ rf"{rst_link_regex}|"
+ rf"{single_backtick_regex}|{double_backtick_regex}|"
+ rf"{bold_text_regex}|{italic_text_regex}"
+ )
+
+ # Get all matches for rst links, single & double backticks/asterisks in the line
+ # Sample: next_line = "The files are **deleted** when the ``GUI`` is closed. For more info"
+ # For example, matches = [('', '', '', '**deleted**', ''), ('', '', '``GUI``', '', '')]
+ matches = re.findall(link_backtick_regex, next_line)
+
+ if matches:
+ # Get all of the matches from the matches list
+ # For example, regex_matches = ['**deleted**', '``GUI``']
+ regex_matches = [
+ element for match in matches for i, element in enumerate(match) if element
+ ]
+
+ # Create a regular expression pattern that matches any URL
+ # For example, pattern = r"\*\*deleted\*\*|``GUI``"
+ pattern = "|".join(map(re.escape, regex_matches))
+
+ # Split the line using the pattern
+ # For example, split_lines = ['The files are ', '**deleted**', ' when the ', '``GUI``',
+ # ' is closed. For more info']
+ split_lines = re.split(f"({pattern})", next_line)
+
+ for line in split_lines:
+ if line in regex_matches:
+ # If it matches RST link regex, append a reference node
+ if re.search(rst_link_regex, line):
+ text, url = re.findall(r"`([^<`]+?) <([^>`]+?)>`_", line)[0]
+ if url.startswith("http") or url.startswith("www"):
+ ref_type = "external"
+ else:
+ ref_type = "internal"
+ paragraph.append(
+ nodes.reference(
+ classes=[f"reference-{ref_type}"],
+ refuri=url,
+ href=url,
+ text=text,
+ )
+ )
+ # If it matches single or double backticks, append a literal node
+ elif re.search(single_backtick_regex, line):
+ text = re.findall(r"`([^`]+?)`", line)[0]
+ paragraph.append(nodes.literal(text=text))
+ elif re.search(double_backtick_regex, line):
+ text = re.findall(r"``(.*?)``", line)[0]
+ paragraph.append(nodes.literal(text=text))
+ # If it matches bold text, append a strong node
+ elif re.search(bold_text_regex, line):
+ text = re.findall(r"\*\*(.*?)\*\*", line)[0]
+ paragraph.append(nodes.strong(text=text))
+ # If it matches italic text, append an emphasis node
+ elif re.search(italic_text_regex, line):
+ text = re.findall(r"\*([^\*]+?)\*", line)[0]
+ paragraph.append(nodes.emphasis(text=text))
+ else:
+ paragraph.append(nodes.inline(text=line))
+ else:
+ # Append the next_line as an inline element, unless it is an empty string. If it's an
+ # empty string, append a line break
+ paragraph.append(nodes.inline(text=next_line)) if next_line != "" else paragraph.append(
+ nodes.line(text="\n")
+ )
+
+ # Add a space at the end of each line
+ paragraph.append(nodes.inline(text=" "))
+
+ # Break the loop if the end of the content is reached
+ if next_line is not None:
+ # Move to the next line in the content
+ next_line = next(content_iterator, None)
+ else:
+ break
+
+ return paragraph, next_line
+
+
+def extract_whatsnew(app: Sphinx, doctree: nodes.document, docname: str) -> None:
+ """Extract the what's new content from the changelog document.
+
+ Parameters
+ ----------
+ app : sphinx.application.Sphinx
+ Application instance for rendering the documentation.
+ doctree : docutils.nodes.document
+ Document tree for the page.
+ docname : str
+ Name of the document being processed.
+ """
+ # Get the html_theme_options from conf.py
+ config_options = app.config.html_theme_options
+
+ # Get the whatsnew key from the html_theme_options
+ whatsnew_options = config_options.get("whatsnew")
+
+ # The source directory of the documentation: {repository_root}/doc/source
+ doc_src_dir = pathlib.Path(app.env.srcdir)
+
+ # Full path to the changelog.rst file starting from the doc/source directory
+ changelog_file = doc_src_dir / whatsnew_options.get("changelog_file_name")
+
+ # Get the number of headers to display in the what's new section in the sidebar
+ # By default, it displays the first three minor versions
+ sidebar_no_of_headers = whatsnew_options.get("sidebar_no_of_headers", 3)
+ # Get the number of what's new content to display under each minor version in the sidebar.
+ # By default, it displays all what's new dropdown titles
+ sidebar_no_of_contents = whatsnew_options.get("sidebar_no_of_contents", None)
+
+ # Get the doctree for the file
+ changelog_file = changelog_file.stem
+ doctree = app.env.get_doctree(changelog_file)
+ docs_content = doctree.traverse(nodes.section)
+ html_title = app.config.html_title or app.config.html_short_title or app.config.project
+
+ if not docs_content:
+ return
+
+ whatsnew = []
+ app.env.whatsnew = []
+
+ # Get a list of nodes whose ids start with "version" that contain "What's new" sections
+ versions_nodes = []
+
+ # Find nodes that contain the minor versions and a "What's New" section
+ for node in docs_content:
+ node_id = node.get("ids")[0]
+ # Get the nodes that contain the minor versions: "Version x.y"
+ if node_id.startswith("version") and "whatsnew" not in node_id:
+ sections = list(node.traverse(nodes.section))
+ # If the section contains a "What's New" section, add it to the list of versions
+ whatsnew_nodes = [
+ section_node
+ for section_node in sections
+ if section_node.get("ids")[0] == f"{node_id}-whatsnew"
+ ]
+ if whatsnew_nodes:
+ versions_nodes.append(node)
+
+ # Get the version nodes up to the specified number of headers
+ versions_nodes = versions_nodes[:sidebar_no_of_headers]
+
+ for version_node in versions_nodes:
+ # Get the version title (e.g., "Version 0.1")
+ version_title = version_node[0].astext()
+ # Get the sections under the version node
+ sections = list(version_node.traverse(nodes.section))
+
+ # Sections with text that contains "what's new"
+ whatsnew_nodes = [node for node in sections if node[0].astext().lower() == "what's new"]
+
+ if not whatsnew_nodes:
+ continue
+
+ # Get the children of the "What's New" section
+ children = [node for node in whatsnew_nodes[0].traverse(nodes.rubric)]
+
+ # Filter the displayed children based on the number of content specified in the config
+ if sidebar_no_of_contents is not None:
+ if len(children) > sidebar_no_of_contents:
+ children = children[:sidebar_no_of_contents]
+
+ contents = {
+ "title": f"{html_title} {version_title}",
+ "title_url": f"{changelog_file}.html#{version_node.get('ids')[0]}",
+ "children": children,
+ "url": f"{changelog_file}.html#{whatsnew_nodes[0]['ids'][0]}",
+ }
+
+ whatsnew.append(contents)
+
+ app.env.whatsnew = whatsnew
+
+
+def add_whatsnew_sidebar(
+ app: Sphinx, pagename: str, templatename: str, context: dict, doctree: nodes.document
+) -> None:
+ """Add the what's new sidebar to the desired pages.
+
+ Parameters
+ ----------
+ app : sphinx.application.Sphinx
+ Application instance for rendering the documentation.
+ pagename : str
+ Name of the current page.
+ templatename : str
+ Name of the template being used.
+ context : dict
+ Context dictionary for the page.
+ doctree : docutils.nodes.document
+ Document tree for the page.
+ """
+ # Get the html_theme_options from conf.py
+ config_options = app.config.html_theme_options
+
+ # Get the whatsnew key from the html_theme_options
+ whatsnew_options = config_options.get("whatsnew")
+
+ # Get the pages the whatsnew section should be displayed on
+ sidebar_pages = whatsnew_options.get("sidebar_pages")
+
+ if pagename not in sidebar_pages:
+ return
+
+ whatsnew = context.get("whatsnew", [])
+ whatsnew.extend(app.env.whatsnew)
+ context["whatsnew"] = whatsnew
+ sidebar = context.get("sidebars", [])
+ sidebar.append("whatsnew_sidebar.html")
+ context["sidebars"] = sidebar
+
+
def setup(app: Sphinx) -> Dict:
"""Connect to the Sphinx theme app.
@@ -577,6 +1139,17 @@ def setup(app: Sphinx) -> Dict:
app.connect("builder-inited", configure_theme_logo)
app.connect("builder-inited", build_quarto_cheatsheet)
app.connect("builder-inited", check_for_depreciated_theme_options)
+
+ # Check for what's new options in the theme configuration
+ whatsnew_file, changelog_file, sidebar_pages = get_whatsnew_options(app)
+
+ if whatsnew_file is not None and changelog_file is not None:
+ app.connect("doctree-read", add_whatsnew_changelog)
+ app.connect("doctree-resolved", extract_whatsnew)
+
+ if sidebar_pages is not None:
+ app.connect("html-page-context", add_whatsnew_sidebar)
+
app.connect("html-page-context", update_footer_theme)
app.connect("html-page-context", fix_edit_html_page_context)
app.connect("html-page-context", add_cheat_sheet)
diff --git a/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/components/whatsnew_sidebar.html b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/components/whatsnew_sidebar.html
new file mode 100644
index 000000000..5c0c7cce8
--- /dev/null
+++ b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/components/whatsnew_sidebar.html
@@ -0,0 +1,23 @@
+{% if whatsnew %}
+
+
+ {% for whatsnew in whatsnew %}
+ {% set title = whatsnew.get('title') %}
+ {% set url = whatsnew.get('url') %}
+ {% set title_url = whatsnew.get('title_url') %}
+ {% set children = whatsnew.get('children') %}
+