diff --git a/.github/workflows/validate-docs.yaml b/.github/workflows/validate-docs.yaml new file mode 100644 index 00000000..5ad8efc0 --- /dev/null +++ b/.github/workflows/validate-docs.yaml @@ -0,0 +1,34 @@ +--- +name: Validate Docs + +"on": + pull_request: + branches: + - main + paths: + - "modules/**" + - "antora.yml" + - "bin/validate-docs.sh" + - "Makefile" + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit + disable-telemetry: true + + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install asciidoctor + run: sudo gem install asciidoctor -v 2.0.26 --no-document + + - name: Validate documentation + run: make validate-docs diff --git a/CLAUDE.md b/CLAUDE.md index 72427d4f..6f9df974 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,18 @@ This is the Conforma User Guide repository, which contains documentation for Con ## Common Commands +### Documentation Validation +```bash +make validate-docs +``` +Lightweight validation that works without external repositories or network access. Checks: +- AsciiDoc syntax errors (requires `asciidoctor`; skips gracefully if not installed) +- Broken `xref:` references to non-existent pages within this component +- Broken `include::` references to non-existent partials +- Navigation references to non-existent pages + +Run this after editing any `.adoc` file to catch errors before pushing. + ### Documentation Preview ```bash make ec-docs-preview diff --git a/Makefile b/Makefile index be956de3..41c49bc6 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,10 @@ HACBS_DOCS_REPO=git@github.com:conforma/conforma.github.io.git $(HACBS_DOCS_DIR): mkdir $(HACBS_DOCS_DIR) && cd $(HACBS_DOCS_DIR) && git clone $(HACBS_DOCS_REPO) . +.PHONY: validate-docs +validate-docs: ## Validate AsciiDoc syntax, xrefs, and includes (no external deps required) + @bash bin/validate-docs.sh + CURRENT_BRANCH=$(shell git rev-parse --abbrev-ref HEAD) ec-docs-preview: $(HACBS_DOCS_DIR) ## Build a preview of the documentation cd $(HACBS_DOCS_DIR) && \ diff --git a/bin/validate-docs.sh b/bin/validate-docs.sh new file mode 100755 index 00000000..9af7da3c --- /dev/null +++ b/bin/validate-docs.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MODULES_DIR="${REPO_ROOT}/modules" +PAGES_DIR="${MODULES_DIR}/ROOT/pages" +PARTIALS_DIR="${MODULES_DIR}/ROOT/partials" +NAV_FILE="${MODULES_DIR}/ROOT/nav.adoc" + +ERROR_LOG=$(mktemp) +WORK_DIR=$(mktemp -d) +trap 'rm -f "${ERROR_LOG}"; rm -rf "${WORK_DIR}"' EXIT + +error() { + echo "ERROR: $1" >&2 + echo "x" >> "${ERROR_LOG}" +} + +warn() { + echo "WARN: $1" >&2 +} + +# --- AsciiDoc syntax validation --- +validate_syntax() { + echo "==> Validating AsciiDoc syntax..." + + if ! command -v asciidoctor &>/dev/null; then + warn "asciidoctor not found, skipping syntax validation." + warn "Install with: gem install asciidoctor" + return + fi + + # Antora's partial$ prefix isn't understood by standalone asciidoctor. + # Resolve it to a relative path via a temp copy with a symlinked partials dir. + cp -a "${PAGES_DIR}" "${WORK_DIR}/pages" + ln -s "${PARTIALS_DIR}" "${WORK_DIR}/partials" + find "${WORK_DIR}/pages" -name '*.adoc' -exec \ + perl -pi -e 's|include::partial\$|include::../partials/|g' {} + + + local syntax_errors=0 + while IFS= read -r -d '' file; do + local output + output=$(asciidoctor \ + --failure-level=WARN \ + --backend=html5 \ + --safe-mode=unsafe \ + --out-file=/dev/null \ + "$file" 2>&1) || true + + if [[ -n "$output" ]]; then + echo "$output" | while IFS= read -r line; do + echo " $line" + done + syntax_errors=$((syntax_errors + 1)) + fi + done < <(find "${WORK_DIR}/pages" -name '*.adoc' -print0) + + if [[ ${syntax_errors} -gt 0 ]]; then + error "${syntax_errors} file(s) produced asciidoctor warnings or errors" + fi +} + +# --- xref validation --- +validate_xrefs() { + echo "==> Validating xref targets..." + + while IFS= read -r -d '' file; do + local relpath="${file#"${REPO_ROOT}/"}" + local matches + matches=$(grep -noE 'xref:[^[]+\[' "$file" 2>/dev/null) || continue + + while IFS=: read -r lineno match; do + local target="${match#xref:}" + target="${target%\[}" + target="${target%%#*}" + + # Skip cross-component xrefs, attribute-substituted targets, and same-page anchors + if [[ "$target" == *:* || "$target" == "{"* || -z "$target" ]]; then + continue + fi + + if [[ ! -f "${PAGES_DIR}/${target}" ]]; then + error "${relpath}:${lineno}: broken xref to '${target}' (file not found in pages/)" + fi + done <<< "$matches" + done < <(find "${MODULES_DIR}" -name '*.adoc' -print0) +} + +# --- include validation --- +validate_includes() { + echo "==> Validating include targets..." + + while IFS= read -r -d '' file; do + local relpath="${file#"${REPO_ROOT}/"}" + local matches + matches=$(grep -noE 'include::[^[]+\[' "$file" 2>/dev/null) || continue + + while IFS=: read -r lineno match; do + local target="${match#include::}" + target="${target%\[}" + + if [[ "$target" == partial\$* ]]; then + local partial_name="${target#partial\$}" + if [[ ! -f "${PARTIALS_DIR}/${partial_name}" ]]; then + error "${relpath}:${lineno}: broken include, partial '${partial_name}' not found in partials/" + fi + fi + done <<< "$matches" + done < <(find "${MODULES_DIR}" -name '*.adoc' -print0) +} + +# --- nav validation --- +validate_nav() { + echo "==> Validating navigation references..." + + if [[ ! -f "${NAV_FILE}" ]]; then + error "Navigation file not found: ${NAV_FILE}" + return + fi + + for navfile in "${NAV_FILE}" "${PARTIALS_DIR}/contents.adoc"; do + [[ -f "$navfile" ]] || continue + local relpath="${navfile#"${REPO_ROOT}/"}" + local matches + matches=$(grep -noE 'xref:[^[]+\[' "$navfile" 2>/dev/null) || continue + + while IFS=: read -r lineno match; do + local target="${match#xref:}" + target="${target%\[}" + target="${target%%#*}" + + if [[ "$target" == *:* || "$target" == "{"* || -z "$target" ]]; then + continue + fi + + if [[ ! -f "${PAGES_DIR}/${target}" ]]; then + error "${relpath}:${lineno}: nav references non-existent page '${target}'" + fi + done <<< "$matches" + done +} + +echo "Validating documentation in ${REPO_ROOT}..." +echo + +validate_syntax +validate_xrefs +validate_includes +validate_nav + +echo +error_count=$(wc -l < "${ERROR_LOG}" | tr -d ' ') +if [[ ${error_count} -gt 0 ]]; then + echo "FAILED: ${error_count} error(s) found." + exit 1 +else + echo "PASSED: All validation checks passed." + exit 0 +fi