mtangle 0.1.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.
- mtangle/__init__.py +3 -0
- mtangle/cli.py +99 -0
- mtangle/core.py +417 -0
- mtangle-0.1.0.dist-info/METADATA +151 -0
- mtangle-0.1.0.dist-info/RECORD +8 -0
- mtangle-0.1.0.dist-info/WHEEL +4 -0
- mtangle-0.1.0.dist-info/entry_points.txt +2 -0
- mtangle-0.1.0.dist-info/licenses/LICENSE +21 -0
mtangle/__init__.py
ADDED
mtangle/cli.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from mtangle.core import TangleError, tangle
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv: list[str] | None = None) -> int:
|
|
11
|
+
parser = argparse.ArgumentParser(
|
|
12
|
+
prog="mtangle",
|
|
13
|
+
description="Tangle codeblocks out of markdown files into source files.",
|
|
14
|
+
)
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"sources",
|
|
17
|
+
nargs="*",
|
|
18
|
+
help="Markdown files or directories to scan. Defaults to '.'",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"-o",
|
|
22
|
+
"--output-dir",
|
|
23
|
+
default=".",
|
|
24
|
+
help="Directory to write tangled sources into (default: cwd)",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"-n",
|
|
28
|
+
"--dry-run",
|
|
29
|
+
action="store_true",
|
|
30
|
+
help="Parse and resolve everything but don't write any files",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"-v",
|
|
34
|
+
"--verbose",
|
|
35
|
+
action="store_true",
|
|
36
|
+
help="Print progress details to stderr",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--disable-path-safety",
|
|
40
|
+
action="store_true",
|
|
41
|
+
help=(
|
|
42
|
+
"Allow file= targets to be absolute or resolve outside the output "
|
|
43
|
+
"directory. Off by default; such targets are warned and skipped."
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"-i",
|
|
48
|
+
"--ignore",
|
|
49
|
+
action="append",
|
|
50
|
+
default=[],
|
|
51
|
+
metavar="PATTERN",
|
|
52
|
+
help=(
|
|
53
|
+
"Gitignore-style pattern to skip during directory walks. "
|
|
54
|
+
"May be given multiple times."
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--disable-ignore-dotfiles",
|
|
59
|
+
action="store_true",
|
|
60
|
+
help="Include dotfiles/dotdirs in directory walks (default: skipped).",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--respect-gitignore",
|
|
64
|
+
action="store_true",
|
|
65
|
+
help="Also apply .gitignore patterns at each walk root (default: off).",
|
|
66
|
+
)
|
|
67
|
+
args = parser.parse_args(argv)
|
|
68
|
+
|
|
69
|
+
sources = args.sources or ["."]
|
|
70
|
+
inputs = [Path(s) for s in sources]
|
|
71
|
+
output_dir = Path(args.output_dir)
|
|
72
|
+
|
|
73
|
+
def info(msg: str) -> None:
|
|
74
|
+
if args.verbose:
|
|
75
|
+
print(msg, file=sys.stderr)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
results = tangle(
|
|
79
|
+
inputs,
|
|
80
|
+
output_dir,
|
|
81
|
+
on_info=info,
|
|
82
|
+
dry_run=args.dry_run,
|
|
83
|
+
path_safety=not args.disable_path_safety,
|
|
84
|
+
ignore_dotfiles=not args.disable_ignore_dotfiles,
|
|
85
|
+
ignore_patterns=args.ignore,
|
|
86
|
+
respect_gitignore=args.respect_gitignore,
|
|
87
|
+
)
|
|
88
|
+
except TangleError as e:
|
|
89
|
+
print(f"error: {e}", file=sys.stderr)
|
|
90
|
+
return 1
|
|
91
|
+
|
|
92
|
+
verb = "would write" if args.dry_run else "wrote"
|
|
93
|
+
for path in sorted(results):
|
|
94
|
+
print(f"{verb} {path}")
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
raise SystemExit(main())
|
mtangle/core.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable, Iterable
|
|
9
|
+
|
|
10
|
+
import pathspec
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TangleError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TangleWarning(UserWarning):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
FENCE_RE = re.compile(r"^(?P<indent>[ \t]*)(?P<fence>`{3,})(?P<info>.*)$")
|
|
22
|
+
ATTR_RE = re.compile(r'([A-Za-z_][A-Za-z0-9_.-]*)=("([^"]*)"|(\S+))')
|
|
23
|
+
IDENT_RE = re.compile(r"([A-Za-z_][A-Za-z0-9_-]*)>>")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class CodeBlock:
|
|
28
|
+
lang: str | None
|
|
29
|
+
file: str | None
|
|
30
|
+
id: str | None
|
|
31
|
+
lines: list[str]
|
|
32
|
+
source: str
|
|
33
|
+
line_no: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_info(info: str) -> tuple[str | None, dict[str, str]]:
|
|
37
|
+
info = info.strip()
|
|
38
|
+
if not info:
|
|
39
|
+
return None, {}
|
|
40
|
+
parts = info.split(None, 1)
|
|
41
|
+
first = parts[0]
|
|
42
|
+
attrs: dict[str, str] = {}
|
|
43
|
+
if "=" in first:
|
|
44
|
+
lang = None
|
|
45
|
+
rest = info
|
|
46
|
+
else:
|
|
47
|
+
lang = first
|
|
48
|
+
rest = parts[1] if len(parts) > 1 else ""
|
|
49
|
+
for m in ATTR_RE.finditer(rest):
|
|
50
|
+
key = m.group(1)
|
|
51
|
+
val = m.group(3) if m.group(3) is not None else m.group(4)
|
|
52
|
+
attrs[key] = val
|
|
53
|
+
return lang, attrs
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _left_align(lines: list[str]) -> list[str]:
|
|
57
|
+
non_blank = [ln for ln in lines if ln.strip()]
|
|
58
|
+
if not non_blank:
|
|
59
|
+
return ["" for _ in lines]
|
|
60
|
+
min_indent = min(len(ln) - len(ln.lstrip(" \t")) for ln in non_blank)
|
|
61
|
+
result: list[str] = []
|
|
62
|
+
for ln in lines:
|
|
63
|
+
if not ln.strip():
|
|
64
|
+
result.append("")
|
|
65
|
+
else:
|
|
66
|
+
result.append(ln[min_indent:])
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_markdown(text: str, source: str = "<string>") -> list[CodeBlock]:
|
|
71
|
+
lines = text.splitlines()
|
|
72
|
+
blocks: list[CodeBlock] = []
|
|
73
|
+
i = 0
|
|
74
|
+
while i < len(lines):
|
|
75
|
+
m = FENCE_RE.match(lines[i])
|
|
76
|
+
if not m:
|
|
77
|
+
i += 1
|
|
78
|
+
continue
|
|
79
|
+
fence = m.group("fence")
|
|
80
|
+
info = m.group("info")
|
|
81
|
+
lang, attrs = _parse_info(info)
|
|
82
|
+
start = i
|
|
83
|
+
i += 1
|
|
84
|
+
body: list[str] = []
|
|
85
|
+
closed = False
|
|
86
|
+
close_re = re.compile(rf"^[ \t]*`{{{len(fence)},}}\s*$")
|
|
87
|
+
while i < len(lines):
|
|
88
|
+
if close_re.match(lines[i]):
|
|
89
|
+
closed = True
|
|
90
|
+
i += 1
|
|
91
|
+
break
|
|
92
|
+
body.append(lines[i])
|
|
93
|
+
i += 1
|
|
94
|
+
if not closed:
|
|
95
|
+
raise TangleError(f"{source}:{start+1}: unclosed fenced code block")
|
|
96
|
+
file_attr = attrs.get("file")
|
|
97
|
+
id_attr = attrs.get("id")
|
|
98
|
+
if file_attr or id_attr:
|
|
99
|
+
aligned = _left_align(body)
|
|
100
|
+
blocks.append(
|
|
101
|
+
CodeBlock(
|
|
102
|
+
lang=lang,
|
|
103
|
+
file=file_attr,
|
|
104
|
+
id=id_attr,
|
|
105
|
+
lines=aligned,
|
|
106
|
+
source=source,
|
|
107
|
+
line_no=start + 1,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
return blocks
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _scan_line(line: str) -> tuple[str, tuple[int, int, str] | None, str]:
|
|
114
|
+
"""Scan a line for at most one active marker.
|
|
115
|
+
|
|
116
|
+
Escape rules:
|
|
117
|
+
- `\\\\` → literal backslash
|
|
118
|
+
- `\\<<` → literal `<<` (marker suppressed)
|
|
119
|
+
- `<<IDENT>>` (no whitespace inside) → marker
|
|
120
|
+
- `<< IDENT >>` or any non-identifier content between `<<` and `>>` → literal
|
|
121
|
+
|
|
122
|
+
Returns (rendered_before, marker_or_None, rendered_after).
|
|
123
|
+
`marker_or_None` is `(start_col_in_rendered, end_col, ident)` where
|
|
124
|
+
start/end refer to positions in the fully-rendered line (before + placeholder + after).
|
|
125
|
+
If there is more than one marker on the line, raises AssertionError-style —
|
|
126
|
+
the caller detects multi-marker via a second scan.
|
|
127
|
+
"""
|
|
128
|
+
out: list[str] = []
|
|
129
|
+
marker: tuple[int, int, str] | None = None
|
|
130
|
+
i = 0
|
|
131
|
+
n = len(line)
|
|
132
|
+
while i < n:
|
|
133
|
+
c = line[i]
|
|
134
|
+
if c == "\\":
|
|
135
|
+
if i + 1 < n and line[i + 1] == "\\":
|
|
136
|
+
out.append("\\")
|
|
137
|
+
i += 2
|
|
138
|
+
continue
|
|
139
|
+
if line.startswith("<<", i + 1):
|
|
140
|
+
out.append("<<")
|
|
141
|
+
i += 3
|
|
142
|
+
continue
|
|
143
|
+
out.append("\\")
|
|
144
|
+
i += 1
|
|
145
|
+
continue
|
|
146
|
+
if line.startswith("<<", i):
|
|
147
|
+
m = IDENT_RE.match(line, i + 2)
|
|
148
|
+
if m:
|
|
149
|
+
if marker is not None:
|
|
150
|
+
marker = (-1, -1, "__MULTI__")
|
|
151
|
+
out.append(line[i : m.end()])
|
|
152
|
+
else:
|
|
153
|
+
ident = m.group(1)
|
|
154
|
+
start = len("".join(out))
|
|
155
|
+
marker = (start, start, ident)
|
|
156
|
+
i = m.end()
|
|
157
|
+
continue
|
|
158
|
+
out.append(c)
|
|
159
|
+
i += 1
|
|
160
|
+
rendered = "".join(out)
|
|
161
|
+
if marker is None:
|
|
162
|
+
return rendered, None, ""
|
|
163
|
+
start, _, ident = marker
|
|
164
|
+
return rendered[:start], marker, rendered[start:]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _count_markers(line: str) -> int:
|
|
168
|
+
"""Count active markers on a line (for multi-marker detection)."""
|
|
169
|
+
count = 0
|
|
170
|
+
i = 0
|
|
171
|
+
n = len(line)
|
|
172
|
+
while i < n:
|
|
173
|
+
c = line[i]
|
|
174
|
+
if c == "\\":
|
|
175
|
+
if i + 1 < n and line[i + 1] == "\\":
|
|
176
|
+
i += 2
|
|
177
|
+
continue
|
|
178
|
+
if line.startswith("<<", i + 1):
|
|
179
|
+
i += 3
|
|
180
|
+
continue
|
|
181
|
+
i += 1
|
|
182
|
+
continue
|
|
183
|
+
if line.startswith("<<", i):
|
|
184
|
+
m = IDENT_RE.match(line, i + 2)
|
|
185
|
+
if m:
|
|
186
|
+
count += 1
|
|
187
|
+
i = m.end()
|
|
188
|
+
continue
|
|
189
|
+
i += 1
|
|
190
|
+
return count
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _expand(
|
|
194
|
+
block: CodeBlock,
|
|
195
|
+
id_map: dict[str, CodeBlock],
|
|
196
|
+
on_warning: Callable[[str], None],
|
|
197
|
+
stack: tuple[str, ...] = (),
|
|
198
|
+
) -> list[str]:
|
|
199
|
+
out: list[str] = []
|
|
200
|
+
for line_idx, line in enumerate(block.lines):
|
|
201
|
+
n_markers = _count_markers(line)
|
|
202
|
+
if n_markers == 0:
|
|
203
|
+
before, _, _ = _scan_line(line)
|
|
204
|
+
out.append(before)
|
|
205
|
+
continue
|
|
206
|
+
if n_markers > 1:
|
|
207
|
+
raise TangleError(
|
|
208
|
+
f"{block.source}:{block.line_no}: line {line_idx + 1} of block "
|
|
209
|
+
f"has multiple substitution markers: {line!r}"
|
|
210
|
+
)
|
|
211
|
+
before, marker, after = _scan_line(line)
|
|
212
|
+
assert marker is not None
|
|
213
|
+
_, _, marker_id = marker
|
|
214
|
+
if before.strip() != "" or after.strip() != "":
|
|
215
|
+
on_warning(
|
|
216
|
+
f"{block.source}:{block.line_no}: substitution marker "
|
|
217
|
+
f"<<{marker_id}>> has non-whitespace neighbors "
|
|
218
|
+
f"(line {line_idx + 1}): {line!r}"
|
|
219
|
+
)
|
|
220
|
+
if before.strip() == "":
|
|
221
|
+
indent = before
|
|
222
|
+
else:
|
|
223
|
+
lead = re.match(r"[ \t]*", before)
|
|
224
|
+
indent = lead.group(0) if lead else ""
|
|
225
|
+
if marker_id not in id_map:
|
|
226
|
+
raise TangleError(
|
|
227
|
+
f"{block.source}:{block.line_no}: unknown id "
|
|
228
|
+
f"'<<{marker_id}>>' referenced"
|
|
229
|
+
)
|
|
230
|
+
if marker_id in stack:
|
|
231
|
+
cycle = " -> ".join(stack + (marker_id,))
|
|
232
|
+
raise TangleError(f"cyclic substitution: {cycle}")
|
|
233
|
+
sub_lines = _expand(id_map[marker_id], id_map, on_warning, stack + (marker_id,))
|
|
234
|
+
for sl in sub_lines:
|
|
235
|
+
out.append(indent + sl if sl else "")
|
|
236
|
+
return out
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
DOTFILE_PATTERNS = [".*", ".*/"]
|
|
240
|
+
MTANGLEIGNORE = ".mtangleignore"
|
|
241
|
+
GITIGNORE = ".gitignore"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _read_pattern_file(path: Path) -> list[str]:
|
|
245
|
+
if not path.is_file():
|
|
246
|
+
return []
|
|
247
|
+
patterns: list[str] = []
|
|
248
|
+
for raw in path.read_text().splitlines():
|
|
249
|
+
line = raw.strip()
|
|
250
|
+
if not line or line.startswith("#"):
|
|
251
|
+
continue
|
|
252
|
+
patterns.append(line)
|
|
253
|
+
return patterns
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _build_spec(patterns: Iterable[str]) -> pathspec.PathSpec:
|
|
257
|
+
return pathspec.PathSpec.from_lines("gitignore", list(patterns))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _walk_dir(
|
|
261
|
+
root: Path,
|
|
262
|
+
ignore_dotfiles: bool,
|
|
263
|
+
cli_patterns: list[str],
|
|
264
|
+
respect_gitignore: bool,
|
|
265
|
+
) -> list[Path]:
|
|
266
|
+
patterns: list[str] = []
|
|
267
|
+
if ignore_dotfiles:
|
|
268
|
+
patterns.extend(DOTFILE_PATTERNS)
|
|
269
|
+
patterns.extend(cli_patterns)
|
|
270
|
+
patterns.extend(_read_pattern_file(root / MTANGLEIGNORE))
|
|
271
|
+
if respect_gitignore:
|
|
272
|
+
patterns.extend(_read_pattern_file(root / GITIGNORE))
|
|
273
|
+
spec = _build_spec(patterns)
|
|
274
|
+
|
|
275
|
+
result: list[Path] = []
|
|
276
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
277
|
+
rel_dir = Path(dirpath).relative_to(root)
|
|
278
|
+
kept_dirs: list[str] = []
|
|
279
|
+
for d in dirnames:
|
|
280
|
+
rel = (rel_dir / d).as_posix() + "/"
|
|
281
|
+
if spec.match_file(rel):
|
|
282
|
+
continue
|
|
283
|
+
kept_dirs.append(d)
|
|
284
|
+
dirnames[:] = kept_dirs
|
|
285
|
+
for f in filenames:
|
|
286
|
+
if not f.endswith(".md"):
|
|
287
|
+
continue
|
|
288
|
+
rel = (rel_dir / f).as_posix()
|
|
289
|
+
if spec.match_file(rel):
|
|
290
|
+
continue
|
|
291
|
+
result.append(Path(dirpath) / f)
|
|
292
|
+
return result
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _collect_md_files(
|
|
296
|
+
inputs: Iterable[Path],
|
|
297
|
+
ignore_dotfiles: bool = True,
|
|
298
|
+
cli_patterns: list[str] | None = None,
|
|
299
|
+
respect_gitignore: bool = False,
|
|
300
|
+
) -> list[Path]:
|
|
301
|
+
cli_patterns = cli_patterns or []
|
|
302
|
+
result: list[Path] = []
|
|
303
|
+
seen: set[Path] = set()
|
|
304
|
+
for p in inputs:
|
|
305
|
+
if p.is_dir():
|
|
306
|
+
for md in _walk_dir(p, ignore_dotfiles, cli_patterns, respect_gitignore):
|
|
307
|
+
rp = md.resolve()
|
|
308
|
+
if rp not in seen:
|
|
309
|
+
seen.add(rp)
|
|
310
|
+
result.append(md)
|
|
311
|
+
elif p.suffix == ".md":
|
|
312
|
+
rp = p.resolve()
|
|
313
|
+
if rp not in seen:
|
|
314
|
+
seen.add(rp)
|
|
315
|
+
result.append(p)
|
|
316
|
+
return result
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _default_warn(msg: str) -> None:
|
|
320
|
+
print(f"warning: {msg}", file=sys.stderr)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _noop(_msg: str) -> None:
|
|
324
|
+
pass
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _is_safe_target(target: str, output_dir: Path) -> tuple[bool, Path, str]:
|
|
328
|
+
"""Return (ok, resolved_path, reason_if_not_ok)."""
|
|
329
|
+
if Path(target).is_absolute():
|
|
330
|
+
return False, output_dir / target, "absolute path"
|
|
331
|
+
combined = (output_dir / target).resolve()
|
|
332
|
+
base = output_dir.resolve()
|
|
333
|
+
try:
|
|
334
|
+
combined.relative_to(base)
|
|
335
|
+
except ValueError:
|
|
336
|
+
return False, combined, f"escapes output directory {base}"
|
|
337
|
+
return True, combined, ""
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def tangle(
|
|
341
|
+
inputs: Iterable[Path],
|
|
342
|
+
output_dir: Path,
|
|
343
|
+
on_warning: Callable[[str], None] | None = None,
|
|
344
|
+
on_info: Callable[[str], None] | None = None,
|
|
345
|
+
dry_run: bool = False,
|
|
346
|
+
path_safety: bool = True,
|
|
347
|
+
ignore_dotfiles: bool = True,
|
|
348
|
+
ignore_patterns: list[str] | None = None,
|
|
349
|
+
respect_gitignore: bool = False,
|
|
350
|
+
) -> dict[Path, str]:
|
|
351
|
+
warn = on_warning if on_warning is not None else _default_warn
|
|
352
|
+
info = on_info if on_info is not None else _noop
|
|
353
|
+
|
|
354
|
+
md_files = sorted(
|
|
355
|
+
_collect_md_files(
|
|
356
|
+
inputs,
|
|
357
|
+
ignore_dotfiles=ignore_dotfiles,
|
|
358
|
+
cli_patterns=ignore_patterns or [],
|
|
359
|
+
respect_gitignore=respect_gitignore,
|
|
360
|
+
),
|
|
361
|
+
key=lambda p: str(p),
|
|
362
|
+
)
|
|
363
|
+
for md in md_files:
|
|
364
|
+
info(f"reading {md}")
|
|
365
|
+
|
|
366
|
+
all_blocks: list[CodeBlock] = []
|
|
367
|
+
for md in md_files:
|
|
368
|
+
text = md.read_text()
|
|
369
|
+
all_blocks.extend(parse_markdown(text, source=str(md)))
|
|
370
|
+
|
|
371
|
+
id_map: dict[str, CodeBlock] = {}
|
|
372
|
+
for b in all_blocks:
|
|
373
|
+
if b.id:
|
|
374
|
+
if b.id in id_map:
|
|
375
|
+
prev = id_map[b.id]
|
|
376
|
+
raise TangleError(
|
|
377
|
+
f"{b.source}:{b.line_no}: duplicate id '{b.id}' "
|
|
378
|
+
f"(also at {prev.source}:{prev.line_no})"
|
|
379
|
+
)
|
|
380
|
+
id_map[b.id] = b
|
|
381
|
+
info(f"registered id '{b.id}' from {b.source}:{b.line_no}")
|
|
382
|
+
|
|
383
|
+
file_groups: dict[str, list[CodeBlock]] = {}
|
|
384
|
+
for b in all_blocks:
|
|
385
|
+
if b.file:
|
|
386
|
+
file_groups.setdefault(b.file, []).append(b)
|
|
387
|
+
|
|
388
|
+
results: dict[Path, str] = {}
|
|
389
|
+
for target, blocks in file_groups.items():
|
|
390
|
+
if path_safety:
|
|
391
|
+
ok, resolved, reason = _is_safe_target(target, output_dir)
|
|
392
|
+
if not ok:
|
|
393
|
+
locs = ", ".join(f"{b.source}:{b.line_no}" for b in blocks)
|
|
394
|
+
warn(
|
|
395
|
+
f"refusing to write '{target}' ({reason}); "
|
|
396
|
+
f"skipping. from: {locs}"
|
|
397
|
+
)
|
|
398
|
+
continue
|
|
399
|
+
out_path = resolved
|
|
400
|
+
else:
|
|
401
|
+
out_path = output_dir / target
|
|
402
|
+
if len(blocks) > 1:
|
|
403
|
+
locs = ", ".join(f"{b.source}:{b.line_no}" for b in blocks)
|
|
404
|
+
warn(f"multiple codeblocks target '{target}' — concatenating: {locs}")
|
|
405
|
+
chunks: list[str] = []
|
|
406
|
+
for b in blocks:
|
|
407
|
+
expanded = _expand(b, id_map, warn)
|
|
408
|
+
chunks.append("\n".join(expanded) + "\n")
|
|
409
|
+
content = "".join(chunks)
|
|
410
|
+
results[out_path] = content
|
|
411
|
+
if dry_run:
|
|
412
|
+
info(f"would write {out_path} ({len(content)} bytes)")
|
|
413
|
+
else:
|
|
414
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
415
|
+
out_path.write_text(content)
|
|
416
|
+
info(f"wrote {out_path} ({len(content)} bytes)")
|
|
417
|
+
return results
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mtangle
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A simple literate programming utility for markdown
|
|
5
|
+
Project-URL: Homepage, https://github.com/usergenic/mtangle
|
|
6
|
+
Project-URL: Repository, https://github.com/usergenic/mtangle
|
|
7
|
+
Author: Brendan Baldwin
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: pathspec>=0.12
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# mtangle
|
|
15
|
+
|
|
16
|
+
A simple literate-programming utility that extracts codeblocks from markdown
|
|
17
|
+
files and writes them to source files.
|
|
18
|
+
|
|
19
|
+
## What it does
|
|
20
|
+
|
|
21
|
+
Given a markdown file:
|
|
22
|
+
|
|
23
|
+
~~~markdown
|
|
24
|
+
```python file=src/something.py
|
|
25
|
+
def cool():
|
|
26
|
+
return 0
|
|
27
|
+
```
|
|
28
|
+
~~~
|
|
29
|
+
|
|
30
|
+
Running `mtangle` writes the block's contents to `src/something.py`.
|
|
31
|
+
|
|
32
|
+
Codeblocks are recognized by attributes on the info string:
|
|
33
|
+
|
|
34
|
+
- `file=PATH` — write this block's contents to `PATH` (relative to the output
|
|
35
|
+
directory).
|
|
36
|
+
- `id=NAME` — register this block under a name so it can be referenced from
|
|
37
|
+
another block.
|
|
38
|
+
|
|
39
|
+
Blocks without either attribute are ignored.
|
|
40
|
+
|
|
41
|
+
## Composition via `<<name>>`
|
|
42
|
+
|
|
43
|
+
Blocks with `file=` can reference `id=` blocks using `<<name>>` markers on their
|
|
44
|
+
own line:
|
|
45
|
+
|
|
46
|
+
~~~markdown
|
|
47
|
+
```python id=cool_func
|
|
48
|
+
def cool():
|
|
49
|
+
return 0
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```python id=neat_func
|
|
53
|
+
def neat():
|
|
54
|
+
return cool()
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```python file=src/my_funcs.py
|
|
58
|
+
<<cool_func>>
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
<<neat_func>>
|
|
62
|
+
```
|
|
63
|
+
~~~
|
|
64
|
+
|
|
65
|
+
Produces `src/my_funcs.py`:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
def cool():
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def neat():
|
|
73
|
+
return cool()
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Indentation
|
|
77
|
+
|
|
78
|
+
mtangle is language-agnostic. When a codeblock is parsed, its non-blank lines
|
|
79
|
+
are left-aligned against the block's least-indented line. When a `<<marker>>`
|
|
80
|
+
is substituted, the substituted content is re-indented to match the marker's
|
|
81
|
+
column. This handles the common case of embedding a snippet inside an already-
|
|
82
|
+
indented body.
|
|
83
|
+
|
|
84
|
+
### Escaping
|
|
85
|
+
|
|
86
|
+
- `<< name >>` (spaces inside) is not a marker — emitted literally.
|
|
87
|
+
- `\<<name>>` emits `<<name>>` literally (no substitution).
|
|
88
|
+
- `\\` emits a literal backslash.
|
|
89
|
+
- To emit a literal `\<<name>>`: use `\\\<<name>>`.
|
|
90
|
+
|
|
91
|
+
## Merging into the same file
|
|
92
|
+
|
|
93
|
+
Multiple codeblocks targeting the same `file=` are concatenated in the order
|
|
94
|
+
they are encountered (across all input markdown files, sorted by path).
|
|
95
|
+
mtangle warns when this happens.
|
|
96
|
+
|
|
97
|
+
## CLI
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
mtangle [SOURCES...] [-o OUTPUT_DIR] [OPTIONS]
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
With no arguments, `mtangle` is equivalent to `mtangle . -o .` — it recursively
|
|
104
|
+
scans the current directory for `.md` files and writes tangled output to the
|
|
105
|
+
current directory.
|
|
106
|
+
|
|
107
|
+
### Options
|
|
108
|
+
|
|
109
|
+
| Flag | Description |
|
|
110
|
+
|------|-------------|
|
|
111
|
+
| `-o`, `--output-dir DIR` | Where to write files (default: cwd) |
|
|
112
|
+
| `-n`, `--dry-run` | Parse and resolve everything but write nothing |
|
|
113
|
+
| `-v`, `--verbose` | Print progress to stderr |
|
|
114
|
+
| `-i`, `--ignore PATTERN` | Gitignore-style skip pattern (repeatable) |
|
|
115
|
+
| `--disable-ignore-dotfiles` | Include dotfiles/dotdirs (default: skipped) |
|
|
116
|
+
| `--respect-gitignore` | Also apply `.gitignore` at each input-dir root |
|
|
117
|
+
| `--disable-path-safety` | Permit `file=` targets outside the output dir |
|
|
118
|
+
|
|
119
|
+
### Ignore behavior
|
|
120
|
+
|
|
121
|
+
- Dotfiles and dotdirs (`.git`, `.venv`, etc.) are skipped by default.
|
|
122
|
+
- A `.mtangleignore` file at the root of each input directory is respected;
|
|
123
|
+
every non-empty, non-`#` line is treated like an `-i` pattern
|
|
124
|
+
(gitignore syntax).
|
|
125
|
+
- `.gitignore` is **not** consulted unless `--respect-gitignore` is set.
|
|
126
|
+
- Explicitly-named files (e.g. `mtangle README.md`) bypass all ignore rules.
|
|
127
|
+
|
|
128
|
+
### Path safety
|
|
129
|
+
|
|
130
|
+
By default, `file=` targets that are absolute or that resolve outside the
|
|
131
|
+
output directory are warned and skipped. Use `--disable-path-safety` to opt
|
|
132
|
+
out.
|
|
133
|
+
|
|
134
|
+
## Install
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
uv pip install git+https://github.com/usergenic/mtangle.git
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Or add to your project:
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
uv add git+https://github.com/usergenic/mtangle.git
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Development
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
uv sync
|
|
150
|
+
uv run pytest
|
|
151
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
mtangle/__init__.py,sha256=yQ5LbKTcTcYC88fnG8ceUUcKfwPx7aqtObF15HQH8ok,172
|
|
2
|
+
mtangle/cli.py,sha256=TUUcmYxuljMigoVNL_JrDPwRiUPXzeRw3Zuec3eL1xs,2741
|
|
3
|
+
mtangle/core.py,sha256=wHMstu7s-lQPUoMoYd7EUV00Fd0wDAfZuPFZpSFXAQU,12798
|
|
4
|
+
mtangle-0.1.0.dist-info/METADATA,sha256=Txm-8rIG1wpZBdK87VQM3qQB8H0dWEZ2zeH2QfBbnok,3791
|
|
5
|
+
mtangle-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
6
|
+
mtangle-0.1.0.dist-info/entry_points.txt,sha256=4gSRi9I7qYMMEHh0yIfyxDqXqhBLUWZEZLn90UDX_ZA,45
|
|
7
|
+
mtangle-0.1.0.dist-info/licenses/LICENSE,sha256=q7JWdDPDSH_-CFwtCtYp7AtUmMYDdMMrd-jO_vdcxWE,1072
|
|
8
|
+
mtangle-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brendan Baldwin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|