diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 5cea1c262d8..0f79b477451 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -78,6 +78,15 @@ jobs:
- name: Build the docs
run: make html
+
+ - name: Setup Node.js (Pagefind)
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24'
+
+ - name: Index HTML with Pagefind
+ run: make pagefind
+
- name: Upload document artifacts
uses: actions/upload-artifact@v4
id: artifact-upload-step
@@ -147,3 +156,12 @@ jobs:
- name: Build the docs
run: make multiversion
+
+
+ - name: Setup Node.js (Pagefind)
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24'
+
+ - name: Index HTML with Pagefind
+ run: make pagefind
diff --git a/.gitignore b/.gitignore
index 652f1b03313..33a07c67418 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,7 @@ _build/
__pycache__
ros2doc/
.DS_Store
+
+# Downloaded at HTML build time for browser-side package lists (large).
+source/_static/rosdistro_cache/*.yaml.gz
+.env
diff --git a/Makefile b/Makefile
index aebff051af3..ac77609ae61 100644
--- a/Makefile
+++ b/Makefile
@@ -23,9 +23,26 @@ multiversion: Makefile
@echo "
" > build/html/index.html
$(PYTHON) make_sitemapindex.py
+# Pagefind static search index (requires Node.js / npx). Run after html or multiversion.
+PAGEFIND_VERSION ?= 1.5.2
+pagefind:
+ npx -y pagefind@$(PAGEFIND_VERSION) --site "$(OUT)/html"
+
+
+# Convenience: Sphinx build + Pagefind index (does not replace plain html / multiversion).
+html-search:
+ $(MAKE) html
+ $(MAKE) pagefind
+
+multiversion-search: multiversion
+ $(MAKE) pagefind
+
%: Makefile
@$(BUILD) -M $@ "$(SOURCE)" "$(OUT)" $(OPTS)
+enhance:
+ git diff --name-only --diff-filter=d HEAD | xargs -r $(PYTHON) tools/enhance_topics.py
+
lint:
./sphinx-lint-with-ros source
@@ -66,4 +83,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 pagefind test test-tools linkcheck serve lint spellcheck check-dictionaries sort-dictionaries
\ No newline at end of file
diff --git a/README.md b/README.md
index 33beec6db19..edeebce47ff 100644
--- a/README.md
+++ b/README.md
@@ -92,6 +92,24 @@ To test building the multisite version deployed to the website use:
**NB:** This will ignore local workspace changes and build from the branches.
+### Pagefind search index
+
+After `make html` or `make multiversion`, run [Pagefind](https://pagefind.app/) so the built HTML under `build/html` is indexed and `build/html/pagefind/` is written (search bundle and Component UI assets). From the repo root:
+
+`make pagefind`
+
+Or use convenience targets that run Sphinx and Pagefind in one step:
+
+- `make html-search` — `make html` then `make pagefind`
+- `make multiversion-search` — `make multiversion` then `make pagefind`
+
+Plain `make html` and `make multiversion` do **not** run Pagefind (Node.js is only required when you index search).
+
+This requires **Node.js** (for `npx`). Pin the CLI with `PAGEFIND_VERSION` in the Makefile if needed.
+
+The production [Jenkins doc job](https://build.ros.org/job/doc_ros2doc) should run the same `pagefind` step on `build/html` after Sphinx so deployed pages include the search bundle.
+
+
### Note for Windows (WSL) Users
When building the documentation on windows using WSL, it is recommended to clone and work with this repository inside the Linux filesystem (for example, under `/home//`) rather than under `/mnt/c`.
diff --git a/conf.py b/conf.py
index b16bb6d540a..f220a031d7c 100644
--- a/conf.py
+++ b/conf.py
@@ -89,8 +89,41 @@
'sphinx_adopters',
'sphinxcontrib.googleanalytics',
'sphinxcontrib.mermaid',
+ 'ros_related_packages',
+ 'ros_related_articles',
+ 'short_description',
+ 'pagefind_meta',
+ 'showmeta',
]
+# pagefind search index configuration.
+
+pagefind_merge_enabled = False
+pagefind_merge_package_pkgs = []
+pagefind_merge_index_base = 'https://docs.ros.org'
+pagefind_merge_index_overrides = {}
+pagefind_merge_filter_per_pkg = None
+pagefind_merge_index_weight_per_pkg = None
+
+# Pagefind search UI (modal + /search.html): result metadata lines and facet sidebar.
+# Dict keys = .. meta:: field names; values = display labels.
+# Order here is facet dropdown order and result-meta line order (allowlist).
+# Only listed keys are indexed as facets; keys must exist on at least one page in the build.
+# Other meta (e.g. description, keywords) stays SEO-only and does not appear in the facet sidebar.
+
+pagefind_result_meta_order = {
+ 'product': 'Product',
+ 'distribution': 'Distribution',
+ 'area': 'Area',
+ 'capability': 'Capability',
+ 'community': 'Community',
+ 'installation': 'Installation',
+ 'framework': 'Framework',
+ 'tool': 'Tools',
+ 'contentType': 'Content type',
+ 'experience': 'Level',
+}
+
# Intersphinx mapping
intersphinx_mapping = {
@@ -192,6 +225,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'
@@ -205,8 +239,52 @@
html_sourcelink_suffix = ''
# Relative to html_static_path
-html_css_files = ['custom.css', 'adopters.css']
-html_js_files = ['adopters.js']
+html_css_files = ['custom.css', 'adopters.css', 'pagefind-docsearch.css']
+html_js_files = [
+ ('vendor/pako.min.js', {'defer': ''}),
+ ('vendor/js-yaml.min.js', {'defer': ''}),
+ 'adopters.js',
+ 'related_packages.js',
+]
+
+# Runtime proxy endpoint for freshest rosdistro cache data (same-origin).
+# Default matches production: /api/rosdistro-cache/{distro}-cache.yaml.gz
+# Override with ROS_RELATED_PACKAGES_PROXY_URL; set to empty string to disable
+# proxy and use bundled _static fallback only.
+# Local testing: python tools/serve_docs_with_proxy.py (serves build/html + /api/).
+def _normalize_ros_related_packages_proxy_url(raw: str) -> str:
+ """Return a browser-safe proxy template.
+
+ On Windows, GNU make / MSYS (common even when the terminal is PowerShell) can
+ rewrite ``/api/...`` into ``C:/Program Files/Git/api/...``. Recover the
+ intended same-origin path when that happens.
+ """
+ value = (raw or '').strip()
+ if not value:
+ return ''
+
+ normalized = value.replace('\\', '/')
+ marker = '/api/rosdistro-cache/'
+ idx = normalized.find(marker)
+ if idx != -1:
+ return normalized[idx:]
+
+ if normalized.startswith('api/rosdistro-cache/'):
+ return '/' + normalized
+
+ return value
+
+
+_DEFAULT_ROS_RELATED_PACKAGES_PROXY_URL = (
+ '/api/rosdistro-cache/{distro}-cache.yaml.gz'
+)
+
+ros_related_packages_proxy_url = _normalize_ros_related_packages_proxy_url(
+ os.environ.get(
+ 'ROS_RELATED_PACKAGES_PROXY_URL',
+ _DEFAULT_ROS_RELATED_PACKAGES_PROXY_URL,
+ )
+)
# -- Options for HTMLHelp output ------------------------------------------
diff --git a/constraints.txt b/constraints.txt
index 56ae59259be..2ba36b535b7 100644
--- a/constraints.txt
+++ b/constraints.txt
@@ -11,11 +11,13 @@ imagesize==1.4.1
iniconfig==2.1.0
Jinja2==3.1.6
MarkupSafe==3.0.3
+openai==2.33.0
packaging==25.0
pluggy==1.6.0
polib==1.2.0
Pygments==2.19.2
pytest==8.4.2
+python-dotenv==1.1.0
PyYAML==6.0.3
regex==2025.9.18
requests==2.32.5
@@ -39,4 +41,6 @@ sphinxcontrib-mermaid==1.0.0
sphinxcontrib-qthelp==2.0.0
sphinxcontrib-serializinghtml==2.0.0
stevedore==5.5.0
+tenacity==9.1.4
+timeout-decorator==0.5.0
urllib3==2.5.0
diff --git a/plugins/meta_util.py b/plugins/meta_util.py
new file mode 100644
index 00000000000..32aef4d2f3b
--- /dev/null
+++ b/plugins/meta_util.py
@@ -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 ```` 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 ```` 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()]
diff --git a/plugins/pagefind_meta.py b/plugins/pagefind_meta.py
new file mode 100644
index 00000000000..42bb5e8155d
--- /dev/null
+++ b/plugins/pagefind_meta.py
@@ -0,0 +1,293 @@
+# Copyright 2026 Open Robotics — Pagefind metadata for ROS 2 documentation
+"""
+Emit SEO tags, Pagefind ``data-pagefind-meta``, and ``data-pagefind-filter``
+from ``.. meta::`` fields on each page.
+
+Only keys in ``pagefind_result_meta_order`` receive ``data-pagefind-filter`` (facet
+sidebar + filtering). Other meta fields are plain ```` for SEO only.
+"""
+
+from __future__ import annotations
+
+import html
+import re
+from pathlib import PurePosixPath
+from typing import Any, Dict, List, Optional, Tuple
+
+from docutils import nodes
+from sphinx.util import logging
+
+from meta_util import all_doctree_meta, expand_all_meta_values, split_meta_values
+
+logger = logging.getLogger(__name__)
+
+
+def _macros_flat(app) -> Dict[str, str]:
+ macros = getattr(app.config, 'macros', {}) or {}
+ return {str(k): str(v) for k, v in macros.items()}
+
+
+def _resolved_page_meta(app, doctree: Optional[nodes.document]) -> Dict[str, str]:
+ raw = all_doctree_meta(doctree)
+ return expand_all_meta_values(raw, _macros_flat(app))
+
+
+def _default_filter_label(key: str) -> str:
+ spaced = re.sub(r'([a-z])([A-Z])', r'\1 \2', key)
+ return spaced.replace('_', ' ').replace('-', ' ').strip().title()
+
+
+def _parse_result_meta_fields(app) -> List[Dict[str, str]]:
+ """Build ordered ``{key, label}`` list from ``pagefind_result_meta_order`` (dict or legacy list)."""
+ raw = getattr(app.config, 'pagefind_result_meta_order', None) or {}
+ out: List[Dict[str, str]] = []
+
+ if isinstance(raw, dict):
+ for key, label in raw.items():
+ k = str(key).strip()
+ if not k:
+ continue
+ lbl = str(label).strip() if label is not None else ''
+ out.append({'key': k, 'label': lbl or _default_filter_label(k)})
+ return out
+
+ if isinstance(raw, (list, tuple)):
+ logger.warning(
+ 'pagefind_result_meta_order should be a dict mapping field names to labels; '
+ 'list form is deprecated.',
+ type='pagefind',
+ )
+ for item in raw:
+ k = str(item).strip()
+ if k:
+ out.append({'key': k, 'label': _default_filter_label(k)})
+ return out
+
+
+def _facet_key_set(app) -> set[str]:
+ return {field['key'] for field in _parse_result_meta_fields(app)}
+
+
+def _facet_filter_keys_for_context(app, env) -> List[str]:
+ """Configured facet keys that appear in at least one document's ``.. meta::``, in dict order."""
+ corpus = set(_union_meta_keys(env))
+ out: List[str] = []
+ for field in _parse_result_meta_fields(app):
+ if field['key'] in corpus:
+ out.append(field['key'])
+ return out
+
+
+def _pagefind_data_meta_attr(values: Dict[str, str]) -> str:
+ """Single data-pagefind-meta attribute value with repeated keys for multi-values."""
+ parts: List[str] = []
+ for key in sorted(values.keys()):
+ for value in split_meta_values(values.get(key, '')):
+ parts.append(f'{key}:{value}')
+ inner = ', '.join(parts)
+ return html.escape(inner, quote=True)
+
+
+def _seo_and_filter_metas(app, values: Dict[str, str]) -> str:
+ """One per value; ``data-pagefind-filter`` only for ``pagefind_result_meta_order`` keys."""
+ facet_keys = _facet_key_set(app)
+ lines: List[str] = []
+ for key in sorted(values.keys()):
+ esc_name = html.escape(key, quote=True)
+ for value in split_meta_values(values.get(key, '')):
+ esc_val = html.escape(value, quote=True)
+ if key in facet_keys:
+ lines.append(
+ f''
+ )
+ else:
+ lines.append(f'')
+ return '\n '.join(lines)
+
+
+def _ensure_meta_keys_store(env) -> Dict[str, Any]:
+ if not hasattr(env, 'pagefind_meta_keys_by_doc'):
+ env.pagefind_meta_keys_by_doc = {}
+ return env.pagefind_meta_keys_by_doc
+
+
+def _collect_meta_keys(app, doctree: nodes.document, docname: str) -> None:
+ if app.builder.format != 'html':
+ return
+ raw = all_doctree_meta(doctree)
+ store = _ensure_meta_keys_store(app.env)
+ store[docname] = set(raw.keys())
+
+
+def _purge_meta_keys(app, env, docname: str) -> None:
+ if hasattr(env, 'pagefind_meta_keys_by_doc') and docname in env.pagefind_meta_keys_by_doc:
+ del env.pagefind_meta_keys_by_doc[docname]
+
+
+def _merge_meta_keys(app, env, docnames, other) -> None:
+ """Merge per-document meta key sets from a parallel read worker environment."""
+ if not hasattr(other, 'pagefind_meta_keys_by_doc'):
+ return
+ store = _ensure_meta_keys_store(env)
+ for docname, keys in other.pagefind_meta_keys_by_doc.items():
+ store[docname] = set(keys)
+
+
+def _union_meta_keys(env) -> List[str]:
+ if not hasattr(env, 'pagefind_meta_keys_by_doc'):
+ return []
+ union: set[str] = set()
+ for keys in env.pagefind_meta_keys_by_doc.values():
+ union |= set(keys)
+ return sorted(union)
+
+
+def _pagefind_bundle_prefix(app, pagename: str) -> str:
+ """Relative URL prefix from current HTML page to the site root ``pagefind/`` directory.
+
+ Must start with ``./`` or ``../`` so the browser resolves dynamic imports (e.g.
+ ``import(bundlePath + 'pagefind.js')``) as URLs, not bare module specifiers.
+
+ For ``sphinx-multiversion``, each distro is built with ``pagename`` relative to that
+ distro tree (e.g. ``index``), but HTML is served under ``/{smv_current_version}/``.
+ The Pagefind bundle lives at the site root (``build/html/pagefind/``), so add one
+ ``../`` when ``smv_current_version`` is set.
+ """
+ builder = getattr(app, 'builder', None)
+ if builder is not None:
+ target_uri = builder.get_target_uri(pagename, typ='html')
+ depth = len(PurePosixPath(target_uri).parent.parts)
+ else:
+ depth = pagename.count('/')
+
+ version = getattr(app.config, 'smv_current_version', '') or ''
+ if version:
+ depth += 1
+
+ if depth == 0:
+ return './pagefind/'
+ return ('../' * depth) + 'pagefind/'
+
+
+def _pagefind_component_urls(app, pagename: str) -> Tuple[str, str]:
+ """(css_href, js_href) relative to current page."""
+ prefix = _pagefind_bundle_prefix(app, pagename)
+ return prefix + 'pagefind-component-ui.css', prefix + 'pagefind-component-ui.js'
+
+
+def _search_results_href(app, pagename: str) -> str:
+ """Relative URL from the current page to Sphinx's ``search.html``.
+
+ Uses the HTML builder's relative URI helper so multiversion pages under
+ ``/{distro}/`` link to ``/{distro}/search.html``, not site-root
+ ``/search.html`` (which may be wrong after ``make multiversion``).
+ """
+ builder = getattr(app, 'builder', None)
+ if builder is None:
+ return 'search.html'
+ try:
+ rel = builder.get_relative_uri(pagename, 'search')
+ if rel:
+ return rel
+ except (AttributeError, KeyError, ValueError):
+ pass
+ return 'search.html'
+
+
+def _merge_index_entries(app, distro: str) -> List[Dict[str, Any]]:
+ """Build mergeIndex list from conf (pinned docs.ros.org template)."""
+ pkgs: List[str] = list(getattr(app.config, 'pagefind_merge_package_pkgs', []) or [])
+ if not pkgs or not getattr(app.config, 'pagefind_merge_enabled', False):
+ return []
+ base = getattr(app.config, 'pagefind_merge_index_base', 'https://docs.ros.org').rstrip('/')
+ overrides = getattr(app.config, 'pagefind_merge_index_overrides', {}) or {}
+ out: List[Dict[str, Any]] = []
+ for pkg in pkgs:
+ key = f'{distro}/{pkg}'
+ if key in overrides:
+ bundle = overrides[key]
+ else:
+ bundle = f'{base}/en/{distro}/p/{pkg}/pagefind'
+ entry: Dict[str, Any] = {'bundlePath': bundle}
+ mf = getattr(app.config, 'pagefind_merge_filter_per_pkg', None)
+ if isinstance(mf, dict) and pkg in mf:
+ entry['mergeFilter'] = mf[pkg]
+ iw = getattr(app.config, 'pagefind_merge_index_weight_per_pkg', None)
+ if isinstance(iw, dict) and pkg in iw:
+ entry['indexWeight'] = iw[pkg]
+ out.append(entry)
+ return out
+
+
+def _html_page_context(
+ app,
+ pagename: str,
+ templatename: str,
+ context: Dict[str, Any],
+ doctree,
+) -> None:
+ facet_keys_ordered = _facet_filter_keys_for_context(app, app.env)
+ filter_csv = ','.join(facet_keys_ordered)
+ result_meta_fields = _parse_result_meta_fields(app)
+
+ empty = {
+ 'pagefind_seo_filter_metas': '',
+ 'pagefind_data_meta_attr': '',
+ 'pagefind_bundle_prefix': './pagefind/',
+ 'pagefind_component_css': './pagefind/pagefind-component-ui.css',
+ 'pagefind_component_js': './pagefind/pagefind-component-ui.js',
+ 'pagefind_merge_index': [],
+ 'pagefind_filter_keys_csv': filter_csv,
+ 'pagefind_result_meta_fields': result_meta_fields,
+ 'pagefind_search_results_href': 'search.html',
+ }
+ context.update(empty)
+
+ if app.builder.format != 'html' or templatename is None:
+ return
+ if not templatename.endswith('.html'):
+ return
+
+ default_distro = (getattr(app.config, 'macros', {}) or {}).get('DISTRO', 'rolling')
+ values = _resolved_page_meta(app, doctree)
+
+ seo_filters = _seo_and_filter_metas(app, values)
+ data_attr = _pagefind_data_meta_attr(values)
+ css_href, js_href = _pagefind_component_urls(app, pagename)
+ bundle_prefix = _pagefind_bundle_prefix(app, pagename)
+
+ merge_distro = (
+ values.get('distro')
+ or values.get('distribution')
+ or str(default_distro)
+ )
+ merge = _merge_index_entries(app, merge_distro)
+ context['pagefind_seo_filter_metas'] = seo_filters
+ context['pagefind_data_meta_attr'] = data_attr
+ context['pagefind_bundle_prefix'] = bundle_prefix
+ context['pagefind_component_css'] = css_href
+ context['pagefind_component_js'] = js_href
+ context['pagefind_merge_index'] = merge
+ context['pagefind_search_results_href'] = _search_results_href(app, pagename)
+
+
+def setup(app) -> Dict[str, Any]:
+ app.add_config_value('pagefind_merge_enabled', default=False, rebuild='html')
+ app.add_config_value('pagefind_merge_package_pkgs', default=[], rebuild='html')
+ app.add_config_value('pagefind_merge_index_base', default='https://docs.ros.org', rebuild='html')
+ app.add_config_value('pagefind_merge_index_overrides', default={}, rebuild='html')
+ app.add_config_value('pagefind_merge_filter_per_pkg', default=None, rebuild='html')
+ app.add_config_value('pagefind_merge_index_weight_per_pkg', default=None, rebuild='html')
+ app.add_config_value('pagefind_result_meta_order', default={}, rebuild='html')
+
+ app.connect('html-page-context', _html_page_context)
+ app.connect('doctree-resolved', _collect_meta_keys)
+ app.connect('env-purge-doc', _purge_meta_keys)
+ app.connect('env-merge-info', _merge_meta_keys)
+
+ return {
+ 'parallel_read_safe': True,
+ 'parallel_write_safe': True,
+ 'version': '1.0.0',
+ }
diff --git a/plugins/ros_related_articles.py b/plugins/ros_related_articles.py
new file mode 100644
index 00000000000..be55a54a6e0
--- /dev/null
+++ b/plugins/ros_related_articles.py
@@ -0,0 +1,224 @@
+# Copyright 2026 Open Robotics and contributors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Sphinx directive for build-time related article lists."""
+
+from __future__ import annotations
+
+from typing import List, TypedDict
+
+from docutils import nodes
+from docutils.parsers.rst import directives
+from sphinx.util.docutils import SphinxDirective
+
+
+def _normalize_field_name(raw: str) -> str:
+ """Normalize a metadata key for comparison (e.g. ``Experience`` -> ``experience``)."""
+ name = raw.strip().lower().rstrip(':')
+ return name.replace(' ', '-')
+
+
+def _field_value_from_doctree(document: nodes.document, wanted: str) -> str | None:
+ """Return the body of the first matching docinfo/rST field in the document."""
+ wanted_norm = _normalize_field_name(wanted)
+ for field in document.traverse(nodes.field):
+ children = getattr(field, 'children', ()) or ()
+ if len(children) < 2:
+ continue
+ label = children[0].astext()
+ if _normalize_field_name(label) != wanted_norm:
+ continue
+ return children[1].astext().strip()
+ return None
+
+
+def _meta_get(metadata: dict, *names: str) -> str | None:
+ """Look up metadata using several possible keys (Sphinx/docutils variants)."""
+ for name in names:
+ for key, val in metadata.items():
+ if not val:
+ continue
+ if _normalize_field_name(str(key)) == _normalize_field_name(name):
+ return str(val).strip()
+ return None
+
+
+def _meta_content_from_docutils(document: nodes.document, meta_name: str) -> str | None:
+ """Read ``docutils.nodes.meta`` emitted by ``.. meta::``."""
+ for node in document.traverse(nodes.meta):
+ if node.get('name') != meta_name:
+ continue
+ raw = node.get('content')
+ if raw:
+ return str(raw).strip()
+ return None
+
+
+def _positive_int_option(argument: str) -> int:
+ """Parse a positive integer option for the directive."""
+ if argument is None:
+ raise ValueError('option requires a number')
+ value = int(argument)
+ if value < 1:
+ raise ValueError('must be positive')
+ return value
+
+
+class RelatedArticle(TypedDict):
+ docname: str
+ title: str
+ area: str
+ experience: str
+
+
+def _normalized_value(raw: str) -> str:
+ """Normalize metadata value for stable matching."""
+ return ' '.join(raw.strip().lower().split())
+
+
+class RosRelatedArticlesNode(nodes.General, nodes.Element):
+ """Placeholder node replaced during ``doctree-resolved``."""
+
+
+class RosRelatedArticlesDirective(SphinxDirective):
+ """Emit a placeholder for static related-article links.
+
+ Uses page metadata values from ``.. meta::``:
+
+ .. code-block:: rst
+
+ .. meta::
+ :area: Tutorials
+ :experience: Beginner
+ """
+
+ has_content = False
+ required_arguments = 0
+ optional_arguments = 0
+ option_spec = {'max': _positive_int_option}
+
+ def run(self) -> List[nodes.Node]:
+ meta = self.env.metadata.get(self.env.docname, {})
+ area = (
+ _meta_content_from_docutils(self.state.document, 'area')
+ or _meta_get(meta, 'area')
+ or _field_value_from_doctree(self.state.document, 'area')
+ or ''
+ )
+ experience = (
+ _meta_content_from_docutils(self.state.document, 'experience')
+ or _meta_get(meta, 'experience')
+ or _field_value_from_doctree(self.state.document, 'experience')
+ or ''
+ )
+
+ if not area or not experience:
+ raise self.error(
+ 'ros-related-articles: define both `area` and `experience` '
+ 'with `.. meta::` (recommended), or field list metadata.'
+ )
+
+ node = RosRelatedArticlesNode()
+ node['area'] = area
+ node['experience'] = experience
+ node['max'] = self.options.get('max', 10)
+ return [node]
+
+
+def _collect_article_index(env) -> List[RelatedArticle]:
+ """Build an index of docs that declare both ``area`` and ``experience`` metadata."""
+ records: List[RelatedArticle] = []
+ for docname in sorted(env.found_docs):
+ doctree = env.get_doctree(docname)
+ meta = env.metadata.get(docname, {})
+ area = (
+ _meta_content_from_docutils(doctree, 'area')
+ or _meta_get(meta, 'area')
+ or _field_value_from_doctree(doctree, 'area')
+ or ''
+ )
+ experience = (
+ _meta_content_from_docutils(doctree, 'experience')
+ or _meta_get(meta, 'experience')
+ or _field_value_from_doctree(doctree, 'experience')
+ or ''
+ )
+ if not area or not experience:
+ continue
+ title_node = env.titles.get(docname)
+ title = title_node.astext().strip() if title_node else docname
+ records.append({
+ 'docname': docname,
+ 'title': title,
+ 'area': _normalized_value(area),
+ 'experience': _normalized_value(experience),
+ })
+ return records
+
+
+def build_related_articles_index(app, env) -> None:
+ """Build metadata map once after Sphinx has read all source documents."""
+ env.ros_related_articles_index = _collect_article_index(env)
+
+
+def resolve_related_articles(app, doctree, fromdocname) -> None:
+ """Replace placeholders with static paragraph + list markup."""
+ index: List[RelatedArticle] = getattr(app.env, 'ros_related_articles_index', [])
+ for node in list(doctree.traverse(RosRelatedArticlesNode)):
+ area = _normalized_value(str(node.get('area', '')))
+ experience = _normalized_value(str(node.get('experience', '')))
+ max_items = int(node.get('max', 10))
+
+ matches = [
+ item for item in index
+ if item['docname'] != fromdocname
+ and item['area'] == area
+ and item['experience'] == experience
+ ]
+ matches.sort(key=lambda item: item['title'].lower())
+ matches = matches[:max_items]
+
+ if not matches:
+ node.replace_self([])
+ continue
+
+ container = nodes.container(classes=['related-articles'])
+ intro = nodes.paragraph()
+ intro += nodes.Text('Related articles:')
+ container += intro
+
+ bullets = nodes.bullet_list()
+ for item in matches:
+ refuri = app.builder.get_relative_uri(fromdocname, item['docname'])
+ link = nodes.reference('', item['title'], refuri=refuri)
+ entry = nodes.list_item()
+ para = nodes.paragraph()
+ para += link
+ entry += para
+ bullets += entry
+ container += bullets
+
+ node.replace_self(container)
+
+
+def setup(app):
+ app.add_directive('ros-related-articles', RosRelatedArticlesDirective)
+ app.add_node(RosRelatedArticlesNode)
+ app.connect('env-updated', build_related_articles_index)
+ app.connect('doctree-resolved', resolve_related_articles)
+ return {
+ 'parallel_read_safe': True,
+ 'parallel_write_safe': True,
+ 'version': '1.0.0',
+ }
diff --git a/plugins/ros_related_packages.py b/plugins/ros_related_packages.py
new file mode 100644
index 00000000000..8321f18397c
--- /dev/null
+++ b/plugins/ros_related_packages.py
@@ -0,0 +1,218 @@
+# Copyright 2026 Open Robotics and contributors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Sphinx directive for runtime ROS distro package lists (filtered in the browser)."""
+
+from __future__ import annotations
+
+import html
+import os
+import urllib.error
+import urllib.request
+from typing import List
+
+from docutils import nodes
+from docutils.parsers.rst import directives
+from sphinx.util import logging as sphinx_logging
+from sphinx.util.docutils import SphinxDirective
+
+LOGGER = sphinx_logging.getLogger(__name__)
+
+ROSDISTRO_CACHE_TEMPLATE = (
+ 'https://repo.ros2.org/rosdistro_cache/{distro}-cache.yaml.gz'
+)
+
+
+def _normalize_field_name(raw: str) -> str:
+ """Normalize a docinfo field label for comparison (e.g. ``Build-type`` → ``build-type``)."""
+ name = raw.strip().lower().rstrip(':')
+ return name.replace(' ', '-')
+
+
+def _field_value_from_doctree(document: nodes.document, wanted: str) -> str | None:
+ """Return the body of the first matching docinfo/rst field in the document."""
+ wanted_norm = _normalize_field_name(wanted)
+ for field in document.traverse(nodes.field):
+ children = getattr(field, 'children', ()) or ()
+ if len(children) < 2:
+ continue
+ label = children[0].astext()
+ if _normalize_field_name(label) != wanted_norm:
+ continue
+ return children[1].astext().strip()
+ return None
+
+
+def _meta_get(metadata: dict, *names: str) -> str | None:
+ """Look up document metadata using several possible keys (Sphinx/docutils variants)."""
+ for name in names:
+ for key, val in metadata.items():
+ if not val:
+ continue
+ if _normalize_field_name(str(key)) == _normalize_field_name(name):
+ return str(val).strip()
+ return None
+
+
+def _meta_content_from_docutils(document: nodes.document, meta_name: str) -> str | None:
+ """Read ``docutils.nodes.meta`` emitted by ``.. meta::`` (typically ```` HTML meta tags).
+
+ Hyphenated names work in rST as ``.. meta::`` fields, e.g. ``:build-type: ament_cmake``.
+ """
+ for node in document.traverse(nodes.meta):
+ if node.get('name') != meta_name:
+ continue
+ raw = node.get('content')
+ if raw:
+ return str(raw).strip()
+ return None
+
+
+def _bundled_cache_href(docname: str, distro: str) -> str:
+ """Relative URL from this page's HTML file to the downloaded gzip in ``_static/``.
+
+ Sphinx emits sibling paths like ``_static/`` under the HTML root (including per-version
+ directories for multiversion builds). Depth follows ``docname`` segments (slashes).
+ """
+ depth = docname.count('/')
+ return ('../' * depth) + f'_static/rosdistro_cache/{distro}-cache.yaml.gz'
+
+
+def _proxy_cache_href(proxy_template: str, distro: str) -> str:
+ """Build runtime proxy URL from template, replacing ``{distro}``."""
+ if not proxy_template:
+ return ''
+ return proxy_template.replace('{distro}', distro)
+
+
+def _positive_int_option(argument: str) -> int:
+ """Parse a positive integer option for the directive."""
+ if argument is None:
+ raise ValueError('option requires a number')
+ value = int(argument)
+ if value < 1:
+ raise ValueError('must be positive')
+ return value
+
+
+class RosRelatedPackagesDirective(SphinxDirective):
+ """Emit a placeholder ``div`` filled at runtime by ``related_packages.js``.
+
+ Filter criteria (currently ``build-type``) should be supplied as **HTML meta tags**
+ via Docutils ``.. meta::`` so values appear in ```` and not in the page body::
+
+ .. meta::
+ :build-type: ament_cmake
+
+ Fallbacks: Sphinx ``env.metadata`` / a visible rST field list ``:build-type:``.
+ Optional ``:build-type:`` on this directive overrides document metadata.
+ """
+
+ has_content = False
+ required_arguments = 0
+ optional_arguments = 0
+ option_spec = {
+ 'build-type': directives.unchanged,
+ 'max': _positive_int_option,
+ }
+
+ def run(self) -> List[nodes.Node]:
+ build_type_opt = self.options.get('build-type')
+ if build_type_opt:
+ build_type = build_type_opt.strip()
+ else:
+ meta = self.env.metadata.get(self.env.docname, {})
+ build_type = (
+ _meta_content_from_docutils(self.state.document, 'build-type')
+ or _meta_get(meta, 'build-type', 'build_type')
+ or _field_value_from_doctree(self.state.document, 'build-type')
+ or ''
+ )
+
+ if not build_type:
+ raise self.error(
+ 'ros-related-packages: define build type with `.. meta::` and '
+ '`:build-type: ament_cmake` (recommended), or a `:build-type:` field list, '
+ 'or pass `:build-type:` on this directive.'
+ )
+
+ max_pkgs = self.options.get('max', 10)
+
+ macros = getattr(self.env.config, 'macros', {}) or {}
+ distro = macros.get('DISTRO', 'rolling')
+
+ escaped_type = html.escape(build_type, quote=True)
+ escaped_distro = html.escape(distro, quote=True)
+ bundled_href = _bundled_cache_href(self.env.docname, distro)
+ escaped_bundled = html.escape(bundled_href, quote=True)
+ proxy_template = getattr(self.env.config, 'ros_related_packages_proxy_url', '')
+ proxy_href = _proxy_cache_href(proxy_template, distro)
+ escaped_proxy = html.escape(proxy_href, quote=True)
+
+ html_body = (
+ '
'
+ '
Loading related packages…
'
+ '
'
+ )
+ return [nodes.raw('', html_body, format='html')]
+
+
+def download_rosdistro_cache(app) -> None:
+ """Fetch the gzipped rosdistro cache into ``source/_static`` for same-origin loads.
+
+ Sphinx 8+ passes only ``app`` to ``builder-inited``; the builder is ``app.builder``.
+ """
+ builder = app.builder
+ if builder is None or builder.format != 'html':
+ return
+
+ macros = getattr(app.config, 'macros', {}) or {}
+ distro = macros.get('DISTRO', 'rolling')
+
+ dest_dir = os.path.join(app.confdir, 'source', '_static', 'rosdistro_cache')
+ os.makedirs(dest_dir, exist_ok=True)
+ dest_path = os.path.join(dest_dir, f'{distro}-cache.yaml.gz')
+ url = ROSDISTRO_CACHE_TEMPLATE.format(distro=distro)
+
+ request = urllib.request.Request(url, headers={'User-Agent': 'ros2-documentation-build/1.0'})
+ try:
+ with urllib.request.urlopen(request, timeout=120) as response:
+ data = response.read()
+ with open(dest_path, 'wb') as handle:
+ handle.write(data)
+ except (urllib.error.URLError, OSError, TimeoutError) as exc:
+ LOGGER.warning(
+ 'Could not download rosdistro cache from %s (%s). '
+ 'Related package lists may not work until the file exists at %s',
+ url,
+ exc,
+ dest_path,
+ )
+
+
+def setup(app):
+ app.add_config_value('ros_related_packages_proxy_url', '', 'html')
+ app.add_directive('ros-related-packages', RosRelatedPackagesDirective)
+ app.connect('builder-inited', download_rosdistro_cache)
+ return {
+ 'parallel_read_safe': True,
+ 'parallel_write_safe': True,
+ 'version': '1.0.0',
+ }
diff --git a/plugins/short_description.py b/plugins/short_description.py
new file mode 100644
index 00000000000..9b275626162
--- /dev/null
+++ b/plugins/short_description.py
@@ -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',
+ }
\ No newline at end of file
diff --git a/plugins/showmeta.py b/plugins/showmeta.py
new file mode 100644
index 00000000000..f11b140429c
--- /dev/null
+++ b/plugins/showmeta.py
@@ -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'{html_module.escape(label_display)} '
+ f'{html_module.escape(val)}'
+ )
+
+ if not parts:
+ node.parent.remove(node)
+ else:
+ inner = ' | '.join(parts)
+ raw = nodes.raw(
+ '',
+ f'
{inner}
',
+ 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,
+ }
diff --git a/requirements.txt b/requirements.txt
index f938445a0bf..261cd094a98 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,12 @@
+# Non-Python build dependency (install separately; used by `make pagefind`):
+# Node.js 18+ with npx — https://nodejs.org/
+# Verify: node -v && npx -v
+
codespell
doc8
docutils
+openai
+python-dotenv
pip
pytest
sphinx
@@ -13,3 +19,5 @@ sphinx-tabs
sphinx-tamer
sphinxcontrib-googleanalytics
sphinxcontrib-mermaid
+tenacity
+timeout-decorator
diff --git a/source/About-ROS.rst b/source/About-ROS.rst
index 05fe7db14e9..d6e5da9bb59 100644
--- a/source/About-ROS.rst
+++ b/source/About-ROS.rst
@@ -1,12 +1,25 @@
+.. meta::
+ :contentType: about
+ :experience: beginner
+ :area: framework, tools, capabilities
+ :capability: simulation
+ :distribution: {DISTRO}
+ :product: {PRODUCT}
+ :description: Overview of ROS, its ecosystem framework/tools/capabilities, community and integrations, plus distributions and supported platforms.
+ :keywords: robotics, framework, tools, capabilities, navigation
+
.. _AboutROS:
About ROS
=========
-ROS (Robot Operating System) is an open-source ecosystem that provides the framework, tools, and libraries for building, deploying, running, and maintaining robotic applications.
-This article introduces the main areas of the ecosystem and outlines their intended use.
+.. short-description::
+ ROS (Robot Operating System) is an open-source ecosystem that provides the framework, tools, and libraries for building, deploying, running, and maintaining robotic applications.
+ This article introduces the main areas of the ecosystem and outlines their intended use.
-**Area: ROS-framework, ROS-tools, ROS-capabilities | Content-type: about | Experience: beginner**
+.. showmeta::
+ :order: area, capability, contentType, experience
+ :labels: area=Area, capability=Capability, contentType=Content type, experience=Level
.. contents:: Table of Contents
:depth: 2
@@ -15,7 +28,7 @@ This article introduces the main areas of the ecosystem and outlines their inten
Summary
-------
-ROS is used in many areas of robotics.
+ROS is used in many areas of robotics...
In logistics, it helps robots move goods in warehouses by providing navigation, mapping, motion control, and coordination between multiple robots.
In manufacturing, it enables advanced tasks such as automated pick-and-place operations using vision systems for accurate handling.
In healthcare, ROS supports robotic systems that assist with patient care and improve efficiency in clinical workflows.
diff --git a/source/Concepts/Basic/Interfaces-Topics-Services-Actions.rst b/source/Concepts/Basic/Interfaces-Topics-Services-Actions.rst
index dddf9a89415..980fb35cb44 100644
--- a/source/Concepts/Basic/Interfaces-Topics-Services-Actions.rst
+++ b/source/Concepts/Basic/Interfaces-Topics-Services-Actions.rst
@@ -7,9 +7,10 @@
Interfaces (topics, services, actions)
======================================
-Interfaces in ROS define how nodes exchange data.
-This article explains the different types of ROS interface and the differences between them.
-With this information, you'll be able to select the right interfaces for your purposes.
+.. short-description::
+ Interfaces in ROS define how nodes exchange data.
+ This article explains the different types of ROS interface and the differences between them.
+ With this information, you'll be able to select the right interfaces for your purposes.
**Area: ROS-framework | Content-type: concept | Experience: beginner**
diff --git a/source/First-Steps.rst b/source/First-Steps.rst
index 9f9783c3f7e..e84bdbc5c03 100644
--- a/source/First-Steps.rst
+++ b/source/First-Steps.rst
@@ -3,9 +3,10 @@
First steps with ROS - learning path
====================================
-ROS (Robot Operating System) is an open-source ecosystem that provides framework, tools, and libraries for building, deploying, running, and maintaining robotic applications.
-This page presents a set of articles and hands-on activities to introduce the main concepts behind the ROS framework.
-Working through these will give you the essential knowledge needed to start developing applications with ROS.
+.. short-description::
+ ROS (Robot Operating System) is an open-source ecosystem that provides framework, tools, and libraries for building, deploying, running, and maintaining robotic applications.
+ This page presents a set of articles and hands-on activities to introduce the main concepts behind the ROS framework.
+ Working through these will give you the essential knowledge needed to start developing applications with ROS.
**Area: ROS-framework | Content-type: learning-path | Experience: beginner**
diff --git a/source/How-To-Guides.rst b/source/How-To-Guides.rst
index 9f65b54cee8..9fcaa02e882 100644
--- a/source/How-To-Guides.rst
+++ b/source/How-To-Guides.rst
@@ -22,6 +22,7 @@ If you are new and looking to learn the ropes, start with the :doc:`Tutorials `__
+
+While predefined interface definitions are useful at the beginning, you soon realize that they can't meet all your needs.
+That's why the ability to create custom interfaces is essential.
+
+Creating custom interfaces involves preparing a package, specifying interface definitions, and registering the interfaces in ``package.xml`` and ``CMakeLists.txt``.
+Using custom interfaces involves configuring a node to include the interfaces in its source, and configuring the node to build with the interfaces in ``CMakeLists.txt``.
+
+.. tip::
+
+ The best practice is to declare interfaces in dedicated interface packages, but sometimes it may be more convenient for you to declare, create and use an interface all in one package.
+
+Prerequisites
+-------------
+
+#. Install :doc:`ROS 2 <../Installation>`, and create your :doc:`workspace <../Tutorials/Beginner-Client-Libraries/Creating-A-Workspace/Creating-A-Workspace>`.
+#. Make sure you understand how to :doc:`create packages <../Tutorials/Beginner-Client-Libraries/Creating-Your-First-ROS2-Package>`.
+
+Steps
+-----
+
+.. note::
+
+ For our examples, we are using the ``msg`` interface type, but the steps below apply to all interface types.
+
+#. In your workspace ``src`` folder, create a ``more_interfaces`` CMake package with a folder for interface definitions.
+ For example:
+
+ .. code-block:: console
+
+ $ ros2 pkg create --build-type ament_cmake more_interfaces
+ $ mkdir -p more_interfaces/msg
+
+ .. note::
+
+ In ROS 2, interfaces can only be defined in CMake packages.
+ You can also use `ament_cmake_python `__ to include Python libraries and nodes in a CMake package.
+
+#. In your interface definitions folder, create a file in which you provide the definitions for the interface.
+ For example, for a message interface, you can create an ``AddressBook.msg`` file that collects personal data:
+
+ .. code-block:: text
+
+ uint8 PHONE_TYPE_HOME=0
+ uint8 PHONE_TYPE_WORK=1
+ uint8 PHONE_TYPE_MOBILE=2
+ string first_name
+ string last_name
+ string phone_number
+ uint8 phone_type
+
+#. In ``package.xml``, add the following code to register your package as part of interface groups:
+ ``rosidl_default_generators``: Needed to generate the code during the build.
+ ``rosidl_default_runtime``: Needed only at run time.
+
+ .. code-block:: xml
+
+ rosidl_default_generators
+ rosidl_default_runtime
+ rosidl_interface_packages
+
+#. In ``CMakeLists.txt``, add the required code to make the runtime libraries available and to generate source files from your interface definition.
+ For example:
+
+ .. code-block:: cmake
+
+ find_package(rosidl_default_generators REQUIRED)
+ set(msg_files "msg/AddressBook.msg")
+ rosidl_generate_interfaces(${PROJECT_NAME} ${msg_files})
+ ament_export_dependencies(rosidl_default_runtime)
+
+#. In the ``more_interfaces/src`` folder, create a node to interact with your new interface.
+ For example, for a message interface, create ``publish_address_book.cpp`` with code to publish the message periodically.
+
+ .. code-block:: c++
+
+ #include
+ #include
+
+ #include "rclcpp/rclcpp.hpp"
+ #include "more_interfaces/msg/address_book.hpp"
+
+ using namespace std::chrono_literals;
+
+ class AddressBookPublisher : public rclcpp::Node
+ {
+ public:
+ AddressBookPublisher()
+ : Node("address_book_publisher")
+ {
+ address_book_publisher_ =
+ this->create_publisher("address_book", 10);
+
+ auto publish_msg = [this]() -> void {
+ auto message = more_interfaces::msg::AddressBook();
+
+ message.first_name = "John";
+ message.last_name = "Doe";
+ message.phone_number = "1234567890";
+ message.phone_type = message.PHONE_TYPE_MOBILE;
+
+ std::cout << "Publishing Contact\nFirst:" << message.first_name <<
+ " Last:" << message.last_name << std::endl;
+
+ this->address_book_publisher_->publish(message);
+ };
+ timer_ = this->create_wall_timer(1s, publish_msg);
+ }
+
+ private:
+ rclcpp::Publisher::SharedPtr address_book_publisher_;
+ rclcpp::TimerBase::SharedPtr timer_;
+ };
+
+
+ int main(int argc, char * argv[])
+ {
+ rclcpp::init(argc, argv);
+ rclcpp::spin(std::make_shared());
+ rclcpp::shutdown();
+
+ return 0;
+ }
+
+#. In ``CMakeLists.txt``, create a new target so the node builds correctly.
+ For example:
+
+ .. code-block:: cmake
+
+ find_package(rclcpp REQUIRED)
+ add_executable(publish_address_book src/publish_address_book.cpp)
+ target_link_libraries(publish_address_book rclcpp::rclcpp)
+ install(TARGETS publish_address_book DESTINATION lib/${PROJECT_NAME})
+
+#. In ``CMakeLists.txt``, link the node to your interface.
+ For example:
+
+ .. code-block:: cmake
+
+ rosidl_get_typesupport_target(cpp_typesupport_target ${PROJECT_NAME} rosidl_typesupport_cpp)
+ target_link_libraries(publish_address_book "${cpp_typesupport_target}")
+
+#. To test your new interface, do the following:
+
+ a) In your workspace root, build the package.
+
+ b) Source the workspace and run the node that uses the interface.
+
+ For example:
+
+ .. tabs::
+
+ .. group-tab:: Linux
+
+ .. code-block:: console
+
+ $ cd ~/ros2_ws
+ $ colcon build --packages-up-to more_interfaces
+ $ source install/local_setup.bash
+ $ ros2 run more_interfaces publish_address_book
+
+ .. group-tab:: macOS
+
+ .. code-block:: console
+
+ $ cd ~/ros2_ws
+ $ colcon build --packages-up-to more_interfaces
+ $ . install/local_setup.bash
+ $ ros2 run more_interfaces publish_address_book
+
+ .. group-tab:: Windows
+
+ .. code-block:: console
+
+ $ cd /ros2_ws
+ $ colcon build --merge-install --packages-up-to more_interfaces
+ $ call install/local_setup.bat
+ $ ros2 run more_interfaces publish_address_book
+
+ Or using Powershell:
+
+ .. code-block:: console
+
+ $ install/local_setup.ps1
+ $ ros2 run more_interfaces publish_address_book
+
+ c) Check the interface or interact with it.
+
+ For example, for a message interface, you could open another terminal and use the following code:
+
+ .. tabs::
+
+ .. group-tab:: Linux
+
+ .. code-block:: console
+
+ $ source install/setup.bash
+ $ ros2 topic echo /address_book
+
+ .. group-tab:: macOS
+
+ .. code-block:: console
+
+ $ . install/setup.bash
+ $ ros2 topic echo /address_book
+
+ .. group-tab:: Windows
+
+ .. code-block:: console
+
+ $ call install/setup.bat
+ $ ros2 topic echo /address_book
+
+ Or using Powershell:
+
+ .. code-block:: console
+
+ $ install/setup.ps1
+ $ ros2 topic echo /address_book
+
+Related content
+---------------
+
+.. ros-related-articles::
+
+.. ros-related-packages::
+
diff --git a/source/The-ROS2-Project/Adopters/adopters.yaml b/source/The-ROS2-Project/Adopters/adopters.yaml
index a92b919dd9b..9f544990425 100644
--- a/source/The-ROS2-Project/Adopters/adopters.yaml
+++ b/source/The-ROS2-Project/Adopters/adopters.yaml
@@ -95,7 +95,7 @@ adopters:
description: "Development platform for autonomous mobile robots across logistics, construction, and retail."
- organization: "RT Corporation"
- organization_url: "https://rt-net.jp"
+ organization_url: "https://en.rt-net.jp"
project: "CRANE-X7"
project_url: "https://github.com/rt-net/crane_x7_ros"
domain:
diff --git a/source/Tutorials/Beginner-Client-Libraries/Creating-A-Workspace/Creating-A-Workspace.rst b/source/Tutorials/Beginner-Client-Libraries/Creating-A-Workspace/Creating-A-Workspace.rst
index 2ade50673cc..7a92e031f7d 100644
--- a/source/Tutorials/Beginner-Client-Libraries/Creating-A-Workspace/Creating-A-Workspace.rst
+++ b/source/Tutorials/Beginner-Client-Libraries/Creating-A-Workspace/Creating-A-Workspace.rst
@@ -4,6 +4,12 @@
.. _ROS2Workspace:
+.. meta::
+ :area: framework
+ :experience: beginner, intermediate
+ :distribution: {DISTRO}
+ :product: {PRODUCT}
+
Creating a workspace
====================
@@ -402,3 +408,8 @@ Next steps
----------
Now that you understand the details behind creating, building and sourcing your own workspace, you can learn how to :doc:`create your own packages <../Creating-Your-First-ROS2-Package>`.
+
+Related content
+---------------
+
+.. ros-related-articles::
diff --git a/source/Tutorials/Beginner-Client-Libraries/Creating-Your-First-ROS2-Package.rst b/source/Tutorials/Beginner-Client-Libraries/Creating-Your-First-ROS2-Package.rst
index 82e31a09eb1..2d246cf1fcf 100644
--- a/source/Tutorials/Beginner-Client-Libraries/Creating-Your-First-ROS2-Package.rst
+++ b/source/Tutorials/Beginner-Client-Libraries/Creating-Your-First-ROS2-Package.rst
@@ -4,6 +4,12 @@
.. _CreatePkg:
+.. meta::
+ :area: framework
+ :experience: beginner, intermediate
+ :distribution: {DISTRO}
+ :product: {PRODUCT}
+
Creating a package
==================
@@ -535,3 +541,8 @@ Next steps
Next, let's add something meaningful to a package.
You'll start with a simple publisher/subscriber system, which you can choose to write in either :doc:`C++ <./Writing-A-Simple-Cpp-Publisher-And-Subscriber>` or :doc:`Python <./Writing-A-Simple-Py-Publisher-And-Subscriber>`.
+
+Related content
+---------------
+
+.. ros-related-articles::
diff --git a/source/_static/custom.css b/source/_static/custom.css
index 4252f921bb8..99f8209fdbb 100644
--- a/source/_static/custom.css
+++ b/source/_static/custom.css
@@ -1,3 +1,10 @@
.wy-nav-content {
max-width: 64rem;
}
+
+.short-description p{
+ font-size: 1.25rem;
+ line-height: 1.5;
+ color: #777777;
+ margin-bottom: 1.5rem;
+}
diff --git a/source/_static/pagefind-docsearch.css b/source/_static/pagefind-docsearch.css
new file mode 100644
index 00000000000..1f507fc202d
--- /dev/null
+++ b/source/_static/pagefind-docsearch.css
@@ -0,0 +1,219 @@
+/* DocSearch-like sidebar trigger for Pagefind modal (plan §3) */
+.ros2-pagefind-search {
+ margin: 0.5rem 0 1rem;
+}
+
+.ros2-pagefind-search pagefind-modal-trigger {
+ display: block;
+ width: 100%;
+}
+
+/* Light styling for the trigger button (Pagefind exposes light DOM button) */
+.ros2-pagefind-search pagefind-modal-trigger::part(button),
+.ros2-pagefind-search button {
+ align-items: center;
+ background: var(--wy-menu-vertical-background-color, #fcfcfc);
+ border: 1px solid #ccc;
+ border-radius: 40px;
+ color: var(--wy-menu-vertical-color, #404040);
+ cursor: pointer;
+ display: flex;
+ font-size: 0.85rem;
+ gap: 0.35rem;
+ justify-content: space-between;
+ min-height: 2.25rem;
+ padding: 0.35rem 0.6rem 0.35rem 0.75rem;
+ text-align: left;
+ width: 100%;
+}
+
+.ros2-pagefind-search pagefind-modal-trigger::part(button):hover,
+.ros2-pagefind-search button:hover {
+ border-color: #999;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
+}
+
+/* Keyboard hint styling (Algolia DocSearch-like) */
+.ros2-pagefind-search .DocSearch-Button-Keys,
+.ros2-pagefind-search pagefind-modal-trigger::part(keys) {
+ display: flex;
+ gap: 0.2rem;
+}
+
+.ros2-pagefind-search kbd,
+.ros2-pagefind-search pagefind-modal-trigger::part(kbd) {
+ align-items: center;
+ background: linear-gradient(-225deg, #d5dbe4, #f8f8f8);
+ border: 0;
+ border-radius: 3px;
+ box-shadow: inset 0 -2px 0 0 #cdcde6, inset 0 0 1px 1px #fff, 0 1px 2px 1px rgba(30, 35, 90, 0.2);
+ color: #969faf;
+ display: flex;
+ font-size: 0.65rem;
+ font-weight: 600;
+ line-height: 1;
+ min-height: 1.25rem;
+ min-width: 1.25rem;
+ padding: 0 0.3rem;
+ justify-content: center;
+}
+
+.wy-nav-side-scroll .ros2-pagefind-search {
+ padding-right: 0.5rem;
+}
+
+.ros-page-meta-summary,
+.ros2-pagefind-search .pf-result-meta-block,
+#ros-search-page .pf-result-meta-block,
+dialog.pf-modal .pf-result-meta-block {
+ margin: -0.25rem 0 1rem !important;
+ padding: 0.45rem 0.75rem !important;
+ border-left: 4px solid #6c757d !important;
+ background: #f8f9fa !important;
+ color: #495057 !important;
+ font-size: 0.85rem !important;
+}
+
+.ros2-pagefind-search dialog.pf-modal {
+ width: clamp(900px, 60vw, 1200px) !important;
+ max-width: 92vw !important;
+ min-width: min(900px, 92vw) !important;
+}
+
+.ros2-pagefind-search .ros-search-two-col,
+#ros-search-page .ros-search-two-col {
+ display: grid;
+ grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
+ gap: 1rem;
+ min-height: 0;
+ width: 100%;
+}
+
+.ros2-pagefind-search .ros-search-facets,
+.ros2-pagefind-search .ros-search-results {
+ max-height: 62vh;
+ overflow: auto;
+ min-width: 0;
+}
+
+#ros-search-page .ros-search-facets,
+#ros-search-page .ros-search-results {
+ min-width: 0;
+}
+
+.ros2-pagefind-search .ros-search-facets {
+ border-right: 1px solid #e9ecef;
+ padding-right: 0.75rem;
+}
+
+#ros-search-page .ros-search-facets {
+ border-right: 1px solid #e9ecef;
+ padding-right: 0.75rem;
+}
+
+.ros2-pagefind-search .ros-search-facets pagefind-filter-pane,
+.ros2-pagefind-search .ros-search-results pagefind-summary,
+.ros2-pagefind-search .ros-search-results pagefind-results,
+#ros-search-page .ros-search-facets pagefind-filter-pane,
+#ros-search-page .ros-search-results pagefind-summary,
+#ros-search-page .ros-search-results pagefind-results {
+ display: block;
+}
+
+.ros2-pagefind-search .ros-search-results pagefind-summary,
+#ros-search-page .ros-search-results pagefind-summary {
+ margin-bottom: 0.75rem;
+}
+
+.ros2-pagefind-search .pf-result-link,
+#ros-search-page .pf-result-link {
+ font-size: 1rem;
+ font-weight: 700;
+ line-height: 1.25;
+}
+
+.ros2-pagefind-search .pf-result-excerpt,
+.ros2-pagefind-search .pf-result-preview,
+#ros-search-page .pf-result-excerpt,
+#ros-search-page .pf-result-preview {
+ font-size: 0.85rem;
+ line-height: 1.35;
+}
+
+.ros2-pagefind-search .pf-result-meta-block,
+#ros-search-page .pf-result-meta-block,
+dialog.pf-modal .pf-result-meta-block {
+ margin-top: 0.35rem !important;
+ margin-bottom: 0.45rem !important;
+ border-radius: 0 !important;
+ display: block !important;
+ line-height: 1.35 !important;
+}
+
+.ros2-pagefind-search .pf-result-meta-block b,
+#ros-search-page .pf-result-meta-block b,
+dialog.pf-modal .pf-result-meta-block b {
+ color: #495057 !important;
+ font-weight: 600 !important;
+}
+
+/* Full-page search results (search.html) */
+.ros-search-page {
+ padding: 0 0 2rem;
+}
+
+.ros-search-page-input-row {
+ margin-bottom: 1.5rem;
+}
+
+.ros-search-page-input-row pagefind-input {
+ display: block;
+ width: 100%;
+}
+
+.ros-search-page-two-col .ros-search-facets,
+.ros-search-page-two-col .ros-search-results {
+ max-height: none;
+ overflow: visible;
+}
+
+/*
+ Force Pagefind's per-result IntersectionObserver to use this
+ element as its root. The component walks up the DOM looking for an ancestor
+ whose computed overflow-y is not "visible" or "hidden"; without this, no
+ ancestor matches on a dedicated search page (everything renders with default
+ overflow), the observer never fires, and result cards remain skeletons.
+
+ Setting overflow-y: auto with no max-height gives the observer a valid root
+ without producing any visible scrollbar - the element grows to fit content
+ naturally and the page itself remains the scroll context for the user.
+*/
+#ros-search-page pagefind-results {
+ overflow-y: auto !important;
+}
+
+@media (max-width: 980px) {
+ .ros2-pagefind-search .ros-search-two-col,
+ #ros-search-page .ros-search-two-col {
+ grid-template-columns: 1fr;
+ }
+
+ .ros2-pagefind-search .ros-search-facets,
+ .ros2-pagefind-search .ros-search-results {
+ max-height: none;
+ }
+
+ .ros2-pagefind-search .ros-search-facets {
+ border-right: 0;
+ border-bottom: 1px solid #e9ecef;
+ margin-bottom: 0.75rem;
+ padding: 0 0 0.75rem;
+ }
+
+ #ros-search-page .ros-search-facets {
+ border-right: 0;
+ border-bottom: 1px solid #e9ecef;
+ margin-bottom: 0.75rem;
+ padding: 0 0 0.75rem;
+ }
+}
diff --git a/source/_static/related_packages.js b/source/_static/related_packages.js
new file mode 100644
index 00000000000..8d7880e8f78
--- /dev/null
+++ b/source/_static/related_packages.js
@@ -0,0 +1,393 @@
+/**
+ * Populate ``.js-related-packages`` widgets from the rosdistro cache YAML.
+ *
+ * Depends on global ``pako`` (gzip) and ``yaml`` / ``jsyaml`` (js-yaml), loaded
+ * earlier via html_js_files in conf.py.
+ */
+(function () {
+ 'use strict';
+
+ /** @type {Record>>} */
+ var cacheByDistro = {};
+
+ /**
+ * Resolve the js-yaml API regardless of how the bundle exposes it.
+ *
+ * @returns {{ load: function(string): unknown }}
+ */
+ function yamlApi() {
+ var g = typeof window !== 'undefined' ? window : globalThis;
+ /* js-yaml UMD sets ``globalThis.jsyaml`` (see dist/js-yaml.min.js). */
+ if (g.jsyaml && typeof g.jsyaml.load === 'function') {
+ return g.jsyaml;
+ }
+ if (g.yaml && typeof g.yaml.load === 'function') {
+ return g.yaml;
+ }
+ throw new Error('js-yaml is not loaded');
+ }
+
+ /**
+ * Directory containing ``related_packages.js`` (ends with slash or empty).
+ *
+ * @returns {string}
+ */
+ function scriptBaseUrl() {
+ var nodes = document.getElementsByTagName('script');
+ var i;
+ var src;
+ for (i = nodes.length - 1; i >= 0; i--) {
+ src = nodes[i].src;
+ if (src && src.indexOf('related_packages.js') !== -1) {
+ return src.replace(/related_packages\.js([?#].*)?$/i, '');
+ }
+ }
+ return '';
+ }
+
+ /**
+ * @param {string} distro
+ * @returns {string|null}
+ */
+ function bundledCacheUrl(distro) {
+ var base = scriptBaseUrl();
+ if (!base) {
+ return null;
+ }
+ return base + 'rosdistro_cache/' + distro + '-cache.yaml.gz';
+ }
+
+ /**
+ * Prefer Sphinx-emitted ``data-bundled-cache-href`` (relative to page); then derive from script URL.
+ *
+ * @param {HTMLElement|null} widget
+ * @param {string} distro
+ * @returns {string|null}
+ */
+ function resolveBundledAbsoluteUrl(widget, distro) {
+ var rel = widget && widget.getAttribute('data-bundled-cache-href');
+ if (rel && typeof URL !== 'undefined') {
+ try {
+ return new URL(rel, window.location.href).href;
+ } catch (e1) {
+ /* ignore */
+ }
+ }
+ return bundledCacheUrl(distro);
+ }
+
+ /**
+ * Proxy URL configured by Sphinx via data attribute.
+ *
+ * @param {HTMLElement|null} widget
+ * @param {string} distro
+ * @returns {string|null}
+ */
+ function resolveProxyUrl(widget, distro) {
+ var templateUrl = widget && widget.getAttribute('data-proxy-cache-href');
+ if (!templateUrl) {
+ return null;
+ }
+ return templateUrl.replace('{distro}', encodeURIComponent(distro));
+ }
+
+ /**
+ * @param {string} distro
+ * @param {HTMLElement|null} sampleWidget widget from this page (for data-bundled-cache-href)
+ * @returns {Promise>}
+ */
+ function loadXmls(distro, sampleWidget) {
+ var cacheKey =
+ distro +
+ '|' +
+ (sampleWidget ? sampleWidget.getAttribute('data-proxy-cache-href') || '' : '') +
+ '|' +
+ (sampleWidget ? sampleWidget.getAttribute('data-bundled-cache-href') || '' : '');
+ if (cacheByDistro[cacheKey]) {
+ return cacheByDistro[cacheKey];
+ }
+ cacheByDistro[cacheKey] = fetchAndParse(
+ distro,
+ resolveProxyUrl(sampleWidget, distro),
+ resolveBundledAbsoluteUrl(sampleWidget, distro)
+ );
+ return cacheByDistro[cacheKey];
+ }
+
+ /**
+ * @param {string} distro
+ * @param {string|null} proxyUrl same-origin backend proxy endpoint (freshest)
+ * @param {string|null} bundledAbsolute resolved same-origin URL to gzip, if any
+ * @returns {Promise>}
+ */
+ function fetchAndParse(distro, proxyUrl, bundledAbsolute) {
+ var remote =
+ 'https://repo.ros2.org/rosdistro_cache/' + encodeURIComponent(distro) + '-cache.yaml.gz';
+ var urls = [];
+ if (proxyUrl) {
+ urls.push(proxyUrl);
+ }
+ if (bundledAbsolute) {
+ urls.push(bundledAbsolute);
+ }
+ /* Final fallback may still fail in browsers due to upstream CORS. */
+ urls.push(remote);
+
+ return tryUrls(urls);
+ }
+
+ /**
+ * @param {string[]} urls
+ * @returns {Promise>}
+ */
+ function tryUrls(urls) {
+ var i = 0;
+
+ function next(lastErr) {
+ if (i >= urls.length) {
+ return Promise.reject(lastErr || new Error('failed to load rosdistro cache'));
+ }
+ var url = urls[i];
+ i += 1;
+ var controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
+ var timer = null;
+ if (controller && i === 1) {
+ /* Keep proxy attempt snappy so fallback isn't delayed. */
+ timer = setTimeout(function () {
+ controller.abort();
+ }, 6000);
+ }
+ return fetch(url, { cache: 'no-cache', signal: controller ? controller.signal : undefined })
+ .then(function (res) {
+ if (timer) {
+ clearTimeout(timer);
+ }
+ if (!res.ok) {
+ throw new Error('HTTP ' + res.status + ' for ' + url);
+ }
+ return res.arrayBuffer();
+ })
+ .then(function (buf) {
+ var g = typeof window !== 'undefined' ? window : globalThis;
+ var inflated = g.pako.inflate(new Uint8Array(buf), { to: 'string' });
+ var data = yamlApi().load(inflated);
+ var xmls = data && data.release_package_xmls;
+ if (!xmls || typeof xmls !== 'object') {
+ throw new Error('release_package_xmls missing in rosdistro cache');
+ }
+ if (typeof console !== 'undefined' && console.info) {
+ console.info('related_packages: loaded rosdistro cache from', url);
+ }
+ return /** @type {Record} */ (xmls);
+ })
+ .catch(function (err) {
+ if (timer) {
+ clearTimeout(timer);
+ }
+ if (typeof console !== 'undefined' && console.warn) {
+ console.warn('related_packages: failed', url, err);
+ }
+ /* Try next URL (e.g. bundled 404 then HTTPS remote — remote may hit CORS). */
+ return next(err);
+ });
+ }
+
+ return next(null);
+ }
+
+ /**
+ * @param {string} xmlStr
+ * @returns {string[]}
+ */
+ function extractBuildTypes(xmlStr) {
+ var out = [];
+ var re = /]*>([^<]+)<\/build_type>/gi;
+ var m;
+ while ((m = re.exec(xmlStr)) !== null) {
+ out.push(m[1].trim());
+ }
+ return out;
+ }
+
+ /**
+ * @param {string} xmlStr
+ * @param {string} want
+ * @returns {boolean}
+ */
+ function matchesBuildType(xmlStr, want) {
+ var types = extractBuildTypes(xmlStr);
+ var k;
+ for (k = 0; k < types.length; k += 1) {
+ if (types[k] === want) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * @param {string} xmlStr
+ * @returns {string}
+ */
+ function extractDescription(xmlStr) {
+ if (typeof DOMParser !== 'undefined') {
+ try {
+ var doc = new DOMParser().parseFromString(xmlStr, 'application/xml');
+ var parseErr = doc.getElementsByTagName('parsererror');
+ if (!parseErr.length) {
+ var nodes = doc.getElementsByTagName('description');
+ if (nodes.length && nodes[0].textContent) {
+ return nodes[0].textContent.replace(/\s+/g, ' ').trim();
+ }
+ }
+ } catch (err) {
+ /* Fall through to regex extraction. */
+ }
+ }
+
+ var match = /]*>([\s\S]*?)<\/description>/i.exec(xmlStr);
+ if (!match) {
+ return '';
+ }
+ return match[1]
+ .replace(//g, '$1')
+ .replace(/<[^>]*>/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+ }
+
+ /**
+ * @param {string} distro
+ * @param {string} pkg
+ * @returns {string}
+ */
+ function docsPackageUrl(distro, pkg) {
+ return (
+ 'https://docs.ros.org/en/' +
+ encodeURIComponent(distro) +
+ '/p/' +
+ encodeURIComponent(pkg) +
+ '/'
+ );
+ }
+
+ /**
+ * @param {HTMLElement} el
+ * @param {Error} err
+ */
+ function showError(el, err) {
+ el.classList.remove('related-packages--loading');
+ el.classList.add('related-packages--error');
+ el.innerHTML =
+ '
Could not load package metadata. ' +
+ 'Rebuild the HTML documentation while online so the rosdistro cache is ' +
+ 'downloaded into _static/rosdistro_cache/, ' +
+ 'or check your network connection.
';
+ if (typeof console !== 'undefined' && console.warn) {
+ console.warn('related_packages:', err);
+ }
+ }
+
+ /**
+ * @param {HTMLElement} el
+ * @param {Record} xmls
+ */
+ function fillWidget(el, xmls) {
+ var want = el.getAttribute('data-build-type') || '';
+ var max = parseInt(el.getAttribute('data-max') || '10', 10);
+ var distro = el.getAttribute('data-distro') || 'rolling';
+
+ var names = Object.keys(xmls).filter(function (name) {
+ var xmlStr = xmls[name];
+ if (typeof xmlStr !== 'string') {
+ return false;
+ }
+ return matchesBuildType(xmlStr, want);
+ });
+ names.sort(function (a, b) {
+ return a.localeCompare(b);
+ });
+ var picked = names.slice(0, max);
+
+ var ul = document.createElement('ul');
+ ul.className = 'related-packages__list';
+ var j;
+ for (j = 0; j < picked.length; j += 1) {
+ var pkg = picked[j];
+ var li = document.createElement('li');
+ var a = document.createElement('a');
+ var description = extractDescription(xmls[pkg] || '');
+ a.href = docsPackageUrl(distro, pkg);
+ a.textContent = pkg;
+ a.rel = 'noopener noreferrer';
+ li.appendChild(a);
+ li.appendChild(document.createTextNode(': ' + description));
+ ul.appendChild(li);
+ }
+
+ el.innerHTML = '';
+ el.classList.remove('related-packages--loading');
+
+ if (picked.length === 0) {
+ var p = document.createElement('p');
+ p.className = 'related-packages__empty';
+ p.textContent = 'No packages matched this filter.';
+ el.appendChild(p);
+ } else {
+ var intro = document.createElement('p');
+ intro.className = 'related-packages__intro';
+ intro.textContent = 'Packages/reference: ';
+ el.appendChild(intro);
+ el.appendChild(ul);
+ }
+ }
+
+ function fillAll() {
+ var widgets = document.querySelectorAll('.js-related-packages');
+ if (!widgets.length) {
+ return;
+ }
+
+ /** @type {Record} */
+ var byDistro = {};
+ var idx;
+ for (idx = 0; idx < widgets.length; idx += 1) {
+ var el = widgets[idx];
+ var d = el.getAttribute('data-distro') || 'rolling';
+ if (!byDistro[d]) {
+ byDistro[d] = [];
+ }
+ byDistro[d].push(el);
+ }
+
+ var distroKeys = Object.keys(byDistro);
+ var di;
+ for (di = 0; di < distroKeys.length; di += 1) {
+ (function (distro) {
+ var group = byDistro[distro];
+ loadXmls(distro, group[0]).then(
+ function (xmls) {
+ var gi;
+ for (gi = 0; gi < group.length; gi += 1) {
+ fillWidget(group[gi], xmls);
+ }
+ },
+ function (err) {
+ var ei;
+ for (ei = 0; ei < group.length; ei += 1) {
+ showError(group[ei], err);
+ }
+ }
+ );
+ })(distroKeys[di]);
+ }
+ }
+
+ if (typeof document !== 'undefined') {
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', fillAll);
+ } else {
+ fillAll();
+ }
+ }
+})();
diff --git a/source/_static/vendor/js-yaml.min.js b/source/_static/vendor/js-yaml.min.js
new file mode 100644
index 00000000000..bdd8eef542b
--- /dev/null
+++ b/source/_static/vendor/js-yaml.min.js
@@ -0,0 +1,2 @@
+/*! js-yaml 4.1.0 https://github.com/nodeca/js-yaml @license MIT */
+!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).jsyaml={})}(this,(function(e){"use strict";function t(e){return null==e}var n={isNothing:t,isObject:function(e){return"object"==typeof e&&null!==e},toArray:function(e){return Array.isArray(e)?e:t(e)?[]:[e]},repeat:function(e,t){var n,i="";for(n=0;nl&&(t=i-l+(o=" ... ").length),n-i>l&&(n=i+l-(a=" ...").length),{str:o+e.slice(t,n).replace(/\t/g,"→")+a,pos:i-t+o.length}}function l(e,t){return n.repeat(" ",t-e.length)+e}var c=function(e,t){if(t=Object.create(t||null),!e.buffer)return null;t.maxLength||(t.maxLength=79),"number"!=typeof t.indent&&(t.indent=1),"number"!=typeof t.linesBefore&&(t.linesBefore=3),"number"!=typeof t.linesAfter&&(t.linesAfter=2);for(var i,r=/\r?\n|\r|\0/g,o=[0],c=[],s=-1;i=r.exec(e.buffer);)c.push(i.index),o.push(i.index+i[0].length),e.position<=i.index&&s<0&&(s=o.length-2);s<0&&(s=o.length-1);var u,p,f="",d=Math.min(e.line+t.linesAfter,c.length).toString().length,h=t.maxLength-(t.indent+d+3);for(u=1;u<=t.linesBefore&&!(s-u<0);u++)p=a(e.buffer,o[s-u],c[s-u],e.position-(o[s]-o[s-u]),h),f=n.repeat(" ",t.indent)+l((e.line-u+1).toString(),d)+" | "+p.str+"\n"+f;for(p=a(e.buffer,o[s],c[s],e.position,h),f+=n.repeat(" ",t.indent)+l((e.line+1).toString(),d)+" | "+p.str+"\n",f+=n.repeat("-",t.indent+d+3+p.pos)+"^\n",u=1;u<=t.linesAfter&&!(s+u>=c.length);u++)p=a(e.buffer,o[s+u],c[s+u],e.position-(o[s]-o[s+u]),h),f+=n.repeat(" ",t.indent)+l((e.line+u+1).toString(),d)+" | "+p.str+"\n";return f.replace(/\n$/,"")},s=["kind","multi","resolve","construct","instanceOf","predicate","represent","representName","defaultStyle","styleAliases"],u=["scalar","sequence","mapping"];var p=function(e,t){if(t=t||{},Object.keys(t).forEach((function(t){if(-1===s.indexOf(t))throw new o('Unknown option "'+t+'" is met in definition of "'+e+'" YAML type.')})),this.options=t,this.tag=e,this.kind=t.kind||null,this.resolve=t.resolve||function(){return!0},this.construct=t.construct||function(e){return e},this.instanceOf=t.instanceOf||null,this.predicate=t.predicate||null,this.represent=t.represent||null,this.representName=t.representName||null,this.defaultStyle=t.defaultStyle||null,this.multi=t.multi||!1,this.styleAliases=function(e){var t={};return null!==e&&Object.keys(e).forEach((function(n){e[n].forEach((function(e){t[String(e)]=n}))})),t}(t.styleAliases||null),-1===u.indexOf(this.kind))throw new o('Unknown kind "'+this.kind+'" is specified for "'+e+'" YAML type.')};function f(e,t){var n=[];return e[t].forEach((function(e){var t=n.length;n.forEach((function(n,i){n.tag===e.tag&&n.kind===e.kind&&n.multi===e.multi&&(t=i)})),n[t]=e})),n}function d(e){return this.extend(e)}d.prototype.extend=function(e){var t=[],n=[];if(e instanceof p)n.push(e);else if(Array.isArray(e))n=n.concat(e);else{if(!e||!Array.isArray(e.implicit)&&!Array.isArray(e.explicit))throw new o("Schema.extend argument should be a Type, [ Type ], or a schema definition ({ implicit: [...], explicit: [...] })");e.implicit&&(t=t.concat(e.implicit)),e.explicit&&(n=n.concat(e.explicit))}t.forEach((function(e){if(!(e instanceof p))throw new o("Specified list of YAML types (or a single Type object) contains a non-Type object.");if(e.loadKind&&"scalar"!==e.loadKind)throw new o("There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported.");if(e.multi)throw new o("There is a multi type in the implicit list of a schema. Multi tags can only be listed as explicit.")})),n.forEach((function(e){if(!(e instanceof p))throw new o("Specified list of YAML types (or a single Type object) contains a non-Type object.")}));var i=Object.create(d.prototype);return i.implicit=(this.implicit||[]).concat(t),i.explicit=(this.explicit||[]).concat(n),i.compiledImplicit=f(i,"implicit"),i.compiledExplicit=f(i,"explicit"),i.compiledTypeMap=function(){var e,t,n={scalar:{},sequence:{},mapping:{},fallback:{},multi:{scalar:[],sequence:[],mapping:[],fallback:[]}};function i(e){e.multi?(n.multi[e.kind].push(e),n.multi.fallback.push(e)):n[e.kind][e.tag]=n.fallback[e.tag]=e}for(e=0,t=arguments.length;e=0?"0b"+e.toString(2):"-0b"+e.toString(2).slice(1)},octal:function(e){return e>=0?"0o"+e.toString(8):"-0o"+e.toString(8).slice(1)},decimal:function(e){return e.toString(10)},hexadecimal:function(e){return e>=0?"0x"+e.toString(16).toUpperCase():"-0x"+e.toString(16).toUpperCase().slice(1)}},defaultStyle:"decimal",styleAliases:{binary:[2,"bin"],octal:[8,"oct"],decimal:[10,"dec"],hexadecimal:[16,"hex"]}}),x=new RegExp("^(?:[-+]?(?:[0-9][0-9_]*)(?:\\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9_]+(?:[eE][-+]?[0-9]+)?|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$");var I=/^[-+]?[0-9]+e/;var S=new p("tag:yaml.org,2002:float",{kind:"scalar",resolve:function(e){return null!==e&&!(!x.test(e)||"_"===e[e.length-1])},construct:function(e){var t,n;return n="-"===(t=e.replace(/_/g,"").toLowerCase())[0]?-1:1,"+-".indexOf(t[0])>=0&&(t=t.slice(1)),".inf"===t?1===n?Number.POSITIVE_INFINITY:Number.NEGATIVE_INFINITY:".nan"===t?NaN:n*parseFloat(t,10)},predicate:function(e){return"[object Number]"===Object.prototype.toString.call(e)&&(e%1!=0||n.isNegativeZero(e))},represent:function(e,t){var i;if(isNaN(e))switch(t){case"lowercase":return".nan";case"uppercase":return".NAN";case"camelcase":return".NaN"}else if(Number.POSITIVE_INFINITY===e)switch(t){case"lowercase":return".inf";case"uppercase":return".INF";case"camelcase":return".Inf"}else if(Number.NEGATIVE_INFINITY===e)switch(t){case"lowercase":return"-.inf";case"uppercase":return"-.INF";case"camelcase":return"-.Inf"}else if(n.isNegativeZero(e))return"-0.0";return i=e.toString(10),I.test(i)?i.replace("e",".e"):i},defaultStyle:"lowercase"}),O=b.extend({implicit:[A,v,C,S]}),j=O,T=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$"),N=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$");var F=new p("tag:yaml.org,2002:timestamp",{kind:"scalar",resolve:function(e){return null!==e&&(null!==T.exec(e)||null!==N.exec(e))},construct:function(e){var t,n,i,r,o,a,l,c,s=0,u=null;if(null===(t=T.exec(e))&&(t=N.exec(e)),null===t)throw new Error("Date resolve error");if(n=+t[1],i=+t[2]-1,r=+t[3],!t[4])return new Date(Date.UTC(n,i,r));if(o=+t[4],a=+t[5],l=+t[6],t[7]){for(s=t[7].slice(0,3);s.length<3;)s+="0";s=+s}return t[9]&&(u=6e4*(60*+t[10]+ +(t[11]||0)),"-"===t[9]&&(u=-u)),c=new Date(Date.UTC(n,i,r,o,a,l,s)),u&&c.setTime(c.getTime()-u),c},instanceOf:Date,represent:function(e){return e.toISOString()}});var E=new p("tag:yaml.org,2002:merge",{kind:"scalar",resolve:function(e){return"<<"===e||null===e}}),M="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\n\r";var L=new p("tag:yaml.org,2002:binary",{kind:"scalar",resolve:function(e){if(null===e)return!1;var t,n,i=0,r=e.length,o=M;for(n=0;n64)){if(t<0)return!1;i+=6}return i%8==0},construct:function(e){var t,n,i=e.replace(/[\r\n=]/g,""),r=i.length,o=M,a=0,l=[];for(t=0;t>16&255),l.push(a>>8&255),l.push(255&a)),a=a<<6|o.indexOf(i.charAt(t));return 0===(n=r%4*6)?(l.push(a>>16&255),l.push(a>>8&255),l.push(255&a)):18===n?(l.push(a>>10&255),l.push(a>>2&255)):12===n&&l.push(a>>4&255),new Uint8Array(l)},predicate:function(e){return"[object Uint8Array]"===Object.prototype.toString.call(e)},represent:function(e){var t,n,i="",r=0,o=e.length,a=M;for(t=0;t>18&63],i+=a[r>>12&63],i+=a[r>>6&63],i+=a[63&r]),r=(r<<8)+e[t];return 0===(n=o%3)?(i+=a[r>>18&63],i+=a[r>>12&63],i+=a[r>>6&63],i+=a[63&r]):2===n?(i+=a[r>>10&63],i+=a[r>>4&63],i+=a[r<<2&63],i+=a[64]):1===n&&(i+=a[r>>2&63],i+=a[r<<4&63],i+=a[64],i+=a[64]),i}}),_=Object.prototype.hasOwnProperty,D=Object.prototype.toString;var U=new p("tag:yaml.org,2002:omap",{kind:"sequence",resolve:function(e){if(null===e)return!0;var t,n,i,r,o,a=[],l=e;for(t=0,n=l.length;t>10),56320+(e-65536&1023))}for(var ie=new Array(256),re=new Array(256),oe=0;oe<256;oe++)ie[oe]=te(oe)?1:0,re[oe]=te(oe);function ae(e,t){this.input=e,this.filename=t.filename||null,this.schema=t.schema||K,this.onWarning=t.onWarning||null,this.legacy=t.legacy||!1,this.json=t.json||!1,this.listener=t.listener||null,this.implicitTypes=this.schema.compiledImplicit,this.typeMap=this.schema.compiledTypeMap,this.length=e.length,this.position=0,this.line=0,this.lineStart=0,this.lineIndent=0,this.firstTabInLine=-1,this.documents=[]}function le(e,t){var n={name:e.filename,buffer:e.input.slice(0,-1),position:e.position,line:e.line,column:e.position-e.lineStart};return n.snippet=c(n),new o(t,n)}function ce(e,t){throw le(e,t)}function se(e,t){e.onWarning&&e.onWarning.call(null,le(e,t))}var ue={YAML:function(e,t,n){var i,r,o;null!==e.version&&ce(e,"duplication of %YAML directive"),1!==n.length&&ce(e,"YAML directive accepts exactly one argument"),null===(i=/^([0-9]+)\.([0-9]+)$/.exec(n[0]))&&ce(e,"ill-formed argument of the YAML directive"),r=parseInt(i[1],10),o=parseInt(i[2],10),1!==r&&ce(e,"unacceptable YAML version of the document"),e.version=n[0],e.checkLineBreaks=o<2,1!==o&&2!==o&&se(e,"unsupported YAML version of the document")},TAG:function(e,t,n){var i,r;2!==n.length&&ce(e,"TAG directive accepts exactly two arguments"),i=n[0],r=n[1],G.test(i)||ce(e,"ill-formed tag handle (first argument) of the TAG directive"),P.call(e.tagMap,i)&&ce(e,'there is a previously declared suffix for "'+i+'" tag handle'),V.test(r)||ce(e,"ill-formed tag prefix (second argument) of the TAG directive");try{r=decodeURIComponent(r)}catch(t){ce(e,"tag prefix is malformed: "+r)}e.tagMap[i]=r}};function pe(e,t,n,i){var r,o,a,l;if(t1&&(e.result+=n.repeat("\n",t-1))}function be(e,t){var n,i,r=e.tag,o=e.anchor,a=[],l=!1;if(-1!==e.firstTabInLine)return!1;for(null!==e.anchor&&(e.anchorMap[e.anchor]=a),i=e.input.charCodeAt(e.position);0!==i&&(-1!==e.firstTabInLine&&(e.position=e.firstTabInLine,ce(e,"tab characters must not be used in indentation")),45===i)&&z(e.input.charCodeAt(e.position+1));)if(l=!0,e.position++,ge(e,!0,-1)&&e.lineIndent<=t)a.push(null),i=e.input.charCodeAt(e.position);else if(n=e.line,we(e,t,3,!1,!0),a.push(e.result),ge(e,!0,-1),i=e.input.charCodeAt(e.position),(e.line===n||e.lineIndent>t)&&0!==i)ce(e,"bad indentation of a sequence entry");else if(e.lineIndentt?g=1:e.lineIndent===t?g=0:e.lineIndentt?g=1:e.lineIndent===t?g=0:e.lineIndentt)&&(y&&(a=e.line,l=e.lineStart,c=e.position),we(e,t,4,!0,r)&&(y?g=e.result:m=e.result),y||(de(e,f,d,h,g,m,a,l,c),h=g=m=null),ge(e,!0,-1),s=e.input.charCodeAt(e.position)),(e.line===o||e.lineIndent>t)&&0!==s)ce(e,"bad indentation of a mapping entry");else if(e.lineIndent=0))break;0===o?ce(e,"bad explicit indentation width of a block scalar; it cannot be less than one"):u?ce(e,"repeat of an indentation width identifier"):(p=t+o-1,u=!0)}if(Q(a)){do{a=e.input.charCodeAt(++e.position)}while(Q(a));if(35===a)do{a=e.input.charCodeAt(++e.position)}while(!J(a)&&0!==a)}for(;0!==a;){for(he(e),e.lineIndent=0,a=e.input.charCodeAt(e.position);(!u||e.lineIndent