diff --git a/conf.py b/conf.py index 68eca7146f7..e16ca0f3852 100644 --- a/conf.py +++ b/conf.py @@ -86,6 +86,7 @@ 'sphinx_tabs.tabs', 'sphinx_rtd_theme', 'sphinx_sitemap_ros', + 'sphinx_adopters', 'sphinxcontrib.googleanalytics', 'sphinxcontrib.mermaid', ] @@ -177,7 +178,8 @@ html_sourcelink_suffix = '' # Relative to html_static_path -html_css_files = ['custom.css'] +html_css_files = ['custom.css', 'adopters.css'] +html_js_files = ['adopters.js'] # -- Options for HTMLHelp output ------------------------------------------ diff --git a/plugins/adopters_schema.py b/plugins/adopters_schema.py new file mode 100644 index 00000000000..d402a30e7db --- /dev/null +++ b/plugins/adopters_schema.py @@ -0,0 +1,102 @@ +# Copyright 2026 Sony Group Corporation. +# +# 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. + +""" +Shared schema constants and validation for the ROS 2 adopters YAML file. +This module has NO Sphinx dependency so it can be used in tests and CI scripts. + +NOTE: The domain constants defined here must be kept in sync with +the corresponding values in source/_static/adopters.js (VALID_DOMAINS). +Any additions or changes must be applied to both files. +""" + +import re + +VALID_DOMAINS = [ + 'Agriculture', # Farming, harvesting, crop monitoring, and precision agriculture + 'Aerial/Drone', # UAVs, drones, aerial inspection, and survey systems + 'Automotive', # Self-driving cars, ADAS, and ground vehicle autonomy + 'Components', # Robot parts and peripherals (cameras, LIDAR, RADAR, SONAR, etc) + 'Construction', # Site inspection, surveying, and construction automation + 'Consumer Robot', # Home robots, entertainment robots, and personal companions + 'Defense/Government', # Military, public safety, and national research programs + 'Education', # University courses, student projects, and teaching platforms + 'Energy', # Oil, gas, solar, nuclear, and power infrastructure operations + 'Healthcare/Medical', # Surgical robots, rehabilitation systems, and medical diagnostics + 'Humanoid', # Bipedal and human-form robots + 'Logistics/Warehouse', # AMRs, inventory management, and last-mile delivery systems + 'Manufacturing', # Industrial automation, assembly lines, and quality control + 'Marine', # Underwater, surface, and coastal robotic systems + 'Research', # General academic, laboratory, or experimental research + 'Space', # Planetary rovers, orbital systems, and space exploration + 'Service Robot', # Hospitality, cleaning, retail, and public-facing service robots +] + +# YYYY-MM-DD format for date_added field. +_DATE_ADDED_RE = re.compile(r'^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$') + +REQUIRED_FIELDS = [ + 'organization', + 'project', + 'domain', + 'date_added', + 'country', + 'description' +] + + +def validate_adopters(adopters): + """Validate a list of adopter entries. Returns list of error strings.""" + errors = [] + if not isinstance(adopters, list): + return ["'adopters' must be a list"] + for i, entry in enumerate(adopters): + prefix = ( + f'Entry {i + 1} ' + f'({entry.get("organization", "unknown")}/{entry.get("project", "unknown")})' + ) + if not isinstance(entry, dict): + errors.append(f'{prefix}: must be a mapping') + continue + for field in REQUIRED_FIELDS: + if field not in entry or not entry[field]: + errors.append(f'{prefix}: missing required field "{field}"') + if 'domain' in entry and entry['domain']: + if not isinstance(entry['domain'], list): + errors.append(f'{prefix}: "domain" must be a list') + else: + for d in entry['domain']: + if d not in VALID_DOMAINS: + errors.append( + f'{prefix}: invalid domain "{d}". ' + f'Must be one of: {", ".join(VALID_DOMAINS)}' + ) + if 'date_added' in entry and entry['date_added']: + date_str = str(entry['date_added']) + if not _DATE_ADDED_RE.match(date_str): + errors.append( + f'{prefix}: "date_added" must be in YYYY-MM-DD format, ' + f'got "{date_str}"' + ) + if 'country' in entry and entry['country']: + if not isinstance(entry['country'], list): + errors.append(f'{prefix}: "country" must be a list') + else: + for code in entry['country']: + if not isinstance(code, str) or len(code) != 2 or not code.isalpha(): + errors.append( + f'{prefix}: each "country" entry must be a 2-letter ' + f'ISO 3166-1 alpha-2 code, got "{code}"' + ) + return errors diff --git a/plugins/sphinx_adopters.py b/plugins/sphinx_adopters.py new file mode 100644 index 00000000000..d5cff6daaa0 --- /dev/null +++ b/plugins/sphinx_adopters.py @@ -0,0 +1,199 @@ +# Copyright 2026 Sony Group Corporation. +# +# 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 extension to render and validate the ROS 2 adopters YAML file. """ + +import os + +import yaml +from docutils import nodes +from docutils.parsers.rst import Directive +from sphinx.errors import ExtensionError + +from adopters_schema import validate_adopters + + +def _escape(text): + """Escape HTML special characters.""" + if text is None: + return '' + return ( + str(text) + .replace('&', '&') + .replace('<', '<') + .replace('>', '>') + .replace('"', '"') + ) + + +def _make_link(text, url): + """Create an HTML link if url is provided, otherwise plain text.""" + escaped_text = _escape(text) + if url: + escaped_url = _escape(url) + return f'{escaped_text}' + return escaped_text + + +class AdoptersTableDirective(Directive): + """Directive to render the adopters YAML as a filterable HTML table.""" + + has_content = False + required_arguments = 0 + optional_arguments = 0 + option_spec = {} + + def run(self): + env = self.state.document.settings.env + # Locate adopters.yaml relative to the source file containing the directive. + source_dir = os.path.dirname(env.doc2path(env.docname)) + yaml_path = os.path.join(source_dir, 'adopters.yaml') + + if not os.path.isfile(yaml_path): + raise ExtensionError( + f'adopters.yaml not found at {yaml_path}' + ) + + # Register adopters.yaml as a dependency so incremental builds + # detect changes and re-read the directive. + env.note_dependency(yaml_path) + + with open(yaml_path, 'r') as f: + data = yaml.safe_load(f) + + adopters = data.get('adopters', []) + errors = validate_adopters(adopters) + if errors: + raise ExtensionError( + 'Adopters YAML validation failed:\n' + '\n'.join(f' - {e}' for e in errors) + ) + + # Sort by date_added descending (newest first), then organization name (A-Z). + adopters.sort(key=lambda a: a.get('organization', '').lower()) + adopters.sort(key=lambda a: a.get('date_added', ''), reverse=True) + + # Collect unique values for filters. + all_domains = sorted({d for a in adopters for d in a.get('domain', [])}) + all_countries = sorted({c for a in adopters for c in a.get('country', [])}) + + # Build HTML. + html_parts = [] + + # Filter controls. + html_parts.append('
') + html_parts.append('') + html_parts.append('') + + html_parts.append('') + html_parts.append('') + + html_parts.append('') + html_parts.append( + '' + ) + + # Show-all-history toggle. + html_parts.append( + '' + '' + 'Showing entries from the past 3 years. ' + 'Check “Show all history” to see all entries.' + '' + ) + + html_parts.append('
') + + # Table. + html_parts.append('') + html_parts.append('') + html_parts.append('') + html_parts.append('') + html_parts.append('') + html_parts.append('') + html_parts.append('') + html_parts.append('') + html_parts.append('') + html_parts.append('') + + for adopter in adopters: + org = _make_link( + adopter.get('organization', ''), + adopter.get('organization_url'), + ) + project = _make_link( + adopter.get('project', ''), + adopter.get('project_url'), + ) + domains = ', '.join(adopter.get('domain', [])) + date_added = adopter.get('date_added', '') + countries = adopter.get('country', []) + country_str = ', '.join(countries) + description = _escape(adopter.get('description', '')) + + # Data attributes for filtering. + data_domains = ' '.join(_escape(d) for d in adopter.get('domain', [])) + data_countries = ' '.join(_escape(c) for c in countries) + html_parts.append( + f'' + ) + html_parts.append(f'') + html_parts.append(f'') + html_parts.append(f'') + html_parts.append(f'') + html_parts.append(f'') + html_parts.append(f'') + html_parts.append('') + + html_parts.append('
OrganizationProjectDomainDate AddedCountryDescription
{org}{project}{_escape(domains)}{_escape(str(date_added))}{_escape(country_str)}{description}
') + + raw_node = nodes.raw('', '\n'.join(html_parts), format='html') + return [raw_node] + + +_ADOPTERS_DOCNAME = 'The-ROS2-Project/Adopters/Adopters' + + +def _get_outdated(app, env, _added, _changed, _removed): + """Force rebuild of the Adopters page when adopters.yaml changes.""" + yaml_path = os.path.join(app.srcdir, 'The-ROS2-Project', 'Adopters', 'adopters.yaml') + if not os.path.isfile(yaml_path): + return [] + doctree_path = os.path.join(app.doctreedir, _ADOPTERS_DOCNAME + '.doctree') + if os.path.isfile(doctree_path): + if os.path.getmtime(yaml_path) > os.path.getmtime(doctree_path): + return [_ADOPTERS_DOCNAME] + return [] + + +def setup(app): + app.add_directive('adopters-table', AdoptersTableDirective) + app.connect('env-get-outdated', _get_outdated) + return { + 'version': '0.1', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } diff --git a/source/The-ROS2-Project.rst b/source/The-ROS2-Project.rst index 8f4b8ef66df..5009041fe61 100644 --- a/source/The-ROS2-Project.rst +++ b/source/The-ROS2-Project.rst @@ -17,3 +17,5 @@ Check out the resources below to learn more about the advancement of the ROS 2 p The-ROS2-Project/Release-Schedule The-ROS2-Project/Marketing The-ROS2-Project/Metrics + The-ROS2-Project/Adopters/Adopters + The-ROS2-Project/Adopters/Add-Your-Project diff --git a/source/The-ROS2-Project/Adopters/Add-Your-Project.rst b/source/The-ROS2-Project/Adopters/Add-Your-Project.rst new file mode 100644 index 00000000000..f046f5a5309 --- /dev/null +++ b/source/The-ROS2-Project/Adopters/Add-Your-Project.rst @@ -0,0 +1,119 @@ +Add Your Project +================ + +Use the form below to generate the YAML entry for your organization or project. +Once generated, you can copy the YAML snippet and submit a pull request to the +`adopters.yaml `__ +file on the ``rolling`` branch. + +Policy +------ + +This list is **self-reported and self-attested**. +Entries are accepted with minimal scrutiny unless a complaint is received. +Since contributions come via Pull Request, they are easy to audit and can be cleaned up later if necessary. + +How to contribute +----------------- + +1. Fill in the form below. +2. Click **Generate YAML** to produce the snippet. +3. Click **Open PR on GitHub** to open the file in GitHub's web editor (the YAML is copied to your clipboard automatically). +4. Paste the generated YAML at the end of the ``adopters:`` list in the file. +5. Commit the change and open a pull request. + +.. note:: + + All pull requests to the ROS 2 documentation repository require a + `Developer Certificate of Origin (DCO) `__ sign-off. + If you use the GitHub web editor, the + `DCO bot `__ will comment on your PR with + instructions to add the sign-off if it is missing. + To sign off via the command line, use ``git commit --signoff``. + +.. raw:: html + +
+
+ +
+ + Company or institution name + +
+ +
+ + Optional + +
+ +
+ + The specific project using ROS + +
+ +
+ + Optional + +
+ +
+ +
+ + + + + + + + + + + + + + + + + +
+
+ +
+ + Auto-generated (YYYY-MM-DD) + +
+ +
+ + Select one or more countries +
+ + +
+
+
+ +
+ + Brief explanation of how you use ROS + +
+ + + + + + + + + +
+
diff --git a/source/The-ROS2-Project/Adopters/Adopters.rst b/source/The-ROS2-Project/Adopters/Adopters.rst new file mode 100644 index 00000000000..a90273018d1 --- /dev/null +++ b/source/The-ROS2-Project/Adopters/Adopters.rst @@ -0,0 +1,10 @@ +ROS 2 Adopters +============== + +This page showcases organizations and projects using ROS in any capacity. +It is a community-maintained, self-reported directory. +If you use ROS, we encourage you to add your project. + +To add your organization or project, see :doc:`Add-Your-Project`. + +.. adopters-table:: diff --git a/source/The-ROS2-Project/Adopters/adopters.yaml b/source/The-ROS2-Project/Adopters/adopters.yaml new file mode 100644 index 00000000000..f15044ab026 --- /dev/null +++ b/source/The-ROS2-Project/Adopters/adopters.yaml @@ -0,0 +1,67 @@ +# ROS 2 Adopters +# +# This file is the source of truth for the ROS 2 Adopters page. +# To add your organization/project, submit a PR to the rolling branch. +# Use the YAML generator at https://docs.ros.org/en/rolling/The-ROS2-Project/Adopters/Add-Your-Project.html +# +# Schema: +# organization (required): Company or institution name +# organization_url (optional): URL of the organization +# project (required): The specific project using ROS +# project_url (optional): URL of the project +# domain (required): List of industry categories from the enumerated values +# date_added (required): Date the entry was added, in YYYY-MM-DD format (auto-generated) +# country (required): List of ISO 3166-1 alpha-2 country codes +# description (required): Brief explanation of how they use ROS +# +# Domain values: +# Agriculture, Aerial/Drone, Automotive, Components, Construction, Consumer Robot, +# Defense/Government, Education, Energy, Healthcare/Medical, Humanoid, +# Logistics/Warehouse, Manufacturing, Marine, Research, Space, Service Robot + +adopters: + - organization: "Sony Group Corporation" + organization_url: "https://www.sony.com" + project: "aibo" + project_url: "https://aibo.sony.jp" + domain: + - Consumer Robot + date_added: "2026-03-25" + country: + - JP + description: "An autonomous robotic companion dog that develops its own unique personality." + + - organization: "Sony Group Corporation" + organization_url: "https://www.sony.com" + project: "poiq" + project_url: "https://www.sony.com/en/brand/futureproof/project/poiq/" + domain: + - Consumer Robot + date_added: "2026-03-25" + country: + - JP + description: "Small AI entertainment robot with conversational capabilities." + + - organization: "Sony Group Corporation" + organization_url: "https://www.sony.com" + project: "Airpeak S1" + project_url: "https://www.sony.com/en/articles/product-specifications-airpeak-s1" + domain: + - Aerial/Drone + date_added: "2026-03-25" + country: + - JP + description: "Professional drone carrying full-frame mirrorless cameras." + + - organization: "Sony Semiconductor Solutions" + organization_url: "https://www.sony-semicon.com" + project: "AITRIOS Robotics Package" + project_url: "https://www.aitrios.sony-semicon.com/robotics" + domain: + - Logistics/Warehouse + - Manufacturing + - Service Robot + date_added: "2026-03-25" + country: + - JP + description: "Development platform for autonomous mobile robots across logistics, construction, and retail." diff --git a/source/_static/adopters.css b/source/_static/adopters.css new file mode 100644 index 00000000000..d67975955e3 --- /dev/null +++ b/source/_static/adopters.css @@ -0,0 +1,231 @@ +/* ROS 2 Adopters page styles */ + +/* ===== Filter controls ===== */ + +.adopters-filters { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin-bottom: 1.5rem; + padding: 1rem; + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 4px; +} + +.adopters-filters label { + font-weight: bold; + margin-right: 0.25rem; +} + +.adopters-filters select, +.adopters-filters input[type="text"] { + padding: 0.35rem 0.5rem; + border: 1px solid #ced4da; + border-radius: 3px; + font-size: 0.9rem; +} + +.adopters-filters input[type="text"] { + min-width: 180px; +} + +.adopters-toggle-label { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-weight: bold; + cursor: pointer; + margin-left: auto; +} + +.adopters-filter-note { + width: 100%; + font-size: 0.85rem; + color: #6c757d; + font-style: italic; +} + +/* ===== Adopters table ===== */ + +.adopters-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 2rem; +} + +.adopters-table th, +.adopters-table td { + padding: 0.6rem 0.8rem; + border: 1px solid #dee2e6; + text-align: left; + vertical-align: top; +} + +.adopters-table thead th { + background: #2980b9; + color: #fff; + font-weight: bold; + position: sticky; + top: 0; +} + +.adopters-table tbody tr:nth-child(even) { + background: #f8f9fa; +} + +.adopters-table tbody tr:hover { + background: #e9ecef; +} + +/* ===== YAML Generator Form ===== */ + +.adopters-form-container { + max-width: 700px; +} + +.adopters-form-container .form-group { + margin-bottom: 1rem; +} + +.adopters-form-container label { + display: block; + font-weight: bold; + margin-bottom: 0.3rem; +} + +.adopters-form-container .form-hint { + display: block; + font-size: 0.85rem; + color: #6c757d; + margin-bottom: 0.3rem; +} + +.adopters-form-container input[type="text"], +.adopters-form-container input[type="url"], +.adopters-form-container select, +.adopters-form-container textarea { + width: 100%; + padding: 0.5rem; + border: 1px solid #ced4da; + border-radius: 3px; + font-size: 0.95rem; + box-sizing: border-box; +} + +.adopters-form-container textarea { + min-height: 80px; + resize: vertical; +} + +.adopters-form-container .domain-checkboxes { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; +} + +.adopters-form-container .domain-checkboxes label { + display: inline-flex; + align-items: center; + font-weight: normal; + gap: 0.3rem; +} + +.adopters-form-container .btn { + display: inline-block; + padding: 0.6rem 1.2rem; + font-size: 0.95rem; + font-weight: bold; + border: none; + border-radius: 4px; + cursor: pointer; + margin-right: 0.5rem; + margin-top: 0.5rem; +} + +.adopters-form-container .btn-primary { + background: #2980b9; + color: #fff; +} + +.adopters-form-container .btn-primary:hover { + background: #2471a3; +} + +.adopters-form-container .btn-secondary { + background: #6c757d; + color: #fff; +} + +.adopters-form-container .btn-secondary:hover { + background: #5a6268; +} + +.adopters-form-container .btn-success { + background: #27ae60; + color: #fff; +} + +.adopters-form-container .btn-success:hover { + background: #229954; +} + +.adopters-country-tags { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.4rem; +} + +.adopters-country-tag { + display: inline-flex; + align-items: center; + background: #e9ecef; + border: 1px solid #ced4da; + border-radius: 3px; + padding: 0.2rem 0.5rem; + font-size: 0.85rem; +} + +.adopters-country-tag-remove { + background: none; + border: none; + color: #721c24; + font-size: 1rem; + cursor: pointer; + padding: 0 0.2rem; + margin-left: 0.2rem; + line-height: 1; +} + +.adopters-country-tag-remove:hover { + color: #c0392b; +} + +#adopters-form-errors { + background: #f8d7da; + border: 1px solid #f5c6cb; + color: #721c24; + padding: 0.75rem 1rem; + border-radius: 4px; + margin-bottom: 1rem; +} + +#adopters-form-errors ul { + margin: 0; + padding-left: 1.2rem; +} + +#adopters-yaml-output { + background: #2d2d2d; + color: #f8f8f2; + padding: 1rem; + border-radius: 4px; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 0.9rem; + white-space: pre; + overflow-x: auto; + margin-top: 1rem; + margin-bottom: 0.5rem; +} diff --git a/source/_static/adopters.js b/source/_static/adopters.js new file mode 100644 index 00000000000..18850ff8cab --- /dev/null +++ b/source/_static/adopters.js @@ -0,0 +1,358 @@ +/** + * ROS 2 Adopters - Table filtering and YAML generator. + * + * Copyright 2026 Sony Group Corporation. + * Licensed under the Apache License, Version 2.0. + * + * NOTE: The VALID_DOMAINS array below must be kept in sync + * with the corresponding constants in plugins/adopters_schema.py. + * Any additions or changes must be applied to both files. + */ + +/* ===== ISO 3166-1 alpha-2 country codes ===== */ + +var ISO_COUNTRIES = [ + {code:'AD',name:'Andorra'},{code:'AE',name:'United Arab Emirates'}, + {code:'AF',name:'Afghanistan'},{code:'AG',name:'Antigua and Barbuda'}, + {code:'AL',name:'Albania'},{code:'AM',name:'Armenia'}, + {code:'AO',name:'Angola'},{code:'AR',name:'Argentina'}, + {code:'AT',name:'Austria'},{code:'AU',name:'Australia'}, + {code:'AZ',name:'Azerbaijan'},{code:'BA',name:'Bosnia and Herzegovina'}, + {code:'BB',name:'Barbados'},{code:'BD',name:'Bangladesh'}, + {code:'BE',name:'Belgium'},{code:'BF',name:'Burkina Faso'}, + {code:'BG',name:'Bulgaria'},{code:'BH',name:'Bahrain'}, + {code:'BI',name:'Burundi'},{code:'BJ',name:'Benin'}, + {code:'BN',name:'Brunei'},{code:'BO',name:'Bolivia'}, + {code:'BR',name:'Brazil'},{code:'BS',name:'Bahamas'}, + {code:'BT',name:'Bhutan'},{code:'BW',name:'Botswana'}, + {code:'BY',name:'Belarus'},{code:'BZ',name:'Belize'}, + {code:'CA',name:'Canada'},{code:'CD',name:'Congo (Democratic Republic)'}, + {code:'CF',name:'Central African Republic'},{code:'CG',name:'Congo'}, + {code:'CH',name:'Switzerland'},{code:'CI',name:"Cote d'Ivoire"}, + {code:'CL',name:'Chile'},{code:'CM',name:'Cameroon'}, + {code:'CN',name:'China'},{code:'CO',name:'Colombia'}, + {code:'CR',name:'Costa Rica'},{code:'CU',name:'Cuba'}, + {code:'CV',name:'Cape Verde'},{code:'CY',name:'Cyprus'}, + {code:'CZ',name:'Czechia'},{code:'DE',name:'Germany'}, + {code:'DJ',name:'Djibouti'},{code:'DK',name:'Denmark'}, + {code:'DM',name:'Dominica'},{code:'DO',name:'Dominican Republic'}, + {code:'DZ',name:'Algeria'},{code:'EC',name:'Ecuador'}, + {code:'EE',name:'Estonia'},{code:'EG',name:'Egypt'}, + {code:'ER',name:'Eritrea'},{code:'ES',name:'Spain'}, + {code:'ET',name:'Ethiopia'},{code:'FI',name:'Finland'}, + {code:'FJ',name:'Fiji'},{code:'FR',name:'France'}, + {code:'GA',name:'Gabon'},{code:'GB',name:'United Kingdom'}, + {code:'GD',name:'Grenada'},{code:'GE',name:'Georgia'}, + {code:'GH',name:'Ghana'},{code:'GM',name:'Gambia'}, + {code:'GN',name:'Guinea'},{code:'GQ',name:'Equatorial Guinea'}, + {code:'GR',name:'Greece'},{code:'GT',name:'Guatemala'}, + {code:'GW',name:'Guinea-Bissau'},{code:'GY',name:'Guyana'}, + {code:'HK',name:'Hong Kong'},{code:'HN',name:'Honduras'}, + {code:'HR',name:'Croatia'},{code:'HT',name:'Haiti'}, + {code:'HU',name:'Hungary'},{code:'ID',name:'Indonesia'}, + {code:'IE',name:'Ireland'},{code:'IL',name:'Israel'}, + {code:'IN',name:'India'},{code:'IQ',name:'Iraq'}, + {code:'IR',name:'Iran'},{code:'IS',name:'Iceland'}, + {code:'IT',name:'Italy'},{code:'JM',name:'Jamaica'}, + {code:'JO',name:'Jordan'},{code:'JP',name:'Japan'}, + {code:'KE',name:'Kenya'},{code:'KG',name:'Kyrgyzstan'}, + {code:'KH',name:'Cambodia'},{code:'KI',name:'Kiribati'}, + {code:'KM',name:'Comoros'},{code:'KN',name:'Saint Kitts and Nevis'}, + {code:'KP',name:'North Korea'},{code:'KR',name:'South Korea'}, + {code:'KW',name:'Kuwait'},{code:'KZ',name:'Kazakhstan'}, + {code:'LA',name:'Laos'},{code:'LB',name:'Lebanon'}, + {code:'LC',name:'Saint Lucia'},{code:'LI',name:'Liechtenstein'}, + {code:'LK',name:'Sri Lanka'},{code:'LR',name:'Liberia'}, + {code:'LS',name:'Lesotho'},{code:'LT',name:'Lithuania'}, + {code:'LU',name:'Luxembourg'},{code:'LV',name:'Latvia'}, + {code:'LY',name:'Libya'},{code:'MA',name:'Morocco'}, + {code:'MC',name:'Monaco'},{code:'MD',name:'Moldova'}, + {code:'ME',name:'Montenegro'},{code:'MG',name:'Madagascar'}, + {code:'MK',name:'North Macedonia'},{code:'ML',name:'Mali'}, + {code:'MM',name:'Myanmar'},{code:'MN',name:'Mongolia'}, + {code:'MR',name:'Mauritania'},{code:'MT',name:'Malta'}, + {code:'MU',name:'Mauritius'},{code:'MV',name:'Maldives'}, + {code:'MW',name:'Malawi'},{code:'MX',name:'Mexico'}, + {code:'MY',name:'Malaysia'},{code:'MZ',name:'Mozambique'}, + {code:'NA',name:'Namibia'},{code:'NE',name:'Niger'}, + {code:'NG',name:'Nigeria'},{code:'NI',name:'Nicaragua'}, + {code:'NL',name:'Netherlands'},{code:'NO',name:'Norway'}, + {code:'NP',name:'Nepal'},{code:'NR',name:'Nauru'}, + {code:'NZ',name:'New Zealand'},{code:'OM',name:'Oman'}, + {code:'PA',name:'Panama'},{code:'PE',name:'Peru'}, + {code:'PG',name:'Papua New Guinea'},{code:'PH',name:'Philippines'}, + {code:'PK',name:'Pakistan'},{code:'PL',name:'Poland'}, + {code:'PR',name:'Puerto Rico'},{code:'PS',name:'Palestine'}, + {code:'PT',name:'Portugal'},{code:'PW',name:'Palau'}, + {code:'PY',name:'Paraguay'},{code:'QA',name:'Qatar'}, + {code:'RO',name:'Romania'},{code:'RS',name:'Serbia'}, + {code:'RU',name:'Russia'},{code:'RW',name:'Rwanda'}, + {code:'SA',name:'Saudi Arabia'},{code:'SB',name:'Solomon Islands'}, + {code:'SC',name:'Seychelles'},{code:'SD',name:'Sudan'}, + {code:'SE',name:'Sweden'},{code:'SG',name:'Singapore'}, + {code:'SI',name:'Slovenia'},{code:'SK',name:'Slovakia'}, + {code:'SL',name:'Sierra Leone'},{code:'SM',name:'San Marino'}, + {code:'SN',name:'Senegal'},{code:'SO',name:'Somalia'}, + {code:'SR',name:'Suriname'},{code:'SS',name:'South Sudan'}, + {code:'SV',name:'El Salvador'},{code:'SY',name:'Syria'}, + {code:'SZ',name:'Eswatini'},{code:'TD',name:'Chad'}, + {code:'TG',name:'Togo'},{code:'TH',name:'Thailand'}, + {code:'TJ',name:'Tajikistan'},{code:'TL',name:'Timor-Leste'}, + {code:'TM',name:'Turkmenistan'},{code:'TN',name:'Tunisia'}, + {code:'TO',name:'Tonga'},{code:'TR',name:'Turkey'}, + {code:'TT',name:'Trinidad and Tobago'},{code:'TV',name:'Tuvalu'}, + {code:'TW',name:'Taiwan'},{code:'TZ',name:'Tanzania'}, + {code:'UA',name:'Ukraine'},{code:'UG',name:'Uganda'}, + {code:'US',name:'United States'},{code:'UY',name:'Uruguay'}, + {code:'UZ',name:'Uzbekistan'},{code:'VC',name:'Saint Vincent and the Grenadines'}, + {code:'VE',name:'Venezuela'},{code:'VN',name:'Vietnam'}, + {code:'VU',name:'Vanuatu'},{code:'WS',name:'Samoa'}, + {code:'YE',name:'Yemen'},{code:'ZA',name:'South Africa'}, + {code:'ZM',name:'Zambia'},{code:'ZW',name:'Zimbabwe'} +]; + +/* ===== Table filtering ===== */ + +function initAdoptersTableFilters() { + var filterDomain = document.getElementById('adopters-filter-domain'); + var filterCountry = document.getElementById('adopters-filter-country'); + var filterSearch = document.getElementById('adopters-filter-search'); + var showAllToggle = document.getElementById('adopters-show-all'); + + if (!filterDomain) return; // Not on the adopters table page. + + // Compute the cutoff date (3 years ago) as YYYY-MM-DD string. + var now = new Date(); + var cutoffYear = now.getFullYear() - 3; + var cutoffMonth = String(now.getMonth() + 1).padStart(2, '0'); + var cutoffDay = String(now.getDate()).padStart(2, '0'); + var cutoffDate = cutoffYear + '-' + cutoffMonth + '-' + cutoffDay; + + function applyFilters() { + var domain = filterDomain.value; + var country = filterCountry.value; + var search = filterSearch.value.toLowerCase(); + var showAll = showAllToggle.checked; + + var rows = document.querySelectorAll('.adopters-table tbody tr'); + rows.forEach(function(row) { + var domainMatch = !domain || row.getAttribute('data-domains').indexOf(domain) !== -1; + var countryMatch = !country || row.getAttribute('data-countries').indexOf(country) !== -1; + var searchMatch = !search || row.textContent.toLowerCase().indexOf(search) !== -1; + + // Date-based filtering: hide entries older than 3 years unless toggle is on. + var dateAdded = row.getAttribute('data-date-added') || ''; + var withinWindow = showAll || dateAdded >= cutoffDate; + + row.style.display = (domainMatch && countryMatch && searchMatch && withinWindow) ? '' : 'none'; + }); + } + + filterDomain.addEventListener('change', applyFilters); + filterCountry.addEventListener('change', applyFilters); + filterSearch.addEventListener('input', applyFilters); + showAllToggle.addEventListener('change', applyFilters); + + // Apply initial filter to hide old entries by default. + applyFilters(); +} + +/* ===== YAML Generator Form ===== */ + +function initAdoptersForm() { + var form = document.getElementById('adopters-yaml-form'); + if (!form) return; // Not on the form page. + + var generateBtn = document.getElementById('adopters-generate-btn'); + var openPrBtn = document.getElementById('adopters-open-pr-btn'); + var copyBtn = document.getElementById('adopters-copy-btn'); + var output = document.getElementById('adopters-yaml-output'); + var errorDiv = document.getElementById('adopters-form-errors'); + + // Auto-populate date_added with current YYYY-MM-DD. + var dateField = document.getElementById('field-date-added'); + if (dateField) { + var now = new Date(); + var yyyy = now.getFullYear(); + var mm = String(now.getMonth() + 1).padStart(2, '0'); + var dd = String(now.getDate()).padStart(2, '0'); + dateField.value = yyyy + '-' + mm + '-' + dd; + } + + // Populate country dropdown from ISO_COUNTRIES list. + var countrySelect = document.getElementById('field-country'); + var addCountryBtn = document.getElementById('adopters-add-country-btn'); + var selectedCountriesDiv = document.getElementById('adopters-selected-countries'); + var selectedCountries = []; + + if (countrySelect) { + ISO_COUNTRIES.forEach(function(c) { + var opt = document.createElement('option'); + opt.value = c.code; + opt.textContent = c.name + ' (' + c.code + ')'; + countrySelect.appendChild(opt); + }); + } + + function renderCountryTags() { + selectedCountriesDiv.innerHTML = ''; + selectedCountries.forEach(function(code) { + var tag = document.createElement('span'); + tag.className = 'adopters-country-tag'; + var entry = ISO_COUNTRIES.find(function(c) { return c.code === code; }); + var label = entry ? entry.name + ' (' + code + ')' : code; + tag.textContent = label + ' '; + var removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'adopters-country-tag-remove'; + removeBtn.textContent = '\u00d7'; + removeBtn.addEventListener('click', function() { + selectedCountries = selectedCountries.filter(function(c) { return c !== code; }); + renderCountryTags(); + }); + tag.appendChild(removeBtn); + selectedCountriesDiv.appendChild(tag); + }); + } + + if (addCountryBtn) { + addCountryBtn.addEventListener('click', function(e) { + e.preventDefault(); + var code = countrySelect.value; + if (code && selectedCountries.indexOf(code) === -1) { + selectedCountries.push(code); + renderCountryTags(); + } + countrySelect.value = ''; + }); + } + + function getFormValues() { + var domains = []; + var checkboxes = form.querySelectorAll('input[name="domain"]:checked'); + checkboxes.forEach(function(cb) { domains.push(cb.value); }); + + return { + organization: form.querySelector('#field-organization').value.trim(), + organization_url: form.querySelector('#field-organization-url').value.trim(), + project: form.querySelector('#field-project').value.trim(), + project_url: form.querySelector('#field-project-url').value.trim(), + domain: domains, + date_added: form.querySelector('#field-date-added').value.trim(), + country: selectedCountries.slice(), + description: form.querySelector('#field-description').value.trim() + }; + } + + function validateForm(values) { + var errors = []; + if (!values.organization) errors.push('Organization is required.'); + if (!values.project) errors.push('Project is required.'); + if (values.domain.length === 0) errors.push('At least one domain must be selected.'); + if (!values.date_added) errors.push('Date added is required.'); + if (values.date_added && !/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/.test(values.date_added)) { + errors.push('Date added must be in YYYY-MM-DD format.'); + } + if (values.country.length === 0) errors.push('At least one country must be selected.'); + if (!values.description) errors.push('Description is required.'); + if (values.organization_url && !isValidUrl(values.organization_url)) { + errors.push('Organization URL is not a valid URL.'); + } + if (values.project_url && !isValidUrl(values.project_url)) { + errors.push('Project URL is not a valid URL.'); + } + return errors; + } + + function isValidUrl(str) { + try { new URL(str); return true; } catch (e) { return false; } + } + + function yamlEscape(str) { + // Quote strings that contain YAML-special characters. + if (/[:#\[\]{}&*!|>'"%@`,?]/.test(str) || str !== str.trim()) { + return '"' + str.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; + } + return '"' + str + '"'; + } + + function generateYaml(values) { + var lines = []; + lines.push(' - organization: ' + yamlEscape(values.organization)); + if (values.organization_url) { + lines.push(' organization_url: ' + yamlEscape(values.organization_url)); + } + lines.push(' project: ' + yamlEscape(values.project)); + if (values.project_url) { + lines.push(' project_url: ' + yamlEscape(values.project_url)); + } + lines.push(' domain:'); + values.domain.forEach(function(d) { + lines.push(' - ' + d); + }); + lines.push(' date_added: ' + yamlEscape(values.date_added)); + lines.push(' country:'); + values.country.forEach(function(c) { + lines.push(' - ' + c); + }); + lines.push(' description: ' + yamlEscape(values.description)); + return lines.join('\n'); + } + + generateBtn.addEventListener('click', function(e) { + e.preventDefault(); + var values = getFormValues(); + var errors = validateForm(values); + + if (errors.length > 0) { + errorDiv.innerHTML = '
    ' + errors.map(function(e) { + return '
  • ' + e + '
  • '; + }).join('') + '
