Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
75cdd76
OPENR-174: Check and update product and distribution metas, and sugge…
keithkirkwood-3di Jul 10, 2026
23b5e6e
OPENR-174: Improved handling and use suggestions when possible
keithkirkwood-3di Jul 13, 2026
10ce9bf
OPENR-174: Review comment obsolete status, and only insert new meta b…
keithkirkwood-3di Jul 14, 2026
f0fdf1e
Merge branch 'ros2:rolling' into enhance-metadata
keithkirkwood-3di Jul 15, 2026
5ba8e08
Merge branch 'rolling' into enhance-metadata
keithkirkwood-3di Jul 16, 2026
c2ac915
Merge branch 'rolling' into enhance-metadata
keithkirkwood-3di Jul 16, 2026
c56fc0f
Merge branch 'enhance-metadata' of https://github.com/3di-for-openrob…
keithkirkwood-3di Jul 16, 2026
51dbdcc
Merge branch 'rolling' into enhance-metadata
keithkirkwood-3di Jul 23, 2026
551ea52
OPENR-174: Add warning line to file for missing meta, and soft fail s…
keithkirkwood-3di Jul 24, 2026
412a0cd
Merge branch 'enhance-metadata' of https://github.com/3di-for-openrob…
keithkirkwood-3di Jul 24, 2026
cff6651
OPENR-174: Allow for generic config of metas, including different sev…
keithkirkwood-3di Jul 24, 2026
cf3c709
OPENR-174: Refactoring and cleanup
keithkirkwood-3di Jul 24, 2026
1a1ff7d
OPENR-174: Some refactoring and tidy up to use Makefile
keithkirkwood-3di Jul 27, 2026
ad3cbbc
OPENR-174: Move more into makefile and supporting sh script to keep …
keithkirkwood-3di Jul 27, 2026
4361dce
OPENR-174: Update PHONY list to ensure catch-all doesn't try to remake
keithkirkwood-3di Jul 27, 2026
e3e3c57
OPENR-174: Improve annotations to include static fields
keithkirkwood-3di Jul 28, 2026
57a8515
OPENR-174: Further clarification on review comment in PR for suggesti…
keithkirkwood-3di Jul 28, 2026
70c1447
OPENR-174: Fix workflow issues found in testing
keithkirkwood-3di Jul 28, 2026
07da8cb
OPENR-174: No longer supersede the suggestion reviews, only summaries.
keithkirkwood-3di Jul 29, 2026
1fd8f75
OPENR-174: Improve review comments with headers and for clarity
keithkirkwood-3di Jul 29, 2026
6420fa4
OPENR-174: Add short description and showmeta directives
keithkirkwood-3di Jul 30, 2026
c2d4a7e
OPENR-174: First pass at short-description and showmeta suggestions o…
keithkirkwood-3di Jul 30, 2026
6fd03d0
OPENR-174: Revert test changes to First steps article.
keithkirkwood-3di Jul 30, 2026
8bc3487
OPENR-174: Renaming throughout to reflect enhancement instead of just…
keithkirkwood-3di Jul 31, 2026
69436c5
OPENR-174: Make enhance config file more consistent
keithkirkwood-3di Jul 31, 2026
4dd1148
OPENR-174: Fix annotation and short desc formatting bugs
keithkirkwood-3di Aug 3, 2026
3dc62ea
OPENR-174: Updates to README
keithkirkwood-3di Aug 3, 2026
971c29b
Merge branch 'rolling' into enhance-metadata
keithkirkwood-3di Aug 4, 2026
3c19c64
Merge branch 'rolling' into enhance-metadata
keithkirkwood-3di Aug 11, 2026
f4977aa
OPENR-174: Remove suggestions and annotations from enhance
keithkirkwood-3di Aug 12, 2026
9bb3332
Merge branch 'enhance-metadata' of https://github.com/3di-for-openrob…
keithkirkwood-3di Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions .github/workflows/enhance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
name: Enhance

