afterpython 0.2.1__py3-none-any.whl → 0.3.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.
@@ -1,17 +1,25 @@
1
1
  from afterpython.builders.content_json import build_content_json
2
+ from afterpython.builders.faq_json import build_faq_json
2
3
  from afterpython.builders.index_md import (
3
4
  create_placeholder_index_md_files,
4
5
  delete_placeholder_index_md_files,
5
6
  )
6
7
  from afterpython.builders.jupyter_notebook import build_jupyter_notebooks
8
+ from afterpython.builders.llms_txt import build_llms_txt
9
+ from afterpython.builders.marimo_notebook import build_marimo_readme
7
10
  from afterpython.builders.markdown import build_markdown
8
11
  from afterpython.builders.metadata import build_metadata
12
+ from afterpython.builders.url_md import build_url_md
9
13
 
10
14
  __all__ = (
11
15
  "build_content_json",
16
+ "build_faq_json",
12
17
  "build_jupyter_notebooks",
18
+ "build_llms_txt",
19
+ "build_marimo_readme",
13
20
  "build_markdown",
14
21
  "build_metadata",
22
+ "build_url_md",
15
23
  "create_placeholder_index_md_files",
16
24
  "delete_placeholder_index_md_files",
17
25
  )
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import click
6
+
7
+ import afterpython as ap
8
+ from afterpython._io.yaml import read_yaml
9
+
10
+ build_path = ap.paths.build_path
11
+
12
+
13
+ def build_faq_json():
14
+ """Build faq.json from the user's afterpython/faq.yml.
15
+
16
+ Output shape: a flat list of {question, answer, category}. `category` is
17
+ always present — empty string when unset — so the frontend can rely on a
18
+ uniform item shape. Order is preserved so the frontend can derive category
19
+ order from first appearance.
20
+ """
21
+ faq_yml_path = ap.paths.afterpython_path / "faq.yml"
22
+
23
+ if not faq_yml_path.exists():
24
+ click.echo("No faq.yml found, skip building faq.json")
25
+ return
26
+
27
+ click.echo("Building faq.json...")
28
+
29
+ raw = read_yaml(faq_yml_path)
30
+ if raw is None:
31
+ items: list[dict] = []
32
+ else:
33
+ items = [
34
+ {
35
+ "question": str(item["question"]),
36
+ "answer": str(item["answer"]),
37
+ "category": str(item["category"]) if item.get("category") else "",
38
+ }
39
+ for item in raw
40
+ ]
41
+
42
+ with open(build_path / "faq.json", "w") as f:
43
+ json.dump(items, f, indent=2)
@@ -5,24 +5,11 @@ from pathlib import Path
5
5
  import click
6
6
 
7
7
  import afterpython as ap
8
+ from afterpython.builders.marimo_notebook import _create_molab_url, _get_molab_badge
8
9
  from afterpython.const import CONTENT_TYPES
9
10
  from afterpython.tools.pyproject import read_metadata
10
11
 
11
12
 
12
- def _get_molab_badge() -> str:
13
- return "https://marimo.io/molab-shield.svg"
14
-
15
-
16
- def _create_molab_url(github_url: str, content_path: Path):
17
- """Create a molab URL for a given content type and notebook path.
18
- Args:
19
- github_url: str, e.g. "https://github.com/AfterPythonOrg/afterpython"
20
- content_path: str, e.g. "tutorial/test.ipynb"
21
- """
22
- github_url = github_url.replace("https://github.com/", "github/")
23
- return f"https://molab.marimo.io/{github_url}/blob/main/afterpython/{content_path.as_posix()}"
24
-
25
-
26
13
  def _read_notebook(notebook_path: Path, content_path: Path) -> dict | None:
27
14
  """Read and validate a Jupyter notebook.
28
15
 
@@ -0,0 +1,248 @@
1
+ # VIBE-CODED
2
+ """Build llms.txt — a single catalog of the site for AI agents.
3
+
4
+ Output: `afterpython/_website/static/llms.txt`. SvelteKit's static adapter
5
+ serves it at `/llms.txt` so agents (Claude, GPT, etc.) can fetch a curated
6
+ overview of the project + a link list of every published page.
7
+
8
+ Conforms to the llms.txt convention (https://llmstxt.org/): a markdown file
9
+ with a project H1, optional context paragraphs, and `## Section` headings
10
+ each containing a link list.
11
+
12
+ Flow
13
+ ----
14
+ build_llms_txt() # entry point
15
+ ├─ _read_readme() + _standardize_readme() # → header content
16
+ └─ for each content_type in ORDERED_CONTENT_TYPES:
17
+ └─ _list_pages(content_type) # → [(title, url, abstract)]
18
+ (skips entries whose source .md no longer exists — those were
19
+ auto-generated placeholder index.md files deleted in postbuild)
20
+ └─ _format_section(content_type, blurb, entries)
21
+ ├─ heading: SECTION_HEADINGS[content_type]
22
+ ├─ blurb: project.description from afterpython/{type}/myst.yml
23
+ └─ entries: `- [Title](/type/slug.md): abstract`
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import re
30
+
31
+ import click
32
+
33
+ import afterpython as ap
34
+ from afterpython._typing import tContentType
35
+ from afterpython.const import CONTENT_TYPES
36
+
37
+ # A line whose entire content is a markdown image (badge), with optional
38
+ # link wrapper and optional HTML-comment wrapper.
39
+ # matches: ![alt](url)
40
+ # [![alt](url)](link)
41
+ # <!-- ![alt](url) -->
42
+ # <!-- [![alt](url)](link) -->
43
+ # Anchored ^...$ so inline references in body prose are NOT matched.
44
+ _BADGE_LINE = re.compile(
45
+ r"""
46
+ ^\s* # leading whitespace
47
+ (?:<!--\s*)? # optional HTML comment open
48
+ \[? # optional link open '['
49
+ !\[[^\]]*\] # ![alt]
50
+ \([^)]+\) # (image_url)
51
+ (?:\]\([^)]+\))? # optional ](link_url)
52
+ (?:\s*-->)? # optional HTML comment close
53
+ \s*$ # trailing whitespace
54
+ """,
55
+ re.VERBOSE,
56
+ )
57
+
58
+ # Order in which content-type sections appear in llms.txt. Sections with zero
59
+ # entries are skipped. Reasoning: an agent answering "how do I X?" should hit
60
+ # concepts first (doc), then how-to (tutorial/guide), then examples, then
61
+ # background (blog) — strongest signal first.
62
+ ORDERED_CONTENT_TYPES: list[tContentType] = [
63
+ "doc",
64
+ "tutorial",
65
+ "guide",
66
+ "example",
67
+ "blog",
68
+ ]
69
+
70
+ # One-line hint that sits between the README and the section list, telling
71
+ # an LLM reading llms.txt that the URLs below are fetchable markdown sources
72
+ # (the Mintlify / Anthropic-docs convention). Costs ~20 tokens and removes
73
+ # any ambiguity about what fetching `/doc/quickstart.md` returns.
74
+ LINK_NOTE = (
75
+ "> Every link below points to the raw markdown source of the page."
76
+ " Fetch any URL directly to read its content as markdown."
77
+ )
78
+
79
+ # Human-readable display name for each content type's `## ` heading in llms.txt.
80
+ SECTION_HEADINGS: dict[tContentType, str] = {
81
+ "doc": "Documentation",
82
+ "tutorial": "Tutorials",
83
+ "guide": "Guides",
84
+ "example": "Examples",
85
+ "blog": "Blog",
86
+ }
87
+
88
+ # Defensive sync check: if a new content type is added to const.CONTENT_TYPES,
89
+ # the developer must remember to slot it into ORDERED_CONTENT_TYPES and
90
+ # SECTION_HEADINGS. Import-time failure beats a silently-missing section.
91
+ assert set(ORDERED_CONTENT_TYPES) == CONTENT_TYPES, (
92
+ "ORDERED_CONTENT_TYPES is out of sync with const.CONTENT_TYPES"
93
+ )
94
+ assert set(SECTION_HEADINGS.keys()) == CONTENT_TYPES, (
95
+ "SECTION_HEADINGS is out of sync with const.CONTENT_TYPES"
96
+ )
97
+
98
+
99
+ def _read_readme() -> str:
100
+ """Read the project's README.md from repo root, verbatim. Empty string if
101
+ the README is missing (e.g. brand new project)."""
102
+ readme_path = ap.paths.user_path / "README.md"
103
+ if not readme_path.exists():
104
+ return ""
105
+ return readme_path.read_text(encoding="utf-8")
106
+
107
+
108
+ def _standardize_readme(text: str) -> str:
109
+ """Clean the README before embedding it in llms.txt.
110
+
111
+ Currently strips badges (shields, download counters, etc.) from the
112
+ README's *header zone* — everything before the first `## ` heading —
113
+ since they're chrome for human readers and noise for LLMs.
114
+
115
+ The header-zone restriction protects content images that legitimately
116
+ appear later in the README (e.g. the `<picture>` website screenshot
117
+ after the first H2 — also untouched because it's HTML, not a markdown
118
+ image).
119
+
120
+ Future ideas if more cleanup is wanted: strip GitHub-flavored anchor
121
+ links, install commands, "table of contents" sections.
122
+ """
123
+ lines = text.split("\n")
124
+
125
+ # Boundary of the header zone: index of the first `## ` line, or end of
126
+ # file if no H2 exists.
127
+ boundary = next(
128
+ (i for i, line in enumerate(lines) if line.startswith("## ")),
129
+ len(lines),
130
+ )
131
+
132
+ cleaned_header = [line for line in lines[:boundary] if not _BADGE_LINE.match(line)]
133
+ rejoined = "\n".join(cleaned_header + lines[boundary:])
134
+
135
+ # After removing 9 consecutive badge lines, you can end up with a stack
136
+ # of blanks where they used to be. Collapse 3+ newlines to 2 (one blank).
137
+ return re.sub(r"\n{3,}", "\n\n", rejoined)
138
+
139
+
140
+ def _read_section_blurb(content_type: tContentType) -> str | None:
141
+ """Pull the section's one-line blurb from `afterpython/{type}/myst.yml`'s
142
+ `project.description`.
143
+
144
+ Returns None if myst.yml is missing or has no description set — in which
145
+ case the section in llms.txt simply omits the `> blurb` line.
146
+ """
147
+ from afterpython._io.yaml import read_yaml
148
+
149
+ myst_yml = ap.paths.afterpython_path / content_type / "myst.yml"
150
+ if not myst_yml.exists():
151
+ return None
152
+
153
+ data = read_yaml(myst_yml) or {}
154
+ description = data.get("project", {}).get("description")
155
+ return str(description).strip() if description else None
156
+
157
+
158
+ def _list_pages(content_type: tContentType) -> list[tuple[str, str, str | None]]:
159
+ """Return `[(title, url, abstract), ...]` for each page MyST built.
160
+
161
+ Reads MyST's `_build/site/content/*.json` files (which already have
162
+ title, slug, and frontmatter resolved — no need to re-parse source).
163
+ Skips entries whose source .md no longer exists; those were auto-generated
164
+ placeholder index.md files that `delete_placeholder_index_md_files`
165
+ removed at the start of postbuild.
166
+ """
167
+ content_dir = ap.paths.afterpython_path / content_type
168
+ myst_content_dir = content_dir / "_build" / "site" / "content"
169
+ if not myst_content_dir.exists():
170
+ return []
171
+
172
+ pages: list[tuple[str, str, str | None]] = []
173
+ for json_file in sorted(myst_content_dir.glob("*.json")):
174
+ try:
175
+ data = json.loads(json_file.read_text(encoding="utf-8"))
176
+ except json.JSONDecodeError:
177
+ continue
178
+
179
+ # Filter placeholder index.md entries: their source has been deleted.
180
+ location = data.get("location", "")
181
+ source_md = content_dir / location.lstrip("/")
182
+ if not source_md.exists():
183
+ continue
184
+
185
+ slug = data.get("slug")
186
+ if not slug:
187
+ continue
188
+
189
+ frontmatter = data.get("frontmatter", {})
190
+ title = frontmatter.get("title") or slug
191
+ abstract = frontmatter.get("abstract")
192
+
193
+ url = f"/{content_type}/{slug}.md"
194
+ pages.append((title, url, abstract))
195
+ return pages
196
+
197
+
198
+ def _format_link_entry(title: str, url: str, abstract: str | None) -> str:
199
+ """One line of the link list.
200
+
201
+ With abstract: `- [Title](url): abstract`
202
+ Without abstract: `- [Title](url)`
203
+ """
204
+ if abstract:
205
+ return f"- [{title}]({url}): {abstract.strip()}"
206
+ return f"- [{title}]({url})"
207
+
208
+
209
+ def _format_section(
210
+ content_type: tContentType,
211
+ blurb: str | None,
212
+ entries: list[tuple[str, str, str | None]],
213
+ ) -> str:
214
+ """Format one `## Section`: heading, optional blurb, link list."""
215
+ parts = [f"## {SECTION_HEADINGS[content_type]}", ""]
216
+ if blurb:
217
+ parts.extend([f"> {blurb}", ""])
218
+ for title, url, abstract in entries:
219
+ parts.append(_format_link_entry(title, url, abstract))
220
+ return "\n".join(parts)
221
+
222
+
223
+ def build_llms_txt() -> None:
224
+ """Generate `_website/static/llms.txt` — one catalog file for the site.
225
+
226
+ Runs at the end of `postbuild()` after MyST output is finalized and
227
+ placeholder index.md files have been removed.
228
+ """
229
+ readme = _standardize_readme(_read_readme())
230
+
231
+ sections: list[str] = []
232
+ total_entries = 0
233
+ for content_type in ORDERED_CONTENT_TYPES:
234
+ entries = _list_pages(content_type)
235
+ if not entries:
236
+ continue
237
+ blurb = _read_section_blurb(content_type)
238
+ sections.append(_format_section(content_type, blurb, entries))
239
+ total_entries += len(entries)
240
+
241
+ body = readme.rstrip()
242
+ if sections:
243
+ body = body + "\n\n" + LINK_NOTE + "\n\n" + "\n\n".join(sections) + "\n"
244
+
245
+ output = ap.paths.website_path / "static" / "llms.txt"
246
+ output.parent.mkdir(parents=True, exist_ok=True)
247
+ output.write_text(body, encoding="utf-8")
248
+ click.echo(f"Generated {output} ({total_entries} entries)")
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ import click
10
+
11
+ import afterpython as ap
12
+ from afterpython.tools.pyproject import read_metadata
13
+
14
+ MarimoExportMode = Literal["wasm", "static"]
15
+
16
+
17
+ def _get_molab_badge() -> str:
18
+ return "https://marimo.io/molab-shield.svg"
19
+
20
+
21
+ def _create_molab_url(github_url: str, content_path: Path) -> str:
22
+ """Create a molab URL for a marimo notebook at content_path (relative to afterpython/)."""
23
+ github_url = github_url.replace("https://github.com/", "github/")
24
+ return f"https://molab.marimo.io/{github_url}/blob/main/afterpython/{content_path.as_posix()}"
25
+
26
+
27
+ def is_marimo_notebook(path: Path) -> bool:
28
+ """Check if a Python file is a marimo notebook.
29
+
30
+ Looks for marimo's two reliable signature lines (`__generated_with` and
31
+ `marimo.App(`) — both are emitted by marimo for any notebook file, and
32
+ a plain script that just `import`s marimo won't match.
33
+ """
34
+ if not path.exists() or not path.is_file():
35
+ return False
36
+ try:
37
+ # marimo's signature is at the top of the file, no need to read all
38
+ head = path.read_text(encoding="utf-8", errors="ignore")[:2048]
39
+ except OSError:
40
+ return False
41
+ return "__generated_with" in head and "marimo.App(" in head
42
+
43
+
44
+ def _export_marimo(source: Path, output_html: Path, mode: MarimoExportMode):
45
+ """Run `marimo export` to produce HTML at `output_html`."""
46
+ output_html.parent.mkdir(parents=True, exist_ok=True)
47
+ subcommand = "html-wasm" if mode == "wasm" else "html"
48
+ cmd = ["marimo", "export", subcommand, str(source), "-o", str(output_html)]
49
+ # WASM-only flag: --mode edit shows code cells (interactive). Without it,
50
+ # marimo's html-wasm subcommand defaults to "run" which hides them.
51
+ if mode == "wasm":
52
+ cmd += ["--mode", "edit"]
53
+ result = subprocess.run(cmd, check=False)
54
+ if result.returncode != 0:
55
+ raise click.ClickException(f"marimo export failed for {source}")
56
+
57
+
58
+ def _inject_molab_badge(html_path: Path, molab_url: str):
59
+ """Insert a fixed-position molab badge near the top of the exported HTML body.
60
+
61
+ Used so users can run the notebook on a real Python server (molab) when the
62
+ static export can't execute, or as an alternative to the in-page WASM kernel.
63
+ """
64
+ if not html_path.exists():
65
+ return
66
+ html = html_path.read_text(encoding="utf-8")
67
+ badge = (
68
+ f'<a href="{molab_url}" target="_top" rel="noopener" '
69
+ # right offset clears marimo's circular toolbar buttons in edit mode;
70
+ # top offset is hand-tuned to vertically center against those buttons
71
+ # (we can't group with marimo's UI, so this is a visual eyeball)
72
+ f'style="position:fixed;top:16px;right:100px;z-index:9999;">'
73
+ f'<img src="{_get_molab_badge()}" alt="Open in molab" />'
74
+ f"</a>"
75
+ )
76
+ new_html, n = re.subn(r"(<body[^>]*>)", r"\1" + badge, html, count=1)
77
+ if n == 0:
78
+ click.echo(f"⚠ Could not locate <body> in {html_path}, skipping molab badge")
79
+ return
80
+ html_path.write_text(new_html, encoding="utf-8")
81
+
82
+
83
+ def build_marimo_notebook(
84
+ source: Path,
85
+ output_html: Path,
86
+ content_path: Path,
87
+ mode: MarimoExportMode = "wasm",
88
+ ):
89
+ """Build a single marimo notebook to HTML and inject the molab badge.
90
+
91
+ Generic core for marimo builds — keep this notebook-agnostic so future
92
+ callers (e.g. content-type builds in blog/tutorial) can reuse it.
93
+
94
+ Args:
95
+ source: Path to the marimo .py file.
96
+ output_html: Path to write the exported HTML to.
97
+ content_path: Path of `source` relative to afterpython/, used to build
98
+ the molab URL (which assumes the file lives under afterpython/ on
99
+ the user's GitHub repo).
100
+ mode: "wasm" (interactive, Pyodide) or "static" (pre-rendered).
101
+ """
102
+ if not is_marimo_notebook(source):
103
+ click.echo(f"{source} is not a marimo notebook, skip building")
104
+ return
105
+
106
+ click.echo(f"Building marimo notebook {source.name} (mode={mode})...")
107
+ _export_marimo(source, output_html, mode)
108
+
109
+ # WASM mode runs the notebook in-browser via Pyodide, so molab (a remote
110
+ # runtime) is redundant. Skip the badge.
111
+ if mode == "wasm":
112
+ return
113
+
114
+ add_molab_badge = os.getenv("AP_MOLAB_BADGE", "1") == "1"
115
+ if not add_molab_badge:
116
+ return
117
+
118
+ metadata = read_metadata()
119
+ github_url = (
120
+ metadata.urls.get("repository") if "repository" in metadata.urls else None
121
+ )
122
+ if not github_url:
123
+ click.echo(
124
+ "⚠ Repository URL not found in [project.urls] in pyproject.toml, "
125
+ "skipping molab badge"
126
+ )
127
+ return
128
+
129
+ molab_url = _create_molab_url(github_url, content_path)
130
+ _inject_molab_badge(output_html, molab_url)
131
+
132
+
133
+ def build_marimo_readme(mode: MarimoExportMode = "wasm"):
134
+ """Build afterpython/README.py to _build/readme_py/readme_py.html.
135
+
136
+ Skips silently if README.py is absent or isn't a marimo notebook so users
137
+ who keep only README.md aren't penalized.
138
+ """
139
+ readme_path = ap.paths.afterpython_path / "README.py"
140
+ if not readme_path.exists():
141
+ return
142
+ output_html = ap.paths.build_path / "readme_py" / "readme_py.html"
143
+ content_path = Path("README.py")
144
+ build_marimo_notebook(readme_path, output_html, content_path, mode)
@@ -36,15 +36,48 @@ def convert_paths():
36
36
 
37
37
 
38
38
  def build_metadata():
39
- """Build metadata.json using pyproject.toml"""
39
+ """Build metadata.json using pyproject.toml + afterpython.toml.
40
+
41
+ Adds two frontend-only fields sourced from `[website]` in afterpython.toml:
42
+ - `announcement`: markdown banner string (empty when unset)
43
+ - `readme_py`: "wasm" | "static" | "" — resolved to "" when README.py is
44
+ missing or isn't a marimo notebook, so the frontend can use a single
45
+ truthiness check to decide between iframe and markdown description.
46
+ """
47
+ from afterpython._io.toml import _from_tomlkit
48
+ from afterpython.tools._afterpython import read_afterpython
40
49
  from afterpython.tools.pyproject import read_metadata
41
50
 
42
51
  click.echo("Building metadata.json...")
43
52
 
44
53
  metadata: StandardMetadata = read_metadata()
54
+ metadata_json = metadata.as_json()
55
+
56
+ afterpython = read_afterpython()
57
+ website = _from_tomlkit(afterpython.get("website", {}))
58
+ metadata_json["announcement"] = str(website.get("announcement", "")).strip()
59
+ metadata_json["readme_py"] = _resolve_readme_py(website)
45
60
 
46
- # Write to metadata.json
47
61
  with open(build_path / "metadata.json", "w") as f:
48
- json.dump(metadata.as_json(), f, indent=2)
62
+ json.dump(metadata_json, f, indent=2)
49
63
 
50
64
  convert_paths()
65
+
66
+
67
+ def _resolve_readme_py(website_config: dict) -> str:
68
+ """Resolve the readme_py field for metadata.json.
69
+
70
+ Returns "wasm" | "static" only when afterpython/README.py exists AND is a
71
+ marimo notebook, so the toml setting is honored only when there's actually
72
+ something to render. Otherwise returns "".
73
+ """
74
+ from afterpython.builders.marimo_notebook import is_marimo_notebook
75
+
76
+ readme_path = ap.paths.afterpython_path / "README.py"
77
+ if not is_marimo_notebook(readme_path):
78
+ return ""
79
+
80
+ mode = str(website_config.get("readme_py", "wasm"))
81
+ if mode not in ("wasm", "static"):
82
+ return "wasm"
83
+ return mode
@@ -0,0 +1,107 @@
1
+ # VIBE-CODED
2
+ """Publish raw markdown alongside rendered HTML.
3
+
4
+ For every page the website serves at `/{type}/{slug}`, also publish the source
5
+ markdown at `/{type}/{slug}.md`. Lets LLMs and AI agents fetch clean content
6
+ without HTML chrome (the Mintlify / Anthropic-docs pattern).
7
+
8
+ Output goes into `afterpython/_website/static/{type}/{slug}.md`. SvelteKit's
9
+ static adapter then serves each file at the matching URL with no SvelteKit-side
10
+ code changes.
11
+
12
+ Flow
13
+ ----
14
+ build_url_md() # entry point
15
+ └─ for each content_type in CONTENT_TYPES:
16
+ └─ for each `*.md` / `*.ipynb` under afterpython/{type}/:
17
+ ├─ _slugify(stem) → URL slug
18
+ ├─ .md → shutil.copy(source, dest) (frontmatter preserved)
19
+ └─ .ipynb → jupytext.read + write(fmt="md") (cell outputs stripped)
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import shutil
25
+ from pathlib import Path
26
+
27
+ import click
28
+ import jupytext
29
+
30
+ import afterpython as ap
31
+ from afterpython._typing import tContentType
32
+ from afterpython.const import CONTENT_TYPES
33
+
34
+
35
+ def _slugify(stem: str) -> str:
36
+ """Mirror MyST's URL slug rule so each `.md` lands at the same URL as its
37
+ rendered HTML sibling.
38
+
39
+ `package_management.md` -> `/doc/package-management.md`
40
+ `CONTRIBUTING.md` -> `/doc/contributing.md`
41
+ """
42
+ return stem.lower().replace("_", "-")
43
+
44
+
45
+ def _publish_md(source: Path, dest: Path) -> None:
46
+ """Copy a `.md` source verbatim. Frontmatter is preserved — LLMs can use
47
+ it (date, authors, tags) and it's harmless if ignored.
48
+ """
49
+ shutil.copy(source, dest)
50
+
51
+
52
+ def _publish_ipynb(source: Path, dest: Path) -> None:
53
+ """Convert a notebook to clean markdown via jupytext.
54
+
55
+ jupytext strips cell outputs by default, producing a leaner file than
56
+ `nbconvert` would. The `md` format is jupytext's default — clean enough
57
+ for LLM consumption while still round-trippable if anyone needs the
58
+ notebook back.
59
+ """
60
+ notebook = jupytext.read(source)
61
+ jupytext.write(notebook, dest, fmt="md")
62
+
63
+
64
+ def _publish_for_type(content_type: tContentType) -> int:
65
+ """Walk one content type's source dir; publish every supported file.
66
+ Returns the number of files written.
67
+ """
68
+ source_dir = ap.paths.afterpython_path / content_type
69
+ if not source_dir.exists():
70
+ return 0
71
+
72
+ dest_dir = ap.paths.website_path / "static" / content_type
73
+ dest_dir.mkdir(parents=True, exist_ok=True)
74
+
75
+ count = 0
76
+ for source in sorted(source_dir.rglob("*")):
77
+ if "_build" in source.parts:
78
+ continue
79
+ if not source.is_file():
80
+ continue
81
+
82
+ slug = _slugify(source.stem)
83
+ dest = dest_dir / f"{slug}.md"
84
+
85
+ if source.suffix == ".md":
86
+ _publish_md(source, dest)
87
+ elif source.suffix == ".ipynb":
88
+ _publish_ipynb(source, dest)
89
+ else:
90
+ continue
91
+
92
+ count += 1
93
+ return count
94
+
95
+
96
+ def build_url_md() -> None:
97
+ """Publish raw markdown for every content type alongside the rendered HTML.
98
+
99
+ Runs as part of `postbuild()` after MyST's HTML has been copied into
100
+ `_website/static/{type}/`. Each `.md` ends up as a sibling of the rendered
101
+ `{slug}/index.html`, served at `/{type}/{slug}.md` by SvelteKit's static
102
+ adapter.
103
+ """
104
+ for content_type in CONTENT_TYPES:
105
+ count = _publish_for_type(content_type)
106
+ if count > 0:
107
+ click.echo(f"Published {count} url-md file(s) for {content_type}/")