diff --git a/.github/workflows/preview_create.yml b/.github/workflows/preview_create.yml index c82feae220..86c208cf35 100644 --- a/.github/workflows/preview_create.yml +++ b/.github/workflows/preview_create.yml @@ -78,31 +78,91 @@ jobs: run: | aws s3api put-object --bucket tiny-cloud-antora-docs-preview --key ${PR}/index.html --body .github/workflows/resources/empty.html --content-type text/html --metadata pointer=${RUN} - # Link check runs last so it never blocks the preview deployment. It - # validates only the external links this PR adds or changes in .adoc source - # (pages or partials) — the changed link is checked once, regardless of how - # many pages include it. Non-blocking at rollout (fail: false); flip to - # fail: true once the baseline is clean. - - name: Extract external links changed in this PR - id: changed_links - run: | - base="origin/${{ github.event.pull_request.base.ref }}" - git fetch --no-tags origin "${{ github.event.pull_request.base.ref }}" - git diff "$base...HEAD" -- 'modules/**/*.adoc' \ - | awk '/^\+[^+]/' \ - | grep -oE 'https?://[^][<>"^ '"'"']+' \ - | sed -E 's/[.,;:]+$//' | sort -u > changed-links.txt || true - count=$(wc -l < changed-links.txt | tr -d '[:space:]') - echo "count=${count:-0}" >> "$GITHUB_OUTPUT" - echo "Changed external links to validate ($count):"; cat changed-links.txt - - - name: Check changed links - if: steps.changed_links.outputs.count != '0' + # Link check runs last so it can never block the preview deployment. + # + # Links are extracted from the BUILT HTML, not from the .adoc diff. lychee's + # HTML parser sees only real anchors, so URLs that appear inside + # [source] code samples (container hostnames, placeholder domains, shell and + # template literals) and Antora attributes that are substituted at build time + # can never reach the checker. Measured over 400 commits of real history, + # extracting from raw .adoc text produced a 16% false-failure rate, none of + # which was genuine link rot. + # + # The diff is still used, but only to scope the *result* to links this PR + # touched. Because it filters rendered anchors rather than feeding the + # checker, an over-broad or malformed match simply fails to intersect a real + # anchor — it cannot manufacture a failure. + - name: Check links in the built site + id: lychee uses: lycheeverse/lychee-action@v2 with: - args: --config .lychee.toml --no-progress changed-links.txt - format: markdown - output: lychee/results.md + args: --config .lychee.toml --no-progress './build/docs/tinymce/latest/**/*.html' + format: json + output: lychee/results.json token: ${{ secrets.GITHUB_TOKEN }} fail: false - jobSummary: true \ No newline at end of file + jobSummary: false + + - name: Report links added or changed by this PR + continue-on-error: true + env: + BASE: ${{ github.event.pull_request.base.ref }} + run: | + git fetch --no-tags --quiet origin "$BASE" + + # Candidate URLs from the diff. Permissive on purpose: this only scopes + # the report, so a bad match is inert rather than a false failure. + git diff "origin/${BASE}...HEAD" -- '*.adoc' \ + | grep '^+' | grep -v '^+++' \ + | grep -oE "https?://[^][<>\"^\`)+,|' ]+" \ + | sed -E 's/[.,;:]+$//' | sort -u > touched-urls.txt || true + + # Broken links found in the rendered site, as page + url + reason. + if [ ! -f lychee/results.json ]; then + echo "Link checker produced no report; skipping." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + # Trim any trailing content after the JSON object: the root object is the + # only one closing at column 0, so this is an exact cut. + sed -n '1,/^}$/p' lychee/results.json > lychee/results.clean.json + + jq -r '.error_map // {} | to_entries[] | .key as $page | .value[] + | [$page, .url, (.status.text // "unknown")] | @tsv' \ + lychee/results.clean.json > broken-all.tsv + + # Keep only the broken links this PR touched (trailing-slash tolerant). + awk -F'\t' ' + function norm(u){ sub(/\/+$/,"",u); return u } + NR==FNR { touched[norm($0)]=1; next } + norm($2) in touched { print } + ' touched-urls.txt broken-all.tsv > broken-mine.tsv + + mine=$(wc -l < broken-mine.tsv | tr -d '[:space:]') + all=$(wc -l < broken-all.tsv | tr -d '[:space:]') + + { + echo "## External link check" + echo + echo "Links changed by this pull request: $(wc -l < touched-urls.txt | tr -d '[:space:]')" + echo + if [ "$mine" -eq 0 ]; then + echo "No broken links were introduced by this pull request." + else + echo "### Broken links introduced by this pull request" + echo + echo "| Page | Link | Reason |" + echo "|------|------|--------|" + sed -E 's#^[^\t]*/docs/tinymce/latest/##' broken-mine.tsv \ + | awk -F'\t' '{ printf "| %s | %s | %s |\n", $1, $2, $3 }' + fi + if [ "$all" -gt "$mine" ]; then + echo + echo "_$((all - mine)) further broken link(s) already present elsewhere in the site; these are tracked by the weekly scheduled scan._" + fi + } >> "$GITHUB_STEP_SUMMARY" + + # Non-blocking at rollout. Change to `exit 1` to make this a merge gate + # once the baseline recorded in the scheduled scan is clean. + if [ "$mine" -gt 0 ]; then + echo "::warning::${mine} broken link(s) introduced by this pull request. See the job summary." + fi diff --git a/.lycheeignore b/.lycheeignore index c728a31347..ad11538a5d 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -35,3 +35,11 @@ https://malavkhatri.com/ # https://www.facebook.com/ # https://twitter.com/ # https://x.com/ + +# Verified by a full-site scan (2026-08-20): reachable in a browser, but the +# automated checker is refused. +# Bot detection -> 403: +https://stackoverflow.com/ +https://www.graylog.org/ +# MCP endpoint: rejects HEAD/GET with 405 by design, it is not a web page. +https://tinymcedocs.mcp.kapa.ai/ diff --git a/modules/ROOT/pages/tinydoc-3514-link-check-proof.adoc b/modules/ROOT/pages/tinydoc-3514-link-check-proof.adoc new file mode 100644 index 0000000000..e73a8e689b --- /dev/null +++ b/modules/ROOT/pages/tinydoc-3514-link-check-proof.adoc @@ -0,0 +1,42 @@ += Link check proof page +:navtitle: Link check proof +:description: Temporary page used to verify the automated external-link check. Not for merge. +:keywords: linkcheck + +This page exists only to exercise the automated external-link check. It is not +part of the documentation and must not be merged. + +== Links that should be reported as broken + +A dead page on a live host: https://developer.mozilla.org/en-US/docs/Web/API/TinyDoc3514DoesNotExist[a page that does not exist]. + +== Links that should pass + +A live specification: https://www.w3.org/TR/wai-aria-1.2/ + +A live page in macro form: https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage[postMessage]. + +== Links that should be ignored + +An internal cross-reference: xref:events.adoc[Events]. + +A link to this site: https://www.tiny.cloud/docs/tinymce/latest/ + +A host that refuses automated checkers: https://www.npmjs.com/package/tinymce[the npm package]. + +== Links that must never reach the checker + +Every URL below sits inside a code sample. None of them is a real anchor, so +none should be checked or reported. + +[source,js] +---- +tinymce.init({ + selector: 'textarea', + ai_request_url: 'http://ai-service:8000/v1/chat/completions', + fallback_url: 'https://ai.yourcompany.com/v1/conversations', + cdn_url: 'https://cdn.tiny.cloud/1/no-api-key/tinymce/{productmajorversion}/tinymce.min.js', + docker_url: 'http://host.docker.internal:11434/v1', + dead_url: 'https://developer.mozilla.org/en-US/docs/Web/API/AlsoDoesNotExistTinyDoc3514' +}); +----