'; + errorDiv.style.display = 'block'; + output.style.display = 'none'; + openPrBtn.style.display = 'none'; + copyBtn.style.display = 'none'; + return; + } + + errorDiv.style.display = 'none'; + var yamlStr = generateYaml(values); + output.textContent = yamlStr; + output.style.display = 'block'; + openPrBtn.style.display = 'inline-block'; + copyBtn.style.display = 'inline-block'; + }); + + copyBtn.addEventListener('click', function(e) { + e.preventDefault(); + var text = output.textContent; + navigator.clipboard.writeText(text).then(function() { + var origText = copyBtn.textContent; + copyBtn.textContent = 'Copied!'; + setTimeout(function() { copyBtn.textContent = origText; }, 2000); + }); + }); + + openPrBtn.addEventListener('click', function(e) { + e.preventDefault(); + var yamlSnippet = output.textContent; + // GitHub web editor URL — opens adopters.yaml for editing on rolling branch. + var editUrl = 'https://github.com/ros2/ros2_documentation/edit/rolling/' + + 'source/The-ROS2-Project/Adopters/adopters.yaml'; + // We cannot pre-fill the edit content via URL, but we copy the YAML to clipboard + // and open the editor so the user can paste. + navigator.clipboard.writeText(yamlSnippet).then(function() { + window.open(editUrl, '_blank'); + }).catch(function() { + // Fallback if clipboard fails. + window.open(editUrl, '_blank'); + }); + }); +} + +/* ===== Init on page load ===== */ + +document.addEventListener('DOMContentLoaded', function() { + initAdoptersTableFilters(); + initAdoptersForm(); +}); diff --git a/source/index.rst b/source/index.rst index 06ed8ef68a4..884e4adb1c7 100644 --- a/source/index.rst +++ b/source/index.rst @@ -101,6 +101,10 @@ If you're interested in the advancement of the ROS 2 project: - Downloadable marketing materials - `Information about the ROS trademark `__ +* :doc:`Adopters ` + + - Organizations and projects using ROS + ROS community resources ----------------------- diff --git a/test/test_adopters.py b/test/test_adopters.py new file mode 100644 index 00000000000..485b7ba71b0 --- /dev/null +++ b/test/test_adopters.py @@ -0,0 +1,207 @@ +# Copyright 2026 Sony Group Corporation. +# +# 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. + +"""Tests for adopters YAML validation.""" + +import os +import sys + +import yaml + +# Make the plugins directory importable. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'plugins')) + +from adopters_schema import VALID_DOMAINS, validate_adopters + + +def _valid_entry(**overrides): + """Return a minimal valid adopter entry, with optional overrides.""" + entry = { + 'organization': 'Test Org', + 'project': 'Test Project', + 'domain': ['Research'], + 'date_added': '2026-03-25', + 'country': ['US'], + 'description': 'A test project.', + } + entry.update(overrides) + return entry + + +class TestValidateAdopters: + + def test_valid_entry(self): + assert validate_adopters([_valid_entry()]) == [] + + def test_valid_entry_with_optional_fields(self): + entry = _valid_entry( + organization_url='https://example.com', + project_url='https://example.com/project', + ) + assert validate_adopters([entry]) == [] + + def test_multiple_domains(self): + entry = _valid_entry(domain=['Research', 'Education']) + assert validate_adopters([entry]) == [] + + def test_multiple_countries(self): + entry = _valid_entry(country=['US', 'JP', 'DE']) + assert validate_adopters([entry]) == [] + + def test_missing_organization(self): + entry = _valid_entry() + del entry['organization'] + errors = validate_adopters([entry]) + assert len(errors) == 1 + assert 'organization' in errors[0] + + def test_missing_project(self): + entry = _valid_entry() + del entry['project'] + errors = validate_adopters([entry]) + assert len(errors) == 1 + assert 'project' in errors[0] + + def test_missing_domain(self): + entry = _valid_entry() + del entry['domain'] + errors = validate_adopters([entry]) + assert len(errors) == 1 + assert 'domain' in errors[0] + + def test_missing_date_added(self): + entry = _valid_entry() + del entry['date_added'] + errors = validate_adopters([entry]) + assert len(errors) == 1 + assert 'date_added' in errors[0] + + def test_missing_country(self): + entry = _valid_entry() + del entry['country'] + errors = validate_adopters([entry]) + assert len(errors) == 1 + assert 'country' in errors[0] + + def test_missing_description(self): + entry = _valid_entry() + del entry['description'] + errors = validate_adopters([entry]) + assert len(errors) == 1 + assert 'description' in errors[0] + + def test_invalid_domain(self): + entry = _valid_entry(domain=['InvalidDomain']) + errors = validate_adopters([entry]) + assert len(errors) == 1 + assert 'InvalidDomain' in errors[0] + + def test_invalid_date_added_year_only(self): + entry = _valid_entry(date_added='2026') + errors = validate_adopters([entry]) + assert len(errors) == 1 + assert 'YYYY-MM-DD' in errors[0] + + def test_invalid_date_added_month_only(self): + entry = _valid_entry(date_added='2026-03') + errors = validate_adopters([entry]) + assert len(errors) == 1 + assert 'YYYY-MM-DD' in errors[0] + + def test_invalid_date_added_bad_month(self): + entry = _valid_entry(date_added='2026-13-01') + errors = validate_adopters([entry]) + assert len(errors) == 1 + assert 'YYYY-MM-DD' in errors[0] + + def test_invalid_date_added_bad_month_zero(self): + entry = _valid_entry(date_added='2026-00-15') + errors = validate_adopters([entry]) + assert len(errors) == 1 + assert 'YYYY-MM-DD' in errors[0] + + def test_invalid_date_added_bad_day_zero(self): + entry = _valid_entry(date_added='2026-03-00') + errors = validate_adopters([entry]) + assert len(errors) == 1 + assert 'YYYY-MM-DD' in errors[0] + + def test_invalid_date_added_bad_day_32(self): + entry = _valid_entry(date_added='2026-03-32') + errors = validate_adopters([entry]) + assert len(errors) == 1 + assert 'YYYY-MM-DD' in errors[0] + + def test_valid_date_added_formats(self): + for date in ['2020-01-01', '2026-12-31', '1999-06-15']: + entry = _valid_entry(date_added=date) + assert validate_adopters([entry]) == [], f'Date {date} should be valid' + + def test_country_not_a_list(self): + entry = _valid_entry(country='US') + errors = validate_adopters([entry]) + assert len(errors) == 1 + assert 'list' in errors[0] + + def test_invalid_country_too_long(self): + entry = _valid_entry(country=['USA']) + errors = validate_adopters([entry]) + assert len(errors) == 1 + assert 'country' in errors[0] + + def test_invalid_country_numeric(self): + entry = _valid_entry(country=['12']) + errors = validate_adopters([entry]) + assert len(errors) == 1 + assert 'country' in errors[0] + + def test_invalid_country_in_list(self): + entry = _valid_entry(country=['US', 'INVALID', 'JP']) + errors = validate_adopters([entry]) + assert len(errors) == 1 + assert 'INVALID' in errors[0] + + def test_domain_not_a_list(self): + entry = _valid_entry(domain='Research') + errors = validate_adopters([entry]) + assert len(errors) == 1 + assert 'list' in errors[0] + + def test_empty_list(self): + assert validate_adopters([]) == [] + + def test_not_a_list(self): + errors = validate_adopters('not a list') + assert len(errors) == 1 + assert 'must be a list' in errors[0] + + def test_all_valid_domains_accepted(self): + for domain in VALID_DOMAINS: + entry = _valid_entry(domain=[domain]) + assert validate_adopters([entry]) == [], f'Domain {domain} should be valid' + + +class TestAdoptersYamlFile: + """Validate the actual adopters.yaml file.""" + + def test_adopters_yaml_is_valid(self): + yaml_path = os.path.join( + os.path.dirname(__file__), '..', 'source', + 'The-ROS2-Project', 'Adopters', 'adopters.yaml', + ) + with open(yaml_path) as f: + data = yaml.safe_load(f) + adopters = data.get('adopters', []) + errors = validate_adopters(adopters) + assert errors == [], f'Validation errors:\n' + '\n'.join(errors)