afterpython 0.1.0.dev17__py3-none-any.whl → 0.1.1__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 (33) hide show
  1. afterpython/builders/__init__.py +12 -2
  2. afterpython/builders/content_json.py +131 -0
  3. afterpython/builders/index_md.py +94 -0
  4. afterpython/builders/jupyter_notebook.py +293 -0
  5. afterpython/builders/markdown.py +271 -0
  6. afterpython/builders/metadata.py +5 -1
  7. afterpython/cli/commands/build.py +90 -62
  8. afterpython/cli/commands/check.py +1 -1
  9. afterpython/cli/commands/clean.py +2 -2
  10. afterpython/cli/commands/commit.py +5 -2
  11. afterpython/cli/commands/commitizen.py +1 -1
  12. afterpython/cli/commands/dev.py +10 -7
  13. afterpython/cli/commands/format.py +1 -1
  14. afterpython/cli/commands/init.py +1 -1
  15. afterpython/cli/commands/install.py +1 -1
  16. afterpython/cli/commands/lock.py +1 -1
  17. afterpython/cli/commands/pre_commit.py +1 -1
  18. afterpython/cli/commands/preview.py +1 -1
  19. afterpython/cli/commands/start.py +1 -1
  20. afterpython/cli/commands/sync.py +33 -6
  21. afterpython/cli/commands/update.py +5 -5
  22. afterpython/templates/ci-workflow-template.yml +1 -1
  23. afterpython/templates/cz-template.toml +0 -1
  24. afterpython/templates/pre-commit-config-template.yaml +1 -1
  25. afterpython/tools/_afterpython.py +71 -9
  26. afterpython/tools/myst.py +42 -3
  27. afterpython/utils.py +73 -0
  28. {afterpython-0.1.0.dev17.dist-info → afterpython-0.1.1.dist-info}/METADATA +14 -9
  29. afterpython-0.1.1.dist-info/RECORD +57 -0
  30. {afterpython-0.1.0.dev17.dist-info → afterpython-0.1.1.dist-info}/WHEEL +1 -1
  31. afterpython/builders/molab.py +0 -100
  32. afterpython-0.1.0.dev17.dist-info/RECORD +0 -54
  33. {afterpython-0.1.0.dev17.dist-info → afterpython-0.1.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,271 @@
1
+ from pathlib import Path
2
+
3
+ import click
4
+
5
+ import afterpython as ap
6
+ from afterpython.const import CONTENT_TYPES
7
+
8
+
9
+ def _read_markdown(markdown_path: Path, content_path: Path) -> str | None:
10
+ """Read and validate a markdown file.
11
+
12
+ Args:
13
+ markdown_path: Absolute path to the markdown file
14
+ content_path: Path relative to afterpython/ for logging
15
+
16
+ Returns:
17
+ Markdown content as a string if valid, None if invalid or error occurred
18
+ """
19
+ try:
20
+ with open(markdown_path, encoding="utf-8") as f:
21
+ content = f.read()
22
+ return content
23
+ except FileNotFoundError:
24
+ click.echo(f"✗ Skipping {content_path} - file not found")
25
+ return None
26
+ except Exception as e:
27
+ click.echo(f"✗ Error reading {content_path}: {e}")
28
+ return None
29
+
30
+
31
+ def _write_markdown(content: str, markdown_path: Path):
32
+ """Write markdown content to file.
33
+
34
+ Args:
35
+ content: The markdown content to write
36
+ markdown_path: Absolute path to the markdown file
37
+ """
38
+ with open(markdown_path, "w", encoding="utf-8") as f:
39
+ f.write(content)
40
+
41
+
42
+ def _has_frontmatter(content: str) -> bool:
43
+ """Check if markdown content has frontmatter.
44
+
45
+ Args:
46
+ content: The markdown file content as a string
47
+
48
+ Returns:
49
+ True if the content starts with ---, False otherwise
50
+ """
51
+ return content.strip().startswith("---")
52
+
53
+
54
+ def _read_frontmatter(content: str) -> str | None:
55
+ """Read frontmatter content from markdown.
56
+
57
+ Args:
58
+ content: The markdown file content as a string
59
+
60
+ Returns:
61
+ Frontmatter content as a string (including --- delimiters) if it exists, None otherwise
62
+ """
63
+ if not _has_frontmatter(content):
64
+ return None
65
+
66
+ lines = content.split("\n")
67
+
68
+ # Find the closing --- of frontmatter
69
+ closing_index = -1
70
+ for i in range(1, len(lines)):
71
+ if lines[i].strip() == "---":
72
+ closing_index = i
73
+ break
74
+
75
+ if closing_index == -1:
76
+ return None
77
+
78
+ # Return frontmatter including both --- delimiters
79
+ return "\n".join(lines[: closing_index + 1])
80
+
81
+
82
+ def _write_frontmatter(content: str, frontmatter: str) -> str:
83
+ """Write frontmatter content to markdown.
84
+
85
+ Low-level function that handles frontmatter replacement.
86
+ If frontmatter exists in content, replaces it. If not, prepends it.
87
+
88
+ Args:
89
+ content: The markdown file content as a string
90
+ frontmatter: The complete frontmatter content as a string (should include --- delimiters)
91
+
92
+ Returns:
93
+ The updated markdown content with the new frontmatter
94
+ """
95
+ if _has_frontmatter(content):
96
+ # Replace existing frontmatter
97
+ lines = content.split("\n")
98
+
99
+ # Find the closing --- of existing frontmatter
100
+ closing_index = -1
101
+ for i in range(1, len(lines)):
102
+ if lines[i].strip() == "---":
103
+ closing_index = i
104
+ break
105
+
106
+ if closing_index > -1:
107
+ # Remove old frontmatter and prepend new one
108
+ body = "\n".join(lines[closing_index + 1 :])
109
+ return frontmatter + "\n" + body
110
+ else:
111
+ # Malformed frontmatter, just prepend new frontmatter
112
+ return frontmatter + "\n" + content
113
+ else:
114
+ # No frontmatter exists, prepend it
115
+ return frontmatter + "\n" + content
116
+
117
+
118
+ def _ensure_blank_line_after_frontmatter(content: str) -> str:
119
+ """Ensure there's a blank line between frontmatter and content.
120
+
121
+ Args:
122
+ content: The markdown content
123
+
124
+ Returns:
125
+ Content with blank line after frontmatter if frontmatter exists
126
+ """
127
+ if not _has_frontmatter(content):
128
+ return content
129
+
130
+ lines = content.split("\n")
131
+
132
+ # Find the closing --- of frontmatter
133
+ closing_index = -1
134
+ for i in range(1, len(lines)):
135
+ if lines[i].strip() == "---":
136
+ closing_index = i
137
+ break
138
+
139
+ if closing_index == -1:
140
+ return content
141
+
142
+ # Check if there's already a blank line after the closing ---, and if the next line is not empty, insert a blank line
143
+ if closing_index + 1 < len(lines) and lines[closing_index + 1].strip() != "":
144
+ lines.insert(closing_index + 1, "")
145
+
146
+ return "\n".join(lines)
147
+
148
+
149
+ def _add_fields_to_frontmatter(content: str, fields: dict[str, str]) -> str:
150
+ """Add or update fields in frontmatter.
151
+
152
+ High-level function that handles frontmatter field manipulation.
153
+ If frontmatter exists, adds new fields to it. If not, creates new frontmatter with those fields.
154
+
155
+ Args:
156
+ content: The markdown file content as a string
157
+ fields: A dict of fields to add/update (e.g., {"thumbnail": "null"})
158
+
159
+ Returns:
160
+ The updated markdown content with the new fields in frontmatter
161
+ """
162
+ existing_frontmatter = _read_frontmatter(content)
163
+
164
+ if existing_frontmatter:
165
+ # Parse existing frontmatter and add new fields
166
+ lines = existing_frontmatter.split("\n")
167
+
168
+ # Find the closing --- of frontmatter
169
+ closing_index = -1
170
+ for i in range(1, len(lines)):
171
+ if lines[i].strip() == "---":
172
+ closing_index = i
173
+ break
174
+
175
+ if closing_index > 0:
176
+ # Insert new fields before the closing ---
177
+ for key, value in fields.items():
178
+ # Check if field already exists
179
+ if not any(line.strip().startswith(f"{key}:") for line in lines):
180
+ lines.insert(closing_index, f"{key}: {value}")
181
+ closing_index += 1 # Adjust index for next insertion
182
+
183
+ new_frontmatter = "\n".join(lines)
184
+ return _write_frontmatter(content, new_frontmatter)
185
+ else:
186
+ # Create new frontmatter with the provided fields
187
+ field_lines = [f"{key}: {value}" for key, value in fields.items()]
188
+ new_frontmatter = "---\n" + "\n".join(field_lines) + "\n---"
189
+ return _write_frontmatter(content, new_frontmatter)
190
+
191
+ return content
192
+
193
+
194
+ def _add_thumbnail_to_markdown(
195
+ content: str, thumbnail: str, content_path: Path
196
+ ) -> tuple[str, bool]:
197
+ """Add thumbnail field to a markdown file's frontmatter.
198
+
199
+ Args:
200
+ content: The markdown content
201
+ thumbnail: The thumbnail value to add
202
+ content_path: Path for logging purposes
203
+
204
+ Returns:
205
+ Tuple of (modified_content, was_modified)
206
+ """
207
+ # Check if thumbnail is already set in frontmatter
208
+ frontmatter_content = _read_frontmatter(content)
209
+ if frontmatter_content and "thumbnail:" in frontmatter_content:
210
+ return content, False
211
+
212
+ # Add thumbnail using the fields parameter
213
+ modified_content = _add_fields_to_frontmatter(
214
+ content, fields={"thumbnail": thumbnail}
215
+ )
216
+
217
+ if frontmatter_content:
218
+ click.echo(
219
+ f" ↳ Added 'thumbnail: {thumbnail}' to frontmatter in: {content_path}"
220
+ )
221
+ else:
222
+ click.echo(
223
+ f" ↳ Created frontmatter with 'thumbnail: {thumbnail}' in: {content_path}"
224
+ )
225
+
226
+ return modified_content, True
227
+
228
+
229
+ def build_markdown():
230
+ """Build markdown files for all content types"""
231
+ from afterpython.tools._afterpython import get_default_thumbnail
232
+
233
+ for content_type in CONTENT_TYPES:
234
+ # Skip doc content type - docs don't need default thumbnails
235
+ if content_type == "doc":
236
+ continue
237
+
238
+ click.echo(f"Building markdown files for {content_type}/...")
239
+
240
+ path = ap.paths.afterpython_path / content_type.lower()
241
+
242
+ # Iterate over all .md files
243
+ for markdown_path in path.rglob("*.md"):
244
+ # Skip files in _build directory
245
+ if "_build" in markdown_path.parts:
246
+ continue
247
+
248
+ # Get path relative to afterpython/
249
+ content_path = markdown_path.relative_to(ap.paths.afterpython_path)
250
+
251
+ # Get default thumbnail for this content type
252
+ default_thumbnail = get_default_thumbnail(content_type, markdown_path)
253
+
254
+ # Read the markdown file
255
+ content = _read_markdown(markdown_path, content_path)
256
+ if content is None:
257
+ continue
258
+
259
+ # Ensure blank line after frontmatter (fixes existing files)
260
+ original_content = content
261
+ content = _ensure_blank_line_after_frontmatter(content)
262
+ blank_line_added = content != original_content
263
+
264
+ # Add thumbnail to frontmatter
265
+ modified_content, was_modified = _add_thumbnail_to_markdown(
266
+ content, default_thumbnail, content_path
267
+ )
268
+
269
+ # Write back if modified (either blank line or thumbnail)
270
+ if was_modified or blank_line_added:
271
+ _write_markdown(modified_content, markdown_path)
@@ -7,6 +7,8 @@ if TYPE_CHECKING:
7
7
 
8
8
  import json
9
9
 
10
+ import click
11
+
10
12
  import afterpython as ap
11
13
 
12
14
  build_path = ap.paths.build_path
@@ -30,13 +32,15 @@ def convert_paths():
30
32
  with open(build_path / "metadata.json", "w") as f:
31
33
  json.dump(metadata, f, indent=2)
32
34
 
33
- print("Completed path conversion in metadata.json")
35
+ click.echo("Completed path conversion in metadata.json")
34
36
 
35
37
 
36
38
  def build_metadata():
37
39
  """Build metadata.json using pyproject.toml"""
38
40
  from afterpython.tools.pyproject import read_metadata
39
41
 
42
+ click.echo("Building metadata.json...")
43
+
40
44
  metadata: StandardMetadata = read_metadata()
41
45
 
42
46
  # Write to metadata.json
@@ -8,7 +8,6 @@ if TYPE_CHECKING:
8
8
 
9
9
  from afterpython._typing import NodeEnv
10
10
 
11
- import os
12
11
  import shutil
13
12
  import subprocess
14
13
 
@@ -17,11 +16,14 @@ from click.exceptions import Exit
17
16
 
18
17
  import afterpython as ap
19
18
  from afterpython.builders import (
20
- add_molab_badge_to_jupyter_notebooks,
19
+ build_content_json,
20
+ build_jupyter_notebooks,
21
+ build_markdown,
21
22
  build_metadata,
23
+ create_placeholder_index_md_files,
24
+ delete_placeholder_index_md_files,
22
25
  )
23
26
  from afterpython.const import CONTENT_TYPES
24
- from afterpython.utils import find_node_env
25
27
 
26
28
 
27
29
  def determine_base_path() -> str:
@@ -69,22 +71,31 @@ def prebuild():
69
71
  "Run 'ap init' first to set up your project."
70
72
  )
