blob: 28e00df395d96f40b74effb810158e45d593234f [file]
#!/usr/bin/env python3
# ***************************************************************************************************************************
# * Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file *
# * distributed with this work for additional information regarding copyright ownership. The ASF licenses this file *
# * to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance *
# * with the License. You may obtain a copy of the License at *
# * *
# * http://www.apache.org/licenses/LICENSE-2.0 *
# * *
# * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an *
# * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the *
# * specific language governing permissions and limitations under the License. *
# ***************************************************************************************************************************
"""
Documentation-integrity checker for the Apache Juneau Docusaurus site.
This verifies the invariants that keep pages/topics/*.md, their YAML frontmatter,
sidebars.ts, and the cross-page links mutually consistent. It is pure Python 3
standard library (no third-party dependencies) and does NOT invoke Docusaurus.
Conventions enforced (see the individual check_* functions for details):
Topic pages live in pages/topics/*.md. Each has YAML frontmatter with:
- id (optional; the EFFECTIVE id is this when present, else the filename stem)
- title (required)
- slug (required; must equal the PascalCase name part of the filename)
Filenames look like NN(.NN)*.PascalName.md -- one or more 2-digit zero-padded
numeric segments, then a PascalCase name, then '.md'. LANDING pages (the target of
a sidebar category's link.id) carry a trailing '.00' numeric segment so they sort
before their children in a directory listing; leaf pages must NOT carry a spurious
trailing '.00'.
sidebars.ts references pages by id ('topics/<ID>'); category link.id targets are
landing pages. Body links between pages use /docs/topics/<slug> (or a bare relative
<slug> within topic pages). MDX imports use @site/pages/topics/<file>.
Checks (ERRORS: 1-9, 11, 12; WARNINGS: 10, 13, 14, 15):
1 frontmatter Required frontmatter (title, slug) present and non-empty.
2 slug-filename slug == PascalCase name part of filename.
3 filename-format Filename matches ^\\d\\d(\\.\\d\\d)*\\.[A-Z][A-Za-z0-9]*\\.md$
4 unique-ids Slugs and effective ids are unique across topic files.
5 landing-00 Category-link targets end in '.00'; leaves don't.
6 dangling Every sidebar topics/<ID> resolves to a file.
7 orphans Every topic file is referenced in sidebars.ts (see allowlist).
8 counts File count vs distinct sidebar doc-ref count (+ extras each side).
9 order Sidebar depth-first order == directory (filename) order.
10 labels Sidebar dotted-number label matches target file prefix. (warn)
11 links /docs/topics/<slug> (and bare relative) links resolve.
12 imports @site/pages/topics/<file> imports resolve.
13 javadoc /site/apidocs/... link count + format sanity. (warn)
14 anchors /docs/topics/<slug>#<frag> heading targets exist. (--anchors, warn)
15 hygiene Duplicate titles, near-empty pages, stray files. (warn)
Usage:
python3 scripts/verify-docs.py [--strict] [--anchors] [--json]
[--only IDS] [--skip IDS] [--docs-dir DIR]
Options:
--strict Treat warnings as errors for the exit code.
--anchors Enable check 14 (anchor-target resolution).
--json Emit machine-readable JSON instead of the text report.
--only IDS Run only the given checks (comma-separated numbers or names).
--skip IDS Skip the given checks (comma-separated numbers or names).
--docs-dir DIR Docs root (defaults to the parent of this script's directory).
Exit code: 0 when there are no errors; non-zero when errors exist (and, under
--strict, when warnings exist).
"""
import argparse
import json
import re
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Check registry (number -> stable name). Order is the reporting order.
# ---------------------------------------------------------------------------
CHECKS = [
(1, 'frontmatter'),
(2, 'slug-filename'),
(3, 'filename-format'),
(4, 'unique-ids'),
(5, 'landing-00'),
(6, 'dangling'),
(7, 'orphans'),
(8, 'counts'),
(9, 'order'),
(10, 'labels'),
(11, 'links'),
(12, 'imports'),
(13, 'javadoc'),
(14, 'anchors'),
(15, 'hygiene'),
]
NAME_BY_NUM = {n: name for n, name in CHECKS}
NUM_BY_NAME = {name: n for n, name in CHECKS}
WARNING_CHECKS = {10, 13, 14, 15}
FILENAME_RE = re.compile(r'^(\d\d(?:\.\d\d)*)\.([A-Z][A-Za-z0-9]*)\.md$')
# Leading numeric prefix + trailing '.md' stripped => PascalCase name part.
NAME_PART_RE = re.compile(r'^\d\d(?:\.\d\d)*\.([A-Z][A-Za-z0-9]*)\.md$')
# ---------------------------------------------------------------------------
# Findings collector
# ---------------------------------------------------------------------------
class Findings:
"""Accumulates errors/warnings/notes tagged with their originating check."""
def __init__(self):
self.items = [] # list of dicts: severity, check, num, message, file, line
def add(self, severity, num, message, file=None, line=None):
self.items.append({
'severity': severity,
'check': NAME_BY_NUM[num],
'num': num,
'message': message,
'file': file,
'line': line,
})
def error(self, num, message, file=None, line=None):
self.add('error', num, message, file, line)
def warning(self, num, message, file=None, line=None):
self.add('warning', num, message, file, line)
def note(self, num, message, file=None, line=None):
self.add('note', num, message, file, line)
def counts(self, strict):
errors = sum(1 for i in self.items if i['severity'] == 'error')
warnings = sum(1 for i in self.items if i['severity'] == 'warning')
return errors, warnings
# ---------------------------------------------------------------------------
# Minimal, dependency-free sidebars.ts parser
# ---------------------------------------------------------------------------
def _strip_js_comments(text):
"""Remove /* */ and // comments while respecting single-quoted strings."""
out = []
i, n = 0, len(text)
in_str = False
while i < n:
c = text[i]
if in_str:
out.append(c)
if c == '\\' and i + 1 < n:
out.append(text[i + 1])
i += 2
continue
if c == "'":
in_str = False
i += 1
continue
if c == "'":
in_str = True
out.append(c)
i += 1
continue
if c == '/' and i + 1 < n and text[i + 1] == '*':
j = text.find('*/', i + 2)
i = n if j == -1 else j + 2
continue
if c == '/' and i + 1 < n and text[i + 1] == '/':
j = text.find('\n', i + 2)
i = n if j == -1 else j
continue
out.append(c)
i += 1
return ''.join(out)
class _JsParser:
"""Parses the restricted JS-object-literal subset used by sidebars.ts."""
def __init__(self, text):
self.s = text
self.i = 0
self.n = len(text)
def _ws(self):
while self.i < self.n and self.s[self.i] in ' \t\r\n':
self.i += 1
def parse_value(self):
self._ws()
c = self.s[self.i]
if c == '{':
return self._object()
if c == '[':
return self._array()
if c == "'":
return self._string()
return self._bareword()
def _object(self):
obj = {}
self.i += 1 # consume '{'
while True:
self._ws()
if self.s[self.i] == '}':
self.i += 1
return obj
key = self._key()
self._ws()
assert self.s[self.i] == ':', f'expected : at {self.i}'
self.i += 1
obj[key] = self.parse_value()
self._ws()
if self.i < self.n and self.s[self.i] == ',':
self.i += 1
def _array(self):
arr = []
self.i += 1 # consume '['
while True:
self._ws()
if self.s[self.i] == ']':
self.i += 1
return arr
arr.append(self.parse_value())
self._ws()
if self.i < self.n and self.s[self.i] == ',':
self.i += 1
def _key(self):
self._ws()
if self.s[self.i] == "'":
return self._string()
start = self.i
while self.i < self.n and (self.s[self.i].isalnum() or self.s[self.i] in '_$'):
self.i += 1
return self.s[start:self.i]
def _string(self):
self.i += 1 # consume opening quote
buf = []
while self.i < self.n:
c = self.s[self.i]
if c == '\\' and self.i + 1 < self.n:
buf.append(self.s[self.i + 1])
self.i += 2
continue
if c == "'":
self.i += 1
return ''.join(buf)
buf.append(c)
self.i += 1
raise ValueError('unterminated string literal')
def _bareword(self):
start = self.i
while self.i < self.n and self.s[self.i] not in ',}] \t\r\n':
self.i += 1
word = self.s[start:self.i]
if word == 'true':
return True
if word == 'false':
return False
if word == 'null':
return None
return word
def parse_sidebars(text):
"""Return the parsed `mainSidebar` array from sidebars.ts source text."""
text = _strip_js_comments(text)
m = re.search(r'mainSidebar\s*:', text)
if not m:
raise ValueError('mainSidebar not found in sidebars.ts')
parser = _JsParser(text)
parser.i = m.end()
return parser.parse_value()
# ---------------------------------------------------------------------------
# Sidebar tree walkers
# ---------------------------------------------------------------------------
def _topics_id(raw):
"""Return the effective id for a raw sidebar id, or None if not a topics ref."""
if isinstance(raw, str) and raw.startswith('topics/'):
return raw[len('topics/'):]
return None
def collect_sidebar(nodes):
"""Walk the sidebar tree, returning:
- doc_refs: effective ids referenced by type:'doc' entries (with multiplicity).
- landing_ids: effective ids that are a category link.id (LANDING pages).
- preorder: depth-first pre-order sequence of topic ids (category link first,
then its items).
- labels: list of (effective_id, label) pairs for topics targets.
"""
doc_refs = []
landing_ids = []
preorder = []
labels = []
def walk(node):
if isinstance(node, list):
for item in node:
walk(item)
return
if not isinstance(node, dict):
return
ntype = node.get('type')
if ntype == 'doc':
tid = _topics_id(node.get('id'))
if tid is not None:
doc_refs.append(tid)
preorder.append(tid)
if 'label' in node:
labels.append((tid, node['label']))
elif ntype == 'category':
link = node.get('link')
link_id = None
if isinstance(link, dict):
link_id = _topics_id(link.get('id'))
if link_id is not None:
landing_ids.append(link_id)
preorder.append(link_id)
if 'label' in node:
labels.append((link_id, node['label']))
for item in node.get('items', []):
walk(item)
walk(nodes)
return doc_refs, landing_ids, preorder, labels
# ---------------------------------------------------------------------------
# Topic file model
# ---------------------------------------------------------------------------
class Topic:
def __init__(self, path):
self.path = path
self.name = path.name
self.text = path.read_text(encoding='utf-8')
self.frontmatter, self.body = _split_frontmatter(self.text)
self.id = self.frontmatter.get('id')
self.title = self.frontmatter.get('title')
self.slug = self.frontmatter.get('slug')
m = FILENAME_RE.match(self.name)
self.numeric_prefix = m.group(1) if m else None
self.name_part = m.group(2) if m else None
@property
def effective_id(self):
# Explicit frontmatter id wins; otherwise the filename without '.md'.
if self.id:
return self.id
return self.name[:-3] if self.name.endswith('.md') else self.name
def _split_frontmatter(text):
"""Return (frontmatter dict, body string). Frontmatter is a simple key: value block."""
m = re.match(r'^---\s*\n(.*?)\n---\s*\n?(.*)$', text, re.DOTALL)
if not m:
return {}, text
fm = {}
for line in m.group(1).splitlines():
line = line.rstrip()
if not line or line.lstrip().startswith('#'):
continue
km = re.match(r'^([A-Za-z0-9_-]+)\s*:\s*(.*)$', line)
if not km:
continue
key = km.group(1)
val = km.group(2).strip()
if len(val) >= 2 and val[0] in '"\'' and val[-1] == val[0]:
val = val[1:-1]
fm[key] = val
return fm, m.group(2)
def _padded_prefix_from_label(label):
"""Extract a zero-padded numeric prefix from a sidebar label, or None.
'3.3.1. Java Beans Support' -> '03.03.01' ; '8. juneau-rest' -> '08'.
"""
m = re.match(r'^\s*(\d+(?:\.\d+)*)\.\s', label)
if not m:
return None
return '.'.join(seg.zfill(2) for seg in m.group(1).split('.'))
# ---------------------------------------------------------------------------
# Body-scan helpers (strip code so links inside code blocks are ignored)
# ---------------------------------------------------------------------------
def _strip_code(text):
text = re.sub(r'```.*?```', '', text, flags=re.DOTALL)
text = re.sub(r'`[^`]*`', '', text)
return text
def _iter_lines_with(text, pattern):
"""Yield (lineno, match) for a compiled pattern over the code-stripped text."""
stripped = _strip_code(text)
for m in pattern.finditer(stripped):
line = stripped.count('\n', 0, m.start()) + 1
yield line, m
def _github_anchor(heading):
"""Approximate Docusaurus/GitHub heading-anchor slugification."""
h = heading.strip().lower()
h = re.sub(r'`', '', h)
h = re.sub(r'[^a-z0-9 \-_]', '', h)
h = h.replace(' ', '-')
h = re.sub(r'-+', '-', h).strip('-')
return h
# ---------------------------------------------------------------------------
# Checks
# ---------------------------------------------------------------------------
def run_checks(docs_dir, enabled, anchors_enabled, findings):
topics_dir = docs_dir / 'pages' / 'topics'
pages_dir = docs_dir / 'pages'
sidebars_path = docs_dir / 'sidebars.ts'
allowlist_path = docs_dir / 'scripts' / 'verify-docs-allowlist.txt'
md_files = sorted(topics_dir.glob('*.md'), key=lambda p: p.name)
topics = [Topic(p) for p in md_files]
file_count = len(topics)
# Effective-id / slug indexes (first occurrence wins for lookups).
effid_to_topic = {}
slug_to_topic = {}
for t in topics:
effid_to_topic.setdefault(t.effective_id, t)
if t.slug:
slug_to_topic.setdefault(t.slug, t)
all_slugs = {t.slug for t in topics if t.slug}
# Parse sidebars.ts once.
sidebar_tree = None
doc_refs = landing_ids = preorder = labels = None
if sidebars_path.exists():
try:
sidebar_tree = parse_sidebars(sidebars_path.read_text(encoding='utf-8'))
doc_refs, landing_ids, preorder, labels = collect_sidebar(sidebar_tree)
except Exception as e: # noqa: BLE001 - report parse failure as an error
findings.error(6, f'Failed to parse sidebars.ts: {e}', file='sidebars.ts')
landing_set = set(landing_ids or [])
referenced_ids = set(doc_refs or []) | landing_set
def on(num):
return num in enabled
# --- 1. frontmatter ----------------------------------------------------
if on(1):
for t in topics:
if not t.title:
findings.error(1, 'Missing/empty frontmatter `title`', file=t.name)
if not t.slug:
findings.error(1, 'Missing/empty frontmatter `slug`', file=t.name)
# --- 2. slug-filename --------------------------------------------------
if on(2):
for t in topics:
if t.name_part is None:
continue # filename-format error reported by check 3
if t.slug and t.slug != t.name_part:
findings.error(
2,
f'slug `{t.slug}` != filename name part `{t.name_part}`',
file=t.name)
# --- 3. filename-format ------------------------------------------------
if on(3):
for t in topics:
if not FILENAME_RE.match(t.name):
findings.error(
3,
'Filename does not match ^\\d\\d(\\.\\d\\d)*\\.[A-Z][A-Za-z0-9]*\\.md$',
file=t.name)
# --- 4. unique-ids -----------------------------------------------------
if on(4):
slug_map = {}
id_map = {}
for t in topics:
if t.slug:
slug_map.setdefault(t.slug, []).append(t.name)
id_map.setdefault(t.effective_id, []).append(t.name)
for slug, names in sorted(slug_map.items()):
if len(names) > 1:
findings.error(4, f'Duplicate slug `{slug}` in: {", ".join(sorted(names))}')
for eid, names in sorted(id_map.items()):
if len(names) > 1:
findings.error(4, f'Duplicate effective id `{eid}` in: {", ".join(sorted(names))}')
# --- 5. landing-00 -----------------------------------------------------
if on(5) and sidebar_tree is not None:
for t in topics:
if t.numeric_prefix is None:
continue
ends_00 = t.numeric_prefix.split('.')[-1] == '00'
is_landing = t.effective_id in landing_set
if is_landing and not ends_00:
findings.error(
5,
f'Landing page (category link target) `{t.effective_id}` '
f'lacks a trailing `.00` prefix segment',
file=t.name)
if not is_landing and ends_00:
findings.error(
5,
f'Leaf page carries a spurious trailing `.00` segment '
f'(prefix `{t.numeric_prefix}`)',
file=t.name)
# --- 6. dangling -------------------------------------------------------
if on(6) and sidebar_tree is not None:
for rid in sorted(referenced_ids):
if rid not in effid_to_topic:
findings.error(6, f'Sidebar reference `topics/{rid}` resolves to no file')
# --- 7. orphans --------------------------------------------------------
allowlist = set()
if allowlist_path.exists():
for line in allowlist_path.read_text(encoding='utf-8').splitlines():
line = line.strip()
if line and not line.startswith('#'):
allowlist.add(line)
if on(7) and sidebar_tree is not None:
for t in topics:
if t.effective_id not in referenced_ids and t.effective_id not in allowlist:
findings.error(
7,
f'Orphan: `{t.effective_id}` is not referenced in sidebars.ts',
file=t.name)
# --- 8. counts ---------------------------------------------------------
if on(8) and sidebar_tree is not None:
distinct_refs = referenced_ids
findings.note(
8,
f'{file_count} topic files vs {len(distinct_refs)} distinct sidebar refs')
files_not_ref = sorted(t.effective_id for t in topics
if t.effective_id not in distinct_refs)
refs_not_file = sorted(r for r in distinct_refs if r not in effid_to_topic)
if files_not_ref:
findings.note(8, f'Files not referenced ({len(files_not_ref)}): '
f'{", ".join(files_not_ref)}')
if refs_not_file:
findings.note(8, f'Refs without a file ({len(refs_not_file)}): '
f'{", ".join(refs_not_file)}')
# --- 9. order ----------------------------------------------------------
if on(9) and sidebar_tree is not None:
seen = set()
seq_files = []
for rid in preorder:
if rid in seen:
continue
seen.add(rid)
t = effid_to_topic.get(rid)
if t is not None: # dangling refs handled by check 6
seq_files.append(t.name)
expected = sorted(seq_files)
if seq_files != expected:
div = None
for idx, (got, want) in enumerate(zip(seq_files, expected)):
if got != want:
div = (idx, got, want)
break
if div is None:
div = (min(len(seq_files), len(expected)), '<end>', '<end>')
idx, got, want = div
findings.error(
9,
f'Sidebar order != directory order at position {idx}: '
f'sidebar has `{got}`, directory-sorted expects `{want}`')
lo = max(0, idx - 2)
hi = idx + 3
findings.note(9, 'sidebar[{}:{}] = {}'.format(lo, hi, seq_files[lo:hi]))
findings.note(9, 'directory[{}:{}] = {}'.format(lo, hi, expected[lo:hi]))
# --- 10. labels (warning) ---------------------------------------------
if on(10) and sidebar_tree is not None:
for eid, label in labels:
padded = _padded_prefix_from_label(label)
if padded is None:
continue
t = effid_to_topic.get(eid)
if t is None or t.numeric_prefix is None:
continue
prefix = t.numeric_prefix
if prefix != padded and prefix != padded + '.00':
findings.warning(
10,
f'Sidebar label `{label}` -> `{padded}` does not match '
f'file prefix `{prefix}` ({t.name})')
# --- 11. links ---------------------------------------------------------
if on(11):
md_link_re = re.compile(r'\]\(([^)\s]+)\)')
docs_topics_re = re.compile(r'/docs/topics/([^)\s#]+)')
for page in sorted(pages_dir.rglob('*.md')) + sorted(pages_dir.rglob('*.mdx')):
text = page.read_text(encoding='utf-8')
rel = page.relative_to(docs_dir).as_posix()
in_topics = topics_dir in page.parents
for line, m in _iter_lines_with(text, md_link_re):
target = m.group(1)
frag = target.split('#', 1)[0]
if frag.startswith('/docs/topics/'):
slug = frag[len('/docs/topics/'):].rstrip('/')
if slug and slug not in all_slugs:
findings.error(
11, f'Broken topic link `/docs/topics/{slug}`',
file=rel, line=line)
elif in_topics and re.fullmatch(r'[A-Za-z][A-Za-z0-9]*', frag):
# Bare relative link within a topic page -> resolves to a sibling slug.
if frag not in all_slugs:
findings.error(
11, f'Broken relative topic link `{frag}`',
file=rel, line=line)
# --- 12. imports -------------------------------------------------------
if on(12):
import_re = re.compile(r'@site/pages/topics/([^\s\'")]+)')
for page in sorted(pages_dir.rglob('*.md')) + sorted(pages_dir.rglob('*.mdx')):
text = page.read_text(encoding='utf-8')
rel = page.relative_to(docs_dir).as_posix()
for line, m in _iter_lines_with(text, import_re):
target = (topics_dir / m.group(1))
if not target.exists():
findings.error(
12, f'Broken import `@site/pages/topics/{m.group(1)}`',
file=rel, line=line)
# --- 13. javadoc (warning) --------------------------------------------
if on(13):
apidocs_re = re.compile(r'/site/apidocs/([^)\s\'">]+)')
total = 0
for page in sorted(pages_dir.rglob('*.md')) + sorted(pages_dir.rglob('*.mdx')):
text = page.read_text(encoding='utf-8')
rel = page.relative_to(docs_dir).as_posix()
for line, m in _iter_lines_with(text, apidocs_re):
total += 1
path_part = m.group(1).split('#', 1)[0]
if not path_part.endswith('.html'):
findings.warning(
13, f'Javadoc link does not point at a .html file: '
f'/site/apidocs/{m.group(1)}',
file=rel, line=line)
findings.note(13, f'{total} /site/apidocs/ links found (format sanity-checked only)')
# --- 14. anchors (warning; off by default) ----------------------------
if on(14) and anchors_enabled:
heading_anchors = {}
for t in topics:
anchors = set()
for line in _strip_code(t.body).splitlines():
hm = re.match(r'^#{1,6}\s+(.*?)\s*$', line)
if hm:
anchors.add(_github_anchor(hm.group(1)))
if t.slug:
heading_anchors[t.slug] = anchors
anchored_re = re.compile(r'/docs/topics/([A-Za-z0-9]+)#([^)\s"<>]+)')
for page in sorted(pages_dir.rglob('*.md')) + sorted(pages_dir.rglob('*.mdx')):
text = page.read_text(encoding='utf-8')
rel = page.relative_to(docs_dir).as_posix()
for line, m in _iter_lines_with(text, anchored_re):
slug, frag = m.group(1), m.group(2)
if slug in heading_anchors and frag not in heading_anchors[slug]:
findings.warning(
14, f'Anchor `#{frag}` not found on `/docs/topics/{slug}`',
file=rel, line=line)
# --- 15. hygiene (warning) --------------------------------------------
if on(15):
title_map = {}
for t in topics:
if t.title:
title_map.setdefault(t.title, []).append(t.name)
for title, names in sorted(title_map.items()):
if len(names) > 1:
findings.warning(
15, f'Duplicate title `{title}` in: {", ".join(sorted(names))}')
for t in topics:
body_chars = len(re.sub(r'\s', '', t.body))
if body_chars < 50:
findings.warning(
15, f'Near-empty page ({body_chars} non-whitespace body chars)',
file=t.name)
if topics_dir.exists():
for p in sorted(topics_dir.iterdir()):
if p.is_file() and not p.name.endswith('.md'):
findings.warning(15, 'Stray non-.md file in pages/topics/', file=p.name)
return file_count
# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------
def parse_check_selection(value):
"""Turn a comma-separated list of numbers/names into a set of check numbers."""
result = set()
for token in value.split(','):
token = token.strip()
if not token:
continue
if token.isdigit():
n = int(token)
if n in NAME_BY_NUM:
result.add(n)
else:
raise ValueError(f'unknown check number: {token}')
elif token in NUM_BY_NAME:
result.add(NUM_BY_NAME[token])
else:
raise ValueError(f'unknown check: {token}')
return result
def _fmt_location(item):
if item['file'] and item['line']:
return f"{item['file']}:{item['line']}"
if item['file']:
return item['file']
return ''
def print_text_report(findings, file_count, enabled):
errors = [i for i in findings.items if i['severity'] == 'error']
warnings = [i for i in findings.items if i['severity'] == 'warning']
notes = [i for i in findings.items if i['severity'] == 'note']
def emit(items, header):
if not items:
return
print(f'\n{header}')
print('=' * len(header))
for i in items:
loc = _fmt_location(i)
tag = f'[{i["num"]:>2} {i["check"]}]'
if loc:
print(f' {tag} {loc}: {i["message"]}')
else:
print(f' {tag} {i["message"]}')
print('Juneau Documentation Integrity Checker')
print('=' * 50)
enabled_str = ', '.join(NAME_BY_NUM[n] for n in sorted(enabled))
print(f'Checks run: {enabled_str}')
emit(errors, f'ERRORS ({len(errors)})')
emit(warnings, f'WARNINGS ({len(warnings)})')
emit(notes, 'NOTES')
print(f'\nverify-docs: {len(errors)} errors, {len(warnings)} warnings '
f'across {file_count} files')
def print_json_report(findings, file_count, enabled):
errors = sum(1 for i in findings.items if i['severity'] == 'error')
warnings = sum(1 for i in findings.items if i['severity'] == 'warning')
payload = {
'file_count': file_count,
'checks_run': [NAME_BY_NUM[n] for n in sorted(enabled)],
'summary': {'errors': errors, 'warnings': warnings},
'findings': findings.items,
}
print(json.dumps(payload, indent=2))
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main(argv=None):
parser = argparse.ArgumentParser(
description='Documentation-integrity checker for the Juneau Docusaurus site.')
parser.add_argument('--strict', action='store_true',
help='Treat warnings as errors for the exit code.')
parser.add_argument('--anchors', action='store_true',
help='Enable anchor-target resolution (check 14).')
parser.add_argument('--json', action='store_true',
help='Emit machine-readable JSON instead of text.')
parser.add_argument('--only', default=None,
help='Run only these checks (comma-separated numbers or names).')
parser.add_argument('--skip', default=None,
help='Skip these checks (comma-separated numbers or names).')
parser.add_argument('--docs-dir', default=None,
help='Docs root (defaults to the parent of scripts/).')
args = parser.parse_args(argv)
docs_dir = Path(args.docs_dir).resolve() if args.docs_dir \
else Path(__file__).parent.parent.resolve()
topics_dir = docs_dir / 'pages' / 'topics'
if not topics_dir.is_dir():
print(f'ERROR: topics directory not found: {topics_dir}', file=sys.stderr)
return 2
enabled = set(NAME_BY_NUM)
try:
if args.only:
enabled = parse_check_selection(args.only)
if args.skip:
enabled -= parse_check_selection(args.skip)
except ValueError as e:
print(f'ERROR: {e}', file=sys.stderr)
return 2
findings = Findings()
file_count = run_checks(docs_dir, enabled, args.anchors, findings)
if args.json:
print_json_report(findings, file_count, enabled)
else:
print_text_report(findings, file_count, enabled)
errors, warnings = findings.counts(args.strict)
if errors > 0:
return 1
if args.strict and warnings > 0:
return 1
return 0
if __name__ == '__main__':
sys.exit(main())