From 9454c88613da78e9dd669cbfbf6a6d04f0d6b868 Mon Sep 17 00:00:00 2001 From: Justin Bunton <130115204+Bunton33@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:07:40 -0400 Subject: [PATCH 1/2] docs: add missing front matter titles to 29 pages Every page now has a title: in its front matter, so the new PR check can require it. Wrapper pages take the title of the page they include; the rest use their H1. All 29 are pages that aren't in the menu (mostly old Homey copies and the LED-1 and H-3 trees). Co-Authored-By: Claude Opus 5.5 --- .../air1/additonal-info/air-quality-sensor-comparison.md | 1 + .../calibrating-and-updating/zone-configuration/zones-ha.md | 1 + .../calibrating-and-updating/zone-configuration/zones-hlk.md | 1 + docs/homey/products/general/contact-us/Facebook.md | 1 + docs/homey/products/general/contact-us/GitHub.md | 1 + docs/homey/products/general/contact-us/Instagram.md | 1 + docs/homey/products/general/contact-us/Reddit.md | 1 + docs/homey/products/general/contact-us/YouTube.md | 1 + docs/homey/products/general/contact-us/apollo-automation-shop.md | 1 + docs/homey/products/general/contact-us/our-team.md | 1 + docs/homey/products/general/setup/getting-started-msr1.md | 1 + docs/homey/products/general/setup/getting-started-temp1.md | 1 + docs/homey/products/general/temp-hum-calibration.md | 1 + .../products/led1/examples/led-1-controlling-govee-lights.md | 1 + docs/homey/products/led1/faq.md | 1 + docs/homey/products/led1/introduction.md | 1 + docs/homey/products/led1/setup/getting-started-led1.md | 1 + docs/homey/products/led1/setup/led1-general-tips.md | 1 + docs/homey/products/led1/setup/led1-pinout-guide.md | 1 + docs/homey/products/led1/setup/led1-replace-fuse.md | 1 + docs/homey/products/led1/troubleshooting/led1-boot-mode.md | 1 + .../led1/troubleshooting/led1-find-ip-address-and-hostname.md | 1 + docs/homey/products/led1/troubleshooting/led1-reflash.md | 1 + .../how-to-use-the-apollo-gpio-header-to-control-an-led-strip.md | 1 + docs/homey/products/rpro1/calibrating-co2.md | 1 + docs/products/apolloodroid/bluetoothtracking.md | 1 + docs/products/general/setup/getting-started-btn1.md | 1 + docs/products/h3/battery-sensors/wake-up-battery-sensor.md | 1 + docs/products/h3/examples/holiday-songs.md | 1 + 29 files changed, 29 insertions(+) diff --git a/docs/homey/products/air1/additonal-info/air-quality-sensor-comparison.md b/docs/homey/products/air1/additonal-info/air-quality-sensor-comparison.md index 723981735c..4c00d0ea76 100755 --- a/docs/homey/products/air1/additonal-info/air-quality-sensor-comparison.md +++ b/docs/homey/products/air1/additonal-info/air-quality-sensor-comparison.md @@ -1,4 +1,5 @@ --- +title: Air quality sensor comparison description: "A technical comparison of air quality sensors used with the AIR-1, including performance, accuracy, and specifications." --- --8<-- "products/air1/additional-info/air-quality-sensor-comparison.md:5:" \ No newline at end of file diff --git a/docs/homey/products/general/calibrating-and-updating/zone-configuration/zones-ha.md b/docs/homey/products/general/calibrating-and-updating/zone-configuration/zones-ha.md index dfec82d7bc..0b9665ef5a 100644 --- a/docs/homey/products/general/calibrating-and-updating/zone-configuration/zones-ha.md +++ b/docs/homey/products/general/calibrating-and-updating/zone-configuration/zones-ha.md @@ -1,4 +1,5 @@ --- +title: MTR-1 Home Assistant zone configuration description: "Tutorial for MTR-1 Home Assistant Zone Configuration." --- --8<-- "products/mtr1/setup/zones-ha.md:5:" diff --git a/docs/homey/products/general/calibrating-and-updating/zone-configuration/zones-hlk.md b/docs/homey/products/general/calibrating-and-updating/zone-configuration/zones-hlk.md index b9bb9a130b..2234b6c7c1 100644 --- a/docs/homey/products/general/calibrating-and-updating/zone-configuration/zones-hlk.md +++ b/docs/homey/products/general/calibrating-and-updating/zone-configuration/zones-hlk.md @@ -1,4 +1,5 @@ --- +title: HLK Radar Tool app description: "Tutorial for MTR-1 HLKRadarTool app Zone Configuration." --- --8<-- "homey/products/mtr1/setup/zones-hlk.md:5:" diff --git a/docs/homey/products/general/contact-us/Facebook.md b/docs/homey/products/general/contact-us/Facebook.md index 093d7ee258..b5b29f1b94 100755 --- a/docs/homey/products/general/contact-us/Facebook.md +++ b/docs/homey/products/general/contact-us/Facebook.md @@ -1,4 +1,5 @@ --- +title: Facebook description: "Join the Apollo Automation community group on Facebook." --- --8<-- "products/general/contact-us/Facebook.md:5:" \ No newline at end of file diff --git a/docs/homey/products/general/contact-us/GitHub.md b/docs/homey/products/general/contact-us/GitHub.md index 527ecb6526..44b49c29c6 100755 --- a/docs/homey/products/general/contact-us/GitHub.md +++ b/docs/homey/products/general/contact-us/GitHub.md @@ -1,4 +1,5 @@ --- +title: GitHub description: "Apollo Automation on GitHub, where the firmware and hardware files live." --- --8<-- "products/general/contact-us/GitHub.md:5:" \ No newline at end of file diff --git a/docs/homey/products/general/contact-us/Instagram.md b/docs/homey/products/general/contact-us/Instagram.md index 66fd25ff69..cbd6fe21ce 100755 --- a/docs/homey/products/general/contact-us/Instagram.md +++ b/docs/homey/products/general/contact-us/Instagram.md @@ -1,4 +1,5 @@ --- +title: Instagram description: "Follow Apollo Automation on Instagram." --- --8<-- "products/general/contact-us/Instagram.md:5:" \ No newline at end of file diff --git a/docs/homey/products/general/contact-us/Reddit.md b/docs/homey/products/general/contact-us/Reddit.md index 4c3948a0d6..0107a25bd4 100755 --- a/docs/homey/products/general/contact-us/Reddit.md +++ b/docs/homey/products/general/contact-us/Reddit.md @@ -1,4 +1,5 @@ --- +title: Reddit description: "The Apollo Automation subreddit." --- --8<-- "products/general/contact-us/Reddit.md:5:" \ No newline at end of file diff --git a/docs/homey/products/general/contact-us/YouTube.md b/docs/homey/products/general/contact-us/YouTube.md index ce5f1d7297..501ef3bcca 100755 --- a/docs/homey/products/general/contact-us/YouTube.md +++ b/docs/homey/products/general/contact-us/YouTube.md @@ -1,4 +1,5 @@ --- +title: YouTube description: "The Apollo Automation YouTube channel, with setup guides, reviews, and live streams." --- --8<-- "products/general/contact-us/YouTube.md:5:" \ No newline at end of file diff --git a/docs/homey/products/general/contact-us/apollo-automation-shop.md b/docs/homey/products/general/contact-us/apollo-automation-shop.md index 71a25363d5..98b62e6eb8 100755 --- a/docs/homey/products/general/contact-us/apollo-automation-shop.md +++ b/docs/homey/products/general/contact-us/apollo-automation-shop.md @@ -1,4 +1,5 @@ --- +title: Apollo Automation shop description: "The Apollo Automation shop, where you can buy sensors, probes, and accessories." --- --8<-- "products/general/contact-us/apollo-automation-shop.md:6:" \ No newline at end of file diff --git a/docs/homey/products/general/contact-us/our-team.md b/docs/homey/products/general/contact-us/our-team.md index 75f6eda4a6..c357bb6b70 100755 --- a/docs/homey/products/general/contact-us/our-team.md +++ b/docs/homey/products/general/contact-us/our-team.md @@ -1,4 +1,5 @@ --- +title: Our team description: "Meet the people behind Apollo Automation." --- --8<-- "products/general/contact-us/our-team.md:5:" \ No newline at end of file diff --git a/docs/homey/products/general/setup/getting-started-msr1.md b/docs/homey/products/general/setup/getting-started-msr1.md index 52a15b418e..dd38f88117 100755 --- a/docs/homey/products/general/setup/getting-started-msr1.md +++ b/docs/homey/products/general/setup/getting-started-msr1.md @@ -1,4 +1,5 @@ --- +title: Getting started description: "Generic getting started article." --- --8<-- "homey/products/general/setup/getting-started.md:6:" \ No newline at end of file diff --git a/docs/homey/products/general/setup/getting-started-temp1.md b/docs/homey/products/general/setup/getting-started-temp1.md index 52a15b418e..dd38f88117 100755 --- a/docs/homey/products/general/setup/getting-started-temp1.md +++ b/docs/homey/products/general/setup/getting-started-temp1.md @@ -1,4 +1,5 @@ --- +title: Getting started description: "Generic getting started article." --- --8<-- "homey/products/general/setup/getting-started.md:6:" \ No newline at end of file diff --git a/docs/homey/products/general/temp-hum-calibration.md b/docs/homey/products/general/temp-hum-calibration.md index c4c757e7d8..469be7b71e 100755 --- a/docs/homey/products/general/temp-hum-calibration.md +++ b/docs/homey/products/general/temp-hum-calibration.md @@ -1,4 +1,5 @@ --- +title: Temp and humidity calibration and offset examples description: "Temp and Humidity Calibration and Offset Examples." --- --8<-- "products/general/temp-hum-calibration.md:5:" \ No newline at end of file diff --git a/docs/homey/products/led1/examples/led-1-controlling-govee-lights.md b/docs/homey/products/led1/examples/led-1-controlling-govee-lights.md index 9b9d60d88d..37df0ceb3e 100644 --- a/docs/homey/products/led1/examples/led-1-controlling-govee-lights.md +++ b/docs/homey/products/led1/examples/led-1-controlling-govee-lights.md @@ -1,4 +1,5 @@ --- +title: LED-1 controlling Govee lights description: How to use the Apollo LED-1 to control your Govee/3rd Party permanent outdoor LED lights! --- --8<-- "products/led1/examples/led-1-controlling-govee-lights.md:7:" diff --git a/docs/homey/products/led1/faq.md b/docs/homey/products/led1/faq.md index 3959c9c6d5..930551bde2 100644 --- a/docs/homey/products/led1/faq.md +++ b/docs/homey/products/led1/faq.md @@ -1,4 +1,5 @@ --- +title: LED-1 FAQ description: "Frequently asked questions about the LED-1 controller." --- --8<-- "products/led1/faq.md:5:" diff --git a/docs/homey/products/led1/introduction.md b/docs/homey/products/led1/introduction.md index 7937e204e7..3b089a34fe 100644 --- a/docs/homey/products/led1/introduction.md +++ b/docs/homey/products/led1/introduction.md @@ -1,4 +1,5 @@ --- +title: Introduction description: "Documentation for LED-1, including setup, usage, and best practices." --- --8<-- "products/led1/introduction.md:5:" diff --git a/docs/homey/products/led1/setup/getting-started-led1.md b/docs/homey/products/led1/setup/getting-started-led1.md index 2b852ba535..1fb53bc893 100644 --- a/docs/homey/products/led1/setup/getting-started-led1.md +++ b/docs/homey/products/led1/setup/getting-started-led1.md @@ -1,4 +1,5 @@ --- +title: Getting started description: "Getting started with your new LED-1!" --- --8<-- "products/led1/setup/getting-started-led1.md:5:" diff --git a/docs/homey/products/led1/setup/led1-general-tips.md b/docs/homey/products/led1/setup/led1-general-tips.md index 348a15815b..d229397feb 100644 --- a/docs/homey/products/led1/setup/led1-general-tips.md +++ b/docs/homey/products/led1/setup/led1-general-tips.md @@ -1,4 +1,5 @@ --- +title: LED-1 general tips description: "Multiple helpful tips to use your LED-1 to the fullest!" --- --8<-- "products/led1/setup/led1-general-tips.md:5:" diff --git a/docs/homey/products/led1/setup/led1-pinout-guide.md b/docs/homey/products/led1/setup/led1-pinout-guide.md index 1d837de801..fffe5a5d94 100644 --- a/docs/homey/products/led1/setup/led1-pinout-guide.md +++ b/docs/homey/products/led1/setup/led1-pinout-guide.md @@ -1,4 +1,5 @@ --- +title: LED-1 pinout guide description: "LED-1 Pinout Guide" --- --8<-- "products/led1/setup/led1-pinout-guide.md:5:" diff --git a/docs/homey/products/led1/setup/led1-replace-fuse.md b/docs/homey/products/led1/setup/led1-replace-fuse.md index 55454161e4..020f569f4a 100644 --- a/docs/homey/products/led1/setup/led1-replace-fuse.md +++ b/docs/homey/products/led1/setup/led1-replace-fuse.md @@ -1,4 +1,5 @@ --- +title: How to replace the LED-1 10 A fuse description: "Tutorial showing how to replace the 10 A Fuse on the LED-1." --- --8<-- "products/led1/setup/led1-replace-fuse.md:5:" diff --git a/docs/homey/products/led1/troubleshooting/led1-boot-mode.md b/docs/homey/products/led1/troubleshooting/led1-boot-mode.md index a3fdedc1af..93e28298ca 100644 --- a/docs/homey/products/led1/troubleshooting/led1-boot-mode.md +++ b/docs/homey/products/led1/troubleshooting/led1-boot-mode.md @@ -1,4 +1,5 @@ --- +title: Putting the LED-1 in boot mode description: "Step by step guide for putting The LED-1 In Boot Mode." --- --8<-- "products/led1/troubleshooting/led1-boot-mode.md:5:" diff --git a/docs/homey/products/led1/troubleshooting/led1-find-ip-address-and-hostname.md b/docs/homey/products/led1/troubleshooting/led1-find-ip-address-and-hostname.md index 1298a8a470..d472a2d3ce 100644 --- a/docs/homey/products/led1/troubleshooting/led1-find-ip-address-and-hostname.md +++ b/docs/homey/products/led1/troubleshooting/led1-find-ip-address-and-hostname.md @@ -1,4 +1,5 @@ --- +title: How to find the IP address and hostname of your LED-1 description: "Step by step guide for finding the IP address and the hostname of your LED-1 device." --- --8<-- "products/led1/troubleshooting/led1-find-ip-address-and-hostname.md:5:" diff --git a/docs/homey/products/led1/troubleshooting/led1-reflash.md b/docs/homey/products/led1/troubleshooting/led1-reflash.md index 93bb754ba8..ee248aa632 100644 --- a/docs/homey/products/led1/troubleshooting/led1-reflash.md +++ b/docs/homey/products/led1/troubleshooting/led1-reflash.md @@ -1,4 +1,5 @@ --- +title: Factory re-flash LED-1 description: "Step by step guide for re-flashing the LED-1 back to factory firmware." --- --8<-- "products/led1/troubleshooting/led1-reflash.md:5:" diff --git a/docs/homey/products/mtr1/examples/how-to-use-the-apollo-gpio-header-to-control-an-led-strip.md b/docs/homey/products/mtr1/examples/how-to-use-the-apollo-gpio-header-to-control-an-led-strip.md index becdf0d1b7..c685f187e1 100755 --- a/docs/homey/products/mtr1/examples/how-to-use-the-apollo-gpio-header-to-control-an-led-strip.md +++ b/docs/homey/products/mtr1/examples/how-to-use-the-apollo-gpio-header-to-control-an-led-strip.md @@ -1,4 +1,5 @@ --- +title: Control an LED strip with the GPIO header description: "Wire a WS2812B LED strip to the Apollo GPIO Header and control it from Home Assistant." --- --8<-- "products/msr2/examples/gpio-header-control-an-led-strip.md:5:" diff --git a/docs/homey/products/rpro1/calibrating-co2.md b/docs/homey/products/rpro1/calibrating-co2.md index 5c6648ac0d..80c9f188d1 100644 --- a/docs/homey/products/rpro1/calibrating-co2.md +++ b/docs/homey/products/rpro1/calibrating-co2.md @@ -1,4 +1,5 @@ --- +title: CO2 calibration tutorial (quick) description: "Step by step guide for re-calibrating your SCD40 CO2 sensor" --- --8<-- "products/rpro1/calibrating-co2.md:5:" diff --git a/docs/products/apolloodroid/bluetoothtracking.md b/docs/products/apolloodroid/bluetoothtracking.md index c345cc2440..bf989d40b9 100755 --- a/docs/products/apolloodroid/bluetoothtracking.md +++ b/docs/products/apolloodroid/bluetoothtracking.md @@ -1,4 +1,5 @@ --- +title: Bluetooth tracker setup description: "Tutorial for how to turn your Apollo device into a BLE tracker to track an iPhone!" --- --8<-- "products/general/setup/bluetooth-tracking.md:5:" \ No newline at end of file diff --git a/docs/products/general/setup/getting-started-btn1.md b/docs/products/general/setup/getting-started-btn1.md index c595f93c01..eb7d1560e1 100755 --- a/docs/products/general/setup/getting-started-btn1.md +++ b/docs/products/general/setup/getting-started-btn1.md @@ -1,4 +1,5 @@ --- +title: Getting started description: "Generic getting started article." --- --8<-- "products/general/setup/getting-started.md:5:" \ No newline at end of file diff --git a/docs/products/h3/battery-sensors/wake-up-battery-sensor.md b/docs/products/h3/battery-sensors/wake-up-battery-sensor.md index 023266d9c9..80d3bfa358 100644 --- a/docs/products/h3/battery-sensors/wake-up-battery-sensor.md +++ b/docs/products/h3/battery-sensors/wake-up-battery-sensor.md @@ -1,4 +1,5 @@ --- +title: How to wake up your sensor description: "How to wake up your Apollo device which is currently sleeping." --- --8<-- "products/general/battery-sensors/wake-up-battery-sensor.md:5:" diff --git a/docs/products/h3/examples/holiday-songs.md b/docs/products/h3/examples/holiday-songs.md index d23da5af1d..532355676c 100644 --- a/docs/products/h3/examples/holiday-songs.md +++ b/docs/products/h3/examples/holiday-songs.md @@ -1,4 +1,5 @@ --- +title: Holiday songs buzzer list description: "Holiday songs you can play on the buzzer in your H-3." --- --8<-- "products/general/holiday-songs.md:7:" From d5d17a9dccf4a671fd3087d0317a9ed5b13959ad Mon Sep 17 00:00:00 2001 From: Justin Bunton <130115204+Bunton33@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:07:40 -0400 Subject: [PATCH 2/2] ci: add docs checks to every pull request A new docs-checks workflow runs on PRs into dev and main and reports problems as annotations on the changed lines: - check_markdown.py: front matter title and description, heading levels that skip, more than one H1, images without alt text, and snippet includes that are missing or start inside the included page's front matter. Warns on em dashes and banned filler words. - check_secrets.py: ESPHome API encryption keys, generated OTA passwords, passwords typed into YAML, and common token formats (allows !secret and obvious placeholders). - check_images.py: photos carrying GPS location data. Warns on images over 1.5 MB that the PR adds or changes. - check_links.py: links, #anchors, filename case, and alt text on the built site, including the site-style links MkDocs doesn't validate. All four pass on the current wiki, and each was shown to fail on a planted example of every problem it covers. CONTRIBUTING.md, AGENTS.md, STYLE_GUIDE.md, and tools/docs-checks/README.md explain the checks and how to fix or run them; the style guide now also covers trimming secrets from recordings and stripping photo location data. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docs-checks.yml | 50 ++++++++++ AGENTS.md | 8 ++ CONTRIBUTING.md | 1 + STYLE_GUIDE.md | 12 ++- tools/docs-checks/README.md | 34 +++++++ tools/docs-checks/check_images.py | 48 ++++++++++ tools/docs-checks/check_links.py | 143 ++++++++++++++++++++++++++++ tools/docs-checks/check_markdown.py | 115 ++++++++++++++++++++++ tools/docs-checks/check_secrets.py | 51 ++++++++++ 9 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/docs-checks.yml create mode 100644 tools/docs-checks/README.md create mode 100644 tools/docs-checks/check_images.py create mode 100644 tools/docs-checks/check_links.py create mode 100644 tools/docs-checks/check_markdown.py create mode 100644 tools/docs-checks/check_secrets.py diff --git a/.github/workflows/docs-checks.yml b/.github/workflows/docs-checks.yml new file mode 100644 index 0000000000..3dd4d4c4fd --- /dev/null +++ b/.github/workflows/docs-checks.yml @@ -0,0 +1,50 @@ +name: docs-checks +# Runs on every pull request into dev or main. Each step reports problems as +# annotations on the PR's changed lines; later steps still run if an earlier +# one fails, so one run shows everything. Scripts live in tools/docs-checks/. +on: + pull_request: + branches: + - dev + - main + workflow_dispatch: +permissions: + contents: read +jobs: + checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-python@v7 + with: + python-version: 3.x + - run: pip install -r requirements.txt + - run: sudo apt-get update && sudo apt-get install -y libimage-exiftool-perl + - name: List changed files + id: changed + run: | + base="${{ github.event.pull_request.base.sha }}" + if [ -n "$base" ]; then + git diff --name-only --diff-filter=AM "$base"...HEAD > changed.txt + else + : > changed.txt + fi + echo "Changed files:"; cat changed.txt + - name: Markdown (front matter, headings, alt text, includes) + run: python tools/docs-checks/check_markdown.py docs + - name: No secrets in text + if: always() + run: python tools/docs-checks/check_secrets.py docs + - name: Images (GPS location data, file size) + if: always() + run: CHANGED_FILES="$(cat changed.txt)" python tools/docs-checks/check_images.py docs/assets + - name: Build the site + if: always() + env: + DISABLE_MKDOCS_2_WARNING: "true" + run: mkdocs build -d site + - name: Links, anchors, and image alt text in the built site + if: always() + run: python tools/docs-checks/check_links.py site docs diff --git a/AGENTS.md b/AGENTS.md index 5dbeef18b1..b45eb2d578 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,14 @@ Read the full style guide, but these are the ones worth checking twice: buttons, edit the snippet file, never the include line on a page. See [Community CTA on starter-kit pages](STYLE_GUIDE.md#11-community-cta-on-starter-kit-pages). +## Automated PR checks + +Every pull request runs `tools/docs-checks/` (see its README): front +matter, heading levels, alt text, snippet includes, secrets, GPS data in +photos, and links and anchors on the built site. Run them locally before +opening a PR. They can't read text inside images, so check screenshots +and recordings for passwords, API keys, and personal details by eye. + ## Keep the style guide current If you notice a recurring pattern that should be standardized but isn't diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f6c819eaf..4d5adcd1ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,6 +43,7 @@ mkdocs serve 4. Verify the build is clean (no broken links or missing-plugin errors) via `mkdocs build`. 5. Fill out the PR template and link any related issues. 6. Use PR title format: `: ` (types: `docs`, `fix`, `feat`, `chore`) +7. Automated checks run on every PR: front matter, heading levels, image alt text, snippet includes, secrets in text, GPS location data in photos, and broken links or anchors on the built site. Problems appear as comments on the changed lines. See [tools/docs-checks/README.md](tools/docs-checks/README.md) for what each check looks for, how to fix it, and how to run them locally. ## Key files and folders diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index c33229b607..bdfa6cce1c 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -415,8 +415,16 @@ shrinks PNGs, so compress video before committing it. - **Phone recordings** (portrait) take `class="phone-media"`, which caps them at phone width and centers them. The class lives in `docs/stylesheets/extra.css`; never size media inline. -- **Trim personal details** (names, email addresses, home names, - notifications) out of every recording before it's published. +- **Trim personal details and secrets** (names, email addresses, home + names, notifications, Wi-Fi passwords, API keys) out of every recording + and screenshot before it's published. Compile and install logs print + the device's full configuration, so end a recording before the log + scrolls through it, and check YAML editor shots for an `api: + encryption: key:` value. +- **Strip location data from photos.** Phones save the GPS location in + every photo. Remove it before committing: + `exiftool -gps:all= -overwrite_original docs/assets/`. The PR + checks fail on any photo that still has it. - **Compress** with H.264, no audio track, and fast start, scaled to 540px wide for clips and 720px for a full phone walkthrough. Aim for under 1 MB per clip and a few MB for a walkthrough: diff --git a/tools/docs-checks/README.md b/tools/docs-checks/README.md new file mode 100644 index 0000000000..44f5a40a2d --- /dev/null +++ b/tools/docs-checks/README.md @@ -0,0 +1,34 @@ +# Docs checks + +These scripts run on every pull request into `dev` or `main` +(`.github/workflows/docs-checks.yml`). Problems show up as comments on the +changed lines in the PR. Fix them and push again; the checks re-run. + +| Check | Fails the PR on | How to fix | +| --- | --- | --- | +| `check_markdown.py` | Missing front matter `title:` or `description:`, heading levels that skip (`##` to `####`), more than one `#` H1, images without alt text, snippet includes that point at a missing file or start inside the included page's front matter | Add the field, use the next heading level, describe the image inside `![...]`, or start the include after the included page's closing `---` | +| `check_secrets.py` | ESPHome API encryption keys, generated OTA passwords, passwords typed into YAML, and access tokens | Use `!secret` (or a placeholder like `your-wifi-password`). If the value was real, change it on the device or account too | +| `check_images.py` | Photos with GPS location data (phones add it by default) | `exiftool -gps:all= -overwrite_original docs/assets/` | +| `check_links.py` | Broken links, links to a missing `#anchor`, filename case mismatches (GitHub Pages is case-sensitive), and images without alt text, checked on the built site | Fix the link target or anchor; match the file name's case exactly | + +Warnings (shown, but they don't fail the PR): em dashes and the filler words +the style guide bans, and images or recordings over 1.5 MB that the PR adds +or changes. + +The checks can't read text inside screenshots or recordings. Before adding +one, look through it for passwords, API keys, email addresses, and other +personal details, and crop or trim them out (see the style guide). + +## Run them locally + +```bash +pip install -r requirements.txt +python tools/docs-checks/check_markdown.py docs +python tools/docs-checks/check_secrets.py docs +python tools/docs-checks/check_images.py docs/assets +mkdocs build -d site +python tools/docs-checks/check_links.py site docs +``` + +`check_images.py` needs exiftool (`brew install exiftool` on macOS, +`sudo apt install libimage-exiftool-perl` on Debian or Ubuntu). diff --git a/tools/docs-checks/check_images.py b/tools/docs-checks/check_images.py new file mode 100644 index 0000000000..70ed526391 --- /dev/null +++ b/tools/docs-checks/check_images.py @@ -0,0 +1,48 @@ +"""Image checks for Apollo Docs pull requests. + +Usage: python tools/docs-checks/check_images.py [assets_dir] + +Fails on: photos that carry GPS location metadata (phones add it by +default, and it publishes where the photo was taken). +Warns on: images over 1.5 MB, which slow pages down. Set CHANGED_FILES +(newline-separated paths) to limit this warning to the files a PR changes. + +Needs exiftool (apt install libimage-exiftool-perl, brew install exiftool). +To fix a GPS error: exiftool -gps:all= -overwrite_original +""" +import os +import shutil +import subprocess +import sys + +ASSETS = sys.argv[1] if len(sys.argv) > 1 else "docs/assets" +EXTS = (".jpg", ".jpeg", ".png", ".webp", ".heic", ".tif", ".tiff") +LIMIT = 1.5 * 1024 * 1024 + +if not shutil.which("exiftool"): + print("::error::exiftool is not installed, so GPS metadata can't be checked.") + sys.exit(1) + +r = subprocess.run( + ["exiftool", "-q", "-q", "-r", "-if", "$GPSLatitude or $GPSLongitude", "-p", "$Directory/$FileName", + *sum((["-ext", e.lstrip(".")] for e in EXTS), []), ASSETS], + capture_output=True, text=True, +) +gps = [l for l in r.stdout.splitlines() if l.strip()] +for path in gps: + print(f"::error file={path}::Photo contains GPS location data. Remove it with: exiftool -gps:all= -overwrite_original \"{path}\"") + +changed = os.environ.get("CHANGED_FILES") +only = {os.path.normpath(f) for f in changed.split()} if changed is not None else None +big = 0 +for root, _, files in os.walk(ASSETS): + for name in files: + p = os.path.join(root, name) + if only is not None and os.path.normpath(p) not in only: + continue + if os.path.getsize(p) > LIMIT and name.lower().endswith(EXTS + (".gif", ".mp4")): + big += 1 + print(f"::warning file={p}::{os.path.getsize(p) / 1048576:.1f} MB. Consider resizing (750 px wide is plenty for most images) or compressing.") + +print(f"check_images: {len(gps)} image(s) with GPS data, {big} large file warning(s)") +sys.exit(1 if gps else 0) diff --git a/tools/docs-checks/check_links.py b/tools/docs-checks/check_links.py new file mode 100644 index 0000000000..20198d3412 --- /dev/null +++ b/tools/docs-checks/check_links.py @@ -0,0 +1,143 @@ +"""Check internal links, anchors, and images in the built Apollo Docs site. + +Usage: mkdocs build -d site && python tools/docs-checks/check_links.py site docs + +MkDocs only validates links written as .md files. The wiki uses site-style +links (../page/, https://wiki.apolloautomation.com/...), so this checks every +link, #anchor, image, and video in page content against the built site. +Filename case must match exactly, because GitHub Pages is case-sensitive. +Fails on any broken link, missing anchor, or image without alt text. +""" +import os, sys, posixpath +from html.parser import HTMLParser +from urllib.parse import urlsplit, unquote + +SITE = sys.argv[1] if len(sys.argv) > 1 else "site" +DOCS = sys.argv[2] if len(sys.argv) > 2 else "docs" +OWN_HOSTS = {"wiki.apolloautomation.com"} + + +class Page(HTMLParser): + def __init__(self): + super().__init__(convert_charrefs=True) + self.ids, self.refs = set(), [] + self.in_article, self.stack = False, 0 + + def handle_starttag(self, tag, attrs): + a = dict(attrs) + if "id" in a: + self.ids.add(a["id"]) + if tag == "a" and "name" in a: + self.ids.add(a["name"]) + if tag == "article" and "md-content__inner" in (a.get("class") or ""): + self.in_article, self.stack = True, 0 + if not self.in_article: + return + if tag == "article": + self.stack += 1 + if tag == "a" and a.get("href") is not None and "headerlink" not in (a.get("class") or ""): + self.refs.append(("a", a["href"], None)) + elif tag == "img": + self.refs.append(("img", a.get("src", ""), a.get("alt"))) + elif tag in ("video", "source", "audio") and a.get("src"): + self.refs.append((tag, a["src"], None)) + if tag == "video" and a.get("poster"): + self.refs.append(("poster", a["poster"], None)) + + def handle_endtag(self, tag): + if self.in_article and tag == "article": + self.stack -= 1 + if self.stack <= 0: + self.in_article = False + + +ALL = set() +for r, _, fs in os.walk(SITE): + for f in fs: + ALL.add("/" + os.path.relpath(os.path.join(r, f), SITE).replace(os.sep, "/")) +LOWER = {f.lower(): f for f in ALL} + +pages = {} +for f in ALL: + if not f.endswith(".html") or f.startswith(("/assets/", "/search/")) or f == "/404.html": + continue + html = open(SITE + f, encoding="utf-8", errors="replace").read() + if 'http-equiv="refresh"' in html[:2000] and "md-content" not in html: + continue + p = Page() + p.feed(html) + pages[f] = p + + +def resolve(base, ref): + sp = urlsplit(ref) + if sp.scheme in ("mailto", "tel", "javascript", "data"): + return None + if sp.scheme in ("http", "https"): + if sp.netloc.lower() not in OWN_HOSTS: + return None + path = sp.path or "/" + elif sp.scheme or sp.netloc: + return None + elif not sp.path: + return base, unquote(sp.fragment) + elif sp.path.startswith("/"): + path = sp.path + else: + path = posixpath.normpath(posixpath.join(posixpath.dirname(base), sp.path)) + if sp.path.endswith("/") and not path.endswith("/"): + path += "/" + return unquote(path), unquote(sp.fragment) + + +case_mismatch = [] + + +def exists(path): + p = "/" + path.lstrip("/") + cands = [p + "index.html"] if p.endswith("/") else [p, p + "/index.html"] + for c in cands: + if c in ALL: + return c + for c in cands: + if c.lower() in LOWER: + case_mismatch.append((path, LOWER[c.lower()])) + return None + + +def src_md(url): + rel = url.lstrip("/") + rel = rel[: -len("/index.html")] if rel.endswith("/index.html") else rel + for cand in (rel + ".md", rel + "/index.md"): + if os.path.isfile(os.path.join(DOCS, cand)): + return "docs/" + cand + return "?" + url + + +broken, bad_anchor, missing_alt = [], [], [] +for url, pr in pages.items(): + for kind, ref, alt in pr.refs: + if kind == "img" and (alt is None or not alt.strip()): + missing_alt.append({"page": src_md(url), "src": ref}) + r = resolve(url, ref) + if r is None: + continue + path, frag = r + t = exists(path) + if not t: + broken.append({"page": src_md(url), "kind": kind, "ref": ref}) + continue + if frag and kind == "a" and t in pages and frag not in pages[t].ids: + bad_anchor.append({"page": src_md(url), "ref": ref}) + +for b in broken: + print(f"::error file={b['page']}::Broken {b['kind']} link: {b['ref']}") +for a in bad_anchor: + print(f"::error file={a['page']}::Link to a missing #anchor: {a['ref']}") +for m in missing_alt: + print(f"::error file={m['page']}::Image without alt text: {m['src']}") +for wrong, right in sorted(set(case_mismatch)): + print(f"::error::Link case doesn't match the file (GitHub Pages is case-sensitive): {wrong} should be {right}") +total = len(broken) + len(bad_anchor) + len(missing_alt) + len(set(case_mismatch)) +print(f"check_links: {len(pages)} pages, {len(broken)} broken, {len(bad_anchor)} bad anchors, {len(missing_alt)} missing alt, {len(set(case_mismatch))} case mismatches") +sys.exit(1 if total else 0) diff --git a/tools/docs-checks/check_markdown.py b/tools/docs-checks/check_markdown.py new file mode 100644 index 0000000000..886dff77ae --- /dev/null +++ b/tools/docs-checks/check_markdown.py @@ -0,0 +1,115 @@ +"""Markdown checks for Apollo Docs pull requests. + +Usage: python tools/docs-checks/check_markdown.py [docs_dir] + +Errors (fail the check): + - front matter missing, or missing title or description + - heading levels that skip (## straight to ####) or more than one H1 + - images without alt text (![](...) or with no alt) + - snippet includes whose target is missing or whose line offset starts + inside the target's front matter (it would render as page text) +Warnings (shown on the PR, do not fail): + - em dashes in prose and the filler words the style guide bans + +Output uses GitHub Actions annotations, so problems show up on the +changed lines in the PR. +""" +import os +import re +import sys + +DOCS = sys.argv[1] if len(sys.argv) > 1 else "docs" +FILLER = re.compile(r"\b(simply|seamlessly|powerful)\b", re.I) +errors = warnings = 0 + + +def note(kind, path, line, msg): + global errors, warnings + if kind == "error": + errors += 1 + else: + warnings += 1 + print(f"::{kind} file={path},line={line}::{msg}") + + +def front_matter_end(lines): + if not lines or lines[0].strip() != "---": + return None + for i in range(1, len(lines)): + if lines[i].strip() == "---": + return i + return None + + +for root, _, files in os.walk(DOCS): + for name in sorted(files): + if not name.endswith(".md"): + continue + path = os.path.join(root, name) + is_snippet = "/_snippets" in path.replace(os.sep, "/") + lines = open(path, encoding="utf-8", errors="replace").read().split("\n") + end = front_matter_end(lines) + + if not is_snippet: + if end is None: + note("error", path, 1, "Missing front matter. Add title: and description: between --- lines.") + else: + fm = "\n".join(lines[1:end]) + if not re.search(r"^title:\s*\S", fm, re.M): + note("error", path, 1, "Front matter is missing title:.") + if not re.search(r"^description:\s*\S", fm, re.M): + note("error", path, 1, "Front matter is missing description:.") + + start = (end + 1) if end is not None else 0 + fence = None + last_level = 0 + h1_count = 0 + for n, line in enumerate(lines[start:], start=start + 1): + m = re.match(r"^\s*(`{3,}|~{3,})", line) + if m: + tok = m.group(1) + if fence is None: + fence = tok + elif tok[0] == fence[0] and len(tok) >= len(fence) and line.strip() == tok: + fence = None + continue + if fence: + continue + + h = re.match(r"^(#{1,6})\s", line) + if h: + level = len(h.group(1)) + h1_count += level == 1 + if last_level and level > last_level + 1: + note("error", path, n, f"Heading jumps from level {last_level} to {level}. Use level {last_level + 1}.") + last_level = level + + for img in re.finditer(r"!\[\s*\]\(([^)\s]+)", line): + note("error", path, n, f"Image has no alt text: {img.group(1)}. Describe what it shows inside the brackets.") + for tag in re.finditer(r"]*>", line, re.I): + if not re.search(r'\balt\s*=\s*"[^"]+"', tag.group(0)): + note("error", path, n, " tag has no alt text. Add alt=\"...\" describing the image.") + + inc = re.search(r'--8<--\s+"([^":]+)((?::\d*){0,2})"', line) + if inc: + target = os.path.join(DOCS, inc.group(1)) + if not os.path.exists(target): + note("error", path, n, f"Snippet include target not found: {inc.group(1)}") + else: + tlines = open(target, encoding="utf-8", errors="replace").read().split("\n") + tend = front_matter_end(tlines) + nums = re.findall(r":(\d*)", inc.group(2)) + first = int(nums[0]) if nums and nums[0] else 1 + if tend is not None and first <= tend + 1: + note("error", path, n, f"Include starts at line {first}, inside {inc.group(1)}'s front matter (ends at line {tend + 1}). Start at line {tend + 2} or later.") + + if "—" in line and not line.lstrip().startswith("|"): + note("warning", path, n, "Em dash in prose. The style guide asks for a comma, period, colon, or parentheses instead.") + if FILLER.search(line): + note("warning", path, n, f"Filler word '{FILLER.search(line).group(0)}'. The style guide asks to drop it or say what it does.") + + if h1_count > 1: + note("error", path, 1, f"{h1_count} H1 headings. A page should have one.") + +print(f"check_markdown: {errors} error(s), {warnings} warning(s)") +sys.exit(1 if errors else 0) diff --git a/tools/docs-checks/check_secrets.py b/tools/docs-checks/check_secrets.py new file mode 100644 index 0000000000..13b3531513 --- /dev/null +++ b/tools/docs-checks/check_secrets.py @@ -0,0 +1,51 @@ +"""Scan docs text for credentials that should never be published. + +Usage: python tools/docs-checks/check_secrets.py [docs_dir] + +Fails on: + - ESPHome API encryption keys (a 44-character base64 key after key:) + - OTA passwords that look generated (32 hex characters) + - Wi-Fi or other passwords typed in YAML instead of !secret + - common access token formats (GitHub, Slack, AWS, JWT, long bearer tokens) + +Placeholders are allowed: !secret references, values containing words like +your/example/password/changeme/xxx, and known public defaults (for example +WLED's own hotspot password). Screenshots can't be scanned automatically; +check those by eye before adding them. +""" +import os +import re +import sys + +DOCS = sys.argv[1] if len(sys.argv) > 1 else "docs" +EXTS = (".md", ".html", ".htm", ".yaml", ".yml", ".txt", ".json") +PLACEHOLDER = re.compile(r"(?i)your|example|password|passw|changeme|change_me|placeholder|secret|xxx|<|>|\$\{|_here|abc123|12345678|wled1234") +PATTERNS = [ + ("ESPHome API encryption key", re.compile(r"""\bkey:\s*["']?([A-Za-z0-9+/]{43}=)["']?""")), + ("generated OTA password", re.compile(r"""\bpassword:\s*["']?([0-9a-f]{32})["']?\s*$""", re.I)), + ("password typed in YAML", re.compile(r"""^\s*(?:password|ap_password|wifi_password):\s*["']([^"'\n]{6,})["']""", re.I)), + ("GitHub token", re.compile(r"\b(gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,})\b")), + ("Slack token", re.compile(r"\b(xox[abposr]-[A-Za-z0-9-]{10,})\b")), + ("AWS access key", re.compile(r"\b(AKIA[0-9A-Z]{16})\b")), + ("JSON Web Token", re.compile(r"\b(eyJ[A-Za-z0-9_-]{15,}\.eyJ[A-Za-z0-9_-]{15,}\.[A-Za-z0-9_-]{15,})")), + ("bearer token", re.compile(r"(?i)\bbearer\s+([A-Za-z0-9._~+/-]{32,}=*)")), +] + +found = 0 +for root, _, files in os.walk(DOCS): + for name in sorted(files): + if not name.endswith(EXTS): + continue + path = os.path.join(root, name) + for n, line in enumerate(open(path, encoding="utf-8", errors="replace"), 1): + for label, pat in PATTERNS: + for m in pat.finditer(line): + value = m.group(1) + if "!secret" in line or PLACEHOLDER.search(value): + continue + found += 1 + shown = value[:4] + "..." if len(value) > 8 else "..." + print(f"::error file={path},line={n}::Possible {label} ({shown}). Replace it with !secret or a placeholder, and rotate it if it was real.") + +print(f"check_secrets: {found} possible secret(s)") +sys.exit(1 if found else 0)