tree2guide 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,218 @@
1
+ """
2
+ tree2guide.renderers.html — renders the tree as a self-contained HTML page.
3
+
4
+ Produces a single .html file with:
5
+ - Collapsible folder nodes (pure CSS + minimal JS, no external deps)
6
+ - Expand/collapse all buttons
7
+ - A monospace tree with the same connector characters as the text/markdown renderers
8
+ - Clean, minimal styling that works in any modern browser
9
+ - An optional title shown as a <h1>
10
+ - An optional attribution footer
11
+
12
+ No external CSS frameworks, fonts, or scripts are loaded — the file works
13
+ fully offline.
14
+ """
15
+
16
+ from __future__ import annotations
17
+ from tree2guide.scanner import TreeNode
18
+
19
+ _CSS = """\
20
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
21
+ body {
22
+ font-family: system-ui, -apple-system, sans-serif;
23
+ background: #0f1117;
24
+ color: #e2e8f0;
25
+ min-height: 100vh;
26
+ padding: 2rem;
27
+ }
28
+ h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 1.25rem; color: #f8fafc; }
29
+ .controls { margin-bottom: 1rem; display: flex; gap: 0.5rem; }
30
+ button {
31
+ background: #1e2433;
32
+ color: #94a3b8;
33
+ border: 1px solid #2d3748;
34
+ border-radius: 6px;
35
+ padding: 0.3rem 0.75rem;
36
+ font-size: 0.8rem;
37
+ cursor: pointer;
38
+ transition: background 0.15s, color 0.15s;
39
+ }
40
+ button:hover { background: #2d3748; color: #e2e8f0; }
41
+ .tree {
42
+ font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
43
+ font-size: 0.88rem;
44
+ line-height: 1.7;
45
+ background: #1a1f2e;
46
+ border: 1px solid #2d3748;
47
+ border-radius: 10px;
48
+ padding: 1.25rem 1.5rem;
49
+ overflow-x: auto;
50
+ }
51
+ ul { list-style: none; padding: 0; }
52
+ li { white-space: nowrap; }
53
+ .entry { display: inline-flex; align-items: center; gap: 0.3rem; }
54
+ .connector { color: #4a5568; user-select: none; }
55
+ .toggle {
56
+ cursor: pointer;
57
+ background: none;
58
+ border: none;
59
+ padding: 0;
60
+ font: inherit;
61
+ color: inherit;
62
+ display: inline-flex;
63
+ align-items: center;
64
+ gap: 0.3rem;
65
+ }
66
+ .toggle:hover .dirname { color: #7dd3fc; }
67
+ .icon { font-size: 0.8em; color: #64748b; user-select: none; transition: transform 0.15s; }
68
+ .collapsed > .children { display: none; }
69
+ .collapsed .icon { transform: rotate(-90deg); }
70
+ .dirname { color: #93c5fd; font-weight: 500; }
71
+ .filename { color: #e2e8f0; }
72
+ .symlink { color: #a78bfa; }
73
+ .symlink-arrow { color: #64748b; }
74
+ .root { color: #34d399; font-weight: 600; }
75
+ footer {
76
+ margin-top: 1.5rem;
77
+ font-size: 0.78rem;
78
+ color: #4a5568;
79
+ }
80
+ footer a { color: #64748b; }
81
+ """
82
+
83
+ _JS = """\
84
+ function expandAll() {
85
+ document.querySelectorAll('.dir-item').forEach(el => el.classList.remove('collapsed'));
86
+ }
87
+ function collapseAll() {
88
+ document.querySelectorAll('.dir-item').forEach(el => el.classList.add('collapsed'));
89
+ }
90
+ function toggle(el) {
91
+ el.closest('.dir-item').classList.toggle('collapsed');
92
+ }
93
+ """
94
+
95
+ FOOTER_HTML = (
96
+ '<footer>Generated with '
97
+ '<a href="https://github.com/law4percent/tree2guide">tree2guide</a> '
98
+ 'by <a href="https://github.com/law4percent">Lawrence Roble</a> '
99
+ '— open source, MIT licensed.</footer>'
100
+ )
101
+
102
+
103
+ def _node_to_html(node: TreeNode, prefix: str = "", is_last: bool = True, is_root: bool = False) -> list[str]:
104
+ lines: list[str] = []
105
+
106
+ if is_root:
107
+ # Root node — no connector, special styling, always expanded
108
+ lines.append('<li class="dir-item">')
109
+ lines.append(
110
+ f' <span class="entry">'
111
+ f'<button class="toggle" onclick="toggle(this)">'
112
+ f'<span class="icon">▾</span>'
113
+ f'<span class="root">{_esc(node.name)}/</span>'
114
+ f'</button></span>'
115
+ )
116
+ if node.children:
117
+ lines.append(' <ul class="children">')
118
+ for i, child in enumerate(node.children):
119
+ lines.extend(_node_to_html(child, prefix="", is_last=(i == len(node.children) - 1)))
120
+ lines.append(" </ul>")
121
+ lines.append("</li>")
122
+ return lines
123
+
124
+ connector = "└── " if is_last else "├── "
125
+ child_prefix = prefix + (" " if is_last else "│ ")
126
+
127
+ if node.is_symlink:
128
+ lines.append("<li>")
129
+ lines.append(
130
+ f' <span class="entry">'
131
+ f'<span class="connector">{_esc(prefix + connector)}</span>'
132
+ f'<span class="symlink">{_esc(node.name)}</span>'
133
+ f'<span class="symlink-arrow"> -> </span>'
134
+ f'<span class="symlink">{_esc(node.symlink_target or "")}</span>'
135
+ f'</span>'
136
+ )
137
+ lines.append("</li>")
138
+ return lines
139
+
140
+ if not node.is_dir:
141
+ lines.append("<li>")
142
+ lines.append(
143
+ f' <span class="entry">'
144
+ f'<span class="connector">{_esc(prefix + connector)}</span>'
145
+ f'<span class="filename">{_esc(node.name)}</span>'
146
+ f'</span>'
147
+ )
148
+ lines.append("</li>")
149
+ return lines
150
+
151
+ # Directory — collapsible
152
+ lines.append('<li class="dir-item">')
153
+ lines.append(
154
+ f' <span class="entry">'
155
+ f'<span class="connector">{_esc(prefix + connector)}</span>'
156
+ f'<button class="toggle" onclick="toggle(this)">'
157
+ f'<span class="icon">▾</span>'
158
+ f'<span class="dirname">{_esc(node.name)}/</span>'
159
+ f'</button></span>'
160
+ )
161
+ if node.children:
162
+ lines.append(' <ul class="children">')
163
+ for i, child in enumerate(node.children):
164
+ lines.extend(_node_to_html(child, prefix=child_prefix, is_last=(i == len(node.children) - 1)))
165
+ lines.append(" </ul>")
166
+ lines.append("</li>")
167
+ return lines
168
+
169
+
170
+ def _esc(s: str) -> str:
171
+ return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
172
+
173
+
174
+ def render_html(
175
+ tree: TreeNode,
176
+ title: str | None = None,
177
+ include_footer: bool = True,
178
+ ) -> str:
179
+ """
180
+ Render a TreeNode as a self-contained HTML page string with a trailing newline.
181
+ """
182
+ title_tag = title or f"{tree.name} — tree2guide"
183
+
184
+ body_parts: list[str] = []
185
+ if title:
186
+ body_parts.append(f"<h1>{_esc(title)}</h1>")
187
+ body_parts.append(
188
+ '<div class="controls">'
189
+ '<button onclick="expandAll()">Expand all</button>'
190
+ '<button onclick="collapseAll()">Collapse all</button>'
191
+ '</div>'
192
+ )
193
+ body_parts.append('<div class="tree"><ul>')
194
+ body_parts.extend(_node_to_html(tree, is_root=True))
195
+ body_parts.append("</ul></div>")
196
+ if include_footer:
197
+ body_parts.append(FOOTER_HTML)
198
+
199
+ body = "\n".join(body_parts)
200
+
201
+ return f"""<!DOCTYPE html>
202
+ <html lang="en">
203
+ <head>
204
+ <meta charset="UTF-8">
205
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
206
+ <title>{_esc(title_tag)}</title>
207
+ <style>
208
+ {_CSS}
209
+ </style>
210
+ </head>
211
+ <body>
212
+ {body}
213
+ <script>
214
+ {_JS}
215
+ </script>
216
+ </body>
217
+ </html>
218
+ """
@@ -0,0 +1,48 @@
1
+ """
2
+ tree2guide.renderers.json_renderer — renders the tree as a JSON document.
3
+
4
+ The JSON shape is designed to be stable and easy to consume by other tools
5
+ (the --llm mode in Phase 4, web-based interactive visualizations, etc.):
6
+
7
+ {
8
+ "name": "src",
9
+ "type": "directory",
10
+ "children": [
11
+ { "name": "main.py", "type": "file" },
12
+ { "name": "api", "type": "directory", "children": [...] }
13
+ ]
14
+ }
15
+
16
+ Symlinks carry an extra "target" key and have type "symlink".
17
+ """
18
+
19
+ from __future__ import annotations
20
+ import json
21
+ from tree2guide.scanner import TreeNode
22
+
23
+
24
+ def _node_to_dict(node: TreeNode) -> dict:
25
+ if node.is_symlink:
26
+ return {
27
+ "name": node.name,
28
+ "type": "symlink",
29
+ "target": node.symlink_target,
30
+ }
31
+ if node.is_dir:
32
+ result: dict = {
33
+ "name": node.name,
34
+ "type": "directory",
35
+ }
36
+ if node.children:
37
+ result["children"] = [_node_to_dict(c) for c in node.children]
38
+ else:
39
+ result["children"] = []
40
+ return result
41
+ return {"name": node.name, "type": "file"}
42
+
43
+
44
+ def render_json(tree: TreeNode, indent: int = 2) -> str:
45
+ """
46
+ Render a TreeNode as a pretty-printed JSON string with a trailing newline.
47
+ """
48
+ return json.dumps(_node_to_dict(tree), indent=indent) + "\n"
@@ -0,0 +1,81 @@
1
+ """
2
+ tree2guide.renderers.llm — AI-consumption-friendly project summary renderer.
3
+
4
+ Produces a structured plain-text document optimised for pasting into an LLM
5
+ context window. The format is deliberately verbose and unambiguous: explicit
6
+ labels, no markdown fences that an LLM might misread as code to execute,
7
+ and a tree that uses the same connector characters humans are used to so
8
+ spatial relationships are clear.
9
+
10
+ No network calls, no API keys, no third-party dependencies.
11
+ """
12
+
13
+ from __future__ import annotations
14
+ from tree2guide.llm import LlmSummary, analyze
15
+ from tree2guide.scanner import TreeNode, render_lines
16
+
17
+
18
+ _SEPARATOR = "=" * 60
19
+
20
+
21
+ def render_llm(tree: TreeNode, title: str | None = None) -> str:
22
+ """
23
+ Render a TreeNode as an LLM-friendly project summary.
24
+ Returns a string with a trailing newline.
25
+ """
26
+ summary: LlmSummary = analyze(tree)
27
+ project_name = title or tree.name
28
+ lines: list[str] = []
29
+
30
+ # ------------------------------------------------------------------ header
31
+ lines.append(_SEPARATOR)
32
+ lines.append(f"PROJECT STRUCTURE SUMMARY: {project_name}")
33
+ lines.append(_SEPARATOR)
34
+ lines.append("")
35
+
36
+ # ----------------------------------------------------------- detected stack
37
+ lines.append("DETECTED STACK / LANGUAGE:")
38
+ if summary.detected_stack:
39
+ for item in summary.detected_stack:
40
+ lines.append(f" - {item}")
41
+ else:
42
+ lines.append(" (No known stack signals detected)")
43
+ lines.append("")
44
+
45
+ # ------------------------------------------------------------------ counts
46
+ lines.append("SIZE:")
47
+ lines.append(f" Files : {summary.file_count}")
48
+ lines.append(f" Directories: {summary.dir_count}")
49
+ lines.append("")
50
+
51
+ # --------------------------------------------------------- top-level layout
52
+ lines.append("TOP-LEVEL LAYOUT:")
53
+ if summary.top_level_dirs:
54
+ lines.append(" Directories:")
55
+ for d in summary.top_level_dirs:
56
+ lines.append(f" {d}")
57
+ if summary.top_level_files:
58
+ lines.append(" Files:")
59
+ for f in summary.top_level_files:
60
+ lines.append(f" {f}")
61
+ lines.append("")
62
+
63
+ # --------------------------------------------------------------- flags
64
+ if summary.notable_flags:
65
+ lines.append("NOTABLE FLAGS:")
66
+ for flag in summary.notable_flags:
67
+ lines.append(f" - {flag}")
68
+ lines.append("")
69
+
70
+ # --------------------------------------------------------------- full tree
71
+ lines.append(_SEPARATOR)
72
+ lines.append("FULL DIRECTORY TREE:")
73
+ lines.append(_SEPARATOR)
74
+ lines.append("")
75
+ lines.extend(render_lines(tree))
76
+ lines.append("")
77
+ lines.append(_SEPARATOR)
78
+ lines.append("END OF PROJECT STRUCTURE SUMMARY")
79
+ lines.append(_SEPARATOR)
80
+
81
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,39 @@
1
+ """
2
+ tree2guide.renderers.markdown — renders the tree as a markdown code block.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ from tree2guide.scanner import TreeNode, render_lines
7
+
8
+ FOOTER = (
9
+ "*Generated with [tree2guide](https://github.com/law4percent/tree2guide) "
10
+ "by Lawrence Roble ([@law4percent](https://github.com/law4percent)) "
11
+ "— open source, MIT licensed. Contributions welcome!*"
12
+ )
13
+
14
+
15
+ def render_markdown(
16
+ tree: "TreeNode | list[str]",
17
+ title: str | None = None,
18
+ include_footer: bool = True,
19
+ ) -> str:
20
+ """
21
+ Render a TreeNode (or raw line list for backward compat) as a markdown document.
22
+ Returns a string with a trailing newline.
23
+ """
24
+ if isinstance(tree, list):
25
+ lines = tree
26
+ else:
27
+ lines = render_lines(tree)
28
+
29
+ md: list[str] = []
30
+ if title:
31
+ md.append(f"# {title}\n")
32
+ md.append("```")
33
+ md.extend(lines)
34
+ md.append("```")
35
+ if include_footer:
36
+ md.append("")
37
+ md.append("---")
38
+ md.append(FOOTER)
39
+ return "\n".join(md) + "\n"
@@ -0,0 +1,29 @@
1
+ """
2
+ tree2guide.renderers.text — renders the tree as plain text (no markdown fencing).
3
+
4
+ Identical visual output to the markdown renderer but with no ``` fences,
5
+ no title heading syntax, and no footer — just the raw tree lines.
6
+ Useful for terminal output, clipboard piping, and non-markdown tools.
7
+ """
8
+
9
+ from __future__ import annotations
10
+ from tree2guide.scanner import TreeNode, render_lines
11
+
12
+
13
+ def render_text(
14
+ tree: "TreeNode | list[str]",
15
+ title: str | None = None,
16
+ ) -> str:
17
+ """
18
+ Render a TreeNode as plain text. Returns a string with a trailing newline.
19
+ Title (if given) is printed as a plain line above the tree, not a heading.
20
+ """
21
+ lines: list[str] = []
22
+ if title:
23
+ lines.append(title)
24
+ lines.append("")
25
+ if isinstance(tree, list):
26
+ lines.extend(tree)
27
+ else:
28
+ lines.extend(render_lines(tree))
29
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,64 @@
1
+ """
2
+ tree2guide.renderers.yaml — renders the tree as YAML, zero third-party deps.
3
+
4
+ Output shape mirrors the JSON renderer exactly, hand-serialized so the
5
+ package stays dependency-free. Each node is a YAML mapping; children are
6
+ a YAML sequence of nested mappings.
7
+
8
+ name: src
9
+ type: directory
10
+ children:
11
+ - name: main.py
12
+ type: file
13
+ - name: api
14
+ type: directory
15
+ children: []
16
+ """
17
+
18
+ from __future__ import annotations
19
+ from tree2guide.scanner import TreeNode
20
+
21
+
22
+ def _node_to_yaml(node: TreeNode, indent: int) -> list[str]:
23
+ pad = " " * indent
24
+ lines: list[str] = []
25
+
26
+ if node.is_symlink:
27
+ lines.append(f"{pad}name: {_scalar(node.name)}")
28
+ lines.append(f"{pad}type: symlink")
29
+ lines.append(f"{pad}target: {_scalar(node.symlink_target or '')}")
30
+ return lines
31
+
32
+ lines.append(f"{pad}name: {_scalar(node.name)}")
33
+ if node.is_dir:
34
+ lines.append(f"{pad}type: directory")
35
+ lines.append(f"{pad}children:")
36
+ if node.children:
37
+ for child in node.children:
38
+ lines.append(f"{pad} -")
39
+ lines.extend(_node_to_yaml(child, indent + 4))
40
+ else:
41
+ lines.append(f"{pad} []")
42
+ else:
43
+ lines.append(f"{pad}type: file")
44
+
45
+ return lines
46
+
47
+
48
+ def _scalar(value: str) -> str:
49
+ """Quote a scalar value if it contains YAML-unsafe characters."""
50
+ unsafe = {":", "#", "{", "}", "[", "]", ",", "&", "*", "?", "|", "-", "<", ">",
51
+ "=", "!", "%", "@", "`", "'", '"', "\n", "\r"}
52
+ if any(c in value for c in unsafe) or not value:
53
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
54
+ return f'"{escaped}"'
55
+ return value
56
+
57
+
58
+ def render_yaml(tree: TreeNode) -> str:
59
+ """
60
+ Render a TreeNode as a YAML string with a trailing newline.
61
+ No third-party dependencies — hand-serialized to keep the package zero-dep.
62
+ """
63
+ lines = _node_to_yaml(tree, indent=0)
64
+ return "\n".join(lines) + "\n"
tree2guide/scanner.py ADDED
@@ -0,0 +1,149 @@
1
+ """
2
+ tree2guide.scanner — walks the filesystem and builds a tree model.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ import os
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from tree2guide.ignore import ExcludeMatcher
11
+
12
+
13
+ @dataclass
14
+ class TreeNode:
15
+ """Internal tree model — one node per filesystem entry."""
16
+ name: str
17
+ is_dir: bool
18
+ is_symlink: bool = False
19
+ symlink_target: str | None = None
20
+ children: list["TreeNode"] = field(default_factory=list)
21
+
22
+
23
+ @dataclass
24
+ class TreeOptions:
25
+ max_depth: int | None = None
26
+ dirs_only: bool = False
27
+ files_only: bool = False
28
+ no_hidden: bool = False
29
+ sort: str = "dirs-first" # "dirs-first" | "files-first" | "alpha"
30
+
31
+
32
+ def _sort_key(sort_mode: str):
33
+ if sort_mode == "alpha":
34
+ return lambda p: p.name.lower()
35
+ if sort_mode == "files-first":
36
+ return lambda p: (p.is_dir(), p.name.lower())
37
+ return lambda p: (p.is_file(), p.name.lower())
38
+
39
+
40
+ def build_node_tree(
41
+ root: Path,
42
+ matcher: ExcludeMatcher,
43
+ options: TreeOptions | None = None,
44
+ ) -> TreeNode:
45
+ """
46
+ Build and return a TreeNode tree rooted at `root`.
47
+
48
+ This is the canonical Scanner output — a single O(n) pass that
49
+ every renderer consumes. build_tree() is kept for backward compat
50
+ and simply calls render_lines() on the result.
51
+ """
52
+ options = options or TreeOptions()
53
+ sort_key = _sort_key(options.sort)
54
+
55
+ def rel_str(path: Path) -> str:
56
+ return path.relative_to(root).as_posix()
57
+
58
+ def is_excluded(path: Path, is_dir: bool) -> bool:
59
+ return matcher.is_excluded(rel_str(path), is_dir)
60
+
61
+ def recurse(dir_path: Path, depth: int) -> list[TreeNode]:
62
+ if options.max_depth is not None and depth > options.max_depth:
63
+ return []
64
+ try:
65
+ entries = sorted(dir_path.iterdir(), key=sort_key)
66
+ except PermissionError:
67
+ return []
68
+
69
+ children: list[TreeNode] = []
70
+ for entry in entries:
71
+ if options.no_hidden and entry.name.startswith("."):
72
+ continue
73
+
74
+ is_symlink = entry.is_symlink()
75
+ entry_is_dir = entry.is_dir() and not is_symlink
76
+ excluded = is_excluded(entry, entry.is_dir())
77
+
78
+ child_nodes = recurse(entry, depth + 1) if entry_is_dir else []
79
+
80
+ hide_for_type = (
81
+ (options.dirs_only and not entry.is_dir())
82
+ or (options.files_only and entry.is_dir() and not child_nodes)
83
+ )
84
+ if hide_for_type:
85
+ continue
86
+
87
+ if excluded and not (entry_is_dir and child_nodes):
88
+ continue
89
+
90
+ symlink_target: str | None = None
91
+ if is_symlink:
92
+ try:
93
+ symlink_target = str(Path(os.readlink(entry)))
94
+ except OSError:
95
+ symlink_target = str(entry.resolve())
96
+
97
+ node = TreeNode(
98
+ name=entry.name,
99
+ is_dir=entry.is_dir(),
100
+ is_symlink=is_symlink,
101
+ symlink_target=symlink_target,
102
+ children=child_nodes,
103
+ )
104
+ children.append(node)
105
+
106
+ return children
107
+
108
+ root_node = TreeNode(name=root.name, is_dir=True, children=recurse(root, depth=1))
109
+ return root_node
110
+
111
+
112
+ def render_lines(node: TreeNode, prefix: str = "") -> list[str]:
113
+ """Flatten a TreeNode tree into connector-prefixed display lines (for text/markdown)."""
114
+ lines: list[str] = [f"{node.name}/"]
115
+ _render_children(node.children, prefix="", lines=lines)
116
+ return lines
117
+
118
+
119
+ def _render_children(children: list[TreeNode], prefix: str, lines: list[str]) -> None:
120
+ for i, child in enumerate(children):
121
+ is_last = i == len(children) - 1
122
+ connector = "└── " if is_last else "├── "
123
+
124
+ if child.is_symlink:
125
+ display = f"{child.name} -> {child.symlink_target}"
126
+ elif child.is_dir:
127
+ display = f"{child.name}/"
128
+ else:
129
+ display = child.name
130
+
131
+ lines.append(f"{prefix}{connector}{display}")
132
+
133
+ if child.is_dir and not child.is_symlink and child.children:
134
+ extension = " " if is_last else "│ "
135
+ _render_children(child.children, prefix + extension, lines)
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Backward-compatible API — kept so existing callers don't break
140
+ # ---------------------------------------------------------------------------
141
+
142
+ def build_tree(
143
+ root: Path,
144
+ matcher: ExcludeMatcher,
145
+ options: TreeOptions | None = None,
146
+ ) -> list[str]:
147
+ """Return tree display lines (backward-compatible wrapper around build_node_tree)."""
148
+ node = build_node_tree(root, matcher, options)
149
+ return render_lines(node)