code2okf 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.
Files changed (48) hide show
  1. code2okf/SPEC.md +1006 -0
  2. code2okf/__init__.py +8 -0
  3. code2okf/cli.py +234 -0
  4. code2okf/clis/inspectmd/pyproject.toml +40 -0
  5. code2okf/clis/inspectmd/src/inspectmd/__init__.py +8 -0
  6. code2okf/clis/inspectmd/src/inspectmd/__main__.py +5 -0
  7. code2okf/clis/inspectmd/src/inspectmd/cli.py +159 -0
  8. code2okf/clis/inspectmd/src/inspectmd/parse.py +212 -0
  9. code2okf/clis/inspectokf/pyproject.toml +40 -0
  10. code2okf/clis/inspectokf/src/inspectokf/__init__.py +8 -0
  11. code2okf/clis/inspectokf/src/inspectokf/__main__.py +5 -0
  12. code2okf/clis/inspectokf/src/inspectokf/cli.py +104 -0
  13. code2okf/clis/merkleokf/pyproject.toml +40 -0
  14. code2okf/clis/merkleokf/src/merkleokf/__init__.py +8 -0
  15. code2okf/clis/merkleokf/src/merkleokf/__main__.py +5 -0
  16. code2okf/clis/merkleokf/src/merkleokf/cli.py +121 -0
  17. code2okf/clis/merkleokf/src/merkleokf/merkle.py +145 -0
  18. code2okf/clis/sizeokf/pyproject.toml +40 -0
  19. code2okf/clis/sizeokf/src/sizeokf/__init__.py +8 -0
  20. code2okf/clis/sizeokf/src/sizeokf/__main__.py +5 -0
  21. code2okf/clis/sizeokf/src/sizeokf/cli.py +93 -0
  22. code2okf/clis/sizeokf/src/sizeokf/sizes.py +155 -0
  23. code2okf/compile.py +267 -0
  24. code2okf/events.py +86 -0
  25. code2okf/kit/README.md +128 -0
  26. code2okf/kit/files/home/.local/lib/code2okf/mount-state.sh +48 -0
  27. code2okf/kit/files/home/.pi/agent/AGENTS.md +185 -0
  28. code2okf/kit/files/home/.pi/agent/models.json +84 -0
  29. code2okf/kit/files/home/.pi/agent/settings.json +7 -0
  30. code2okf/kit/files/home/.pi/agent/skills/compile-okf/SKILL.md +142 -0
  31. code2okf/kit/files/home/.pi/agent/skills/compile-okf/scripts/check-okf.sh +155 -0
  32. code2okf/kit/files/home/.pi/agent/skills/compile-okf/scripts/frontmatter-guard.py +289 -0
  33. code2okf/kit/files/home/.pi/agent/skills/curate-okf/SKILL.md +68 -0
  34. code2okf/kit/files/home/.pi/agent/skills/inspect-md/SKILL.md +52 -0
  35. code2okf/kit/files/home/.pi/agent/skills/inspect-okf/SKILL.md +47 -0
  36. code2okf/kit/files/home/.pi/agent/skills/merkle-okf/SKILL.md +59 -0
  37. code2okf/kit/files/home/.pi/agent/skills/size-okf/SKILL.md +52 -0
  38. code2okf/kit/spec.yaml +312 -0
  39. code2okf/resources.py +74 -0
  40. code2okf/sandbox.py +266 -0
  41. code2okf/workbench.py +572 -0
  42. code2okf-0.1.0.dist-info/METADATA +391 -0
  43. code2okf-0.1.0.dist-info/RECORD +48 -0
  44. code2okf-0.1.0.dist-info/WHEEL +4 -0
  45. code2okf-0.1.0.dist-info/entry_points.txt +2 -0
  46. code2okf-0.1.0.dist-info/licenses/LICENSE +21 -0
  47. code2okf-0.1.0.dist-info/licenses/LICENSE-OKF-SPEC.txt +203 -0
  48. code2okf-0.1.0.dist-info/licenses/NOTICE-OKF-SPEC.md +37 -0
