afterpython 0.2.0__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.
- afterpython/builders/__init__.py +8 -0
- afterpython/builders/faq_json.py +43 -0
- afterpython/builders/jupyter_notebook.py +1 -14
- afterpython/builders/llms_txt.py +248 -0
- afterpython/builders/marimo_notebook.py +144 -0
- afterpython/builders/metadata.py +36 -3
- afterpython/builders/url_md.py +107 -0
- afterpython/cli/commands/build.py +30 -0
- afterpython/cli/commands/init.py +30 -11
- afterpython/cli/commands/serve.py +29 -0
- afterpython/cli/commands/sync.py +6 -0
- afterpython/cli/commands/update.py +84 -4
- afterpython/cli/main.py +2 -0
- afterpython/templates/ci-workflow-template.yml +16 -9
- afterpython/templates/deploy-workflow-template.yml +1 -0
- afterpython/tools/_afterpython.py +2 -0
- afterpython/tools/github_actions.py +56 -22
- afterpython/tools/pyproject.py +8 -2
- {afterpython-0.2.0.dist-info → afterpython-0.3.0.dist-info}/METADATA +21 -18
- {afterpython-0.2.0.dist-info → afterpython-0.3.0.dist-info}/RECORD +22 -17
- {afterpython-0.2.0.dist-info → afterpython-0.3.0.dist-info}/WHEEL +1 -1
- {afterpython-0.2.0.dist-info → afterpython-0.3.0.dist-info}/entry_points.txt +0 -0
afterpython/builders/__init__.py
CHANGED
|
@@ -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: 
|
|
40
|
+
# [](link)
|
|
41
|
+
# <!--  -->
|
|
42
|
+
# <!-- [](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)
|
afterpython/builders/metadata.py
CHANGED
|
@@ -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(
|
|
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}/")
|