202 lines
6.1 KiB
Python
202 lines
6.1 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
from collections import Counter, defaultdict
|
|
from pathlib import Path
|
|
|
|
|
|
def safe_user_path(path_value, base_dir="."):
|
|
"""Resolve a CLI path under the current workspace."""
|
|
if base_dir != ".":
|
|
raise ValueError("Custom base directories are not supported for CLI paths")
|
|
base_path = Path.cwd().resolve()
|
|
resolved_path = Path(path_value).expanduser().resolve()
|
|
try:
|
|
resolved_path.relative_to(base_path)
|
|
except ValueError as exc:
|
|
raise ValueError(f"Path escapes allowed directory: {path_value}") from exc
|
|
return resolved_path
|
|
|
|
try:
|
|
import tomllib
|
|
except ModuleNotFoundError: # pragma: no cover
|
|
tomllib = None
|
|
|
|
|
|
SHORTCODE_RE = re.compile(
|
|
r"\{\{[<%](?:/\*)?\s*(?!/)([A-Za-z0-9][A-Za-z0-9_/-]*)",
|
|
re.MULTILINE,
|
|
)
|
|
YAML_KEY_RE = re.compile(r"^([A-Za-z0-9_-]+):", re.MULTILINE)
|
|
TOML_KEY_RE = re.compile(r"^([A-Za-z0-9_.-]+)\s*=", re.MULTILINE)
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser(
|
|
description="Inventory Hugo config, shortcodes, render hooks, and content patterns.",
|
|
)
|
|
parser.add_argument("--site-root", required=True, help="Path to the Hugo site root")
|
|
parser.add_argument("--output", help="Write JSON output to this file")
|
|
return parser.parse_args()
|
|
|
|
|
|
def ensure_dir(path: Path) -> Path:
|
|
resolved = path.expanduser().resolve()
|
|
if not resolved.is_dir():
|
|
raise SystemExit(f"Site root does not exist or is not a directory: {resolved}")
|
|
return resolved
|
|
|
|
|
|
def read_text(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8", errors="replace")
|
|
|
|
|
|
def relpath(path: Path, root: Path) -> str:
|
|
return str(path.resolve().relative_to(root))
|
|
|
|
|
|
def find_config_files(root: Path):
|
|
direct = [
|
|
root / "hugo.toml",
|
|
root / "hugo.yaml",
|
|
root / "hugo.yml",
|
|
root / "hugo.json",
|
|
root / "config.toml",
|
|
root / "config.yaml",
|
|
root / "config.yml",
|
|
root / "config.json",
|
|
]
|
|
found = [p for p in direct if p.is_file()]
|
|
config_dir = root / "config"
|
|
if config_dir.is_dir():
|
|
found.extend(sorted(p for p in config_dir.rglob("*") if p.is_file()))
|
|
return found
|
|
|
|
|
|
def load_config_summary(path: Path):
|
|
suffix = path.suffix.lower()
|
|
if suffix == ".toml" and tomllib is not None:
|
|
data = tomllib.loads(read_text(path))
|
|
elif suffix == ".json":
|
|
data = json.loads(read_text(path))
|
|
else:
|
|
return {
|
|
"path": str(path),
|
|
"format": suffix.lstrip("."),
|
|
"parsed": False,
|
|
}
|
|
|
|
module_mounts = data.get("module", {}).get("mounts", [])
|
|
markup = data.get("markup", {})
|
|
goldmark = markup.get("goldmark", {})
|
|
passthrough = (
|
|
goldmark.get("extensions", {})
|
|
.get("passthrough", {})
|
|
.get("delimiters", {})
|
|
)
|
|
parser_attribute = goldmark.get("parser", {}).get("attribute", {})
|
|
render_hook_params = data.get("params", {}).get("render_hooks", {}).get("link", {})
|
|
|
|
return {
|
|
"path": str(path),
|
|
"format": suffix.lstrip("."),
|
|
"parsed": True,
|
|
"module_mounts": module_mounts,
|
|
"goldmark_passthrough_delimiters": passthrough,
|
|
"goldmark_block_attributes": parser_attribute.get("block"),
|
|
"link_render_error_level": render_hook_params.get("errorLevel"),
|
|
}
|
|
|
|
|
|
def extract_frontmatter_keys(text: str):
|
|
if text.startswith("---\n"):
|
|
end = text.find("\n---", 4)
|
|
if end != -1:
|
|
return sorted(set(YAML_KEY_RE.findall(text[4:end])))
|
|
if text.startswith("+++\n"):
|
|
end = text.find("\n+++", 4)
|
|
if end != -1:
|
|
return sorted({k.split(".")[0] for k in TOML_KEY_RE.findall(text[4:end])})
|
|
return []
|
|
|
|
|
|
def content_files(root: Path):
|
|
content_root = root / "content"
|
|
if not content_root.is_dir():
|
|
return []
|
|
exts = {".md", ".markdown", ".mdown", ".gotmpl"}
|
|
return sorted(p for p in content_root.rglob("*") if p.is_file() and p.suffix.lower() in exts)
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
site_root = ensure_dir(Path(args.site_root))
|
|
|
|
shortcode_files = sorted((site_root / "layouts" / "_shortcodes").glob("*"))
|
|
render_hook_files = sorted((site_root / "layouts" / "_markup").glob("render-*"))
|
|
|
|
shortcode_usage = Counter()
|
|
shortcode_locations = defaultdict(list)
|
|
frontmatter_keys = Counter()
|
|
content_entries = []
|
|
|
|
for path in content_files(site_root):
|
|
text = read_text(path)
|
|
keys = extract_frontmatter_keys(text)
|
|
for key in keys:
|
|
frontmatter_keys[key] += 1
|
|
|
|
names = SHORTCODE_RE.findall(text)
|
|
unique_names = sorted(set(names))
|
|
for name in names:
|
|
shortcode_usage[name] += 1
|
|
for name in unique_names:
|
|
shortcode_locations[name].append(relpath(path, site_root))
|
|
|
|
content_entries.append(
|
|
{
|
|
"path": relpath(path, site_root),
|
|
"frontmatter_keys": keys,
|
|
"shortcodes": unique_names,
|
|
}
|
|
)
|
|
|
|
result = {
|
|
"site_root": str(site_root),
|
|
"config_files": [load_config_summary(path) for path in find_config_files(site_root)],
|
|
"shortcode_templates": [
|
|
{
|
|
"name": path.stem,
|
|
"path": relpath(path, site_root),
|
|
}
|
|
for path in shortcode_files
|
|
if path.is_file()
|
|
],
|
|
"render_hooks": [
|
|
{
|
|
"name": path.stem,
|
|
"path": relpath(path, site_root),
|
|
}
|
|
for path in render_hook_files
|
|
if path.is_file()
|
|
],
|
|
"frontmatter_key_frequency": dict(frontmatter_keys.most_common()),
|
|
"shortcode_usage_frequency": dict(shortcode_usage.most_common()),
|
|
"shortcode_locations": dict(sorted(shortcode_locations.items())),
|
|
"content_files": content_entries,
|
|
}
|
|
|
|
payload = json.dumps(result, indent=2, sort_keys=True)
|
|
if args.output:
|
|
output = safe_user_path(args.output)
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_text(payload + "\n", encoding="utf-8")
|
|
else:
|
|
print(payload)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|