code2okf/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """code2okf: compile Markdown into an OKF wiki with the Pi coding agent."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("code2okf")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0+local"
code2okf/cli.py ADDED
@@ -0,0 +1,234 @@
1
+ """The code2okf command.
2
+
3
+ Compiles Markdown into an OKF wiki with the Pi coding agent, via a sandboxed
4
+ sbx runtime. See .claude/plans/interface-plan.md.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from code2okf import __version__, resources, sandbox, workbench
14
+ from code2okf import compile as compile_mod
15
+
16
+
17
+ def _build_parser() -> argparse.ArgumentParser:
18
+ parser = argparse.ArgumentParser(
19
+ prog="code2okf",
20
+ description="Compile Markdown into an OKF wiki with the Pi coding agent.",
21
+ )
22
+ parser.add_argument(
23
+ "paths", nargs="*", metavar="FILE|DIR", help="Markdown files or folders; '-' or none means stdin"
24
+ )
25
+ parser.add_argument("-o", "--output", default="okf", metavar="DIR", help="wiki output directory (default: ./okf)")
26
+ parser.add_argument("--spec", metavar="FILE", help="OKF spec file (default: the bundled SPEC.md)")
27
+ parser.add_argument(
28
+ "-n",
29
+ type=int,
30
+ default=compile_mod.DEFAULT_MAX_ITERATIONS,
31
+ metavar="N",
32
+ help="max Ralph loop iterations per document (default: 10)",
33
+ )
34
+ parser.add_argument("--fresh", action="store_true", help="recreate the sandbox even if it could be reused")
35
+ parser.add_argument("--dry-run", action="store_true", help="resolve and print what would run; do nothing paid")
36
+ verbosity = parser.add_mutually_exclusive_group()
37
+ verbosity.add_argument(
38
+ "-q", "--quiet", action="store_true", help="suppress progress and TSV rows (fatal errors still print)"
39
+ )
40
+ verbosity.add_argument("-v", "--verbose", action="store_true", help="also show Pi's tool calls and prose")
41
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
42
+ return parser
43
+
44
+
45
+ def _resolve_inputs(
46
+ args: argparse.Namespace,
47
+ ) -> tuple[list[compile_mod.Document], Path, Path, workbench.Workbench] | int:
48
+ """Everything decided before any work starts. Returns exit code 2 on failure."""
49
+ try:
50
+ if args.n < 1:
51
+ raise compile_mod.UsageError(f"-n must be at least 1 (got {args.n})")
52
+ documents = compile_mod.resolve_documents(args.paths)
53
+ spec_path = Path(args.spec) if args.spec else resources.spec_md()
54
+ workbench.reject_if_unsafe(spec_path, what="--spec")
55
+ if not spec_path.is_file():
56
+ raise compile_mod.UsageError(f"--spec is not a file: {spec_path}")
57
+
58
+ output_dir = Path(args.output)
59
+ if not workbench.is_adoptable_output(output_dir):
60
+ raise compile_mod.UsageError(
61
+ f"-o {output_dir} is not empty and is not a recognised OKF bundle root; refusing to adopt it"
62
+ )
63
+
64
+ wb = workbench.Workbench.default()
65
+ overlap_paths = [Path(raw) for raw in args.paths if raw != "-"]
66
+ overlap_paths += [spec_path, output_dir, wb.root]
67
+ workbench.check_no_overlap(overlap_paths)
68
+ except (compile_mod.UsageError, workbench.WorkbenchError, resources.ResourcesError) as exc:
69
+ print(f"code2okf: {exc}", file=sys.stderr)
70
+ return 2
71
+ return documents, spec_path, output_dir, wb
72
+
73
+
74
+ def _print_dry_run(
75
+ documents: list[compile_mod.Document], spec_path: Path, output_dir: Path, wb: workbench.Workbench
76
+ ) -> None:
77
+ print("code2okf --dry-run: resolving only -- no sandbox will be created, nothing paid will run.")
78
+ print(f" spec: {spec_path}")
79
+ print(f" output: {output_dir}")
80
+ print(" documents:")
81
+ for doc in documents:
82
+ print(f" {doc.display} -> work/md/{doc.basename}")
83
+ print(" mounts:")
84
+ for mount in wb.mounts():
85
+ print(f" {mount.as_arg()}")
86
+ print(" commands:")
87
+ print(f" {_format_sbx_run(wb)}")
88
+ for doc in documents:
89
+ print(f" {_format_sbx_exec_pi(wb, doc)}")
90
+
91
+
92
+ def _format_sbx_run(wb: workbench.Workbench) -> str:
93
+ """The `sbx run` line that would create the sandbox, if one is needed.
94
+
95
+ Whether it actually runs depends on sandbox reuse -- a live decision
96
+ --dry-run must not make (that would mean querying `sbx`). This shows
97
+ what *would* run if a (re)creation turns out to be necessary.
98
+ """
99
+ mount_args = " ".join(mount.as_arg() for mount in wb.mounts())
100
+ return (
101
+ f"sbx run --detached --name {workbench.SANDBOX_NAME} "
102
+ f"-e CODE2OKF_STATE_DIR={wb.root} {resources.kit_dir()} {mount_args}"
103
+ )
104
+
105
+
106
+ def _format_sbx_exec_pi(wb: workbench.Workbench, doc: compile_mod.Document) -> str:
107
+ """The first-iteration `sbx exec ... pi` line for one document.
108
+
109
+ Later Ralph loop iterations append the continuation prompt; --dry-run
110
+ shows only the first, since how many would actually run is exactly
111
+ what compiling determines.
112
+ """
113
+ document_path = wb.work_md / doc.basename
114
+ prompt = compile_mod.COMPILE_PROMPT.format(document=document_path)
115
+ return f"sbx exec {workbench.SANDBOX_NAME} -- pi --mode json {prompt!r}"
116
+
117
+
118
+ def _ensure_sandbox(wb: workbench.Workbench, args: argparse.Namespace) -> int | None:
119
+ """Reuse or (re)create the sandbox. Returns an exit code on failure, else None."""
120
+ try:
121
+ workbench.ensure_sandbox(wb, fresh=args.fresh)
122
+ except (
123
+ workbench.UnownedSandboxError,
124
+ workbench.KeyNotProxyManagedError,
125
+ sandbox.SandboxError,
126
+ resources.ResourcesError,
127
+ ) as exc:
128
+ print(f"code2okf: {exc}", file=sys.stderr)
129
+ return 2
130
+ return None
131
+
132
+
133
+ def _run(
134
+ args: argparse.Namespace,
135
+ documents: list[compile_mod.Document],
136
+ spec_path: Path,
137
+ output_dir: Path,
138
+ wb: workbench.Workbench,
139
+ ) -> int:
140
+ wb.ensure_roots()
141
+
142
+ failure = _ensure_sandbox(wb, args)
143
+ if failure is not None:
144
+ return failure
145
+
146
+ try:
147
+ clis_dir = resources.clis_dir()
148
+ except resources.ResourcesError as exc:
149
+ print(f"code2okf: {exc}", file=sys.stderr)
150
+ return 2
151
+
152
+ try:
153
+ workbench.restage(
154
+ wb,
155
+ inputs=compile_mod.stage_items(documents),
156
+ clis_dir=clis_dir,
157
+ spec_source=spec_path,
158
+ output_dir=output_dir,
159
+ )
160
+ except workbench.WorkbenchError as exc:
161
+ print(f"code2okf: {exc}", file=sys.stderr)
162
+ return 1
163
+
164
+ def on_progress(line: str) -> None:
165
+ if not args.quiet:
166
+ print(line, file=sys.stderr)
167
+
168
+ def on_event(line: str) -> None:
169
+ if args.verbose:
170
+ print(line, file=sys.stderr)
171
+
172
+ for doc in documents:
173
+ try:
174
+ row = compile_mod.compile_document(
175
+ workbench.SANDBOX_NAME, doc, wb, output_dir, args.n, on_progress=on_progress, on_event=on_event
176
+ )
177
+ except compile_mod.CompileError as exc:
178
+ print(f"code2okf: {exc}", file=sys.stderr)
179
+ return 1
180
+ if not args.quiet:
181
+ print(row.as_tsv())
182
+ return 0
183
+
184
+
185
+ def main(argv: list[str] | None = None) -> int:
186
+ """Parse argv, run, and return a process exit code."""
187
+ args = _build_parser().parse_args(argv)
188
+
189
+ resolved = _resolve_inputs(args)
190
+ if isinstance(resolved, int):
191
+ return resolved
192
+ documents, spec_path, output_dir, wb = resolved
193
+
194
+ if args.dry_run:
195
+ try:
196
+ _print_dry_run(documents, spec_path, output_dir, wb)
197
+ except resources.ResourcesError as exc:
198
+ print(f"code2okf: {exc}", file=sys.stderr)
199
+ return 2
200
+ return 0
201
+
202
+ try:
203
+ sandbox.preflight()
204
+ except sandbox.SandboxError as exc:
205
+ print(f"code2okf: {exc}", file=sys.stderr)
206
+ return 2
207
+
208
+ try:
209
+ with workbench.lock():
210
+ return _run(args, documents, spec_path, output_dir, wb)
211
+ except workbench.LockHeld:
212
+ print("code2okf: another code2okf run is using the sandbox; try again later", file=sys.stderr)
213
+ return 2
214
+ except workbench.UnsafeLockFile as exc:
215
+ # Caught here rather than with the setup errors above because lock()
216
+ # is entered after them; it is still an environment problem decided
217
+ # before any work starts, so exit 2. Kept narrow on purpose: a broad
218
+ # WorkbenchError clause here would also swallow run-phase failures
219
+ # that owe the caller exit 1.
220
+ print(f"code2okf: {exc}", file=sys.stderr)
221
+ return 2
222
+ except KeyboardInterrupt:
223
+ # Ctrl-C is a failed run (exit 1), not a crash: a bare traceback tells
224
+ # the user nothing about what survived. The lock is already released
225
+ # by lock()'s own finally, and mirror_out() only ever runs after a
226
+ # completed iteration, so -o DIR cannot hold a half-written pass --
227
+ # though the copy itself is not atomic, hence "may be partial" for
228
+ # the workbench side.
229
+ print(
230
+ f"\ncode2okf: interrupted. {output_dir} holds the last completed pass; "
231
+ f"the workbench copy at {wb.work_okf} may be a partial one.",
232
+ file=sys.stderr,
233
+ )
234
+ return 1
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "inspectmd"
3
+ version = "0.1.0"
4
+ description = "Print a Markdown heading map with line ranges and word counts"
5
+ requires-python = ">=3.12"
6
+ dependencies = []
7
+
8
+ [project.scripts]
9
+ inspectmd = "inspectmd.cli:entrypoint"
10
+
11
+ [build-system]
12
+ requires = ["hatchling"]
13
+ build-backend = "hatchling.build"
14
+
15
+ [dependency-groups]
16
+ test = ["pytest>=8.4"]
17
+
18
+ # Paths are relative to this file, so they resolve wherever the project is
19
+ # invoked from. pythonpath keeps `import inspectmd` working without an install.
20
+ [tool.pytest.ini_options]
21
+ minversion = "8.0"
22
+ addopts = ["-ra", "--strict-markers", "--strict-config"]
23
+ testpaths = ["tests"]
24
+ pythonpath = ["src"]
25
+
26
+ [tool.ruff]
27
+ target-version = "py312"
28
+ line-length = 120
29
+
30
+ # Same rule set as web2md: owned here under the zero-overlap rule, not shared.
31
+ [tool.ruff.lint]
32
+ select = ["E", "W", "F", "I", "UP", "B", "SIM", "C4", "RET", "PT", "D"]
33
+
34
+ [tool.ruff.lint.pydocstyle]
35
+ convention = "google"
36
+
37
+ [tool.ruff.lint.per-file-ignores]
38
+ # A test's name is its documentation; a docstring on each of them would be noise.
39
+ # Fixtures and helpers are still expected to explain themselves.
40
+ "tests/*" = ["D100", "D103"]
@@ -0,0 +1,8 @@
1
+ """Inspect Markdown heading structure (stdlib only)."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("inspectmd")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0+local"
@@ -0,0 +1,5 @@
1
+ """Allow `python -m inspectmd`."""
2
+
3
+ from inspectmd.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,159 @@
1
+ """Command-line interface for inspectmd."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+
10
+ from inspectmd import __version__
11
+ from inspectmd.parse import Section, inspect_markdown
12
+
13
+
14
+ def format_table(sections: Sequence[Section], *, max_level: int | None = None) -> str:
15
+ """Render sections as a fixed-width table.
16
+
17
+ When ``max_level`` is set, only the preamble (level 0) and headings with
18
+ ``level <= max_level`` are shown.
19
+ """
20
+ visible = [
21
+ s
22
+ for s in sections
23
+ if max_level is None or s.level == 0 or s.level <= max_level
24
+ ]
25
+ if not visible:
26
+ return "(no sections at this depth)\n"
27
+
28
+ headers = ("Index", "Level", "Lines", "Words", "Slug", "Title")
29
+ rows: list[tuple[str, str, str, str, str, str]] = []
30
+ for s in visible:
31
+ rows.append(
32
+ (
33
+ str(s.index),
34
+ str(s.level),
35
+ f"{s.start}-{s.end}",
36
+ str(s.words),
37
+ s.slug,
38
+ s.title,
39
+ )
40
+ )
41
+
42
+ widths = [len(h) for h in headers]
43
+ for row in rows:
44
+ for i, cell in enumerate(row):
45
+ widths[i] = max(widths[i], len(cell))
46
+
47
+ def fmt(row: tuple[str, ...]) -> str:
48
+ return " ".join(cell.ljust(widths[i]) for i, cell in enumerate(row))
49
+
50
+ lines = [fmt(headers), fmt(tuple("-" * w for w in widths))]
51
+ lines.extend(fmt(row) for row in rows)
52
+ return "\n".join(lines) + "\n"
53
+
54
+
55
+ def format_section_range(section: Section) -> str:
56
+ """One section as ``start:end`` plus word count, for a ranged read."""
57
+ return f"{section.start}:{section.end} {section.words} words\n"
58
+
59
+
60
+ def _build_parser() -> argparse.ArgumentParser:
61
+ parser = argparse.ArgumentParser(
62
+ prog="inspectmd",
63
+ formatter_class=argparse.RawDescriptionHelpFormatter,
64
+ description="Print a Markdown heading map with line ranges and word counts.",
65
+ epilog="""\
66
+ Output columns (default table):
67
+ Index Section number in document order (0 = preamble when present).
68
+ Pass this value to --section.
69
+ Level Heading depth: 0 for the preamble, 1 for #, 2 for ##, …, 6 for ######.
70
+ Lines 1-based inclusive line range of the section (start-end).
71
+ Words Whitespace-split word count of that range.
72
+ Slug Kebab-case slug derived from the heading title (OKF file-name style).
73
+ Title Heading text as written (or "(preamble)" / "(empty)").
74
+
75
+ --section N prints only "start:end N words" for ranged reads.
76
+ """,
77
+ )
78
+ parser.add_argument(
79
+ "file",
80
+ type=Path,
81
+ help="Markdown file to inspect",
82
+ )
83
+ parser.add_argument(
84
+ "--version",
85
+ action="version",
86
+ version=f"%(prog)s {__version__}",
87
+ )
88
+ parser.add_argument(
89
+ "--section",
90
+ type=int,
91
+ metavar="N",
92
+ help="print only section N as start:end and word count",
93
+ )
94
+ parser.add_argument(
95
+ "-L",
96
+ "--level",
97
+ type=int,
98
+ metavar="N",
99
+ help="show only headings at this level or above (1=H1, …)",
100
+ )
101
+ return parser
102
+
103
+
104
+ def main(argv: list[str] | None = None) -> int:
105
+ """Parse argv and print the heading map. Returns a process exit code."""
106
+ parser = _build_parser()
107
+ args = parser.parse_args(argv)
108
+
109
+ # Checked before the path so the message is the same wherever it is run
110
+ # from, and so every `-L` in this repo rejects the same values.
111
+ level: int | None = args.level
112
+ if level is not None and level < 1:
113
+ print(f"inspectmd: --level must be 1 or greater (got {level})", file=sys.stderr)
114
+ return 2
115
+
116
+ path: Path = args.file
117
+ if not path.is_file():
118
+ print(f"inspectmd: not a file: {path}", file=sys.stderr)
119
+ return 2
120
+
121
+ try:
122
+ text = path.read_text(encoding="utf-8")
123
+ except OSError as exc:
124
+ print(f"inspectmd: {exc}", file=sys.stderr)
125
+ return 2
126
+
127
+ line_count = text.count("\n") + (0 if text.endswith("\n") or text == "" else 1)
128
+ if text == "":
129
+ line_count = 0
130
+ sections = inspect_markdown(text)
131
+
132
+ if args.section is not None:
133
+ match = next((s for s in sections if s.index == args.section), None)
134
+ if match is None:
135
+ print(
136
+ f"inspectmd: section {args.section} out of range "
137
+ f"(0..{sections[-1].index if sections else 'none'})",
138
+ file=sys.stderr,
139
+ )
140
+ return 2
141
+ sys.stdout.write(format_section_range(match))
142
+ return 0
143
+
144
+ summary = (
145
+ f"{path.name}: {line_count} lines, {len(text.split())} words, "
146
+ f"{len(sections)} sections"
147
+ )
148
+ sys.stdout.write(summary + "\n")
149
+ if not sections:
150
+ sys.stdout.write("(no headings)\n")
151
+ return 0
152
+
153
+ sys.stdout.write(format_table(sections, max_level=args.level))
154
+ return 0
155
+
156
+
157
+ def entrypoint() -> None:
158
+ """Console-script entry: exit with ``main``'s return code."""
159
+ raise SystemExit(main())
@@ -0,0 +1,212 @@
1
+ """Parse ATX Markdown headings into a section map.
2
+
3
+ Only ATX headings (``#`` … ``######``) count. Setext underlines are ignored on
4
+ purpose: both producers in this repo emit ATX (``web2md`` sets
5
+ ``heading_style = ATX``, and Marker output under ``md/`` is ATX throughout).
6
+ Headings inside fenced code blocks (backticks or tildes) are ignored. A leading
7
+ YAML frontmatter block is skipped so its ``---`` lines are not mistaken for
8
+ content. Text between the frontmatter and the first heading is section 0, the
9
+ preamble.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import unicodedata
16
+ from dataclasses import dataclass
17
+
18
+ _ATX_RE = re.compile(r"^( {0,3})(#{1,6})(?:[ \t]+(.*))?$")
19
+ _CLOSING_HASHES_RE = re.compile(r"[ \t]+#*[ \t]*$")
20
+ _FENCE_RE = re.compile(r"^( {0,3})(`{3,}|~{3,})(.*)$")
21
+ _NON_ALNUM_RE = re.compile(r"[^a-z0-9]+")
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Section:
26
+ """One preamble or ATX-headed region of a Markdown document."""
27
+
28
+ index: int
29
+ """0 is the preamble, if any; otherwise the first section is 0."""
30
+
31
+ level: int
32
+ """1–6 for ATX headings; 0 for the preamble."""
33
+
34
+ title: str
35
+ slug: str
36
+ """Kebab-case slug matching the AGENTS.md file-name convention."""
37
+
38
+ start: int
39
+ """1-based inclusive line number (the heading line itself, or first body)."""
40
+
41
+ end: int
42
+ """1-based inclusive line number."""
43
+
44
+ words: int
45
+ """Whitespace-split word count of the section's lines."""
46
+
47
+
48
+ def count_words(text: str) -> int:
49
+ """Count whitespace-separated tokens in ``text``."""
50
+ return len(text.split())
51
+
52
+
53
+ def slugify(title: str) -> str:
54
+ """Reduce a heading title to a kebab-case slug.
55
+
56
+ Lowercases ASCII letters, strips combining marks from non-ASCII characters,
57
+ replaces any remaining non ``[a-z0-9]`` run with a single hyphen, and trims
58
+ leading/trailing hyphens. Empty input yields an empty string.
59
+ """
60
+ normalized = unicodedata.normalize("NFKD", title)
61
+ ascii_only = "".join(c for c in normalized if not unicodedata.combining(c))
62
+ return _NON_ALNUM_RE.sub("-", ascii_only.casefold()).strip("-")
63
+
64
+
65
+ def split_frontmatter(text: str) -> tuple[int, str]:
66
+ """Strip a leading YAML frontmatter block.
67
+
68
+ Returns ``(last_frontmatter_line, remainder)``. Line numbers are 1-based:
69
+ ``last_frontmatter_line`` is the closing ``---`` line, or ``0`` when there
70
+ is no frontmatter. The remainder starts at the first content line.
71
+ """
72
+ if text.startswith("\ufeff"):
73
+ text = text[1:]
74
+
75
+ if not text.startswith("---"):
76
+ return 0, text
77
+
78
+ lines = text.splitlines(keepends=True)
79
+ if not lines or lines[0].strip() != "---":
80
+ return 0, text
81
+
82
+ for i in range(1, len(lines)):
83
+ if lines[i].strip() == "---":
84
+ return i + 1, "".join(lines[i + 1 :])
85
+ return 0, text
86
+
87
+
88
+ def _fence_opener(line: str) -> tuple[str, int] | None:
89
+ """Return ``(fence_char, length)`` if ``line`` opens a fenced code block."""
90
+ match = _FENCE_RE.match(line.rstrip("\n"))
91
+ if match is None:
92
+ return None
93
+ marker = match.group(2)
94
+ return marker[0], len(marker)
95
+
96
+
97
+ def _fence_closer(line: str, char: str, length: int) -> bool:
98
+ """True if ``line`` closes a fence opened with ``char`` × ``length``."""
99
+ match = _FENCE_RE.match(line.rstrip("\n"))
100
+ if match is None:
101
+ return False
102
+ marker = match.group(2)
103
+ info = match.group(3)
104
+ if marker[0] != char or len(marker) < length:
105
+ return False
106
+ # Closing fences may only carry trailing whitespace as "info".
107
+ return info.strip() == ""
108
+
109
+
110
+ def _parse_atx(line: str) -> tuple[int, str] | None:
111
+ """Return ``(level, title)`` for an ATX heading line, else ``None``."""
112
+ match = _ATX_RE.match(line.rstrip("\n"))
113
+ if match is None:
114
+ return None
115
+ hashes = match.group(2)
116
+ rest = match.group(3)
117
+ if rest is None:
118
+ return len(hashes), ""
119
+ title = _CLOSING_HASHES_RE.sub("", rest).strip()
120
+ return len(hashes), title
121
+
122
+
123
+ def parse_sections(text: str, *, line_offset: int = 0) -> list[Section]:
124
+ """Parse ``text`` into sections.
125
+
126
+ ``line_offset`` is the number of lines already consumed before ``text``
127
+ (typically the frontmatter). Section line numbers are 1-based in the full
128
+ file: the first line of ``text`` is ``line_offset + 1``.
129
+ """
130
+ lines = text.splitlines(keepends=True)
131
+ if not lines:
132
+ return []
133
+
134
+ headings: list[tuple[int, int, str]] = []
135
+ fence: tuple[str, int] | None = None
136
+
137
+ for i, line in enumerate(lines):
138
+ if fence is not None:
139
+ char, length = fence
140
+ if _fence_closer(line, char, length):
141
+ fence = None
142
+ continue
143
+ opener = _fence_opener(line)
144
+ if opener is not None:
145
+ fence = opener
146
+ continue
147
+ atx = _parse_atx(line)
148
+ if atx is not None:
149
+ headings.append((i, atx[0], atx[1]))
150
+
151
+ sections: list[Section] = []
152
+
153
+ def text_between(start_i: int, end_i: int) -> str:
154
+ return "".join(lines[j] for j in range(start_i, end_i + 1))
155
+
156
+ first_heading_i = headings[0][0] if headings else None
157
+
158
+ if first_heading_i is None:
159
+ body = "".join(lines)
160
+ if body.strip():
161
+ sections.append(
162
+ Section(
163
+ index=0,
164
+ level=0,
165
+ title="(preamble)",
166
+ slug="preamble",
167
+ start=line_offset + 1,
168
+ end=line_offset + len(lines),
169
+ words=count_words(body),
170
+ )
171
+ )
172
+ return sections
173
+
174
+ if first_heading_i > 0:
175
+ preamble_text = "".join(lines[:first_heading_i])
176
+ if preamble_text.strip():
177
+ sections.append(
178
+ Section(
179
+ index=0,
180
+ level=0,
181
+ title="(preamble)",
182
+ slug="preamble",
183
+ start=line_offset + 1,
184
+ end=line_offset + first_heading_i,
185
+ words=count_words(preamble_text),
186
+ )
187
+ )
188
+
189
+ for idx, (start_i, level, title) in enumerate(headings):
190
+ end_i = headings[idx + 1][0] - 1 if idx + 1 < len(headings) else len(lines) - 1
191
+ next_index = sections[-1].index + 1 if sections else 0
192
+ display_title = title if title else "(empty)"
193
+ display_slug = slugify(title) if title else "empty"
194
+ sections.append(
195
+ Section(
196
+ index=next_index,
197
+ level=level,
198
+ title=display_title,
199
+ slug=display_slug,
200
+ start=line_offset + start_i + 1,
201
+ end=line_offset + end_i + 1,
202
+ words=count_words(text_between(start_i, end_i)),
203
+ )
204
+ )
205
+
206
+ return sections
207
+
208
+
209
+ def inspect_markdown(text: str) -> list[Section]:
210
+ """Split frontmatter and parse the remainder into sections."""
211
+ offset, body = split_frontmatter(text)
212
+ return parse_sections(body, line_offset=offset)