71
73
 
72
- def _clean_build_directory():
73
- print("Cleaning up build directory...")
74
+ def _clean_up_builds():
75
+ click.echo("Cleaning up builds...")
74
76
  build_path = ap.paths.build_path
75
- if build_path.exists():
76
- shutil.rmtree(build_path)
77
+ website_build_path = ap.paths.website_path / "build"
78
+ # clean up content builds in _website/static/{content_type}
79
+ website_static_path = ap.paths.website_path / "static"
80
+ content_build_paths = [
81
+ website_static_path / content_type for content_type in CONTENT_TYPES
82
+ ]
83
+ for path in [build_path, website_build_path, *content_build_paths]:
84
+ if path.exists():
85
+ shutil.rmtree(path)
77
86
  build_path.mkdir(parents=True, exist_ok=True)
78
87
 
79
88
  _check_initialized()
80
- _clean_build_directory()
89
+ _clean_up_builds()
81
90
 
82
- if os.getenv("AP_MOLAB_BADGE", "1") == "1":
83
- for content_type in CONTENT_TYPES:
84
- add_molab_badge_to_jupyter_notebooks(content_type)
91
+ delete_placeholder_index_md_files()
92
+ create_placeholder_index_md_files()
93
+ build_metadata()
94
+ build_markdown()
95
+ build_jupyter_notebooks()
85
96
 
