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') %} +

    {{ title }}

    + + + + {% for child in children %} +
  • {{ child }}
  • + {% endfor %} +
    + {% endfor %} +
+
+ + + +{% endif %} diff --git a/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/layout.html b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/layout.html index c59cb3698..6e3f241ba 100644 --- a/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/layout.html +++ b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/layout.html @@ -6,11 +6,13 @@ + {% if page_assets is defined and page_assets|length > 0 %} {% set assets = page_assets.get(pagename, {}) %} {% if assets.get("needs_datatables") %} {% endif %} + {% endif %} {% if theme_show_breadcrumbs %} @@ -35,6 +37,7 @@ {{ super() }} + {% if page_assets is defined and page_assets|length > 0 %} {% set assets = page_assets.get(pagename, {}) %} {% if assets.get("needs_datatables") %} {% endif %} + {% endif %} {%- endblock %} diff --git a/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/static/css/whatsnew.css b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/static/css/whatsnew.css new file mode 100644 index 000000000..604a4cef5 --- /dev/null +++ b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/static/css/whatsnew.css @@ -0,0 +1,80 @@ +.whatsnew-sidebar { + /* border: 1px solid var(--ast-color-table-inner-border); */ + padding: 8px; + background-color: var(--pst-color-background); + /* border-radius: 10px; */ + /* box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); */ + margin-bottom: 20px; +} + +.whatsnew-sidebar h2 { + font-size: 1em; + font-family: var(--ast-font-family-base); + margin-bottom: 10px; + margin-top: 10px; + padding: 4px; +} + +.whatsnew-sidebar h3 { + font-size: 1em; + font-family: var(--ast-font-family-base); + margin: 8px 4px; + padding: 4px 8px; +} + +.whatsnew-sidebar a.current { + color: var(--ast-color-text); + background: var(--ast-sidebar-active-background); + text-decoration: none; + box-shadow: 1px 1px 2px 0px var(--ast-sidebar-active-background); + border-radius: var(--ast-sphinx-design-border-radius); + font-weight: bold; + padding: 4px 24px 4px 32px; +} + +.whatsnew-sidebar h3:hover { + color: var(--ast-color-text); + background: var(--ast-sidebar-active-background); + text-decoration: none; + box-shadow: 1px 1px 2px 0px var(--ast-sidebar-active-background); + border-radius: var(--ast-sphinx-design-border-radius); + font-weight: bold; +} + +.whatsnew-sidebar ul { + list-style: none; + padding-left: 12px; /* Remove default padding */ + line-height: 30px; +} + +.whatsnew-sidebar li { + padding: 4px 24px 4px 32px; +} + +.whatsnew-sidebar li:hover { + color: var(--ast-color-text); + background: var(--ast-sidebar-active-background); + text-decoration: none; + box-shadow: 1px 1px 2px 0px var(--ast-sidebar-active-background); + border-radius: var(--ast-sphinx-design-border-radius); + font-weight: bold; +} + +.whatsnew-sidebar li ul { + margin-left: 15px; /* Indent sub-items */ +} + +.whatsnew-sidebar li ul li { + list-style-type: disc; /* Bullet points */ + margin-left: 20px; /* Spacing for bullet points */ +} + +.whatsnew-sidebar a { + text-decoration: none; /* Remove underline */ + color: var(--ast-color-text); /* Blue link color */ +} + +.whatsnew-sidebar a:hover { + text-decoration: underline; /* Underline on hover */ + color: var(--ast-color-text); /* Blue link color on hover */ +} diff --git a/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/theme.conf b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/theme.conf index 6e6e18fe4..b25441b24 100644 --- a/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/theme.conf +++ b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/theme.conf @@ -18,4 +18,5 @@ cheatsheet = ansys_sphinx_theme_autoapi = logo = static_search = +whatsnew = use_ansys_search = True