diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..b761b56
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,23 @@
+name: Test
+
+# Runs scripts/update-readme.test.mjs on every PR into main. Intended to be
+# added as a required status check on the prToMain ruleset, so a PR that
+# breaks scripts/update-readme.mjs (including one opened by
+# update-readme.yml's own daily run) can't merge — see the "required check"
+# discussion on PR #2.
+
+on:
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - run: node --test scripts/*.test.mjs
diff --git a/.github/workflows/update-readme.yml b/.github/workflows/update-readme.yml
new file mode 100644
index 0000000..f24bb99
--- /dev/null
+++ b/.github/workflows/update-readme.yml
@@ -0,0 +1,111 @@
+name: Update README
+
+# Daily refresh of profile/README.md's auto-generated sections (see
+# scripts/update-readme.mjs): the repository table (from the GitHub API) and
+# the software tools tables (imported from robotiq/robotiq.github.io's
+# docs/intro.mdx). Opens a PR only when the regenerated content actually
+# differs, then auto-merges it — mirroring
+# robotiq.github.io's .github/workflows/dependabot-auto-merge.yml.
+#
+# Why this needs a PAT instead of just GITHUB_TOKEN: the prToMain ruleset's
+# required-review rule blocks a PR's *author* from approving its own PR. In
+# dependabot-auto-merge.yml, Dependabot (a distinct actor) opens the PR and
+# GITHUB_TOKEN (github-actions[bot]) approves it — two different actors, so
+# it's not self-approval. Here there's no Dependabot; this workflow itself
+# generates the content. So the "open PR" step authenticates as
+# README_BOT_PAT (a fine-grained PAT scoped to just this repo, Contents +
+# Pull requests: write) instead of GITHUB_TOKEN, making its author a
+# distinct actor from the github-actions[bot] identity that then approves
+# and merges it in the next step.
+#
+# Why this is safe to merge without further human review: the generated
+# content is either the GitHub API's own repo descriptions or
+# robotiq.github.io's own published docs/intro.mdx — both already public and
+# already reviewed upstream. What actually gates the merge is the "Test"
+# workflow (.github/workflows/test.yml) as a required status check on the
+# prToMain ruleset: `gh pr merge --auto` waits on it, so a change that breaks
+# update-readme.mjs sits as an open, failing PR instead of reaching the org's
+# public landing page. Without that required check, `--auto` has nothing to
+# wait on and errors out instead of merging — see the review on PR #2.
+#
+# Also requires: `allow_auto_merge` enabled on this repo (Settings → General
+# → Pull Requests), and the commit's author email resolving to a real GitHub
+# account (below) so prToMain's require_extra_approval_for_unattributed_changes
+# rule doesn't kick in and demand a second approval nothing here can produce.
+
+on:
+ schedule:
+ - cron: '17 6 * * *'
+ workflow_dispatch: {}
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ update-readme:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.README_BOT_PAT }}
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - name: Regenerate README
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: node scripts/update-readme.mjs
+
+ - name: Open PR if content changed
+ id: pr
+ env:
+ GH_TOKEN: ${{ secrets.README_BOT_PAT }}
+ run: |
+ if git diff --quiet -- profile/README.md; then
+ echo "changed=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ # Fixed name rather than one timestamped per run: a run that lands
+ # before yesterday's PR merged (still waiting on the required
+ # check) force-pushes onto the same branch/PR instead of piling up
+ # a new branch and a new unmergeable PR each day.
+ BRANCH="auto/update-readme"
+ git config user.name "robotiq-readme-bot"
+ # This must resolve to a real GitHub account (github-actions[bot]'s
+ # own noreply address) or the prToMain ruleset's
+ # require_extra_approval_for_unattributed_changes rule treats the
+ # commit as unattributed and demands a second approval this
+ # workflow has no way to produce.
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git checkout -b "$BRANCH"
+ git add profile/README.md
+ git commit -m "chore: update README repository table and software tools section"
+ git push --force origin "$BRANCH"
+
+ EXISTING_PR=$(gh pr list --base main --head "$BRANCH" --state open --json url --jq '.[0].url')
+ if [ -n "$EXISTING_PR" ]; then
+ PR_URL="$EXISTING_PR"
+ else
+ PR_URL=$(gh pr create \
+ --base main \
+ --head "$BRANCH" \
+ --title "chore: update README repository table" \
+ --body "Automated daily refresh of the repository table (GitHub API) and software tools section (robotiq.github.io docs/intro.mdx) — see \`scripts/update-readme.mjs\`.")
+ fi
+
+ echo "changed=true" >> "$GITHUB_OUTPUT"
+ echo "url=$PR_URL" >> "$GITHUB_OUTPUT"
+
+ - name: Approve and auto-merge
+ if: steps.pr.outputs.changed == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PR_URL: ${{ steps.pr.outputs.url }}
+ run: |
+ gh pr review --approve "$PR_URL"
+ gh pr merge --auto --squash --delete-branch "$PR_URL"
diff --git a/profile/README.md b/profile/README.md
index a31b7dd..a319a4f 100644
--- a/profile/README.md
+++ b/profile/README.md
@@ -1,22 +1,88 @@
# Robotiq
-Open-source tools and drivers for Robotiq products.
+Open-source software tools for Robotiq developers.
+
+
+
+
+
+
+
+ Full documentation, guides, and API references for `Robotiq` software tools.
+ Start there to find your product, then jump into the matching repository below.
+
+
+## Repositories
+
+
+
| Repository | Description |
|---|---|
-| [grippers](https://github.com/Robotiq/grippers) | Standalone C++ SDK for the 2F adaptive grippers (2F-85 / 2F-140 / Hand-E) over Modbus RTU |
-| [tactile_sensors](https://github.com/Robotiq/tactile_sensors) | SDK, sensor I/O, and quickstart tools for the TSF-85 tactile sensor |
-| [ros](https://github.com/Robotiq/ROS_Packages) | Robotiq ROS packages (grippers, tactile sensor) |
+| [grippers](https://github.com/robotiq/grippers) | Standalone C++ SDK for Robotiq 2F adaptive grippers (2F-85 / 2F-140 / Hand-E) over Modbus RTU |
+| [isaacsim_assets](https://github.com/robotiq/isaacsim_assets) | Our Isaac Sim assets available to the public |
+| [ros](https://github.com/robotiq/ros) | Collection of ROS packages for controlling Robotiq hardware. |
+| [tactile_sensors](https://github.com/robotiq/tactile_sensors) | Get started with Robotiqs TSF-85 tactile sensors |
+
-## Community & Third-Party
+## Software tools
-A curated list of community-maintained drivers and useful third-party projects related to Robotiq products and robotic grasping.
+
+
+#### SDKs/languages
-| Repository | Description |
+| Product | C | C++ | Python |
+|---|---|---|---|
+| [2F / Hand-E](https://robotiq.github.io/docs/drivers/2F%20hande) | - | [](https://robotiq.github.io/docs/drivers/2F%20hande/SDK/C++) | [](https://robotiq.github.io/docs/drivers/2F%20hande/SDK/Python) |
+| [FT300-S](https://robotiq.github.io/docs/drivers/FT300) | [](https://robotiq.github.io/docs/drivers/FT300/SDK/C) | - | [](https://robotiq.github.io/docs/drivers/FT300/SDK/Python) |
+| [TSF-85](https://robotiq.github.io/docs/drivers/TSF-85) | - | [](https://robotiq.github.io/docs/drivers/TSF-85/SDK/C++) | [](https://robotiq.github.io/docs/drivers/TSF-85/SDK/Python) |
+
+- **C** — Low-level C driver talking directly to the hardware's communication protocol (e.g. Modbus RTU, serial).
+- **C++** — Low-level C++ driver/SDK for direct hardware integration.
+- **Python** — Python driver/SDK for scripting and rapid prototyping.
+
+#### ROS2
+
+| Product | Rolling | Jazzy | Iron | Humble |
+|---|---|---|---|---|
+| [2F / Hand-E](https://robotiq.github.io/docs/drivers/2F%20hande) | [](https://robotiq.github.io/docs/drivers/2F%20hande/ROS/ROS2-Rolling) | - | [](https://robotiq.github.io/docs/drivers/2F%20hande/ROS/ROS2-Iron) | [](https://robotiq.github.io/docs/drivers/2F%20hande/ROS/ROS2-Humble) |
+| [FT300-S](https://robotiq.github.io/docs/drivers/FT300) | - | - | - | [](https://robotiq.github.io/docs/drivers/FT300/ROS/ROS2-Humble) |
+| [TSF-85](https://robotiq.github.io/docs/drivers/TSF-85) | - | [](https://robotiq.github.io/docs/drivers/TSF-85/ROS/ROS2-Jazzy) | - | - |
+
+- **Rolling** — ROS 2 rolling development distro, always tracking the latest sources.
+- **Jazzy** — ROS 2 LTS release (2024), supported until 2029.
+- **Iron** — ROS 2 release (2023), end of life.
+- **Humble** — ROS 2 LTS release (2022), supported until 2027.
+
+#### ROS1
+
+| Product | Melodic | Kinetic | Jade | Indigo |
+|---|---|---|---|---|
+| [2F / Hand-E](https://robotiq.github.io/docs/drivers/2F%20hande) | [](https://robotiq.github.io/docs/drivers/2F%20hande/ROS/ROS1-Melodic) | [](https://robotiq.github.io/docs/drivers/2F%20hande/ROS/ROS1-Kinetic) | [](https://robotiq.github.io/docs/drivers/2F%20hande/ROS/ROS1-Jade) | [](https://robotiq.github.io/docs/drivers/2F%20hande/ROS/ROS1-Indigo) |
+
+- **Melodic** — ROS 1 release (2018), end of life.
+- **Kinetic** — ROS 1 release (2016), end of life.
+- **Jade** — ROS 1 release (2015), end of life.
+- **Indigo** — ROS 1 release (2014), end of life.
+
+#### Physics engine
+
+| Product | Isaac Sim | PyBullet |
+|---|---|---|
+| [2F / Hand-E](https://robotiq.github.io/docs/drivers/2F%20hande) | [](https://robotiq.github.io/docs/drivers/2F%20hande/Physics%20Engine/Isaac%20Sim) | [](https://robotiq.github.io/docs/drivers/2F%20hande/Physics%20Engine/PyBullet) |
+| [TSF-85](https://robotiq.github.io/docs/drivers/TSF-85) | [](https://robotiq.github.io/docs/drivers/TSF-85/Physics%20Engine/Isaac%20Sim) | - |
+
+- **Isaac Sim** — NVIDIA Isaac Sim integration for simulating the hardware.
+- **PyBullet** — PyBullet integration for physics-based simulation.
+
+#### Other community projects
+
+| Product | GraspGen |
|---|---|
-| [TSF-85 Isaac Sim Extension](https://github.com/Lab-CORO/TSF-85) | An Isaac Sim extension generating synthetic tactile maps for the TSF-85 |
-| [pyRobotiqGripper](https://github.com/castetsb/pyRobotiqGripper) | Python library for controlling Robotiq grippers (2F-85, 2F-140, Hand-E) via Modbus RTU |
-| [ur_rtde](https://sdurobotics.gitlab.io/ur_rtde) | Provides some useful [instructions](https://sdurobotics.gitlab.io/ur_rtde/guides/guides.html#use-with-robotiq-gripper) on how to control a Robotiq gripper directly using a raw TCP socket |
-| [pyFT300](https://github.com/castetsb/pyFT300) | Python library for reading data from the Robotiq FT-300 force-torque sensor |
-| [pybullet_ur5_robotiq](https://github.com/ElectronicElephant/pybullet_ur5_robotiq) | UR5 + Robotiq 85/140 gripper simulation in PyBullet |
-| [GraspGen](https://github.com/NVlabs/GraspGen) | GraspGen: a diffusion-based framework for 6-DOF grasping |
+| [2F / Hand-E](https://robotiq.github.io/docs/drivers/2F%20hande) | [](https://robotiq.github.io/docs/drivers/2F%20hande/Other/GraspGen) |
+
+- **GraspGen** — NVIDIA GraspGen asset/model package with Robotiq gripper definitions for grasp synthesis research.
+
+
diff --git a/scripts/fixtures/intro.mdx b/scripts/fixtures/intro.mdx
new file mode 100644
index 0000000..25818c8
--- /dev/null
+++ b/scripts/fixtures/intro.mdx
@@ -0,0 +1,83 @@
+---
+title: Overview
+sidebar_label: Overview
+sidebar_position: 0
+---
+
+## Hardware documentation
+
+Robotiq hardware communication protocols are described in detail in the product
+manuals, which serve as the reference for developing custom drivers.
+
+Hardware manuals are available on Robotiq support website:
+https://robotiq.com/support
+
+## Software tools
+
+Badge colors indicate who maintains the integration:  an official Robotiq-maintained driver,  a community-maintained project.
+
+### SDKs/languages
+
+{/* AUTO-GENERATED-SDK-TABLE:START */}
+| Product | C | C++ | Python |
+|---|---|---|---|
+| [2F / Hand-E](drivers/2F%20hande) | - | [](drivers/2F%20hande/SDK/C++) | [](drivers/2F%20hande/SDK/Python) |
+| [FT300-S](drivers/FT300) | [](drivers/FT300/SDK/C) | - | [](drivers/FT300/SDK/Python) |
+| [TSF-85](drivers/TSF-85) | - | [](drivers/TSF-85/SDK/C++) | [](drivers/TSF-85/SDK/Python) |
+
+- **C** — Low-level C driver talking directly to the hardware's communication protocol (e.g. Modbus RTU, serial).
+- **C++** — Low-level C++ driver/SDK for direct hardware integration.
+- **Python** — Python driver/SDK for scripting and rapid prototyping.
+{/* AUTO-GENERATED-SDK-TABLE:END */}
+
+### ROS
+
+#### ROS2
+
+{/* AUTO-GENERATED-ROS2-TABLE:START */}
+| Product | Rolling | Jazzy | Iron | Humble |
+|---|---|---|---|---|
+| [2F / Hand-E](drivers/2F%20hande) | [](drivers/2F%20hande/ROS/ROS2-Rolling) | - | [](drivers/2F%20hande/ROS/ROS2-Iron) | [](drivers/2F%20hande/ROS/ROS2-Humble) |
+| [FT300-S](drivers/FT300) | - | - | - | [](drivers/FT300/ROS/ROS2-Humble) |
+| [TSF-85](drivers/TSF-85) | - | [](drivers/TSF-85/ROS/ROS2-Jazzy) | - | - |
+
+- **Rolling** — ROS 2 rolling development distro, always tracking the latest sources.
+- **Jazzy** — ROS 2 LTS release (2024), supported until 2029.
+- **Iron** — ROS 2 release (2023), end of life.
+- **Humble** — ROS 2 LTS release (2022), supported until 2027.
+{/* AUTO-GENERATED-ROS2-TABLE:END */}
+
+#### ROS1
+
+{/* AUTO-GENERATED-ROS1-TABLE:START */}
+| Product | Melodic | Kinetic | Jade | Indigo |
+|---|---|---|---|---|
+| [2F / Hand-E](drivers/2F%20hande) | [](drivers/2F%20hande/ROS/ROS1-Melodic) | [](drivers/2F%20hande/ROS/ROS1-Kinetic) | [](drivers/2F%20hande/ROS/ROS1-Jade) | [](drivers/2F%20hande/ROS/ROS1-Indigo) |
+
+- **Melodic** — ROS 1 release (2018), end of life.
+- **Kinetic** — ROS 1 release (2016), end of life.
+- **Jade** — ROS 1 release (2015), end of life.
+- **Indigo** — ROS 1 release (2014), end of life.
+{/* AUTO-GENERATED-ROS1-TABLE:END */}
+
+### Physics engine
+
+{/* AUTO-GENERATED-PHYSICS_ENGINE-TABLE:START */}
+| Product | Isaac Sim | PyBullet |
+|---|---|---|
+| [2F / Hand-E](drivers/2F%20hande) | [](drivers/2F%20hande/Physics%20Engine/Isaac%20Sim) | [](drivers/2F%20hande/Physics%20Engine/PyBullet) |
+| [TSF-85](drivers/TSF-85) | [](drivers/TSF-85/Physics%20Engine/Isaac%20Sim) | - |
+
+- **Isaac Sim** — NVIDIA Isaac Sim integration for simulating the hardware.
+- **PyBullet** — PyBullet integration for physics-based simulation.
+{/* AUTO-GENERATED-PHYSICS_ENGINE-TABLE:END */}
+
+### Other community projects
+
+{/* AUTO-GENERATED-OTHER-TABLE:START */}
+| Product | GraspGen |
+|---|---|
+| [2F / Hand-E](drivers/2F%20hande) | [](drivers/2F%20hande/Other/GraspGen) |
+
+- **GraspGen** — NVIDIA GraspGen asset/model package with Robotiq gripper definitions for grasp synthesis research.
+{/* AUTO-GENERATED-OTHER-TABLE:END */}
\ No newline at end of file
diff --git a/scripts/update-readme.mjs b/scripts/update-readme.mjs
new file mode 100644
index 0000000..0d419c0
--- /dev/null
+++ b/scripts/update-readme.mjs
@@ -0,0 +1,198 @@
+// Regenerates the auto-generated sections of profile/README.md:
+// - "Repositories": one row per public, non-archived, non-fork repo in the
+// robotiq GitHub org (excluding this repo and the docs site itself),
+// pulled live from the GitHub API.
+// - "Software tools": the SDK / ROS2 / ROS1 / Physics engine / Other tables
+// imported from robotiq/robotiq.github.io's docs/intro.mdx (itself kept
+// up to date by that repo's own scripts/generate-tools-table.js), with
+// relative doc links rewritten to absolute robotiq.github.io URLs.
+//
+// Run via `.github/workflows/update-readme.yml` on a daily schedule, or
+// locally with `node scripts/update-readme.mjs` (optionally set GITHUB_TOKEN
+// to avoid the unauthenticated API rate limit). Pure helpers are exported
+// for scripts/update-readme.test.mjs; fetchOrgRepos/fetchDocsIntro/main hit
+// the network and aren't unit-tested.
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const README_PATH = path.join(ROOT, 'profile', 'README.md');
+
+const ORG = 'robotiq';
+const DOCS_REPO = 'robotiq.github.io';
+const DOCS_BRANCH = 'main';
+const DOCS_SITE_URL = 'https://robotiq.github.io';
+
+// Org repos that aren't "software tools" and shouldn't appear in the table.
+const EXCLUDED_REPOS = new Set(['.github', DOCS_REPO]);
+
+const SOFTWARE_SECTIONS = [
+ { key: 'SDK', heading: 'SDKs/languages' },
+ { key: 'ROS2', heading: 'ROS2' },
+ { key: 'ROS1', heading: 'ROS1' },
+ { key: 'PHYSICS_ENGINE', heading: 'Physics engine' },
+ { key: 'OTHER', heading: 'Other community projects' },
+];
+
+async function githubApi(url) {
+ const headers = {
+ Accept: 'application/vnd.github+json',
+ 'User-Agent': 'robotiq-profile-readme-bot',
+ };
+ if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
+
+ const res = await fetch(url, { headers });
+ if (!res.ok) {
+ throw new Error(`[update-readme] GitHub API request failed (${res.status} ${res.statusText}): ${url}`);
+ }
+ return res.json();
+}
+
+// Public, non-archived, non-fork repos in the org, paginated.
+export async function fetchOrgRepos() {
+ const repos = [];
+ for (let page = 1; ; page += 1) {
+ const batch = await githubApi(`https://api.github.com/orgs/${ORG}/repos?type=public&per_page=100&page=${page}`);
+ if (batch.length === 0) break;
+ repos.push(...batch);
+ if (batch.length < 100) break;
+ }
+ return repos
+ .filter((r) => !r.archived && !r.fork && !EXCLUDED_REPOS.has(r.name))
+ .map((r) => ({ name: r.name, url: r.html_url, description: (r.description || '').trim() }))
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
+}
+
+export function escapeCell(text) {
+ return text.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
+}
+
+export function buildRepoTable(repos) {
+ const header = '| Repository | Description |';
+ const separator = '|---|---|';
+ const rows = repos.map((r) => `| [${r.name}](${r.url}) | ${escapeCell(r.description) || '_No description yet._'} |`);
+ return [header, separator, ...rows].join('\n');
+}
+
+async function fetchDocsIntro() {
+ const url = `https://raw.githubusercontent.com/${ORG}/${DOCS_REPO}/${DOCS_BRANCH}/docs/intro.mdx`;
+ const res = await fetch(url, { headers: { 'User-Agent': 'robotiq-profile-readme-bot' } });
+ if (!res.ok) {
+ throw new Error(`[update-readme] Failed to fetch ${url} (${res.status} ${res.statusText})`);
+ }
+ return res.text();
+}
+
+export function extractMarkerBlock(raw, key) {
+ const re = new RegExp(
+ `\\{/\\* AUTO-GENERATED-${key}-TABLE:START \\*/\\}\\n([\\s\\S]*?)\\n\\{/\\* AUTO-GENERATED-${key}-TABLE:END \\*/\\}`
+ );
+ const m = raw.match(re);
+ return m ? m[1].trim() : null;
+}
+
+// docs/intro.mdx lives at the docs/ root, so a document-relative link in it
+// ("drivers/...", "img/...", "./drivers/...", "contribute/...") resolves
+// against that root. A site-root-relative link ("/docs/drivers/...",
+// "/img/...") already names its full path from the domain root, so only the
+// domain goes in front of it — prepending "/docs/" too would double it into
+// ".../docs/docs/...". Absolute http(s) links and same-page #anchors are
+// left untouched. Rewriting every other shape (rather than only the
+// "drivers/" links this table happens to use today) means a new link shape
+// upstream still resolves correctly here instead of silently 404ing on the
+// org landing page.
+export function absolutizeDocLinks(markdown) {
+ return markdown.replace(/\]\((?!https?:|#)([^)]+)\)/g, (_, href) => (
+ href.startsWith('/') ? `](${DOCS_SITE_URL}${href})` : `](${DOCS_SITE_URL}/docs/${href.replace(/^\.\//, '')})`
+ ));
+}
+
+// generate-tools-table.js (in robotiq.github.io) always writes every one of
+// these marker pairs, even when a category has no products yet (it fills
+// the gap with a "_No ... documented yet._" message) — so a missing marker
+// here never means "legitimately empty", only that the fetched page isn't
+// the shape this script expects (docs site restructured, marker renamed,
+// truncated response, ...). Treat that as a hard failure rather than
+// silently publishing a README with a gutted software tools section.
+export function buildSoftwareToolsSection(introRaw) {
+ const parts = [];
+ for (const { key, heading } of SOFTWARE_SECTIONS) {
+ const block = extractMarkerBlock(introRaw, key);
+ if (!block) {
+ throw new Error(
+ `[update-readme] Could not import the "${heading}" software tools section: no ` +
+ `AUTO-GENERATED-${key}-TABLE markers found in ${DOCS_REPO}'s docs/intro.mdx. ` +
+ `It may have been restructured — refusing to publish a partial section.`
+ );
+ }
+ parts.push(`#### ${heading}\n\n${absolutizeDocLinks(block)}`);
+ }
+ return parts.join('\n\n');
+}
+
+function escapeRegExp(str) {
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+// Pure text transform (no I/O), so it can be composed left-to-right over an
+// in-memory string and unit-tested without touching the filesystem. Throws
+// if `key`'s markers aren't present in `text` — callers are expected to run
+// every replacement they need before writing anything back out, so one
+// missing marker fails before any bytes are written, rather than after some
+// sections are already on disk and others aren't.
+export function replaceBetweenMarkers(text, key, content) {
+ const startMarker = ``;
+ const endMarker = ``;
+ const markerRegex = new RegExp(`${escapeRegExp(startMarker)}[\\s\\S]*?${escapeRegExp(endMarker)}`);
+ if (!markerRegex.test(text)) {
+ throw new Error(`[update-readme] Markers not found in profile/README.md: ${startMarker}`);
+ }
+ const block = `${startMarker}\n${content}\n${endMarker}`;
+ // A function replacer is used because `content` (live repo descriptions,
+ // docs-site table cells) is untrusted as a String.replace() replacement
+ // *string* — "$&", "$`", "$'", "$1" etc. in it would otherwise be
+ // interpreted as replacement patterns instead of inserted literally,
+ // silently corrupting the marker block. A function replacer gets no such
+ // special-pattern handling.
+ return text.replace(markerRegex, () => block);
+}
+
+async function main() {
+ const [repos, introRaw] = await Promise.all([fetchOrgRepos(), fetchDocsIntro()]);
+
+ // An org with zero matching repos is far more likely a broken fetch
+ // (rate limit, wrong org, API change) than reality — same reasoning as
+ // buildSoftwareToolsSection's marker check: fail loudly instead of
+ // publishing a README with an emptied-out repository table.
+ if (repos.length === 0) {
+ throw new Error(
+ `[update-readme] Fetched 0 repositories for org "${ORG}" — refusing to overwrite the ` +
+ 'repository table. Check GITHUB_TOKEN / API rate limits / the ORG constant.'
+ );
+ }
+
+ // Build and validate both sections, and check both marker pairs actually
+ // exist in the README, entirely in memory before writing anything — one
+ // read, one write, so a failure partway through (e.g. a damaged marker)
+ // never leaves the README with only one section refreshed.
+ const repoTable = buildRepoTable(repos);
+ const softwareToolsSection = buildSoftwareToolsSection(introRaw);
+
+ let readme = fs.readFileSync(README_PATH, 'utf8');
+ readme = replaceBetweenMarkers(readme, 'REPOS-TABLE', repoTable);
+ readme = replaceBetweenMarkers(readme, 'SOFTWARE-TOOLS', softwareToolsSection);
+ fs.writeFileSync(README_PATH, readme, 'utf8');
+
+ console.log(`[update-readme] Wrote ${repos.length} repositories and the software tools section to profile/README.md`);
+}
+
+// Only run when executed directly (`node scripts/update-readme.mjs`), not
+// when imported by scripts/update-readme.test.mjs.
+if (import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+ });
+}
diff --git a/scripts/update-readme.test.mjs b/scripts/update-readme.test.mjs
new file mode 100644
index 0000000..fdfa1de
--- /dev/null
+++ b/scripts/update-readme.test.mjs
@@ -0,0 +1,188 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ escapeCell,
+ buildRepoTable,
+ extractMarkerBlock,
+ absolutizeDocLinks,
+ buildSoftwareToolsSection,
+ replaceBetweenMarkers,
+} from './update-readme.mjs';
+
+const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures');
+
+test('escapeCell escapes pipes and collapses newlines', () => {
+ assert.equal(escapeCell('a | b'), 'a \\| b');
+ assert.equal(escapeCell('line one\nline two'), 'line one line two');
+ assert.equal(escapeCell('line one\r\nline two'), 'line one line two');
+});
+
+test('buildRepoTable renders one row per repo, falling back on empty description', () => {
+ const table = buildRepoTable([
+ { name: 'grippers', url: 'https://github.com/robotiq/grippers', description: 'A driver' },
+ { name: 'ros', url: 'https://github.com/robotiq/ros', description: '' },
+ ]);
+ assert.match(table, /\| \[grippers\]\(https:\/\/github\.com\/robotiq\/grippers\) \| A driver \|/);
+ assert.match(table, /\| \[ros\]\(https:\/\/github\.com\/robotiq\/ros\) \| _No description yet\._ \|/);
+});
+
+test('buildRepoTable escapes a pipe in a live repo description', () => {
+ const table = buildRepoTable([
+ { name: 'x', url: 'https://github.com/robotiq/x', description: 'Do A | Do B' },
+ ]);
+ assert.match(table, /Do A \\\| Do B/);
+});
+
+test('extractMarkerBlock returns the trimmed content between markers', () => {
+ const raw = [
+ '{/* AUTO-GENERATED-SDK-TABLE:START */}',
+ '| a | b |',
+ '{/* AUTO-GENERATED-SDK-TABLE:END */}',
+ ].join('\n');
+ assert.equal(extractMarkerBlock(raw, 'SDK'), '| a | b |');
+});
+
+test('extractMarkerBlock returns null when the marker pair is absent', () => {
+ assert.equal(extractMarkerBlock('no markers here', 'SDK'), null);
+});
+
+test('absolutizeDocLinks rewrites relative links, leaves absolute/anchor links alone', () => {
+ const input = '[a](drivers/Foo) [b](img/x.png) [c](./drivers/Foo) [d](https://example.com) [e](#section)';
+ const output = absolutizeDocLinks(input);
+ assert.match(output, /\[a\]\(https:\/\/robotiq\.github\.io\/docs\/drivers\/Foo\)/);
+ assert.match(output, /\[b\]\(https:\/\/robotiq\.github\.io\/docs\/img\/x\.png\)/);
+ assert.match(output, /\[c\]\(https:\/\/robotiq\.github\.io\/docs\/drivers\/Foo\)/);
+ assert.match(output, /\[d\]\(https:\/\/example\.com\)/);
+ assert.match(output, /\[e\]\(#section\)/);
+});
+
+test('absolutizeDocLinks prepends only the domain to a site-root-relative link, without doubling /docs/', () => {
+ const output = absolutizeDocLinks('[a](/docs/drivers/Foo) [b](/img/x.png)');
+ assert.match(output, /\[a\]\(https:\/\/robotiq\.github\.io\/docs\/drivers\/Foo\)/);
+ assert.doesNotMatch(output, /docs\/docs/);
+ assert.match(output, /\[b\]\(https:\/\/robotiq\.github\.io\/img\/x\.png\)/);
+});
+
+function fakeIntro(overrides = {}) {
+ const sections = { SDK: '| sdk |', ROS2: '| ros2 |', ROS1: '| ros1 |', PHYSICS_ENGINE: '| phys |', OTHER: '| other |', ...overrides };
+ return Object.entries(sections)
+ .filter(([, body]) => body !== null)
+ .map(([key, body]) => `{/* AUTO-GENERATED-${key}-TABLE:START */}\n${body}\n{/* AUTO-GENERATED-${key}-TABLE:END */}`)
+ .join('\n\n');
+}
+
+test('buildSoftwareToolsSection includes every section heading, in order, when all markers are present', () => {
+ const section = buildSoftwareToolsSection(fakeIntro());
+ const headings = [...section.matchAll(/^#### (.+)$/gm)].map((m) => m[1]);
+ assert.deepEqual(headings, ['SDKs/languages', 'ROS2', 'ROS1', 'Physics engine', 'Other community projects']);
+});
+
+test('buildSoftwareToolsSection throws instead of publishing a partial section when a marker is missing', () => {
+ assert.throws(() => buildSoftwareToolsSection(fakeIntro({ PHYSICS_ENGINE: null })), /Physics engine/);
+});
+
+test('replaceBetweenMarkers replaces content between an existing marker pair', () => {
+ const text = '# Title\n\n\nold\n\n';
+ const result = replaceBetweenMarkers(text, 'REPOS-TABLE', 'new content');
+ assert.match(result, /\nnew content\n/);
+});
+
+test('replaceBetweenMarkers throws when the marker pair is missing, instead of silently no-op-ing', () => {
+ assert.throws(() => replaceBetweenMarkers('# Title\n\nno markers', 'REPOS-TABLE', 'new content'), /Markers not found/);
+});
+
+test('replaceBetweenMarkers treats content as a literal string, not a replacement pattern', () => {
+ const text = '\nold\n';
+ // A live GitHub repo description containing "$&" would previously splice
+ // the entire matched marker block back into itself here.
+ const result = replaceBetweenMarkers(text, 'REPOS-TABLE', 'weird repo description with $& and $1 in it');
+ assert.match(result, /weird repo description with \$& and \$1 in it/);
+});
+
+test('replaceBetweenMarkers only touches the named marker pair, leaving the rest of the document untouched', () => {
+ const text = [
+ '# Robotiq',
+ '',
+ 'old repos',
+ '',
+ 'old tools',
+ ].join('\n');
+ const result = replaceBetweenMarkers(text, 'REPOS-TABLE', 'new repos');
+ assert.match(result, /new repos/);
+ assert.match(result, /old tools/);
+});
+
+// Mirrors main()'s actual sequence: read the README once, chain
+// replaceBetweenMarkers calls over the in-memory string, write once at the
+// end. If a later call throws (e.g. the README's own SOFTWARE-TOOLS marker
+// is damaged), fs.writeFileSync is never reached — proving that on disk,
+// not just in the return value of one function call.
+test('a failed second replacement leaves the README file on disk completely unchanged', () => {
+ const dir = mkdtempSync(path.join(tmpdir(), 'update-readme-test-'));
+ const file = path.join(dir, 'README.md');
+ const original = [
+ '# Robotiq',
+ '',
+ 'old repos',
+ '',
+ 'no software tools markers here',
+ ].join('\n');
+ writeFileSync(file, original, 'utf8');
+
+ try {
+ assert.throws(() => {
+ let text = readFileSync(file, 'utf8');
+ text = replaceBetweenMarkers(text, 'REPOS-TABLE', 'new repos');
+ text = replaceBetweenMarkers(text, 'SOFTWARE-TOOLS', 'new tools'); // throws — SOFTWARE-TOOLS marker absent
+ writeFileSync(file, text, 'utf8'); // never reached
+ }, /Markers not found/);
+
+ assert.equal(readFileSync(file, 'utf8'), original);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+// A real docs/intro.mdx snapshot (scripts/fixtures/intro.mdx), not the
+// minimal synthetic markers used above — exercises the actual regexes
+// against real badge markup, multi-column tables and legend bullets, so a
+// change to extractMarkerBlock/absolutizeDocLinks that breaks on real
+// formatting (but not on the synthetic fixture) shows up here.
+test('buildSoftwareToolsSection matches a real intro.mdx fixture (catches upstream format drift)', () => {
+ const fixture = readFileSync(path.join(FIXTURES_DIR, 'intro.mdx'), 'utf8');
+ const section = buildSoftwareToolsSection(fixture);
+
+ const headings = [...section.matchAll(/^#### (.+)$/gm)].map((m) => m[1]);
+ assert.deepEqual(headings, ['SDKs/languages', 'ROS2', 'ROS1', 'Physics engine', 'Other community projects']);
+
+ // Links absolutized against the docs site, not left root-relative.
+ assert.match(section, /\[2F \/ Hand-E\]\(https:\/\/robotiq\.github\.io\/docs\/drivers\/2F%20hande\)/);
+ assert.match(section, /\]\(https:\/\/robotiq\.github\.io\/docs\/drivers\/2F%20hande\/SDK\/C\+\+\)/);
+ // Legend text (not just table rows) survives.
+ assert.match(section, /ROS 2 LTS release \(2022\), supported until 2027\./);
+ // Nothing upstream-relative leaks into the README unresolved.
+ assert.doesNotMatch(section, /\]\(drivers\//);
+});
+
+test('regenerating from the same inputs is idempotent — byte-identical output both times', () => {
+ const repos = [
+ { name: 'grippers', url: 'https://github.com/robotiq/grippers', description: 'A driver' },
+ { name: 'ros', url: 'https://github.com/robotiq/ros', description: 'ROS packages' },
+ ];
+ assert.equal(buildRepoTable(repos), buildRepoTable(repos));
+
+ const fixture = readFileSync(path.join(FIXTURES_DIR, 'intro.mdx'), 'utf8');
+ assert.equal(buildSoftwareToolsSection(fixture), buildSoftwareToolsSection(fixture));
+
+ // Applying the same replacement twice in sequence (as a second run of the
+ // script would, against its own previous output) reaches a fixed point.
+ const text = 'x';
+ const content = buildRepoTable(repos);
+ const once = replaceBetweenMarkers(text, 'REPOS-TABLE', content);
+ const twice = replaceBetweenMarkers(once, 'REPOS-TABLE', content);
+ assert.equal(once, twice);
+});