86
97
 
87
- def postbuild():
98
+ def postbuild(dev_build: bool = False):
88
99
  def _move_files(
89
100
  source: Path,
90
101
  destination: Path,
@@ -112,6 +123,9 @@ def postbuild():
112
123
  shutil.move(str(source), str(destination))
113
124
  print(f"Moved: {source} to {destination}")
114
125
 
126
+ build_content_json()
127
+ delete_placeholder_index_md_files()
128
+
115
129
  website_static = ap.paths.website_path / "static"
116
130
  website_static.mkdir(parents=True, exist_ok=True)
117
131
 
@@ -119,15 +133,24 @@ def postbuild():
119
133
  myst_build = ap.paths.afterpython_path / content_type / "_build"
120
134
  afterpython_content_build = ap.paths.build_path / content_type
121
135
  # Move myst builds from afterpython/{content_type}/_build to afterpython/_build/{content_type}
122
- _move_files(myst_build, afterpython_content_build, is_copy=False)
123
- # Copy afterpython/_build/{content_type}/html to afterpython/_website/static/{content_type}
124
- _move_files(
125
- afterpython_content_build / "html",
126
- website_static / content_type,
127
- is_copy=True,
128
- )
136
+ _move_files(myst_build, afterpython_content_build, is_copy=True)
137
+ # for production build, copy afterpython/_build/{content_type}/html to afterpython/_website/static/{content_type}
138
+ if not dev_build:
139
+ _move_files(
140
+ afterpython_content_build / "html",
141
+ website_static / content_type,
142
+ is_copy=True,
143
+ )
144
+ # for dev build, copy the files (mainly images, e.g. thumbnails) in site/public/ to afterpython/_website/static/{content_type}/build
145
+ else:
146
+ _move_files(
147
+ afterpython_content_build / "site" / "public",
148
+ # add build/ to mimic the structure of the production build
149
+ website_static / content_type / "build",
150
+ is_copy=True,
151
+ )
129
152
 
130
- # Copy all files from afterpython/_build to afterpython/_website/static/
153
+ # Copy all files (e.g. metadata.json, blog.json etc.) from afterpython/_build to afterpython/_website/static/
131
154
  _move_files(
132
155
  ap.paths.build_path,
133
156
  website_static,
@@ -150,16 +173,10 @@ def postbuild():
150
173
  ),
151
174
  )
