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