Files
export_1c_help/export1c_help/docs_export.py
T
mihailkudravcev 31659d4287 fix(export_docs): sidebar folder tree follows parent/child paths
Render _Sidebar entries in DFS order instead of sorting by depth globally.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 13:42:22 +03:00

912 lines
30 KiB
Python

"""Export arbitrary markdown docs to wiki subfolders."""
from __future__ import annotations
import glob
import hashlib
import json
import os
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
from urllib.parse import quote
from export1c_help import __version__
from export1c_help.fscompat import copy_file, ensure_dir, remove_file, write_text
from export1c_help.wiki import WikiPushError, prepare_wiki_clone
_MD_LINK_RE = re.compile(r"(!?\[[^\]]*\]\()([^)]+)(\))")
_MD_REF_DEF_RE = re.compile(
r"^(?P<prefix>\s*\[[^\]]+\]:\s*)(?P<target><[^>]+>|\S+)(?P<suffix>.*)$",
re.MULTILINE,
)
_URL_SCHEMES = ("http://", "https://", "mailto:", "ftp://")
_EXPORT_DOCS_MARKER_BEGIN = "<!-- export_docs:begin -->"
_EXPORT_DOCS_MARKER_END = "<!-- export_docs:end -->"
_RESERVED_ROOT_PAGES = {"Home.md", "History.md", "_Sidebar.md"}
@dataclass(frozen=True)
class RepoLinkContext:
"""Rewrite links to repo files (outside wiki export) as Gitea src/ URLs."""
root: Path
web_base: str
branch: str = "main"
def _git_web_base(remote_url: str) -> str:
u = remote_url.strip().rstrip("/")
if u.lower().endswith(".git"):
return u[:-4]
return u
def _repo_src_url(ctx: RepoLinkContext, abs_path: Path) -> str | None:
try:
rel = abs_path.resolve().relative_to(ctx.root.resolve())
except ValueError:
return None
return f"{ctx.web_base.rstrip('/')}/src/branch/{ctx.branch}/{rel.as_posix()}"
@dataclass(frozen=True)
class DocItem:
src: Path
src_rel: Path
dst_rel: Path
pattern_root: Path
@dataclass(frozen=True)
class DocsStats:
files_total: int
files_written: int
files_deleted: int
markdown_files: int
attachment_files: int
folders: dict[str, tuple[int, int]]
def _has_glob(s: str) -> bool:
return any(ch in s for ch in ("*", "?", "["))
def _static_prefix(pattern: str) -> Path:
path = Path(pattern)
parts = list(path.parts)
keep: list[str] = []
for part in parts:
if _has_glob(part):
break
keep.append(part)
if not keep:
return Path.cwd()
return Path(*keep)
def _iter_markdown_files(raw_pattern: str) -> list[Path]:
p = Path(raw_pattern)
if p.is_file():
return [p.resolve()] if p.suffix.lower() == ".md" else []
if p.is_dir():
return sorted(x.resolve() for x in p.rglob("*.md") if x.is_file())
matched = [Path(x).resolve() for x in glob.glob(raw_pattern, recursive=True)]
return sorted(x for x in matched if x.is_file() and x.suffix.lower() == ".md")
def collect_docs(files_patterns: list[str], *, folder: str) -> list[DocItem]:
if not files_patterns:
raise ValueError("no --files patterns supplied")
folder_clean = folder.strip().strip("/").strip("\\")
dst_root = Path(folder_clean) if folder_clean else Path(".")
explicit_files = [
Path(raw).resolve()
for raw in files_patterns
if not _has_glob(raw) and Path(raw).is_file()
]
explicit_root: Path | None = None
if explicit_files:
common = os.path.commonpath([str(p) for p in explicit_files])
common_path = Path(common)
explicit_root = common_path if common_path.is_dir() else common_path.parent
items: dict[Path, DocItem] = {}
for raw in files_patterns:
raw_path = Path(raw)
if explicit_root is not None and not _has_glob(raw) and raw_path.is_file():
root = explicit_root
else:
root = _static_prefix(raw).resolve()
for src in _iter_markdown_files(raw):
try:
src_rel = src.relative_to(root)
except ValueError:
src_rel = Path(src.name)
dst_rel = dst_root / src_rel
items[src] = DocItem(
src=src,
src_rel=src_rel,
dst_rel=dst_rel,
pattern_root=root,
)
return sorted(items.values(), key=lambda x: x.src.as_posix())
def _split_target(value: str) -> tuple[str, str]:
s = value.strip()
if not s:
return "", ""
if s.startswith("<"):
idx = s.find(">")
if idx > 0:
return s[1:idx], s[idx + 1 :]
parts = s.split(maxsplit=1)
if len(parts) == 1:
return parts[0], ""
return parts[0], " " + parts[1]
def _rewrite_links(
text: str,
*,
src: Path,
dst_rel: Path,
src_to_md_dst: dict[Path, Path],
map_src_abs_to_dst_rel,
attachments: set[Path],
repo: RepoLinkContext | None = None,
) -> str:
def repl(m: re.Match[str]) -> str:
left, raw_target, right = m.group(1), m.group(2), m.group(3)
target, tail = _split_target(raw_target)
if not target:
return m.group(0)
if target.startswith(_URL_SCHEMES) or target.startswith(("#", "/")):
return m.group(0)
anchor = ""
base = target
if "#" in target:
base, anchor = target.split("#", 1)
anchor = "#" + anchor
if not base:
return m.group(0)
cand = (src.parent / base).resolve()
dst_md_target = src_to_md_dst.get(cand)
if dst_md_target is None and Path(base).suffix == "":
dst_md_target = src_to_md_dst.get(cand.with_suffix(".md"))
if dst_md_target is not None:
dst_target_rel = dst_md_target
else:
if not cand.is_file():
return m.group(0)
if repo is not None:
src_url = _repo_src_url(repo, cand)
if src_url:
return f"{left}{src_url}{anchor}{tail}{right}"
dst_target_rel = map_src_abs_to_dst_rel(cand)
if dst_target_rel is None:
return m.group(0)
attachments.add(cand)
rel = os.path.relpath(
dst_target_rel.as_posix(), start=dst_rel.parent.as_posix()
)
rel = rel.replace("\\", "/")
return f"{left}{_encode_md_target(rel)}{anchor}{tail}{right}"
out = _MD_LINK_RE.sub(repl, text)
def repl_def(m: re.Match[str]) -> str:
prefix = m.group("prefix")
raw_target = m.group("target")
suffix = m.group("suffix") or ""
target = raw_target.strip()
if target.startswith("<") and target.endswith(">"):
target = target[1:-1].strip()
if not target:
return m.group(0)
if target.startswith(_URL_SCHEMES) or target.startswith(("#", "/")):
return m.group(0)
if "#" in target:
base, anchor = target.split("#", 1)
anchor = "#" + anchor
else:
base, anchor = target, ""
if not base:
return m.group(0)
cand = (src.parent / base).resolve()
dst_md_target = src_to_md_dst.get(cand)
if dst_md_target is None and Path(base).suffix == "":
dst_md_target = src_to_md_dst.get(cand.with_suffix(".md"))
if dst_md_target is not None:
dst_target_rel = dst_md_target
else:
if not cand.is_file():
return m.group(0)
if repo is not None:
src_url = _repo_src_url(repo, cand)
if src_url:
return f"{prefix}{src_url}{anchor}{suffix}"
dst_target_rel = map_src_abs_to_dst_rel(cand)
if dst_target_rel is None:
return m.group(0)
attachments.add(cand)
rel = os.path.relpath(dst_target_rel.as_posix(), start=dst_rel.parent.as_posix())
rel = rel.replace("\\", "/")
return f"{prefix}{_encode_md_target(rel)}{anchor}{suffix}"
return _MD_REF_DEF_RE.sub(repl_def, out)
def _manifest_name(folder: str) -> str:
key = folder.strip().strip("/").strip("\\") or "root"
digest = hashlib.sha1(key.encode("utf-8")).hexdigest()[:8]
return f"manifest.export_docs.{digest}.json"
def _flatten_rel_path(rel: Path) -> Path:
parts = [p for p in rel.as_posix().split("/") if p and p != "."]
if not parts:
return Path("page.md")
name = "__".join(parts)
return Path(name)
def _extract_doc_title(text: str, fallback_stem: str) -> str:
m = re.search(r"^\s*#\s+(.+?)\s*$", text, flags=re.MULTILINE)
if m:
return m.group(1).strip()
fallback = fallback_stem.replace("_", " ").strip()
return fallback or "Untitled"
def _target_rel_for(rel: Path, *, flat_root: bool) -> Path:
return _flatten_rel_path(rel) if flat_root else rel
def _encode_md_target(target: str) -> str:
# Gitea wiki page URLs omit the `.md` suffix (file `foo.md` → page `/wiki/foo`).
t = target.replace("\\", "/")
if t.lower().endswith(".md"):
t = t[:-3]
return quote(t, safe="/#._-~")
def _is_folder_index_src(src: Path) -> bool:
return src.name.casefold() == "index.md"
def _strip_leading_h1(text: str) -> str:
return re.sub(r"^\s*#\s+.+?\n+", "", text, count=1, flags=re.MULTILINE).lstrip()
def _folder_label(folder_rel: Path, folder_clean: str) -> str:
if folder_rel == Path("."):
return folder_clean or "root"
return folder_rel.as_posix()
def _folder_short_name(folder_rel: Path, folder_clean: str) -> str:
if folder_rel == Path("."):
return folder_clean or "root"
return folder_rel.name or folder_rel.as_posix()
def _rel_link(from_rel: Path, to_rel: Path) -> str:
rel = os.path.relpath(to_rel.as_posix(), start=from_rel.parent.as_posix())
return _encode_md_target(rel.replace("\\", "/"))
def _merge_marker_block(existing: str, block: str) -> str:
import re
# Be tolerant: allow whitespace differences around markers.
marker_re = re.compile(
r"<!--\s*export_docs:begin\s*-->.*?<!--\s*export_docs:end\s*-->",
flags=re.DOTALL,
)
new_block = f"{_EXPORT_DOCS_MARKER_BEGIN}\n{block}\n{_EXPORT_DOCS_MARKER_END}"
if marker_re.search(existing):
return marker_re.sub(new_block, existing)
if existing and not existing.endswith("\n"):
existing += "\n"
if existing and not existing.endswith("\n\n"):
existing += "\n"
return existing + "\n" + new_block + "\n"
def _docs_index_rel(folder: str, managed_files: set[str]) -> str | None:
folder_clean = folder.strip().strip("/").strip("\\")
if folder_clean:
cand = f"{folder_clean}/INDEX.md"
else:
cand = "INDEX.md"
if cand in managed_files:
return cand
flat = _flatten_rel_path(Path(cand)).as_posix()
if flat in managed_files:
return flat
return None
def _sidebar_folder_links(folder: str, managed_files: set[str]) -> list[tuple[int, str, str]]:
"""
Return (depth, label, target) for folder index pages in wiki, in tree order.
Supports both:
- nested mode: docs/sub/INDEX.md
- flat mode: docs__sub__INDEX.md
"""
folder_clean = folder.strip().strip("/").strip("\\")
if not folder_clean:
return []
target_by_folder: dict[str, str] = {}
for rel in managed_files:
if not rel.endswith("/INDEX.md"):
continue
if rel.startswith(folder_clean + "/"):
folder_rel = rel[: -len("/INDEX.md")]
target_by_folder[folder_rel] = rel
root_flat = f"{folder_clean}__INDEX.md"
if root_flat in managed_files:
target_by_folder[folder_clean] = root_flat
prefix = f"{folder_clean}__"
suffix = "__INDEX.md"
for rel in managed_files:
if not rel.startswith(prefix) or not rel.endswith(suffix) or rel == root_flat:
continue
mid = rel[len(prefix) : -len(suffix)]
if not mid:
continue
folder_rel = folder_clean + "/" + mid.replace("__", "/")
target_by_folder[folder_rel] = rel
if not target_by_folder:
return []
children: dict[str, list[str]] = {}
for folder_rel in target_by_folder:
if folder_rel == folder_clean:
continue
parent = Path(folder_rel).parent.as_posix()
if parent != folder_clean and not parent.startswith(folder_clean + "/"):
parent = folder_clean
children.setdefault(parent, []).append(folder_rel)
ordered: list[tuple[int, str, str]] = []
def walk(parent: str, depth: int) -> None:
for child in sorted(children.get(parent, []), key=lambda x: x.casefold()):
ordered.append((depth, child, target_by_folder[child]))
walk(child, depth + 1)
if folder_clean in target_by_folder:
ordered.append((0, folder_clean, target_by_folder[folder_clean]))
walk(folder_clean, 1)
else:
walk(folder_clean, 0)
return ordered
def ensure_root_navigation(
*,
work_dir: Path,
folder: str,
managed_files: set[str],
) -> None:
index_rel = _docs_index_rel(folder, managed_files)
home_path = work_dir / "Home.md"
sidebar_path = work_dir / "_Sidebar.md"
home_text = home_path.read_text(encoding="utf-8", errors="ignore") if home_path.is_file() else ""
sidebar_text = (
sidebar_path.read_text(encoding="utf-8", errors="ignore") if sidebar_path.is_file() else ""
)
if index_rel:
# Gitea wiki page URLs typically omit the `.md` extension:
# file `docs/INDEX.md` → page `/wiki/docs/INDEX`.
link_target = index_rel[:-3] if index_rel.endswith(".md") else index_rel
# For nested wiki paths, INDEX is often addressed as lowercase `index`.
if "/" in link_target:
parts = link_target.split("/")
parts[-1] = parts[-1].lower()
link_target = "/".join(parts)
display = link_target
home_block = "\n".join(
[
"## Документация (docs)",
"",
f"- [{display}]({_encode_md_target(link_target)})",
]
)
tree = _sidebar_folder_links(folder, managed_files)
tree_lines: list[str] = []
for depth, label, target in tree:
indent = " " * depth
short = Path(label).name or label
tree_lines.append(
f"{indent}- [📁 {short}]({_encode_md_target(target)})"
)
if not tree_lines:
root_short = Path(link_target).name or display
tree_lines = [f"- [📁 {root_short}]({_encode_md_target(link_target)})"]
sidebar_block = "\n".join(
[
"### Документация (docs)",
"",
*tree_lines,
"",
]
)
else:
home_block = "\n".join(["## Документация (docs)", "", "_INDEX.md в этой выгрузке не найден._"])
sidebar_block = "\n".join(["### Документация (docs)", "", "_INDEX.md в этой выгрузке не найден._", ""])
home_text = _merge_marker_block(home_text, home_block)
sidebar_text = _merge_marker_block(sidebar_text, sidebar_block)
write_text(home_path, home_text)
write_text(sidebar_path, sidebar_text)
def build_docs_output(
items: list[DocItem],
*,
out_dir: Path,
folder: str,
flat_root: bool = True,
clean: bool = True,
repo: RepoLinkContext | None = None,
) -> tuple[DocsStats, str]:
out_dir = out_dir.resolve()
if clean and out_dir.exists():
for child in out_dir.iterdir():
if child.name == ".git":
continue
if child.is_dir():
import shutil
shutil.rmtree(child)
else:
remove_file(child)
ensure_dir(out_dir)
folder_clean = folder.strip().strip("/").strip("\\")
dst_root = Path(folder_clean) if folder_clean else Path(".")
pattern_roots = sorted({x.pattern_root.resolve() for x in items})
# Avoid clobbering export_1c_help navigation pages when publishing into wiki root.
items_ok: list[DocItem] = []
for item in items:
if (
not folder_clean
and item.dst_rel.parent == Path(".")
and item.dst_rel.name in _RESERVED_ROOT_PAGES
):
continue
items_ok.append(item)
def map_src_abs_to_dst_rel(abs_src: Path) -> Path | None:
abs_src = abs_src.resolve()
best_root: Path | None = None
best_rel: Path | None = None
for r in pattern_roots:
try:
rel = abs_src.relative_to(r)
except ValueError:
continue
if best_root is None or len(r.as_posix()) > len(best_root.as_posix()):
best_root = r
best_rel = rel
if best_root is None or best_rel is None:
return None
return dst_root / best_rel
md_dst_by_src: dict[Path, Path] = {}
title_by_src: dict[Path, str] = {}
for x in items_ok:
rel = x.dst_rel
rel = _target_rel_for(rel, flat_root=flat_root)
md_dst_by_src[x.src.resolve()] = rel
src_to_md_dst = md_dst_by_src
attachments: set[Path] = set()
managed_rels: set[Path] = set(src_to_md_dst.values())
folder_docs: dict[Path, list[tuple[str, Path]]] = {}
index_body_by_folder: dict[Path, str] = {}
index_src_by_folder: dict[Path, Path] = {}
for item in items_ok:
raw = item.src.read_text(encoding="utf-8-sig", errors="ignore")
title_by_src[item.src.resolve()] = _extract_doc_title(raw, item.src.stem)
dst_rel = src_to_md_dst[item.src.resolve()]
src_folder = item.dst_rel.parent
if _is_folder_index_src(item.src):
index_body_by_folder[src_folder] = raw
index_src_by_folder[src_folder] = item.src
continue
rendered = _rewrite_links(
raw,
src=item.src,
dst_rel=dst_rel,
src_to_md_dst=src_to_md_dst,
map_src_abs_to_dst_rel=map_src_abs_to_dst_rel,
attachments=attachments,
repo=repo,
)
folder_index_rel = _target_rel_for(
(src_folder / "INDEX.md") if src_folder != Path(".") else Path("INDEX.md"),
flat_root=flat_root,
)
short = _folder_short_name(src_folder, folder_clean)
nav = "\n".join(
[
f"## 📁 {short}",
"",
f"[⤴️ Вернуться в {short}]({_rel_link(dst_rel, folder_index_rel)})",
"",
]
)
rendered = nav + rendered.lstrip()
write_text(out_dir / dst_rel, rendered)
folder_docs.setdefault(src_folder, []).append(
(title_by_src[item.src.resolve()], dst_rel)
)
for abs_src in sorted(attachments, key=lambda p: p.as_posix()):
dst_rel = map_src_abs_to_dst_rel(abs_src)
if dst_rel is None:
continue
dst_rel = _target_rel_for(dst_rel, flat_root=flat_root)
if dst_rel not in managed_rels:
managed_rels.add(dst_rel)
copy_file(abs_src, out_dir / dst_rel)
# Build per-folder index pages with links to articles and child folders.
all_folders = sorted(
set(folder_docs.keys()) | set(index_body_by_folder.keys()),
key=lambda p: p.as_posix(),
)
child_folders: dict[Path, set[Path]] = {}
folder_set = set(all_folders)
for f in all_folders:
parent = f.parent
while parent != Path(".") and parent not in folder_set:
parent = parent.parent
if parent in folder_set and parent != f:
child_folders.setdefault(parent, set()).add(f)
if f.parent == Path(".") and Path(".") in folder_set and f != Path("."):
child_folders.setdefault(Path("."), set()).add(f)
index_target_by_folder: dict[Path, Path] = {}
for f in all_folders:
idx_rel = (f / "INDEX.md") if f != Path(".") else Path("INDEX.md")
index_target_by_folder[f] = _target_rel_for(idx_rel, flat_root=flat_root)
for f in all_folders:
lines: list[str] = []
heading = _folder_label(f, folder_clean)
lines.append(f"# Индекс: {heading}")
lines.append("")
if f != Path("."):
parent = f.parent if f.parent != Path(".") else Path(".")
parent_label = _folder_label(parent, folder_clean)
parent_short = _folder_short_name(parent, folder_clean)
parent_target = index_target_by_folder.get(parent)
if parent_target is not None:
lines.append(f"## 📁 {parent_short}")
lines.append("")
lines.append(
f"[⤴️ Вернуться в {parent_short}]({_rel_link(index_target_by_folder[f], parent_target)})"
)
lines.append("")
children = sorted(child_folders.get(f, set()), key=lambda p: p.as_posix().casefold())
if children:
lines.append("## Папки")
lines.append("")
for ch in children:
target_rel = index_target_by_folder[ch]
ch_label = _folder_short_name(ch, folder_clean)
rel_link = _rel_link(index_target_by_folder[f], target_rel)
lines.append(f"- [📁 {ch_label}]({rel_link})")
lines.append("")
docs = sorted(folder_docs.get(f, []), key=lambda x: x[0].casefold())
idx_target = index_target_by_folder[f]
if docs:
lines.append("## Статьи")
lines.append("")
for title, target_rel in docs:
if target_rel == idx_target:
continue
rel_link = _rel_link(index_target_by_folder[f], target_rel)
lines.append(f"- [{title}]({rel_link})")
lines.append("")
index_raw = index_body_by_folder.get(f)
if index_raw:
idx_dst = index_target_by_folder[f]
index_src = index_src_by_folder.get(f, Path("INDEX.md"))
index_rendered = _rewrite_links(
_strip_leading_h1(index_raw),
src=index_src,
dst_rel=idx_dst,
src_to_md_dst=src_to_md_dst,
map_src_abs_to_dst_rel=map_src_abs_to_dst_rel,
attachments=attachments,
repo=repo,
)
if index_rendered.strip():
lines.append(index_rendered.rstrip())
lines.append("")
write_text(out_dir / idx_target, "\n".join(lines).rstrip() + "\n")
managed_rels.add(idx_target)
manifest_name = _manifest_name(folder)
manifest = {
"tool": "export_docs",
"version": __version__,
"folder": folder,
"files": [p.as_posix() for p in sorted(managed_rels, key=lambda x: x.as_posix())],
"sources": [x.src.as_posix() for x in items_ok],
}
write_text(out_dir / manifest_name, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
folders: dict[str, list[int]] = {}
markdown_rels = set(src_to_md_dst.values()) | set(index_target_by_folder.values())
attachment_rels = managed_rels - markdown_rels
for rel in markdown_rels:
key = rel.parent.as_posix() if rel.parent.as_posix() != "." else "/"
folders.setdefault(key, [0, 0])[0] += 1
for rel in attachment_rels:
key = rel.parent.as_posix() if rel.parent.as_posix() != "." else "/"
folders.setdefault(key, [0, 0])[1] += 1
return DocsStats(
files_total=len(items_ok) + len(attachments) + len(index_target_by_folder),
files_written=len(managed_rels),
files_deleted=0,
markdown_files=len(markdown_rels),
attachment_files=len(attachment_rels),
folders={k: (v[0], v[1]) for k, v in sorted(folders.items())},
), manifest_name
def _run_git(cmd: list[str], *, cwd: Path) -> str:
proc = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True, check=False)
if proc.returncode != 0:
raise WikiPushError(f"$ {' '.join(cmd)}\n{proc.stdout}\n{proc.stderr}".strip())
return proc.stdout
def _wiki_clear_index_flags(work_dir: Path, *names: str) -> None:
"""Gitea may mark Home/_Sidebar assume-unchanged; git add then skips them."""
for name in names:
if not (work_dir / name).is_file():
continue
_run_git(["git", "update-index", "--no-assume-unchanged", name], cwd=work_dir)
_run_git(["git", "update-index", "--no-skip-worktree", name], cwd=work_dir)
def _git_has_staged_changes(work_dir: Path) -> bool:
proc = subprocess.run(
["git", "diff", "--cached", "--quiet"],
cwd=str(work_dir),
capture_output=True,
text=True,
check=False,
)
return proc.returncode != 0
def _git_stage_and_ready(work_dir: Path) -> bool:
_wiki_clear_index_flags(work_dir, "Home.md", "_Sidebar.md")
_run_git(["git", "add", "-A"], cwd=work_dir)
return _git_has_staged_changes(work_dir)
def _load_prev_managed(work_dir: Path, manifest_name: str) -> set[str]:
p = work_dir / manifest_name
if not p.is_file():
return set()
try:
data = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return set()
files = data.get("files") or []
return {str(x) for x in files if isinstance(x, str)}
def push_docs_wiki(
*,
wiki_url: str,
out_dir: Path,
work_dir: Path,
message: str,
folder: str,
branch: str = "main",
dry_run: bool = False,
) -> DocsStats:
out_dir = out_dir.resolve()
work_dir = work_dir.resolve()
prepare_wiki_clone(wiki_url, work_dir, branch=branch)
manifest_name = _manifest_name(folder)
new_manifest = out_dir / manifest_name
if not new_manifest.is_file():
raise WikiPushError(f"missing manifest in output: {new_manifest}")
new_data = json.loads(new_manifest.read_text(encoding="utf-8"))
new_files = {str(x) for x in (new_data.get("files") or [])}
prev_files = _load_prev_managed(work_dir, manifest_name)
deleted = 0
for rel in sorted(prev_files - new_files):
p = work_dir / rel
if p.is_file():
remove_file(p)
deleted += 1
for rel in sorted(new_files):
src = out_dir / rel
if src.is_file():
copy_file(src, work_dir / rel)
copy_file(new_manifest, work_dir / manifest_name)
# Update root navigation pages to expose published docs.
ensure_root_navigation(
work_dir=work_dir,
folder=folder,
managed_files=new_files,
)
if dry_run:
_wiki_clear_index_flags(work_dir, "Home.md", "_Sidebar.md")
_run_git(["git", "add", "-A"], cwd=work_dir)
return DocsStats(
files_total=len(new_files),
files_written=len(new_files),
files_deleted=deleted,
markdown_files=0,
attachment_files=0,
folders={},
)
if not _git_stage_and_ready(work_dir):
return DocsStats(
files_total=len(new_files),
files_written=len(new_files),
files_deleted=deleted,
markdown_files=0,
attachment_files=0,
folders={},
)
_run_git(["git", "commit", "-m", message], cwd=work_dir)
_run_git(["git", "push", "origin", f"HEAD:{branch}"], cwd=work_dir)
return DocsStats(
files_total=len(new_files),
files_written=len(new_files),
files_deleted=deleted,
markdown_files=0,
attachment_files=0,
folders={},
)
def clean_docs_wiki(
*,
wiki_url: str,
work_dir: Path,
folder: str,
message: str,
branch: str = "main",
dry_run: bool = False,
) -> DocsStats:
"""
Remove docs pages previously managed by export_docs for one folder.
Also removes export_docs marker blocks from Home/_Sidebar.
"""
work_dir = work_dir.resolve()
prepare_wiki_clone(wiki_url, work_dir, branch=branch)
manifest_name = _manifest_name(folder)
prev_files = _load_prev_managed(work_dir, manifest_name)
deleted = 0
for rel in sorted(prev_files):
p = work_dir / rel
if p.is_file():
remove_file(p)
deleted += 1
manifest_path = work_dir / manifest_name
if manifest_path.is_file():
remove_file(manifest_path)
# Drop export_docs nav block if present.
for name in ("Home.md", "_Sidebar.md"):
p = work_dir / name
if not p.is_file():
continue
raw = p.read_text(encoding="utf-8", errors="ignore")
cleaned = re.sub(
r"<!--\s*export_docs:begin\s*-->.*?<!--\s*export_docs:end\s*-->\n?",
"",
raw,
flags=re.DOTALL,
).rstrip()
cleaned = cleaned + "\n" if cleaned else ""
if cleaned != raw:
write_text(p, cleaned)
if dry_run:
_wiki_clear_index_flags(work_dir, "Home.md", "_Sidebar.md")
_run_git(["git", "add", "-A"], cwd=work_dir)
return DocsStats(
files_total=deleted,
files_written=0,
files_deleted=deleted,
markdown_files=0,
attachment_files=0,
folders={},
)
if not _git_stage_and_ready(work_dir):
return DocsStats(
files_total=deleted,
files_written=0,
files_deleted=deleted,
markdown_files=0,
attachment_files=0,
folders={},
)
_run_git(["git", "commit", "-m", message], cwd=work_dir)
_run_git(["git", "push", "origin", f"HEAD:{branch}"], cwd=work_dir)
return DocsStats(
files_total=deleted,
files_written=0,
files_deleted=deleted,
markdown_files=0,
attachment_files=0,
folders={},
)
def validate_folder(folder: str) -> str:
f = folder.strip().strip("\\").strip("/")
if not f:
return ""
p = Path(f)
if any(part in ("..", "") for part in p.parts):
raise ValueError(f"invalid folder path: {folder}")
return p.as_posix()
def format_patterns(patterns: Iterable[str]) -> str:
return ", ".join(patterns)
def format_folder_summary(stats: DocsStats) -> str:
if not stats.folders:
return " /: 0 md, 0 attachments"
lines: list[str] = []
for folder, (md_count, attachment_count) in stats.folders.items():
lines.append(f" {folder}: {md_count} md, {attachment_count} attachments")
return "\n".join(lines)