# pull_request_target grants GITHUB_TOKEN write access on fork PRs.
# PR code is checked out as data only; scripts run from the trusted base checkout.
on:
pull_request_target:
types:
- opened
- synchronize
- reopened

permissions:
contents: read
pull-requests: write

jobs:
ensure-enhancements:
runs-on: ubuntu-24.04
steps:
- name: Checkout PR code
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
fetch-depth: 0

- name: Checkout trusted base scripts
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.sha }}
path: .trusted-base

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Install PyYAML
run: pip install --no-warn-script-location pyyaml

- name: Ensure documentation enhancements
id: ensure
continue-on-error: true
env:
DIFF_BASE: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
git fetch origin "$DIFF_BASE"
make -f .trusted-base/Makefile ensure-enhancements \
TOOLS_DIR=.trusted-base/tools \
DIFF_BASE="$DIFF_BASE" \
STATUS_FILE="$GITHUB_OUTPUT"

# The ensure step soft-fails whenever enhancements are missing, so an empty
# enhancements_checked is the only signal that the check itself never ran.
- name: Verify enhancement check ran
if: steps.ensure.outputs.enhancements_checked == ''
run: |
echo "The enhancement check produced no outputs; see the ensure step log."
exit 1

- name: Supersede stale enhancement reviews
if: steps.ensure.outputs.enhancements_checked == 'true'
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
make -f .trusted-base/Makefile supersede-enhancement-reviews \
TOOLS_DIR=.trusted-base/tools

# Posts the summary so the Conversation view has a current review after
# the stale ones are minimised.
- name: Post enhancement review comment
if: >-
${{ !cancelled()
&& steps.ensure.outputs.has_results == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REVIEW_COMMENT: ${{ steps.ensure.outputs.comment }}
run: |
set -euo pipefail
gh pr review "$PR_NUMBER" \
--comment \
--body "$REVIEW_COMMENT"

- name: Enforce required enhancements
if: always() && steps.ensure.outputs.has_errors == 'true'
run: |
echo "Required enhancements (error level) are still missing."
exit 1
32 changes: 30 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ PYTHON := python3
ifeq ($(OS),Windows_NT)
PYTHON := python
endif
BASH := bash
BUILD = $(PYTHON) -m sphinx
JOBS ?= auto
# Attached form (-j<JOBS>, no space) so sphinx-multiversion forwards it to sphinx-build
Expand All @@ -15,6 +16,12 @@ OPTS =-c . -W -j$(JOBS) # Treat warnings as errors, build in parallel ($(J
LIVE_HOST ?= 0.0.0.0
LIVE_PORT ?= 2022

TOOLS_DIR ?= tools
DIFF_BASE ?=
STATUS_FILE ?=
PR_NUMBER ?=
REPOSITORY ?=

DICTIONARIES := codespell_dictionary.txt codespell_whitelist.txt

help:
Expand All @@ -36,11 +43,32 @@ test:
doc8 --ignore D001 --ignore-path $(OUT) -- $(SOURCE)

test-tools:
$(PYTHON) -m pytest test/
$(PYTHON) -m pytest test/ tools/tests/

spellcheck:
git ls-files '*.md' '*.rst' | xargs codespell --config codespell.cfg

ensure-enhancements:
ifndef DIFF_BASE
$(error DIFF_BASE is required)
endif
ifndef STATUS_FILE
$(error STATUS_FILE is required)
endif
$(PYTHON) $(TOOLS_DIR)/ensure_enhancements.py \
--config $(TOOLS_DIR)/enhance.yaml \
--diff-base $(DIFF_BASE) \
--status-file $(STATUS_FILE)

supersede-enhancement-reviews:
ifndef PR_NUMBER
$(error PR_NUMBER is required)
endif
ifndef REPOSITORY
$(error REPOSITORY is required)
endif
$(BASH) $(TOOLS_DIR)/supersede_enhancement_reviews.sh

check-dictionaries:
@echo "Checking dictionaries..."
@for dict in $(DICTIONARIES); do \
Expand Down Expand Up @@ -69,4 +97,4 @@ linkcheck:
serve:
sphinx-autobuild --host $(LIVE_HOST) --port $(LIVE_PORT) -c . $(SOURCE) $(OUT)/html

.PHONY: help Makefile multiversion test test-tools linkcheck serve lint spellcheck check-dictionaries sort-dictionaries
.PHONY: help Makefile multiversion test test-tools linkcheck serve lint spellcheck check-dictionaries sort-dictionaries ensure-enhancements supersede-enhancement-reviews $(MAKEFILE_LIST)
3 changes: 3 additions & 0 deletions conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@
'sphinxcontrib.googleanalytics',
'sphinxcontrib.mermaid',
'sphinxext.opengraph',
'short_description',
'showmeta'
]

# Intersphinx mapping
Expand Down Expand Up @@ -186,6 +188,7 @@
'DISTRO_UBUNTU_DEB_PLATFORM': distro_ubuntu_deb_platform['rolling'],
'DISTRO_ARM_STATUS_SUFFIX': distro_arm_status_suffix.get('rolling', 'unv8'),
'REPOS_FILE_BRANCH': 'rolling',
'PRODUCT': 'ROS 2',
}

