diff --git a/.github/workflows/check-component-index.yml b/.github/workflows/check-component-index.yml
new file mode 100644
index 0000000000..6d4b316eab
--- /dev/null
+++ b/.github/workflows/check-component-index.yml
@@ -0,0 +1,140 @@
+name: Check Component Index Order
+
+on:
+ pull_request_target:
+ types: [opened, reopened, synchronize]
+ paths:
+ - 'src/content/docs/components/index.mdx'
+
+permissions:
+ pull-requests: write
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+env:
+ BOT_NAME: "github-actions[bot]"
+ REVIEW_MARKER: "ImgTable blocks are not in alphabetical order"
+
+jobs:
+ check:
+ name: Component Index Ordering
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout PR head
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
+
+ - name: Set up Node.js
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version: '20'
+
+ - name: Check ordering
+ id: check
+ run: |
+ set +e
+ OUTPUT=$(node script/check_component_index.mjs --suggestions 2>&1)
+ EXIT_CODE=$?
+ set -e
+
+ if [ "$EXIT_CODE" -eq 0 ]; then
+ echo "sorted=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "sorted=false" >> "$GITHUB_OUTPUT"
+ # Write the JSON output to a file for the next step
+ echo "$OUTPUT" > /tmp/suggestions.json
+ fi
+
+ - name: Post or dismiss review
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ SORTED: ${{ steps.check.outputs.sorted }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ with:
+ script: |
+ const fs = require('fs');
+ const { owner, repo } = context.repo;
+ const pr_number = parseInt(process.env.PR_NUMBER);
+ const sorted = process.env.SORTED === 'true';
+
+ // Helper: find the most recent bot review for this check
+ async function findBotReview() {
+ const { data: reviews } = await github.rest.pulls.listReviews({
+ owner,
+ repo,
+ pull_number: pr_number
+ });
+ return reviews
+ .filter(r =>
+ r.user.login === process.env.BOT_NAME &&
+ r.state === 'CHANGES_REQUESTED' &&
+ r.body && r.body.includes(process.env.REVIEW_MARKER)
+ )
+ .sort((a, b) => new Date(b.submitted_at) - new Date(a.submitted_at))[0];
+ }
+
+ if (sorted) {
+ console.log('All ImgTable blocks are correctly sorted.');
+ // Dismiss any previous review from this bot
+ const botReview = await findBotReview();
+ if (botReview) {
+ console.log('Dismissing previous review', botReview.id);
+ await github.rest.pulls.dismissReview({
+ owner,
+ repo,
+ pull_number: pr_number,
+ review_id: botReview.id,
+ message: 'Component index ordering has been fixed — dismissing review.'
+ });
+ }
+ return;
+ }
+
+ // Read the suggestions JSON produced by the script
+ const data = JSON.parse(fs.readFileSync('/tmp/suggestions.json', 'utf-8'));
+ console.log(`Found ${data.suggestions.length} unsorted table(s).`);
+
+ // Build inline suggestion comments
+ const comments = data.suggestions.map(s => ({
+ path: data.file,
+ start_line: s.startLine,
+ line: s.endLine,
+ side: 'RIGHT',
+ body: [
+ `**${s.section}**: items are not in alphabetical order.`,
+ '',
+ '```suggestion',
+ s.body,
+ '```'
+ ].join('\n')
+ }));
+
+ // Create REQUEST_CHANGES review with inline suggestions
+ await github.rest.pulls.createReview({
+ owner,
+ repo,
+ pull_number: pr_number,
+ commit_id: process.env.HEAD_SHA,
+ event: 'REQUEST_CHANGES',
+ body: [
+ `### ${process.env.REVIEW_MARKER}`,
+ '',
+ `Found ${data.suggestions.length} ImgTable block(s) with incorrect ordering below **Network Protocols**.`,
+ 'Each table has at most one Core item (the first name ending with " Core"), pinned first, followed by Template items (names starting with "Template "), then all remaining items sorted alphabetically.',
+ '',
+ 'You can fix this automatically by running:',
+ '```',
+ 'node script/check_component_index.mjs --fix',
+ '```',
+ '',
+ 'See the inline suggestions below for the correct order in each section.'
+ ].join('\n'),
+ comments
+ });
+
+ console.log('Posted REQUEST_CHANGES review with inline suggestions.');
diff --git a/script/check_component_index.mjs b/script/check_component_index.mjs
new file mode 100755
index 0000000000..4a09bfab3f
--- /dev/null
+++ b/script/check_component_index.mjs
@@ -0,0 +1,223 @@
+#!/usr/bin/env node
+/**
+ * Check (and optionally fix) alphabetical ordering of ImgTable blocks
+ * in the components index page.
+ *
+ * Only operates on tables below the "## Network Protocols" heading.
+ * Within each table the pinned order is:
+ * 1. "Core" item — at most one, the first name ending with " Core"
+ * 2. "Template" items — names starting with "Template " (e.g. "Template Sensor")
+ * All remaining items must be sorted case-insensitively by display name.
+ *
+ * Usage:
+ * node script/check_component_index.mjs # check only (exit 1 if unsorted)
+ * node script/check_component_index.mjs --fix # rewrite the file in-place
+ * node script/check_component_index.mjs --suggestions # output JSON for CI review comments
+ */
+
+import { readFileSync, writeFileSync } from "fs";
+import { join, dirname } from "path";
+import { fileURLToPath } from "url";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const INDEX_REL = "src/content/docs/components/index.mdx";
+const INDEX_PATH = join(__dirname, "..", INDEX_REL);
+const SORT_AFTER_HEADING = "## Network Protocols";
+
+// ── helpers ──────────────────────────────────────────────────────────────────
+
+/** Extract the display name from a raw item line, e.g. ' ["Foo", …],' → 'Foo' */
+function getName(line) {
+ const m = line.match(/\["([^"]+)"/);
+ return m ? m[1] : "";
+}
+
+/**
+ * Identify the single "Core" entry in a table, if any.
+ *
+ * Each table has at most one core item — the primary entry point for
+ * the section (e.g. "Sensor Core", "Display Core", "Switch Core").
+ * A core item's display name ends with " Core".
+ *
+ * Only the *first* matching item is treated as core. This avoids
+ * false positives like "Display Menu Core", which lives in the
+ * Display Components table but is not the section's core item
+ * (that role belongs to "Display Core").
+ *
+ * Product names that happen to contain "Core" as a substring
+ * (e.g. "iAQ-Core") are not matched because the regex requires
+ * a preceding whitespace character.
+ */
+function identifyCoreItem(lines) {
+ for (let i = 0; i < lines.length; i++) {
+ const name = getName(lines[i]);
+ if (/\sCore$/i.test(name)) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+/**
+ * Decide whether an item is a "Template" entry.
+ *
+ * A template item's display name starts with "Template " (e.g.
+ * "Template Sensor", "Template Switch"). These are pinned right
+ * after Core items in each table, before the alphabetically sorted
+ * remainder.
+ */
+function isTemplateItem(name) {
+ return /^Template\s/i.test(name);
+}
+
+/** Normalize an item line to consistent 2-space indent + trailing comma */
+function normalizeLine(line) {
+ const stripped = line.trim().replace(/,$/, "");
+ return ` ${stripped},`;
+}
+
+// ── main logic ───────────────────────────────────────────────────────────────
+
+function processFile(content) {
+ const headingIdx = content.indexOf(SORT_AFTER_HEADING);
+ if (headingIdx === -1) {
+ console.error(`Could not find "${SORT_AFTER_HEADING}" in ${INDEX_PATH}`);
+ process.exit(2);
+ }
+
+ const before = content.slice(0, headingIdx);
+ const after = content.slice(headingIdx);
+ // Line number where the "after" region starts (1-indexed)
+ const afterStartLine = before.split("\n").length;
+
+ const tableRe = /()/g;
+
+ const results = []; // { section, startLine, endLine, original, fixed }
+ let tableIndex = 0;
+
+ const replaced = after.replace(
+ tableRe,
+ (match, prefix, itemsText, suffix, offset) => {
+ tableIndex++;
+
+ // Section heading for context
+ const textBefore = after.slice(0, offset);
+ const headingMatch = textBefore.match(/^(#{2,3}) (.+)$/gm);
+ const sectionName = headingMatch
+ ? headingMatch[headingMatch.length - 1].replace(/^#+\s*/, "")
+ : `table #${tableIndex}`;
+
+ // Parse item lines (skip blanks)
+ const allItemLines = itemsText.split("\n");
+ const lines = allItemLines.filter((l) => l.trim().length > 0);
+
+ if (lines.length <= 1) return match;
+
+ // Core item stays first (at most one), then Template items, then the rest sorted
+ const coreIdx = identifyCoreItem(lines);
+ const coreLines = coreIdx >= 0 ? [lines[coreIdx]] : [];
+ const templateLines = lines.filter(
+ (_, i) => i !== coreIdx && isTemplateItem(getName(lines[i])),
+ );
+ const restLines = lines.filter(
+ (_, i) => i !== coreIdx && !isTemplateItem(getName(lines[i])),
+ );
+
+ const sorted = [...restLines].sort((a, b) =>
+ getName(a).toLowerCase().localeCompare(getName(b).toLowerCase()),
+ );
+
+ const expectedOrder = [...coreLines, ...templateLines, ...sorted];
+ const isFullyOrdered = lines.every(
+ (line, i) => getName(line) === getName(expectedOrder[i]),
+ );
+
+ if (!isFullyOrdered) {
+ // Compute 1-indexed line numbers in the original file.
+ // offset is the position within `after`; the items text starts
+ // after the prefix ( l.trim().length > 0).length - 1;
+
+ const originalBlock = lines.map(normalizeLine).join("\n");
+ const fixedBlock = [...coreLines, ...templateLines, ...sorted]
+ .map(normalizeLine)
+ .join("\n");
+
+ results.push({
+ section: sectionName,
+ startLine,
+ endLine,
+ original: originalBlock,
+ fixed: fixedBlock,
+ });
+ }
+
+ // Build the fixed version (used by --fix)
+ const fixedResult = [...coreLines, ...templateLines, ...sorted]
+ .map(normalizeLine)
+ .join("\n");
+
+ return `${prefix}${fixedResult}\n${suffix}`;
+ },
+ );
+
+ return { before, after: replaced, results };
+}
+
+// ── entry point ──────────────────────────────────────────────────────────────
+
+const mode = process.argv.includes("--fix")
+ ? "fix"
+ : process.argv.includes("--suggestions")
+ ? "suggestions"
+ : "check";
+
+const content = readFileSync(INDEX_PATH, "utf-8");
+const { before, after, results } = processFile(content);
+
+if (results.length === 0) {
+ console.log("All ImgTable blocks are correctly sorted.");
+ process.exit(0);
+}
+
+switch (mode) {
+ case "fix":
+ writeFileSync(INDEX_PATH, before + after, "utf-8");
+ console.log(`Fixed ${results.length} ImgTable block(s) in ${INDEX_PATH}`);
+ process.exit(0);
+ break;
+
+ case "suggestions":
+ // Output JSON consumed by the GitHub Actions workflow.
+ // Each entry has the file path, line range, and the suggested replacement.
+ console.log(
+ JSON.stringify({
+ file: INDEX_REL,
+ suggestions: results.map((r) => ({
+ section: r.section,
+ startLine: r.startLine,
+ endLine: r.endLine,
+ body: r.fixed,
+ })),
+ }),
+ );
+ process.exit(1);
+ break;
+
+ default:
+ console.error(
+ "Component index ImgTable blocks are not sorted correctly:\n",
+ );
+ for (const r of results) {
+ console.error(` ${r.section} (lines ${r.startLine}-${r.endLine})`);
+ }
+ console.error(
+ "\nRun `node script/check_component_index.mjs --fix` to fix automatically.",
+ );
+ process.exit(1);
+}
diff --git a/src/content/docs/components/index.mdx b/src/content/docs/components/index.mdx
index d365661df1..6b64cb131e 100644
--- a/src/content/docs/components/index.mdx
+++ b/src/content/docs/components/index.mdx
@@ -100,26 +100,26 @@ ESPHome-specific components or components supporting ESPHome device provisioning
## Bluetooth/BLE
@@ -128,13 +128,13 @@ ESPHome-specific components or components supporting ESPHome device provisioning
## Update Installation
@@ -165,8 +165,8 @@ Create update entities simplifying management of OTA updates.
["I²S Audio", "/components/i2s_audio/", "i2s_audio.svg"],
["OpenTherm", "/components/opentherm/", "opentherm.png"],
["SPI Bus", "/components/spi/", "spi.svg"],
- ["UART", "/components/uart/", "uart.svg"],
["TinyUSB", "/components/tinyusb/", "usb.svg", "dark-invert"],
+ ["UART", "/components/uart/", "uart.svg"],
["USB CDC-ACM", "/components/usb_cdc_acm/", "usb.svg", "dark-invert"],
["USB Host", "/components/usb_host/", "usb.svg", "dark-invert"],
["USB UART", "/components/usb_uart/", "usb.svg", "dark-invert"],
@@ -231,8 +231,8 @@ Sensors are organized into categories; if a given sensor fits into more than one
### Air Quality
@@ -563,9 +563,9 @@ Binary Sensors are organized into categories; if a given sensor fits into more t
["Template Binary Sensor", "/components/binary_sensor/template/", "description.svg", "dark-invert"],
["GPIO", "/components/binary_sensor/gpio/", "gpio.svg"],
["Home Assistant", "/components/binary_sensor/homeassistant/", "home-assistant.svg", "dark-invert"],
+ ["Host SDL2", "/components/binary_sensor/sdl/", "sdl.png"],
["Status", "/components/binary_sensor/status/", "server-network.svg", "dark-invert"],
["Switch", "/components/binary_sensor/switch/", "electric-switch.svg", "dark-invert"],
- ["Host SDL2", "/components/binary_sensor/sdl/", "sdl.png"],
]} />
### Capacitive Touch
@@ -605,9 +605,9 @@ Often known as "tag" or "card" readers within the community.
["Touchscreen Core", "/components/touchscreen/", "touch.svg", "dark-invert"],
["FT5X06", "/components/touchscreen/ft5x06/", "indicator.jpg"],
["GT911", "/components/touchscreen/gt911/", "esp32_s3_box_3.png"],
+ ["LVGL widget", "/components/binary_sensor/lvgl/", "lvgl_c_bns.png"],
["Nextion Binary Sensor", "/components/binary_sensor/nextion/", "nextion.jpg"],
["TT21100", "/components/touchscreen/tt21100/", "esp32-s3-korvo-2-lcd.png"],
- ["LVGL widget", "/components/binary_sensor/lvgl/", "lvgl_c_bns.png"],
]} />
### Presence Detection
@@ -719,16 +719,16 @@ Often known as "tag" or "card" readers within the community.
@@ -737,22 +737,22 @@ Often known as "tag" or "card" readers within the community.
## Electromechanical
@@ -976,6 +976,7 @@ Used for creating infrared (IR) remote control transmitters and/or receivers.
["Generic Output Switch", "/components/switch/output/", "upload.svg", "dark-invert"],
["GPIO Switch", "/components/switch/gpio/", "gpio.svg"],
["H-bridge Switch", "/components/switch/hbridge/", "hbridge-relay.jpg"],
+ ["Home Assistant", "/components/switch/homeassistant/", "home-assistant.svg", "dark-invert"],
["LVGL Widget", "/components/switch/lvgl/", "lvgl_c_swi.png"],
["Modbus Switch", "/components/switch/modbus_controller/", "modbus.png"],
["Nextion Switch", "/components/switch/nextion/", "nextion.jpg"],
@@ -984,7 +985,6 @@ Used for creating infrared (IR) remote control transmitters and/or receivers.
["Shutdown Switch", "/components/switch/shutdown/", "power_settings.svg", "dark-invert"],
["Tuya Switch", "/components/switch/tuya/", "tuya.png"],
["UART Switch", "/components/switch/uart/", "uart.svg"],
- ["Home Assistant", "/components/switch/homeassistant/", "home-assistant.svg", "dark-invert"],
]} />
## Text Components
@@ -1026,8 +1026,8 @@ Used for creating infrared (IR) remote control transmitters and/or receivers.
["GPS Time", "/components/time/gps/", "crosshairs-gps.svg", "dark-invert"],
["Home Assistant Time", "/components/time/homeassistant/", "home-assistant.svg", "dark-invert"],
["PCF85063 RTC", "/components/time/pcf85063/", "clock-outline.svg", "dark-invert"],
- ["RX8130 RTC", "/components/time/rx8130/", "clock-outline.svg", "dark-invert"],
["PCF8563 RTC", "/components/time/pcf8563/", "clock-outline.svg", "dark-invert"],
+ ["RX8130 RTC", "/components/time/rx8130/", "clock-outline.svg", "dark-invert"],
["SNTP", "/components/time/sntp/", "clock-outline.svg", "dark-invert"],
["Zigbee Time", "/components/time/zigbee/", "zigbee.svg"],
]} />
@@ -1037,9 +1037,9 @@ Used for creating infrared (IR) remote control transmitters and/or receivers.
## Contributing