diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml new file mode 100644 index 0000000000..132b2b487d --- /dev/null +++ b/.github/workflows/link-check.yml @@ -0,0 +1,60 @@ +name: Scheduled Link Check + +# Weekly crawl of the full published documentation for dead external links. +# The production playbook (antora-playbook.yml) builds every published version +# (tinymce/5, /6, /7, /8) into build/site, so this single job covers all +# versions. Scheduled workflows only run from the default branch, so this file +# lives on main by design. + +on: + schedule: + - cron: '0 6 * * 1' # every Monday 06:00 UTC + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + check-links: + name: Check external links + runs-on: ubuntu-latest + + defaults: + run: + shell: bash + + steps: + - name: Checkout branch + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + cache: 'yarn' + node-version: 22 + + - name: Install dependencies + run: yarn install + + - name: Build Website + run: yarn antora ./antora-playbook.yml + + - name: Check links in built site + id: lychee + uses: lycheeverse/lychee-action@v2 + with: + args: --config .lychee.toml --no-progress './build/site/**/*.html' + format: markdown + output: lychee/results.md + token: ${{ secrets.GITHUB_TOKEN }} + fail: false + jobSummary: true + + - name: Create issue on broken links + if: steps.lychee.outputs.exit_code != 0 + uses: peter-evans/create-issue-from-file@v5 + with: + title: 'Link check — broken links detected in published documentation' + content-filepath: lychee/results.md + labels: link-rot diff --git a/.gitignore b/.gitignore index 490a27221c..bafaad8088 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,10 @@ vendor # Antora tmp files _site/ +# lychee link checker (local cache + CI report output) +.lycheecache +lychee/ + # Cursor setup (local only, do not push) .cursor/ AGENTS.md diff --git a/.lychee.toml b/.lychee.toml new file mode 100644 index 0000000000..ca70b9c49c --- /dev/null +++ b/.lychee.toml @@ -0,0 +1,58 @@ +# lychee link-checker configuration +# https://github.com/lycheeverse/lychee +# +# Policy: a link is only "broken" if it fails to resolve or returns a genuine +# error (4xx/5xx). Redirects and rate-limiting are treated as healthy. Hosts +# that block automated checkers (403 to bots) are curated in .lycheeignore. + +# --- Request behaviour --- +timeout = 30 # seconds before a request is considered timed out +max_retries = 3 # retry transient failures (covers flaky 5xx / network blips) +retry_wait_time = 2 # seconds between retries +max_concurrency = 16 +max_redirects = 10 # follow redirect chains to their final destination + +# A browser-like user agent reduces false 403s from bot-hostile hosts. +user_agent = "Mozilla/5.0 (compatible; TinyMCE-Docs-Link-Checker/1.0; +https://www.tiny.cloud/docs)" + +# Resolve root-relative links (leading "/") against the live domain instead of +# erroring on them. The site is served from https://www.tiny.cloud/docs/, so a +# link like "/docs/_/css/site.css" or "/roadmap/" becomes an absolute URL that +# is HTTP-checked. Relative page-to-page links are still resolved locally on +# disk, so internal cross-reference integrity is still verified. +# NOTE: an `exclude = ["^/"]` pattern does NOT suppress these — lychee raises the +# "cannot resolve root-relative link" error before the exclude filter runs, so a +# base_url is the correct mechanism. +base_url = "https://www.tiny.cloud" + +# --- What counts as success --- +# Redirects are followed, so the final status is what matters; the 3xx codes are +# listed defensively for chains that exceed max_redirects. 429 = rate-limited, +# not dead. 403 is NOT accepted globally (that would hide genuinely dead pages) — +# bot-hostile hosts are curated per-URL in .lycheeignore instead. +accept = ["200..=206", "301", "302", "307", "308", "429"] + +# --- Caching (keeps PR-time runs fast) --- +cache = true +max_cache_age = "2d" + +# --- Structural excludes (not link rot) --- +include_mail = false # skip mailto: links +exclude = [ + # The site's own domains: self-referential links, UI-bundle chrome (CSS, + # favicons, footer images) and canonical URLs. base_url resolves root-relative + # links to these, and this filter then drops them. They are validated by the + # build/deploy pipeline; HTTP-checking them here would hammer our own + # production site (a rate-limit storm that fails thousands of links) and 404 on + # pages that a PR has added but not yet deployed. Internal .adoc cross-links are + # still verified locally (relative links resolve on disk) and by Antora at build. + '^https?://([a-z0-9-]+\.)*tiny\.cloud', + # Loopback / local addresses that only exist during a build or in examples + '^https?://localhost', + '^https?://127\.0\.0\.1', + '^https?://0\.0\.0\.0', + # GitHub action URLs that require auth and are not real navigational links + '^https://github\.com/[^/]+/[^/]+/(edit|new|delete)/', + # Internal Jira referenced in tickets/notes, not public docs + '^https://[a-z]+\.atlassian\.net', +] diff --git a/.lycheeignore b/.lycheeignore new file mode 100644 index 0000000000..c728a31347 --- /dev/null +++ b/.lycheeignore @@ -0,0 +1,37 @@ +# .lycheeignore — curated allow-list of URLs/patterns lychee should skip. +# Works like .gitignore, but for links. One URL or regex per line. +# +# Add an entry here ONLY after manually confirming the link works in a browser. +# This is the escape hatch for hosts that return 403/429 to automated checkers +# but are perfectly valid for readers (LinkedIn, Instagram, some CDNs, etc.). +# +# Seed entries below are commented out; uncomment / extend as the baseline crawl +# surfaces real false positives. Keep this list minimal — genuinely dead links +# must NOT be parked here. + +# Verified alive in a browser but rejected by the automated checker +# (bot detection / rate limiting): +https://www.stylemanual.gov.au/ +# Footer "join our Slack" link: tinyurl -> join.slack.com invite. Valid, but +# tinyurl/Slack return 403 to bots. Appears on every page. +https://www.tinyurl.com/tinyslack + +# Hosts that serve a JS/WAF challenge or 403/999 to automated checkers but load +# fine in a browser. Verified reachable manually; cannot be checked by any bot, +# so checking them only produces false failures. +https://www.npmjs.com/ +https://codepen.io/ +https://dotnet.microsoft.com/ +https://platform.openai.com/ +https://help.openai.com/ +https://www.researchgate.net/ +https://researchgate.net/ +https://benmarshall.me/ +https://malavkhatri.com/ + +# Common social hosts that block automated checkers — uncomment if they surface: +# https://www.linkedin.com/ +# https://www.instagram.com/ +# https://www.facebook.com/ +# https://twitter.com/ +# https://x.com/ diff --git a/package.json b/package.json index 5b8dd0a2e5..cf03b76b81 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build:production": "antora ./antora-playbook.yml", "build:markdown": "node scripts/generate-markdown.mjs", "build:all": "yarn build:production && yarn build:markdown", + "check-links": "lychee --config .lychee.toml --no-progress './build/site/**/*.html'", "clean": "rm -rf ./build", "nodemon-dev": "nodemon --exec yarn antora ./antora-playbook.yml", "server": "http-server build/site/ --port 4000",