152
175
  @click.pass_context
153
- @click.option(
154
- "--dev",
155
- is_flag=True,
156
- hidden=True, # Internal flag used by `ap dev`, not exposed to users
157
- help="Development build - only build metadata for the landing page, skip content and production builds",
158
- )
159
176
  @click.option(
160
177
  "--execute", is_flag=True, help="Execute Jupyter notebooks for all content types"
161
178
  )
162
- def build(ctx, dev: bool, execute: bool):
179
+ def build(ctx: click.Context, execute: bool):
163
180
  """Build the project website and all contents for production.
164
181
 
165
182
  This command builds MyST content (doc/blog/tutorial/example/guide) and the SvelteKit website.
@@ -169,7 +186,11 @@ def build(ctx, dev: bool, execute: bool):
169
186
 
170
187
  Use --execute to execute Jupyter notebooks for all content types.
171
188
  """
172
- from afterpython.utils import handle_passthrough_help, has_content_for_myst
189
+ from afterpython.utils import (
190
+ find_node_env,
191
+ handle_passthrough_help,
192
+ has_content_for_myst,
193
+ )
173
194
 
174
195
  # Show both our options and myst's help and exit
175
196
  handle_passthrough_help(
@@ -187,46 +208,53 @@ def build(ctx, dev: bool, execute: bool):
187
208
  click.echo(f"Using BASE_PATH: {base_path}")
188
209
  else:
189
210
  click.echo("Using BASE_PATH: (empty - custom domain)")
190
-
191
- click.echo("Building metadata.json...")
192
- build_metadata()
211
+ node_env: NodeEnv = find_node_env()
193
212
 
194
213
  # myst's production build
195
- if not dev:
196
- node_env: NodeEnv = find_node_env()
197
- for content_type in CONTENT_TYPES:
198
- content_path = paths.afterpython_path / content_type
199
- if not has_content_for_myst(content_path):
200
- click.echo(f"Skipping {content_type}/ (no content files found)")
201
- continue
202
-
203
- click.echo(f"Building {content_type}/...")
204
- # NOTE: needs to set BASE_URL so that the project website can link to the content pages correctly at e.g. localhost:5173/doc
205
- base_url = f"{base_path}/{content_type}"
206
- build_env = {**node_env, "BASE_URL": base_url, "BASE_PATH": base_path}
207
- result = subprocess.run(
208
- [
209
- "myst",
210
- "build",
211
- "--html",
212
- *(["--execute"] if execute else []),
213
- *ctx.args,
214
- ],
215
- cwd=content_path,
216
- env=build_env,
217
- check=False,
214
+ for content_type in CONTENT_TYPES:
215
+ content_path = ap.paths.afterpython_path / content_type
216
+ myst_yml_path = content_path / "myst.yml"
217
+
218
+ if not content_path.exists():
219
+ click.echo(f"{content_path} does not exist, skip building {content_type}/")
220
+ continue
221
+
222
+ if not myst_yml_path.exists():
223
+ click.echo(
224
+ f"No myst.yml found in {content_path}, skip building {content_type}/"
218
225
  )
219
- if result.returncode != 0:
220
- raise Exit(result.returncode)
226
+ continue
221
227
 
222
- postbuild()
228
+ if not has_content_for_myst(content_path):
229
+ click.echo(f"Skipping {content_type}/ (no content files found)")
230
+ continue
223
231
 
224
- # website's production build
225
- if not dev:
226
- click.echo("Building project website...")
227
- website_env = {**node_env, "BASE_PATH": base_path}
232
+ click.echo(f"Building {content_type}/...")
233
+ # NOTE: needs to set BASE_URL (used by MyST) so that the project website can link to the content pages correctly at e.g. localhost:5173/doc
234
+ base_url = f"{base_path}/{content_type}"
235
+ build_env = {**node_env, "BASE_URL": base_url, "BASE_PATH": base_path}
228
236
  result = subprocess.run(
229
- ["pnpm", "build"], cwd=paths.website_path, env=website_env, check=False
237
+ [
238
+ "myst",
239
+ "build",
240
+ "--html",
241
+ *(["--execute"] if execute else []),
242
+ *ctx.args,
243
+ ],
244
+ cwd=content_path,
245
+ env=build_env,
246
+ check=False,
230
247
  )
248
+
231
249
  if result.returncode != 0:
232
250
  raise Exit(result.returncode)
251
+
252
+ postbuild(dev_build=False)
253
+
254
+ click.echo("Building project website...")
255
+ website_env = {**node_env, "BASE_PATH": base_path}
256
+ result = subprocess.run(
257
+ ["pnpm", "build"], cwd=paths.website_path, env=website_env, check=False
258
+ )
259
+ if result.returncode != 0:
260
+ raise Exit(result.returncode)
@@ -13,7 +13,7 @@ from click.exceptions import Exit
13
13
  )
14
14
  @click.pass_context
15
15
  def check(ctx):
16
- """Simple wrapper for ruff check for convenience, always use the afterpython/ruff.toml if it exists"""
16
+ """Run ruff linter (uses afterpython/ruff.toml if available)"""
17
17
  from afterpython.utils import handle_passthrough_help
18
18
 
19
19
  # Show both our options and ruff's help and exit
@@ -26,10 +26,10 @@ from afterpython.utils import find_node_env, has_content_for_myst
26
26
  @click.option(
27
27
  "--all",
28
28
  is_flag=True,
29
- help='--all passed to "myst clean", also clean afterpython build directories (_build/ and _website/build/)',
29
+ help="Also clean AfterPython build directories (_build/ and _website/build/)",
30
30
  )
31
31
  def clean(ctx, all: bool):
32
- """Clean the build directory"""
32
+ """Clean build artifacts from content types and website"""
33
33
  from afterpython.utils import handle_passthrough_help
34
34
 
35
35
  # Show both our options and myst's help and exit
@@ -13,7 +13,10 @@ import click
13
13
  )
