#!/bin/bash
# Read-only HTML quality analyzer (v2). No filesystem mutations.
# stderr = human logs, stdout = structured JSON.
set -euo pipefail
MAX_FINDINGS=100
MAX_PER_CATEGORY_PER_FILE=20 # cap per high-volume check per file so one category can't fill MAX_FINDINGS
fail() {
local type="$1" msg="$2" suggestion="$3"
if command -v jq >/dev/null 2>&1; then
jq -n \
--arg type "$type" \
--arg msg "$msg" \
--arg suggestion "$suggestion" \
'{success: false, error: {type: $type, message: $msg, retryable: false, suggestion: $suggestion}}'
else
printf '{"success":false,"error":{"type":"%s","message":"%s","suggestion":"%s","retryable":false}}\n' \
"$type" "$msg" "$suggestion"
fi
exit 1
}
command -v jq >/dev/null 2>&1 || \
fail "missing_dependency" "jq is required for safe JSON output" "Install: brew install jq"
[ $# -ge 1 ] || fail "invalid_input" "No target provided" "Usage: $0 "
TARGET="$1"
[ -e "$TARGET" ] || fail "invalid_input" "Target not found: $TARGET" "Pass an existing file or directory path"
ISSUES=()
WARNINGS=()
analyze_html() {
local file="$1"
echo "Analyzing: $file" >&2
grep -qi "" "$file" || ISSUES+=("$file:0: Missing HTML5 doctype")
grep -qi 'charset.*utf-8' "$file" || WARNINGS+=("$file:0: Missing or non-UTF-8 charset")
grep -qi 'name="viewport"' "$file" || ISSUES+=("$file:0: Missing viewport meta tag")
grep -qi ']*lang=' "$file" || ISSUES+=("$file:0: Missing lang attribute on ")
grep -qi '' "$file" || ISSUES+=("$file:0: Missing tag")
#
without alt — two-pass replaces broken PCRE lookahead
local alt_count=0
while IFS=: read -r ln tag; do
if grep -qE 'alt=' <<<"$tag"; then continue; fi
if [ "$alt_count" -ge "$MAX_PER_CATEGORY_PER_FILE" ]; then
WARNINGS+=("$file:0:
-without-alt findings truncated (>${MAX_PER_CATEGORY_PER_FILE} in this file)")
break
fi
WARNINGS+=("$file:$ln:
without alt attribute")
alt_count=$((alt_count + 1))
done < <(grep -noE '
]*>' "$file" || true)
# Non-HTTPS URLs with line numbers
local http_count=0
while IFS=: read -r ln _; do
if [ "$http_count" -ge "$MAX_PER_CATEGORY_PER_FILE" ]; then
WARNINGS+=("$file:0: Non-HTTPS URL findings truncated (>${MAX_PER_CATEGORY_PER_FILE} in this file)")
break
fi
WARNINGS+=("$file:$ln: Non-HTTPS URL")
http_count=$((http_count + 1))
done < <(grep -noE 'http://[^"'\''[:space:]>]*' "$file" || true)
}
# Process substitution keeps arrays in main shell (fixes v1 subshell bug)
if [ -d "$TARGET" ]; then
while IFS= read -r -d '' file; do
analyze_html "$file"
done < <(find "$TARGET" \( -name "*.html" -o -name "*.htm" \) -print0)
elif [ -f "$TARGET" ]; then
analyze_html "$TARGET"
else
fail "invalid_input" "Target is not a regular file or directory: $TARGET" "Pass a path to an .html/.htm file or a directory"
fi
issue_total=${#ISSUES[@]}
warning_total=${#WARNINGS[@]}
to_json_array() {
printf '%s\n' "$@" | jq -Rs 'split("\n") | map(select(length > 0))'
}
if [ "$issue_total" -gt 0 ]; then
issues_json=$(to_json_array "${ISSUES[@]:0:$MAX_FINDINGS}")
else
issues_json='[]'
fi
if [ "$warning_total" -gt 0 ]; then
warnings_json=$(to_json_array "${WARNINGS[@]:0:$MAX_FINDINGS}")
else
warnings_json='[]'
fi
echo "Scanned. $issue_total issues, $warning_total warnings." >&2
jq -n \
--argjson issues "$issues_json" \
--argjson warnings "$warnings_json" \
--argjson issue_total "$issue_total" \
--argjson warning_total "$warning_total" \
--argjson max "$MAX_FINDINGS" \
'{
success: true,
issues: $issues,
warnings: $warnings,
issueCount: $issue_total,
warningCount: $warning_total,
truncated: (($issue_total > $max) or ($warning_total > $max))
}'