@tanlinfeng/project-docs-workflow 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -152
- package/bin/install.js +9 -3
- package/package.json +1 -1
- package/skills/docs-html-view/SKILL.md +119 -0
- package/skills/docs-html-view/agents/openai.yaml +5 -0
- package/skills/docs-html-view/assets/conf.py.template +44 -0
- package/skills/docs-html-view/assets/guard.py +128 -0
- package/skills/docs-html-view/references/technical.md +151 -0
- package/skills/docs-html-view/requirements.txt +5 -0
- package/skills/docs-html-view/scripts/gen_nav.py +306 -0
- package/skills/docs-html-view/scripts/prepare_env.py +154 -0
- package/skills/project-docs-workflow/SKILL.md +15 -6
- package/skills/project-docs-workflow/assets/templates/entries/current-module.md +19 -0
- package/skills/project-docs-workflow/assets/templates/entries/pdr-spec.md +1 -1
- package/skills/project-docs-workflow/assets/templates/project/adr-readme.md +2 -1
- package/skills/project-docs-workflow/assets/templates/project/agents-block.md +1 -1
- package/skills/project-docs-workflow/assets/templates/project/agents-issues.md +6 -5
- package/skills/project-docs-workflow/assets/templates/project/agents-workflow.md +9 -6
- package/skills/project-docs-workflow/assets/templates/project/current-00-readme.md +42 -0
- package/skills/project-docs-workflow/assets/templates/project/pdr-00-readme.md +6 -7
- package/skills/project-docs-workflow/references/adr.md +5 -1
- package/skills/project-docs-workflow/references/current.md +39 -0
- package/skills/project-docs-workflow/references/issues.md +3 -2
- package/skills/project-docs-workflow/references/pdr.md +15 -10
- package/skills/project-docs-workflow/references/rendering.md +29 -0
- package/skills/project-docs-workflow/references/workflow.md +6 -5
- package/skills/project-docs-workflow/assets/templates/entries/pdr-current.md +0 -19
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# docs-html-view technical reference
|
|
2
|
+
|
|
3
|
+
## Pinned dependencies
|
|
4
|
+
|
|
5
|
+
Verified on PyPI 2026-10-14; the set is mutually compatible. Sphinx 9.1.0 requires Python >= 3.12.
|
|
6
|
+
|
|
7
|
+
| Package | Pin | Note |
|
|
8
|
+
| :--- | :--- | :--- |
|
|
9
|
+
| Sphinx | `Sphinx==9.1.0` | needs Python >= 3.12 |
|
|
10
|
+
| MyST parser | `myst-parser==5.1.0` | Markdown support |
|
|
11
|
+
| RTD theme | `sphinx-rtd-theme==3.1.0` | stock theme via `html_theme` |
|
|
12
|
+
|
|
13
|
+
`requirements.txt` ships this declaration. `prepare_env.py` installs nothing unless `--install` is passed; the caller runs it only after approval.
|
|
14
|
+
|
|
15
|
+
## Layout
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
<project root>/
|
|
19
|
+
.venv-docs/ # default render venv (or the project's docs env)
|
|
20
|
+
docs/ # Sphinx source root
|
|
21
|
+
pdr|current|adr/ # approved trees, never modified
|
|
22
|
+
issues/ agents/ ... # excluded
|
|
23
|
+
docsHtmlView/ # workspace
|
|
24
|
+
conf.py # from assets/conf.py.template
|
|
25
|
+
guard.py # copied from assets/guard.py
|
|
26
|
+
index.md # generated landing page (marked)
|
|
27
|
+
nav/<tree>/**/index.md # generated toctrees (marked)
|
|
28
|
+
docs-html-view.config.json # optional, user-authored
|
|
29
|
+
build/html/docsHtmlView/index.html # landing page (root_doc = docsHtmlView/index)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`docsHtmlView` is the workspace directory name, not the site name.
|
|
33
|
+
|
|
34
|
+
## Files shipped by this skill
|
|
35
|
+
|
|
36
|
+
- `scripts/prepare_env.py` — create/reuse a recognized venv; optional pinned install / freeze. Stdlib only.
|
|
37
|
+
- `scripts/gen_nav.py` — generate `index.md` + `nav/**` toctrees (marked files only).
|
|
38
|
+
- `assets/conf.py.template` — the managed `conf.py` for a new site.
|
|
39
|
+
- `assets/guard.py` — the parse/render guard extension.
|
|
40
|
+
- `requirements.txt` — the pins.
|
|
41
|
+
|
|
42
|
+
## Commands
|
|
43
|
+
|
|
44
|
+
Inspect is done with normal read-only tools; there is no inspect subcommand and nothing runs the project interpreter before approval. After approval, define the prepared environment's absolute paths from the project root (override `--venv` to reuse a project docs env):
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
SKILL="${SKILL:-$HOME/.agents/skills/docs-html-view}"
|
|
48
|
+
SITE_TITLE="APPROVED TITLE" # the confirmed project/site title
|
|
49
|
+
DOCS_PY="$PWD/.venv-docs/bin/python" # venv python
|
|
50
|
+
DOCS_SPHINXBUILD="$PWD/.venv-docs/bin/sphinx-build" # venv sphinx-build
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
First-time setup (new site only; skip if `docs/docsHtmlView/` already exists and instead show a manual merge plan):
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python3 "$SKILL"/scripts/prepare_env.py --project-root . --venv .venv-docs
|
|
57
|
+
python3 "$SKILL"/scripts/prepare_env.py --project-root . --venv .venv-docs --install
|
|
58
|
+
mkdir -p docs/docsHtmlView
|
|
59
|
+
( cd docs/docsHtmlView && "$DOCS_PY" -m sphinx.cmd.quickstart -q -p "$SITE_TITLE" -a "AUTHOR" -v "1.0" --no-sep )
|
|
60
|
+
# apply assets/conf.py.template -> docs/docsHtmlView/conf.py
|
|
61
|
+
# tokens are UNQUOTED; replace __SITE_TITLE__/__AUTHOR__/__RELEASE__ with repr(value)
|
|
62
|
+
# copy assets/guard.py -> docs/docsHtmlView/guard.py
|
|
63
|
+
# remove the generated docs/docsHtmlView/index.rst
|
|
64
|
+
# Makefile: SOURCEDIR = .. BUILDDIR = build SPHINXOPTS = -c . -w build/warnings.log
|
|
65
|
+
"$DOCS_PY" "$SKILL"/scripts/gen_nav.py --project-root . --title "$SITE_TITLE"
|
|
66
|
+
mkdir -p docs/docsHtmlView/build
|
|
67
|
+
( cd docs/docsHtmlView && make html SPHINXBUILD="$DOCS_SPHINXBUILD" SPHINXOPTS='-c . -w build/warnings.log' )
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Repeat render (same approved scope and title, no re-ask):
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
"$DOCS_PY" "$SKILL"/scripts/gen_nav.py --project-root . --title "$SITE_TITLE"
|
|
74
|
+
mkdir -p docs/docsHtmlView/build
|
|
75
|
+
( cd docs/docsHtmlView && make html SPHINXBUILD="$DOCS_SPHINXBUILD" SPHINXOPTS='-c . -w build/warnings.log' )
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
No `make`:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
mkdir -p docs/docsHtmlView/build
|
|
82
|
+
( cd docs/docsHtmlView && "$DOCS_SPHINXBUILD" -b html -c . -d build/doctrees -w build/warnings.log .. build/html )
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`gen_nav.py` exits `1` when no approved tree contains documents, so a later `make` cannot silently build a stale site. Warnings (missing/empty trees) go to stderr; `--json` prints the summary on stdout. Without `--title` the site title defaults to `Documentation`; pass the approved `--title` (or set `site_title`) so repeat renders keep the agreed title.
|
|
86
|
+
|
|
87
|
+
Optional resolved-version snapshot (approved output):
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
python3 "$SKILL"/scripts/prepare_env.py --project-root . --venv .venv-docs \
|
|
91
|
+
--freeze docs/docsHtmlView/requirements.lock.txt
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`prepare_env.py` flags: `--project-root` (default cwd), `--venv` (default `.venv-docs`, relative to project root unless absolute), `--base-python` (interpreter used to create the venv), `--requirements`, `--install`, `--freeze PATH`. It refuses to reuse a path that exists without `pyvenv.cfg`, refuses Python < 3.12, and refuses `--install`/`--freeze` unless the interpreter actually belongs to the target venv. It never installs Python/uv and never uses global/system pip.
|
|
95
|
+
|
|
96
|
+
`gen_nav.py` flags: `--project-root` (default cwd), `--title` (site title override), `--json`.
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
## Sphinx configuration
|
|
100
|
+
|
|
101
|
+
`SOURCEDIR=..`, config dir `docs/docsHtmlView` (`-c .`), `BUILDDIR=build` → output `build/html`; `SPHINXOPTS = -c . -w build/warnings.log` (create `build/` first) so warnings are captured for reporting. `root_doc = docsHtmlView/index`, so the landing page is `build/html/docsHtmlView/index.html` (the `build/html/` layout is otherwise unchanged; no redirect or extra page).
|
|
102
|
+
|
|
103
|
+
`conf.py` uses:
|
|
104
|
+
|
|
105
|
+
- `include_patterns` — **enumerated**: `docsHtmlView/index.md`, `docsHtmlView/nav/**`, and one `<root>/**` per approved tree. Not a `docsHtmlView/**` wildcard.
|
|
106
|
+
- `exclude_patterns` — build, `.venv-docs`, workspace venvs, VCS internals.
|
|
107
|
+
- `myst_enable_extensions = ["colon_fence", "deflist", "fieldlist"]`, `myst_heading_anchors = 3`.
|
|
108
|
+
- `docs_html_view_allowed_roots` — the same roots, read by `guard.py`.
|
|
109
|
+
|
|
110
|
+
**Changing `allowed_dirs` is a manual, approved edit with no automatic sync.** Update all three by hand — `include_patterns` and `docs_html_view_allowed_roots` in `conf.py`, and `allowed_dirs` in `docs-html-view.config.json` — then re-run `gen_nav.py`; a mismatch between them must be resolved before `make`. Missing/empty trees are fine (reported and skipped).
|
|
111
|
+
|
|
112
|
+
### Guard (`assets/guard.py`)
|
|
113
|
+
|
|
114
|
+
- `builder-inited`: registers a refusing directive for `include`, `literalinclude`, `raw` (case-insensitive; also hit via `eval-rst` and colon fences), and validates each allowed root is a simple relative name resolving inside `docs`.
|
|
115
|
+
- `doctree-read` (priority 100, before Sphinx's asset collectors at 500): rejects any `raw` node that is not purely HTML comment(s)+whitespace (so PDR-template comments pass, active markup fails); for images and `download_reference` nodes, resolves the original reference via `app.env.relfn2path`, requires it inside an approved root using `realpath`/`commonpath` (symlinks rejected), or raises `SphinxError` before any copy; remote targets are replaced with an inline placeholder and a Sphinx warning, never fetched.
|
|
116
|
+
- This is the authoritative boundary and the reason no regex-only scan is used. It guards trusted docs; `conf.py` is trusted executable code and is not sandboxed.
|
|
117
|
+
|
|
118
|
+
## Optional project config
|
|
119
|
+
|
|
120
|
+
`docs/docsHtmlView/docs-html-view.config.json` is user-authored and never overwritten. It must be a JSON object; a parse error, a non-object, a non-string `site_title`, a non-string `navigation_order`, or an unsafe `allowed_dirs` entry is refused (not silently dropped).
|
|
121
|
+
|
|
122
|
+
| Key | Read by | Effect |
|
|
123
|
+
| :--- | :--- | :--- |
|
|
124
|
+
| `site_title` | `gen_nav.py` | site title (also settable via `gen_nav.py --title`) |
|
|
125
|
+
| `allowed_dirs` | `gen_nav.py` | explicit trees; one directory name each (no separators, not hidden, not `build`/`docsHtmlView`); defaults to `pdr`, `current`, `adr` |
|
|
126
|
+
| `navigation_order` | `gen_nav.py` | list of path strings relative to `docs/` placed first, in order; then numeric prefix, then case-insensitive name |
|
|
127
|
+
| `author`, `release` | human / agent only | template token values when applying `conf.py.template`; not read by the scripts |
|
|
128
|
+
| `python` | human / agent only | preferred interpreter when choosing the venv path; not read by the scripts |
|
|
129
|
+
|
|
130
|
+
## Navigation rules
|
|
131
|
+
|
|
132
|
+
- Marked generated files only (`% docs-html-view: generated navigation; do not edit`, a MyST comment); unmarked collisions and symlinks are refused.
|
|
133
|
+
- **Preflight before any write:** approved dirs, source trees, and any existing `nav/` (including ancestors) are checked for symlinks; every output path and existing collision is validated before the first write or prune, so a refusal leaves the tree unchanged.
|
|
134
|
+
- `gen_nav.py` exits `1` when no approved tree has documents, and prints warnings (missing/empty trees) to stderr.
|
|
135
|
+
- Hierarchy mirrors the tree; each directory gets a `{toctree}` of its documents and child-index links; entry titles come from the source H1 (path-only toctree entries), so no source text can inject MyST.
|
|
136
|
+
- Directory/site headings are sanitized (no `<`, `>`, backticks, braces, brackets, pipes, newlines).
|
|
137
|
+
- The full ADR tree renders, including retired/deleted tombstones.
|
|
138
|
+
- Re-running prunes only marked stale nav files; user files are never touched.
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
## Approval and consent
|
|
142
|
+
|
|
143
|
+
The Skill asks once, before any write/install/scaffold, covering packages, environment, files, `.gitignore` additions, output path, and title. There is no stored consent state or fingerprint. Repeated renders inside the approved scope do not re-ask; dependency upgrades, overwriting existing files, or changing the approved roots/range require a fresh ask. Missing trees or malformed required config/venv → report and stop, never guess. The template tokens are unquoted, so substitute each with `repr(value)` (valid, escaped Python literal), never raw interpolation into executable config.
|
|
144
|
+
|
|
145
|
+
## Limitations
|
|
146
|
+
|
|
147
|
+
- `.md` / `.rst` only; no `.mdx`.
|
|
148
|
+
- `include` / `literalinclude` / `raw` refused.
|
|
149
|
+
- Remote assets become inline placeholders plus a warning; active HTML is refused (only HTML comments are allowed), and `conf.py` is trusted code.
|
|
150
|
+
- No custom theme, browser, server, or deployment.
|
|
151
|
+
- Failed `make` may leave stale/partial `build/` output; no publisher normalises it.
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate docs-html-view navigation under docs/docsHtmlView.
|
|
3
|
+
|
|
4
|
+
Writes only marked files inside the workspace: ``docsHtmlView/index.md`` and
|
|
5
|
+
``docsHtmlView/nav/**/index.md``. Never writes into source trees. Validates every
|
|
6
|
+
symlink and collision in a preflight pass *before* the first write or prune, so a
|
|
7
|
+
refusal leaves the tree unchanged. Removes only stale files carrying the marker.
|
|
8
|
+
Runs before ``make html``; it never invokes Sphinx itself.
|
|
9
|
+
|
|
10
|
+
Exit codes: 0 = generated (or nothing needed); 1 = refused, or no documents in
|
|
11
|
+
the approved trees (so a later ``make`` cannot silently build a stale site).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import posixpath
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
MARK = "% docs-html-view: generated navigation; do not edit"
|
|
23
|
+
DEFAULT_ROOTS = ["pdr", "current", "adr"]
|
|
24
|
+
SUFFIXES = (".md", ".rst")
|
|
25
|
+
RESERVED = {"build", "docsHtmlView"}
|
|
26
|
+
BIG = float("inf")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def die(msg: str) -> None:
|
|
30
|
+
sys.exit("refusing: " + msg)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def warn(msg: str) -> None:
|
|
34
|
+
print("warning: " + msg, file=sys.stderr)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def sanitize(text: str) -> str:
|
|
38
|
+
text = re.sub(r"[<>`{}\[\]|]", "", text)
|
|
39
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def display_name(name: str) -> str:
|
|
43
|
+
name = re.sub(r"^\d+[-_. ]*", "", name).replace("-", " ").replace("_", " ").strip()
|
|
44
|
+
return sanitize(name) or "section"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def num_key(name: str):
|
|
48
|
+
match = re.match(r"^(\d+)", name)
|
|
49
|
+
return (0, int(match.group(1))) if match else (1, 0)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def has_docs(path: str) -> bool:
|
|
53
|
+
for _dirpath, _dirnames, filenames in os.walk(path, followlinks=False):
|
|
54
|
+
if any(f.lower().endswith(SUFFIXES) for f in filenames):
|
|
55
|
+
return True
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def file_owned(path: str) -> bool:
|
|
60
|
+
try:
|
|
61
|
+
with open(path, encoding="utf-8") as fh:
|
|
62
|
+
return fh.read().lstrip().startswith(MARK)
|
|
63
|
+
except OSError:
|
|
64
|
+
die("cannot read " + path)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def read_config(workspace: str) -> dict:
|
|
68
|
+
path = os.path.join(workspace, "docs-html-view.config.json")
|
|
69
|
+
if not os.path.lexists(path):
|
|
70
|
+
return {}
|
|
71
|
+
if os.path.islink(path):
|
|
72
|
+
die("config is a symlink: " + path)
|
|
73
|
+
try:
|
|
74
|
+
with open(path, encoding="utf-8") as fh:
|
|
75
|
+
data = json.load(fh)
|
|
76
|
+
except (OSError, ValueError) as exc:
|
|
77
|
+
die("cannot parse config %s: %s" % (path, exc))
|
|
78
|
+
if not isinstance(data, dict):
|
|
79
|
+
die("config must be a JSON object: " + path)
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def validate_dir_name(name: str) -> None:
|
|
84
|
+
if not name or name in (".", ".."):
|
|
85
|
+
die("invalid allowed_dirs entry: %r" % (name,))
|
|
86
|
+
if "/" in name or "\\" in name:
|
|
87
|
+
die("allowed_dirs entry must be one directory name, no separators: %r" % (name,))
|
|
88
|
+
if name.startswith("."):
|
|
89
|
+
die("allowed_dirs entry must not be hidden: %r" % (name,))
|
|
90
|
+
if name in RESERVED:
|
|
91
|
+
die("allowed_dirs entry may not be %r" % (name,))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def resolve_roots(cfg: dict, docs: str):
|
|
95
|
+
allowed = cfg.get("allowed_dirs")
|
|
96
|
+
if allowed is None:
|
|
97
|
+
candidates = list(DEFAULT_ROOTS)
|
|
98
|
+
else:
|
|
99
|
+
if not isinstance(allowed, list):
|
|
100
|
+
die("allowed_dirs must be a list of simple directory names")
|
|
101
|
+
for name in allowed:
|
|
102
|
+
if not isinstance(name, str):
|
|
103
|
+
die("allowed_dirs entries must be strings")
|
|
104
|
+
validate_dir_name(name)
|
|
105
|
+
candidates = list(dict.fromkeys(allowed))
|
|
106
|
+
|
|
107
|
+
present, missing = [], []
|
|
108
|
+
for name in candidates:
|
|
109
|
+
path = os.path.join(docs, name)
|
|
110
|
+
if os.path.islink(path):
|
|
111
|
+
die("approved dir is a symlink: " + path)
|
|
112
|
+
if os.path.isdir(path) and has_docs(path):
|
|
113
|
+
present.append(name)
|
|
114
|
+
else:
|
|
115
|
+
missing.append(name)
|
|
116
|
+
return present, missing
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def assert_no_symlinks(base: str, label: str) -> None:
|
|
120
|
+
if not os.path.isdir(base):
|
|
121
|
+
return
|
|
122
|
+
for dirpath, dirnames, filenames in os.walk(base, followlinks=False):
|
|
123
|
+
for name in list(dirnames) + filenames:
|
|
124
|
+
path = os.path.join(dirpath, name)
|
|
125
|
+
if os.path.islink(path):
|
|
126
|
+
die("symlink under %s: %s" % (label, path))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def toctree(entries, maxdepth=1) -> str:
|
|
130
|
+
if not entries:
|
|
131
|
+
return "<!-- (no documents) -->\n"
|
|
132
|
+
lines = ["```{toctree}", ":maxdepth: %d" % maxdepth, ""] + list(entries) + ["```"]
|
|
133
|
+
return "\n".join(lines) + "\n"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def build_outputs(docs, workspace, roots, order_map, site_title):
|
|
137
|
+
outputs = {}
|
|
138
|
+
|
|
139
|
+
def order_key(base_rel):
|
|
140
|
+
def key(name):
|
|
141
|
+
rel = posixpath.join(base_rel, name) if base_rel else name
|
|
142
|
+
return (order_map.get(rel, BIG), num_key(name), name.casefold())
|
|
143
|
+
return key
|
|
144
|
+
|
|
145
|
+
def generate(source_root, rel_parts, src_dir):
|
|
146
|
+
base_rel = posixpath.join(source_root, *rel_parts)
|
|
147
|
+
out_rel = posixpath.join("docsHtmlView", "nav", source_root, *rel_parts)
|
|
148
|
+
children = []
|
|
149
|
+
for name in sorted(os.listdir(src_dir)):
|
|
150
|
+
path = os.path.join(src_dir, name)
|
|
151
|
+
if os.path.islink(path):
|
|
152
|
+
die("symlink in source tree: " + path)
|
|
153
|
+
if os.path.isdir(path):
|
|
154
|
+
if has_docs(path):
|
|
155
|
+
children.append((name, "dir"))
|
|
156
|
+
elif name.lower().endswith(SUFFIXES):
|
|
157
|
+
children.append((name, "file"))
|
|
158
|
+
children.sort(key=lambda item: order_key(base_rel)(item[0]))
|
|
159
|
+
|
|
160
|
+
entries = []
|
|
161
|
+
for name, kind in children:
|
|
162
|
+
if kind == "dir":
|
|
163
|
+
entries.append(posixpath.join(name, "index"))
|
|
164
|
+
else:
|
|
165
|
+
target = posixpath.join(source_root, *rel_parts, name)
|
|
166
|
+
entries.append(posixpath.relpath(target, out_rel))
|
|
167
|
+
|
|
168
|
+
title = display_name(rel_parts[-1]) if rel_parts else display_name(source_root)
|
|
169
|
+
out_file = os.path.join(docs, *out_rel.split("/"), "index.md")
|
|
170
|
+
outputs[out_file] = MARK + "\n\n# " + title + "\n\n" + toctree(entries)
|
|
171
|
+
|
|
172
|
+
for name, kind in children:
|
|
173
|
+
if kind == "dir":
|
|
174
|
+
generate(source_root, list(rel_parts) + [name], os.path.join(src_dir, name))
|
|
175
|
+
|
|
176
|
+
for source_root in roots:
|
|
177
|
+
generate(source_root, [], os.path.join(docs, source_root))
|
|
178
|
+
|
|
179
|
+
index_entries = [posixpath.join("nav", r, "index") for r in roots]
|
|
180
|
+
outputs[os.path.join(workspace, "index.md")] = (
|
|
181
|
+
MARK + "\n\n# " + site_title + "\n\n" + toctree(index_entries, maxdepth=2)
|
|
182
|
+
)
|
|
183
|
+
return outputs
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def collision_preflight(outputs, nav_root) -> None:
|
|
187
|
+
out_abs = {os.path.abspath(p) for p in outputs}
|
|
188
|
+
for path in sorted(out_abs):
|
|
189
|
+
if os.path.islink(path):
|
|
190
|
+
die("symlinked output: " + path)
|
|
191
|
+
if os.path.lexists(path) and not file_owned(path):
|
|
192
|
+
die("existing unmarked file: " + path)
|
|
193
|
+
if os.path.isdir(nav_root):
|
|
194
|
+
for dirpath, _dirnames, filenames in os.walk(nav_root, followlinks=False):
|
|
195
|
+
for name in filenames:
|
|
196
|
+
path = os.path.abspath(os.path.join(dirpath, name))
|
|
197
|
+
if path in out_abs:
|
|
198
|
+
continue
|
|
199
|
+
if os.path.islink(path):
|
|
200
|
+
die("symlinked nav file: " + path)
|
|
201
|
+
if not file_owned(path):
|
|
202
|
+
die("unmarked file under nav/: " + path)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def verify_inside(outputs, workspace) -> None:
|
|
206
|
+
workspace_real = os.path.realpath(workspace)
|
|
207
|
+
for path in outputs:
|
|
208
|
+
real = os.path.realpath(path)
|
|
209
|
+
if real != workspace_real and not real.startswith(workspace_real + os.sep):
|
|
210
|
+
die("output escapes workspace: " + path)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def write_outputs(outputs) -> None:
|
|
214
|
+
for path in sorted(outputs):
|
|
215
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
216
|
+
with open(path, "w", encoding="utf-8", newline="\n") as fh:
|
|
217
|
+
fh.write(outputs[path])
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def prune(nav_root, outputs) -> None:
|
|
221
|
+
out_abs = {os.path.abspath(p) for p in outputs}
|
|
222
|
+
if not os.path.isdir(nav_root):
|
|
223
|
+
return
|
|
224
|
+
for dirpath, _dirnames, filenames in os.walk(nav_root, followlinks=False):
|
|
225
|
+
for name in filenames:
|
|
226
|
+
path = os.path.abspath(os.path.join(dirpath, name))
|
|
227
|
+
if path not in out_abs and file_owned(path):
|
|
228
|
+
os.remove(path)
|
|
229
|
+
for dirpath, _dirs, _files in os.walk(nav_root, topdown=False):
|
|
230
|
+
if dirpath == nav_root:
|
|
231
|
+
continue
|
|
232
|
+
try:
|
|
233
|
+
os.rmdir(dirpath) # only succeeds when empty
|
|
234
|
+
except OSError:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def main() -> None:
|
|
239
|
+
ap = argparse.ArgumentParser(description="Generate docs-html-view navigation (marked files only).")
|
|
240
|
+
ap.add_argument("--project-root", default=os.getcwd())
|
|
241
|
+
ap.add_argument("--title", default=None, help="site title override")
|
|
242
|
+
ap.add_argument("--json", action="store_true", help="print a machine-readable summary on stdout")
|
|
243
|
+
args = ap.parse_args()
|
|
244
|
+
|
|
245
|
+
root = os.path.abspath(args.project_root)
|
|
246
|
+
docs = os.path.join(root, "docs")
|
|
247
|
+
if not os.path.isdir(docs):
|
|
248
|
+
die("no docs/ directory at " + root)
|
|
249
|
+
if os.path.islink(docs):
|
|
250
|
+
die("docs/ is a symlink")
|
|
251
|
+
|
|
252
|
+
workspace = os.path.join(docs, "docsHtmlView")
|
|
253
|
+
if not os.path.isdir(workspace):
|
|
254
|
+
die("no docs/docsHtmlView workspace; run quickstart/setup first")
|
|
255
|
+
if os.path.islink(workspace):
|
|
256
|
+
die("docsHtmlView/ is a symlink")
|
|
257
|
+
|
|
258
|
+
cfg = read_config(workspace)
|
|
259
|
+
site_title_cfg = cfg.get("site_title")
|
|
260
|
+
if site_title_cfg is not None and not isinstance(site_title_cfg, str):
|
|
261
|
+
die("site_title must be a string")
|
|
262
|
+
order = cfg.get("navigation_order")
|
|
263
|
+
if order is None:
|
|
264
|
+
order = []
|
|
265
|
+
elif not isinstance(order, list) or not all(isinstance(x, str) for x in order):
|
|
266
|
+
die("navigation_order must be a list of strings")
|
|
267
|
+
order_map = {rel.strip("/"): i for i, rel in enumerate(order)}
|
|
268
|
+
|
|
269
|
+
roots, missing = resolve_roots(cfg, docs)
|
|
270
|
+
if missing:
|
|
271
|
+
warn("missing/empty trees: " + ", ".join(missing))
|
|
272
|
+
if not roots:
|
|
273
|
+
print(
|
|
274
|
+
"no documents in the approved trees; nothing to scaffold (run after adding docs)",
|
|
275
|
+
file=sys.stderr,
|
|
276
|
+
)
|
|
277
|
+
sys.exit(1)
|
|
278
|
+
|
|
279
|
+
for name in roots:
|
|
280
|
+
assert_no_symlinks(os.path.join(docs, name), "source tree " + name)
|
|
281
|
+
nav_root = os.path.join(workspace, "nav")
|
|
282
|
+
if os.path.islink(nav_root):
|
|
283
|
+
die("nav/ is a symlink")
|
|
284
|
+
assert_no_symlinks(nav_root, "nav/")
|
|
285
|
+
|
|
286
|
+
site_title = sanitize(str(site_title_cfg or args.title or "Documentation"))
|
|
287
|
+
outputs = build_outputs(docs, workspace, roots, order_map, site_title)
|
|
288
|
+
collision_preflight(outputs, nav_root)
|
|
289
|
+
verify_inside(outputs, workspace)
|
|
290
|
+
|
|
291
|
+
write_outputs(outputs)
|
|
292
|
+
prune(nav_root, outputs)
|
|
293
|
+
|
|
294
|
+
if args.json:
|
|
295
|
+
print(
|
|
296
|
+
json.dumps(
|
|
297
|
+
{"roots": roots, "generated": sorted(os.path.relpath(p, root) for p in outputs)},
|
|
298
|
+
indent=2,
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
print("generated %d nav file(s) for roots: %s" % (len(outputs), ", ".join(roots)))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == "__main__":
|
|
306
|
+
main()
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Prepare the docs render environment for docs-html-view.
|
|
3
|
+
|
|
4
|
+
Stdlib only. Never installs Python or uv, and never touches global/system
|
|
5
|
+
site-packages. It creates or reuses one *recognized* virtualenv and, only when
|
|
6
|
+
explicitly asked, installs the pinned requirements into that venv.
|
|
7
|
+
|
|
8
|
+
Run this only AFTER the user has approved the packages, the environment, and the
|
|
9
|
+
files (the Skill asks; this helper stores no consent state).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
MIN_PY = (3, 12)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def venv_python(venv: str) -> str:
|
|
22
|
+
if os.name == "nt":
|
|
23
|
+
return os.path.join(venv, "Scripts", "python.exe")
|
|
24
|
+
return os.path.join(venv, "bin", "python")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_recognized_venv(venv: str) -> bool:
|
|
28
|
+
return os.path.isfile(os.path.join(venv, "pyvenv.cfg"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def python_version(python: str):
|
|
32
|
+
code = "import sys;print('%d.%d' % sys.version_info[:2])"
|
|
33
|
+
try:
|
|
34
|
+
out = subprocess.run(
|
|
35
|
+
[python, "-I", "-c", code], capture_output=True, text=True, check=True
|
|
36
|
+
).stdout.strip()
|
|
37
|
+
except (OSError, subprocess.CalledProcessError) as exc:
|
|
38
|
+
return None, str(exc)
|
|
39
|
+
try:
|
|
40
|
+
return tuple(int(x) for x in out.split(".")[:2]), out
|
|
41
|
+
except ValueError:
|
|
42
|
+
return None, out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run(cmd, cwd=None):
|
|
46
|
+
print("+ " + " ".join(cmd))
|
|
47
|
+
return subprocess.run(cmd, cwd=cwd)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def has_pip(python: str) -> bool:
|
|
51
|
+
try:
|
|
52
|
+
return subprocess.run(
|
|
53
|
+
[python, "-m", "pip", "--version"], capture_output=True, text=True
|
|
54
|
+
).returncode == 0
|
|
55
|
+
except OSError:
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def venv_prefix(python: str):
|
|
60
|
+
try:
|
|
61
|
+
out = subprocess.run(
|
|
62
|
+
[python, "-c", "import sys;print(sys.prefix)"], capture_output=True, text=True
|
|
63
|
+
)
|
|
64
|
+
except OSError:
|
|
65
|
+
return None
|
|
66
|
+
return out.stdout.strip() if out.returncode == 0 else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def pip_in_venv(python: str, venv: str):
|
|
70
|
+
if not has_pip(python):
|
|
71
|
+
return False, "pip is not available in the interpreter"
|
|
72
|
+
prefix = venv_prefix(python)
|
|
73
|
+
if prefix is None or os.path.realpath(prefix) != os.path.realpath(venv):
|
|
74
|
+
return False, "interpreter does not belong to %s (sys.prefix=%s)" % (venv, prefix)
|
|
75
|
+
return True, ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main() -> None:
|
|
79
|
+
default_req = os.path.join(
|
|
80
|
+
os.path.dirname(os.path.abspath(__file__)), os.pardir, "requirements.txt"
|
|
81
|
+
)
|
|
82
|
+
ap = argparse.ArgumentParser(
|
|
83
|
+
description="Create/reuse the docs render venv and optionally install pinned requirements."
|
|
84
|
+
)
|
|
85
|
+
ap.add_argument("--project-root", default=os.getcwd())
|
|
86
|
+
ap.add_argument("--venv", default=".venv-docs", help="venv path (relative to project root unless absolute)")
|
|
87
|
+
ap.add_argument("--base-python", default=None, help="interpreter used to create the venv (default: this one)")
|
|
88
|
+
ap.add_argument("--requirements", default=default_req)
|
|
89
|
+
ap.add_argument("--install", action="store_true", help="install the pinned requirements (post-consent)")
|
|
90
|
+
ap.add_argument("--freeze", metavar="PATH", default=None, help="write `pip freeze` output to PATH (post-consent)")
|
|
91
|
+
args = ap.parse_args()
|
|
92
|
+
|
|
93
|
+
root = os.path.abspath(args.project_root)
|
|
94
|
+
venv = args.venv if os.path.isabs(args.venv) else os.path.join(root, args.venv)
|
|
95
|
+
requirements = os.path.abspath(args.requirements)
|
|
96
|
+
|
|
97
|
+
if os.path.lexists(venv) and not is_recognized_venv(venv):
|
|
98
|
+
sys.exit("refusing: %s exists but is not a virtualenv (no pyvenv.cfg); move it aside first" % venv)
|
|
99
|
+
|
|
100
|
+
if not is_recognized_venv(venv):
|
|
101
|
+
base = args.base_python or sys.executable
|
|
102
|
+
ver, raw = python_version(base)
|
|
103
|
+
if ver is None or ver < MIN_PY:
|
|
104
|
+
sys.exit("refusing: %s reports %s; need Python >= %d.%d" % (base, raw, *MIN_PY))
|
|
105
|
+
print("+ %s -m venv %s" % (base, venv))
|
|
106
|
+
created = subprocess.run(
|
|
107
|
+
[base, "-m", "venv", venv], capture_output=True, text=True
|
|
108
|
+
)
|
|
109
|
+
if created.returncode != 0:
|
|
110
|
+
detail = (created.stderr or created.stdout or "").strip()
|
|
111
|
+
sys.exit(
|
|
112
|
+
"failed to create virtualenv at %s (exit %d).\n%s\n"
|
|
113
|
+
"Ensure python3-venv/ensurepip is available, or pass --venv pointing at "
|
|
114
|
+
"an existing project docs environment." % (venv, created.returncode, detail)
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
print("reusing virtualenv: " + venv)
|
|
118
|
+
|
|
119
|
+
py = venv_python(venv)
|
|
120
|
+
ver, raw = python_version(py)
|
|
121
|
+
if ver is None or ver < MIN_PY:
|
|
122
|
+
sys.exit("refusing: venv python reports %s; need Python >= %d.%d" % (raw, *MIN_PY))
|
|
123
|
+
|
|
124
|
+
if args.install or args.freeze:
|
|
125
|
+
ok, why = pip_in_venv(py, venv)
|
|
126
|
+
if not ok:
|
|
127
|
+
sys.exit("refusing: %s; point --venv at a working docs environment" % why)
|
|
128
|
+
|
|
129
|
+
if args.install:
|
|
130
|
+
if not os.path.isfile(requirements):
|
|
131
|
+
sys.exit("requirements not found: " + requirements)
|
|
132
|
+
result = run(
|
|
133
|
+
[py, "-m", "pip", "install", "--no-input", "--disable-pip-version-check", "-r", requirements],
|
|
134
|
+
cwd=root,
|
|
135
|
+
)
|
|
136
|
+
if result.returncode != 0:
|
|
137
|
+
sys.exit("pip install failed (exit %d)" % result.returncode)
|
|
138
|
+
|
|
139
|
+
if args.freeze:
|
|
140
|
+
result = subprocess.run([py, "-m", "pip", "freeze"], capture_output=True, text=True)
|
|
141
|
+
if result.returncode != 0:
|
|
142
|
+
sys.exit("pip freeze failed (exit %d)" % result.returncode)
|
|
143
|
+
dest = args.freeze if os.path.isabs(args.freeze) else os.path.join(root, args.freeze)
|
|
144
|
+
os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
|
|
145
|
+
with open(dest, "w", encoding="utf-8", newline="\n") as fh:
|
|
146
|
+
fh.write(result.stdout)
|
|
147
|
+
print("wrote freeze: " + dest)
|
|
148
|
+
|
|
149
|
+
print("python: " + py)
|
|
150
|
+
print("install: " + ("yes" if args.install else "no"))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
main()
|