14
14
  @click.pass_context
15
15
  @click.option(
16
- "--no-cz", "--no-commitizen", is_flag=True, help="Skip running 'cz commit'"
16
+ "--no-cz",
17
+ "--no-commitizen",
18
+ is_flag=True,
19
+ help="Skip 'commitizen' and 'commitizen-branch' pre-commit hooks",
17
20
  )
18
21
  @click.option(
19
22
  "--no-pc",
@@ -22,7 +25,7 @@ import click
22
25
  help="Skip running pre-commit checks before commit",
23
26
  )
24
27
  def commit(ctx, no_cz: bool, no_pc: bool):
25
- """Run 'cz commit'"""
28
+ """Create a conventional commit with interactive prompts"""
26
29
  from afterpython.utils import handle_passthrough_help
27
30
 
28
31
  # Show both our options and commitizen's help and exit
@@ -13,7 +13,7 @@ from click.exceptions import Exit
13
13
  )
14
14
  @click.pass_context
15
15
  def commitizen(ctx):
16
- """Run commitizen"""
16
+ """Run commitizen CLI (uses afterpython/cz.toml)"""
17
17
  from afterpython.utils import handle_passthrough_help
18
18
 
19
19
  # Show both our options and commitizen's help and exit
@@ -11,6 +11,7 @@ import time
11
11
  import click