html_favicon = 'favicon.ico'
Expand Down
70 changes: 70 additions & 0 deletions plugins/meta_util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Copyright 2026 Open Robotics — shared helpers for ``.. meta::`` / Pagefind
"""
Collect every ``.. meta::`` field from the doctree, sanitize keys, and expand
``{MACRO}`` placeholders using the Sphinx ``macros`` config (longest keys first).

Sphinx / the HTML theme may also emit plain ``<meta>`` tags for the same fields.
The Pagefind extension emits additional tags with ``data-pagefind-filter`` and may
split comma-separated values into multiple tags for faceted search.
"""

from __future__ import annotations

import re
from typing import Dict, List, Optional

from docutils import nodes

# HTML ``<meta name="...">`` names should be conservative; allow common patterns.
_META_NAME_RE = re.compile(r'^[A-Za-z0-9_.:-]+$')


def sanitize_meta_key(raw: str) -> Optional[str]:
s = str(raw).strip()
if not s or not _META_NAME_RE.match(s):
return None
return s


def all_doctree_meta(doctree: Optional[nodes.document]) -> Dict[str, str]:
"""Return last-wins mapping of every ``nodes.meta`` ``name``/``property`` → ``content``."""
if doctree is None:
return {}

out: Dict[str, str] = {}
for meta in doctree.findall(nodes.meta):
if meta.get('http-equiv'):
continue
content = meta.get('content')
if not content:
continue
key: Optional[str] = None
name = meta.get('name')
if name:
key = sanitize_meta_key(str(name))
else:
prop = meta.get('property')
if prop:
key = sanitize_meta_key(str(prop))
if not key:
continue
out[key] = str(content).strip()
return out


def expand_meta_macros(text: str, macros: Dict[str, str]) -> str:
"""Expand ``{KEY}`` placeholders; longer macro names first to avoid partial matches."""
result = text
for key, value in sorted(macros.items(), key=lambda kv: len(kv[0]), reverse=True):
result = result.replace(f'{{{key}}}', value)
return result


def expand_all_meta_values(meta: Dict[str, str], macros: Dict[str, str]) -> Dict[str, str]:
"""Apply ``expand_meta_macros`` to every meta value."""
return {k: expand_meta_macros(v, macros) for k, v in meta.items()}


def split_meta_values(value: str) -> List[str]:
"""Return comma-separated metadata values as individual Pagefind values."""
return [part.strip() for part in value.split(',') if part.strip()]
32 changes: 32 additions & 0 deletions plugins/short_description.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from __future__ import annotations

from docutils import nodes
from sphinx.util.docutils import SphinxDirective


class ShortDescriptionDirective(SphinxDirective):
"""Directive to render the short description of an article."""

has_content = True
required_arguments = 0
optional_arguments = 0
option_spec = {}