12
12
  from click.exceptions import Exit
13
13
 
14
+ from afterpython.cli.commands.build import postbuild, prebuild
14
15
  from afterpython.const import CONTENT_TYPES
15
16
  from afterpython.utils import find_available_port, find_node_env
16
17
 
@@ -107,9 +108,6 @@ def dev(
107
108
 
108
109
  paths = ctx.obj["paths"]
109
110
 
110
- # OPTIMIZE: should implement incremental build?
111
- subprocess.run(["ap", "build", "--dev"])
112
-
113
111
  def cleanup_processes():
114
112
  """Clean up all MyST server processes"""
115
113
  click.echo("\nShutting down MyST servers...")
@@ -140,11 +138,14 @@ def dev(
140
138
  enabled_content_types = {ct for ct, flag in content_flags.items() if flag}
141
139
 
142
140
  try:
143
- if enabled_content_types:
144
- # Clear .env.development before writing new ports
145
- env_file = paths.website_path / ".env.development"
146
- env_file.write_text("") # Clear existing content
141
+ prebuild()
142
+
143
+ # Clear .env.development before writing new ports
144
+ env_file = paths.website_path / ".env.development"
145
+ env_file.write_text("") # Clear existing content
147
146
 
147
+ # myst development servers
148
+ if enabled_content_types:
148
149
  next_port = 3000
149
150
  for content_type in CONTENT_TYPES:
150
151
  # Skip content types that are not enabled
@@ -186,6 +187,8 @@ def dev(
186
187
  # causing "address already in use" errors.
187
188
  time.sleep(3)
188
189
 
190
+ postbuild(dev_build=True)
191
+
189
192
  if not no_website:
190
193
  node_env: NodeEnv = find_node_env()
191
194
  click.echo("Running the web dev server...")
@@ -13,7 +13,7 @@ from click.exceptions import Exit
13
13
  )
14
14
  @click.pass_context
15
15
  def format(ctx):
16
- """Simple wrapper for ruff format for convenience"""
16
+ """Run ruff formatter"""
17
17
  from afterpython.utils import handle_passthrough_help
18
18
 
19
19
  # Show both our options and ruff's help and exit
@@ -48,7 +48,7 @@ def init_py_typed():
48
48
  )
49
49
  @click.pass_context
50
50
  def init(ctx, yes):
51
- """Initialize afterpython with MyST Markdown (by default) and project website template"""
51
+ """Initialize AfterPython project structure and website template"""
52
52
  from afterpython.tools._afterpython import init_afterpython
53
53
  from afterpython.tools.commitizen import init_commitizen
54
54
  from afterpython.tools.github_actions import (
@@ -13,7 +13,7 @@ from afterpython.utils import has_pixi, has_uv
13
13
  )
14
14
  )
15
15
  def install():
16
- """Install all dependencies (runs 'uv sync --all-extras --all-groups' and 'pixi install' if present)"""
16
+ """Install all project dependencies (manages both uv and pixi if present)"""
17
17
  if not has_uv():
18
18
  click.echo("uv not found. Please install uv first.")
19
19
  return
@@ -8,7 +8,7 @@ from afterpython.utils import has_pixi, has_uv
8
8
 
9
9
  @click.command()
10
10
  def lock():
11
- """Lock the dependencies (runs 'uv lock' and 'pixi lock' if present)"""
11
+ """Lock project dependencies (manages both uv and pixi if present)"""
12
12
  if not has_uv():
13
13
  click.echo("uv not found. Please install uv first.")
14
14
  return
@@ -27,7 +27,7 @@ def _command_supports_config(command: str) -> bool:
27
27
  )
28
28
  @click.pass_context
29
29
  def pre_commit(ctx):
30
- """Run pre-commit, always use the afterpython/.pre-commit-config.yaml if it exists"""
30
+ """Run pre-commit hooks (uses afterpython/.pre-commit-config.yaml if available)"""
31
31
  from afterpython.utils import handle_passthrough_help
32
32
 
33
33
  # Show both our options and pre-commit's help and exit