def run(self) -> list[nodes.Node]:
# Create a container node to hold the parsed content
node = nodes.container()
node['classes'].append('short-description')

# Parse the directive content into the container node
self.state.nested_parse(self.content, self.content_offset, node)

return [node]


def setup(app):
app.add_directive('short-description', ShortDescriptionDirective)
return {
'parallel_read_safe': True,
'parallel_write_safe': True,
'version': '0.1.0',
}
120 changes: 120 additions & 0 deletions plugins/showmeta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Copyright 2026 Open Robotics — explicit in-body ``.. showmeta::`` summary
"""
Render selected ``.. meta::`` fields in the document body with author-controlled
order and labels. Place ``.. showmeta::`` where the summary should appear (HTML only).
"""

from __future__ import annotations

import html as html_module
import re
from typing import List

from docutils import nodes
from docutils.parsers.rst import directives
from sphinx.util.docutils import SphinxDirective

from .meta_util import all_doctree_meta, expand_all_meta_values


def _macros_flat(app) -> dict[str, str]:
return {str(k): str(v) for k, v in (getattr(app.config, 'macros', {}) or {}).items()}


def _default_showmeta_label(key: str) -> str:
spaced = re.sub(r'([a-z])([A-Z])', r'\1 \2', key)
return spaced.replace('_', ' ').replace('-', ' ').strip().title()


class showmeta_node(nodes.General, nodes.Element):
"""Placeholder replaced on ``doctree-resolved`` (HTML builds only)."""


class ShowMetaDirective(SphinxDirective):
"""Insert a visible metadata line built from ``.. meta::`` on this page."""

has_content = False
option_spec = {
'order': directives.unchanged,
'labels': directives.unchanged,
}

def run(self) -> List[nodes.Node]:
node = showmeta_node()
node['order'] = self.options.get('order', '')
node['labels'] = self.options.get('labels', '')
self.set_source_info(node)
return [node]


def visit_skip_showmeta(self, node: showmeta_node) -> None:
raise nodes.SkipNode


def depart_showmeta_noop(self, node: showmeta_node) -> None:
pass


def _parse_labels(raw: str) -> dict[str, str]:
out: dict[str, str] = {}
for part in [p.strip() for p in raw.split(',') if p.strip() and '=' in p]:
key, _, value = part.partition('=')
key, value = key.strip(), value.strip()
if key:
out[key] = value
return out


def replace_showmeta_nodes(app, doctree: nodes.document, docname: str) -> None:
if app.builder.format != 'html':
for node in list(doctree.findall(showmeta_node)):
node.parent.remove(node)
return

macros = _macros_flat(app)
meta = expand_all_meta_values(all_doctree_meta(doctree), macros)

for node in list(doctree.findall(showmeta_node)):
order = [x.strip() for x in node.get('order', '').split(',') if x.strip()]
labels_map = _parse_labels(node.get('labels', ''))
if not order:
node.parent.remove(node)
continue

parts: List[str] = []
for key in order:
val = meta.get(key, '').strip()
if not val:
continue
label_base = labels_map.get(key) or _default_showmeta_label(key)
label_display = label_base if label_base.rstrip().endswith(':') else f'{label_base}:'
parts.append(
f'<strong>{html_module.escape(label_display)}</strong> '
f'{html_module.escape(val)}'
)

if not parts:
node.parent.remove(node)
else:
inner = ' | '.join(parts)
raw = nodes.raw(
'',
f'<p class="ros-page-meta-summary" data-pagefind-ignore="all">{inner}</p>',
format='html',
)
node.replace_self(raw)


def setup(app):
app.add_node(
showmeta_node,
html=(visit_skip_showmeta, depart_showmeta_noop),
latex=(visit_skip_showmeta, depart_showmeta_noop),
)
app.add_directive('showmeta', ShowMetaDirective)
app.connect('doctree-resolved', replace_showmeta_nodes)
return {
'version': '1.0.0',
'parallel_read_safe': True,
'parallel_write_safe': True,
}
Loading
Loading