jamstack 2.0.0rc1__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.
jamstack/__about__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "2.0.0rc1"
jamstack/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from jamstack.__about__ import __version__
2
+
3
+ __all__ = ["__version__"]
jamstack/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from jamstack.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
jamstack/builder.py ADDED
@@ -0,0 +1,70 @@
1
+ import shutil
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+
5
+ from jinja2 import Environment, TemplateNotFound
6
+ from markupsafe import Markup
7
+
8
+ from jamstack.config import Config
9
+ from jamstack.content import Page, load_pages, render_markdown
10
+ from jamstack.feed import write_feed, write_sitemap
11
+ from jamstack.render import create_environment
12
+ from jamstack.theme import resolve_theme
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class BuildResult:
17
+ output: Path
18
+ page_count: int
19
+
20
+
21
+ def build(config: Config, *, clean: bool = False, drafts: bool = False) -> BuildResult:
22
+ theme = resolve_theme(config.theme, config.root)
23
+
24
+ template_dirs = [path for path in (config.root / "templates", theme.templates) if path.is_dir()]
25
+ environment = create_environment(template_dirs)
26
+
27
+ pages = load_pages(config.root / "content", drafts=drafts)
28
+
29
+ if clean and config.output.exists():
30
+ shutil.rmtree(config.output)
31
+ config.output.mkdir(parents=True, exist_ok=True)
32
+
33
+ for page in pages:
34
+ context = {"site": config, "page": page, "pages": pages}
35
+ page.content = render_page_body(environment, page, context)
36
+ html = environment.get_template(page.template).render(context)
37
+ destination = config.output / page.output
38
+ destination.parent.mkdir(parents=True, exist_ok=True)
39
+ destination.write_text(html, encoding="utf-8")
40
+
41
+ copy_static(theme.static, config.output)
42
+ copy_static(config.root / "static", config.output)
43
+ write_sitemap(config, pages, config.output)
44
+ write_feed(config, pages, config.output)
45
+ render_not_found(environment, config, pages, config.output)
46
+
47
+ return BuildResult(output=config.output, page_count=len(pages))
48
+
49
+
50
+ def render_not_found(
51
+ environment: Environment, config: Config, pages: list[Page], output: Path
52
+ ) -> None:
53
+ try:
54
+ template = environment.get_template("404.html")
55
+ except TemplateNotFound:
56
+ return
57
+
58
+ destination = output / "404.html"
59
+ destination.write_text(template.render(site=config, pages=pages), encoding="utf-8")
60
+
61
+
62
+ def render_page_body(environment: Environment, page: Page, context: dict[str, object]) -> Markup:
63
+ if page.source.suffix == ".html":
64
+ return Markup(environment.from_string(page.body).render(context))
65
+ return render_markdown(page.body)
66
+
67
+
68
+ def copy_static(source: Path, destination: Path) -> None:
69
+ if source.is_dir():
70
+ shutil.copytree(source, destination, dirs_exist_ok=True)
jamstack/cli.py ADDED
@@ -0,0 +1,113 @@
1
+ import json
2
+ import shutil
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from jamstack.__about__ import __version__
8
+ from jamstack.builder import build as build_site
9
+ from jamstack.config import JamstackError, load_config
10
+ from jamstack.server import serve as serve_site
11
+ from jamstack.theme import discover_themes, resolve_theme
12
+
13
+ INDEX_PAGE = """+++
14
+ title = "Welcome"
15
+ +++
16
+
17
+ This is your new site. Edit `content/index.md` and run `jamstack build`.
18
+ """
19
+
20
+
21
+ @click.group()
22
+ @click.version_option(__version__, prog_name="jamstack")
23
+ def main() -> None:
24
+ """Static site generator built on Python and Jinja."""
25
+
26
+
27
+ @main.command()
28
+ @click.argument("path", default=".", type=click.Path(path_type=Path))
29
+ @click.option(
30
+ "--theme", default="minimal", show_default=True, help="Theme to scaffold the site with."
31
+ )
32
+ @click.option("--force", is_flag=True, help="Write into a non-empty directory.")
33
+ def init(path: Path, theme: str, force: bool) -> None:
34
+ """Create a new site."""
35
+ if path.is_file():
36
+ raise click.ClickException(f"{path} is a file, not a directory")
37
+
38
+ if path.is_dir() and any(path.iterdir()) and not force:
39
+ raise click.ClickException(f"{path} is not empty. Use --force to write anyway.")
40
+
41
+ selected = resolve_theme(theme, path)
42
+
43
+ site_toml = f"""[site]
44
+ title = {json.dumps(path.resolve().name.replace("-", " ").replace("_", " ").title())}
45
+ description = ""
46
+ # Absolute public URL. Required to generate sitemap.xml and feed.xml.
47
+ base_url = ""
48
+ language = "en"
49
+
50
+ [build]
51
+ output = "dist"
52
+
53
+ [theme]
54
+ name = {json.dumps(theme)}
55
+ """
56
+
57
+ if selected.scaffold:
58
+ shutil.copytree(selected.scaffold, path, dirs_exist_ok=True)
59
+
60
+ files = {
61
+ "site.toml": site_toml,
62
+ "content/index.md": INDEX_PAGE,
63
+ ".gitignore": "dist/\n",
64
+ }
65
+ for relative, content in files.items():
66
+ destination = path / relative
67
+ destination.parent.mkdir(parents=True, exist_ok=True)
68
+ destination.write_text(content, encoding="utf-8")
69
+
70
+ click.echo(f"Created site in {path} using the {theme!r} theme")
71
+ click.echo("Next steps:")
72
+ click.echo(f" cd {path}")
73
+ click.echo(" jamstack build")
74
+
75
+
76
+ @main.command()
77
+ @click.option("--clean", is_flag=True, help="Delete the output directory before building.")
78
+ @click.option("--drafts", is_flag=True, help="Include pages marked as drafts.")
79
+ def build(clean: bool, drafts: bool) -> None:
80
+ """Build the site into the output directory."""
81
+ try:
82
+ config = load_config(Path.cwd())
83
+ result = build_site(config, clean=clean, drafts=drafts)
84
+ except JamstackError as error:
85
+ raise click.ClickException(str(error)) from error
86
+
87
+ click.echo(f"Built {result.page_count} page(s) into {result.output}")
88
+
89
+
90
+ @main.command()
91
+ @click.option("--port", default=8000, show_default=True, type=click.IntRange(0, 65535))
92
+ @click.option("--drafts", is_flag=True, help="Include pages marked as drafts.")
93
+ def serve(port: int, drafts: bool) -> None:
94
+ """Build the site, serve it, and rebuild on changes."""
95
+ try:
96
+ config = load_config(Path.cwd())
97
+ serve_site(config, port=port, drafts=drafts)
98
+ except JamstackError as error:
99
+ raise click.ClickException(str(error)) from error
100
+
101
+
102
+ @main.command("themes")
103
+ def list_themes() -> None:
104
+ """List the available themes."""
105
+ try:
106
+ themes = discover_themes(Path.cwd())
107
+ except JamstackError as error:
108
+ raise click.ClickException(str(error)) from error
109
+
110
+ width = max(len(theme.name) for theme in themes)
111
+ for theme in themes:
112
+ origin = "bundled" if theme.bundled else "local"
113
+ click.echo(f"{theme.name:<{width}} {origin:<7} {theme.description}")
jamstack/config.py ADDED
@@ -0,0 +1,60 @@
1
+ import re
2
+ import tomllib
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ THEME_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
7
+
8
+
9
+ class JamstackError(Exception):
10
+ """Base class for expected, user-facing failures."""
11
+
12
+
13
+ class ConfigError(JamstackError):
14
+ """Raised when site.toml is missing or invalid."""
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Config:
19
+ root: Path
20
+ title: str
21
+ description: str
22
+ author: str
23
+ base_url: str
24
+ language: str
25
+ output: Path
26
+ theme: str
27
+
28
+
29
+ def load_config(root: Path) -> Config:
30
+ path = root / "site.toml"
31
+ if not path.is_file():
32
+ raise ConfigError(f"{path} not found. Run 'jamstack init' first.")
33
+
34
+ try:
35
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
36
+ except tomllib.TOMLDecodeError as error:
37
+ raise ConfigError(f"{path}: {error}") from error
38
+
39
+ site = data.get("site", {})
40
+ build = data.get("build", {})
41
+ theme = data.get("theme", {})
42
+
43
+ title = str(site.get("title", "")).strip()
44
+ if not title:
45
+ raise ConfigError(f"{path}: [site].title is required")
46
+
47
+ theme_name = str(theme.get("name", "minimal"))
48
+ if not THEME_NAME_PATTERN.fullmatch(theme_name):
49
+ raise ConfigError(f"{path}: invalid theme name {theme_name!r}")
50
+
51
+ return Config(
52
+ root=root,
53
+ title=title,
54
+ description=str(site.get("description", "")),
55
+ author=str(site.get("author", "")),
56
+ base_url=str(site.get("base_url", "")),
57
+ language=str(site.get("language", "en")),
58
+ output=root / str(build.get("output", "dist")),
59
+ theme=theme_name,
60
+ )
jamstack/content.py ADDED
@@ -0,0 +1,131 @@
1
+ import tomllib
2
+ from dataclasses import dataclass, field
3
+ from datetime import date, datetime
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from markdown_it import MarkdownIt
8
+ from markupsafe import Markup
9
+
10
+ from jamstack.config import JamstackError
11
+
12
+ FRONT_MATTER_DELIMITER = "+++"
13
+ MARKDOWN = MarkdownIt("commonmark", {"html": True})
14
+
15
+
16
+ class ContentError(JamstackError):
17
+ """Raised when a content file cannot be parsed."""
18
+
19
+
20
+ @dataclass
21
+ class Page:
22
+ source: Path
23
+ url: str
24
+ output: Path
25
+ title: str
26
+ template: str
27
+ date: date | None
28
+ draft: bool
29
+ body: str
30
+ meta: dict[str, Any] = field(default_factory=dict)
31
+ content: Markup = field(default_factory=Markup)
32
+
33
+
34
+ def load_pages(content_dir: Path, *, drafts: bool = False) -> list[Page]:
35
+ if not content_dir.is_dir():
36
+ return []
37
+
38
+ sources = sorted(
39
+ path
40
+ for path in content_dir.rglob("*")
41
+ if path.is_file() and path.suffix in {".md", ".html"}
42
+ )
43
+
44
+ pages: list[Page] = []
45
+ seen_outputs: dict[Path, Path] = {}
46
+ for source in sources:
47
+ page = load_page(source, content_dir)
48
+ if page.draft and not drafts:
49
+ continue
50
+ if page.output in seen_outputs:
51
+ raise ContentError(
52
+ f"{source} and {seen_outputs[page.output]} both build to {page.output}"
53
+ )
54
+ seen_outputs[page.output] = source
55
+ pages.append(page)
56
+
57
+ pages.sort(key=lambda page: page.title.lower())
58
+ pages.sort(key=lambda page: page.date or date.min, reverse=True)
59
+ return pages
60
+
61
+
62
+ def load_page(source: Path, content_dir: Path) -> Page:
63
+ text = source.read_text(encoding="utf-8")
64
+ meta, body = split_front_matter(text, source)
65
+
66
+ relative = source.relative_to(content_dir).with_suffix("")
67
+ parts = list(relative.parts)
68
+ if parts[-1] == "index":
69
+ parts.pop()
70
+
71
+ return Page(
72
+ source=source,
73
+ url="/" + "".join(f"{part}/" for part in parts) if parts else "/",
74
+ output=Path(*parts, "index.html"),
75
+ title=parse_title(meta, relative.name),
76
+ template=parse_template(meta, relative.name),
77
+ date=parse_date(meta.get("date"), source),
78
+ draft=bool(meta.get("draft", False)),
79
+ body=body,
80
+ meta=meta,
81
+ )
82
+
83
+
84
+ def split_front_matter(text: str, source: Path) -> tuple[dict[str, Any], str]:
85
+ lines = text.splitlines(keepends=True)
86
+ if not lines or lines[0].strip() != FRONT_MATTER_DELIMITER:
87
+ return {}, text
88
+
89
+ for index in range(1, len(lines)):
90
+ if lines[index].strip() != FRONT_MATTER_DELIMITER:
91
+ continue
92
+
93
+ raw = "".join(lines[1:index])
94
+ body = "".join(lines[index + 1 :]).lstrip("\n")
95
+ try:
96
+ return tomllib.loads(raw), body
97
+ except tomllib.TOMLDecodeError as error:
98
+ raise ContentError(f"{source}: invalid front matter: {error}") from error
99
+
100
+ raise ContentError(f"{source}: unterminated front matter")
101
+
102
+
103
+ def parse_title(meta: dict[str, Any], stem: str) -> str:
104
+ title = str(meta.get("title", "")).strip()
105
+ return title or stem.replace("-", " ").replace("_", " ").title()
106
+
107
+
108
+ def parse_template(meta: dict[str, Any], stem: str) -> str:
109
+ template = str(meta.get("template", "")).strip()
110
+ if not template:
111
+ template = "index" if stem == "index" else "page"
112
+ return template if Path(template).suffix else f"{template}.html"
113
+
114
+
115
+ def parse_date(value: Any, source: Path) -> date | None:
116
+ if value is None:
117
+ return None
118
+ if isinstance(value, datetime):
119
+ return value.date()
120
+ if isinstance(value, date):
121
+ return value
122
+ if isinstance(value, str):
123
+ try:
124
+ return date.fromisoformat(value)
125
+ except ValueError as error:
126
+ raise ContentError(f"{source}: invalid date {value!r}") from error
127
+ raise ContentError(f"{source}: date must be an ISO date, got {value!r}")
128
+
129
+
130
+ def render_markdown(body: str) -> Markup:
131
+ return Markup(MARKDOWN.render(body))
jamstack/feed.py ADDED
@@ -0,0 +1,75 @@
1
+ from datetime import UTC, date, datetime, time
2
+ from pathlib import Path
3
+ from xml.etree import ElementTree
4
+
5
+ from jamstack.config import Config
6
+ from jamstack.content import Page
7
+
8
+ ATOM_NAMESPACE = "http://www.w3.org/2005/Atom"
9
+ SITEMAP_NAMESPACE = "http://www.sitemaps.org/schemas/sitemap/0.9"
10
+
11
+
12
+ def write_sitemap(config: Config, pages: list[Page], output: Path) -> Path | None:
13
+ if not config.base_url or not pages:
14
+ return None
15
+
16
+ root = ElementTree.Element("urlset", xmlns=SITEMAP_NAMESPACE)
17
+ for page in pages:
18
+ url = ElementTree.SubElement(root, "url")
19
+ ElementTree.SubElement(url, "loc").text = absolute_url(config, page.url)
20
+ if page.date:
21
+ ElementTree.SubElement(url, "lastmod").text = page.date.isoformat()
22
+
23
+ return _write(root, output / "sitemap.xml")
24
+
25
+
26
+ def write_feed(config: Config, pages: list[Page], output: Path) -> Path | None:
27
+ entries = [page for page in pages if page.date is not None]
28
+ if not config.base_url or not entries:
29
+ return None
30
+
31
+ site_url = absolute_url(config, "/")
32
+ root = ElementTree.Element("feed", xmlns=ATOM_NAMESPACE, lang=config.language)
33
+ ElementTree.SubElement(root, "title").text = config.title
34
+ if config.description:
35
+ ElementTree.SubElement(root, "subtitle").text = config.description
36
+ ElementTree.SubElement(root, "link", href=site_url, rel="alternate")
37
+ ElementTree.SubElement(root, "link", href=absolute_url(config, "/feed.xml"), rel="self")
38
+ ElementTree.SubElement(root, "id").text = site_url
39
+ ElementTree.SubElement(root, "updated").text = _timestamp(
40
+ max(page.date for page in entries if page.date)
41
+ )
42
+ author = ElementTree.SubElement(root, "author")
43
+ ElementTree.SubElement(author, "name").text = config.author or config.title
44
+
45
+ for page in entries:
46
+ url = absolute_url(config, page.url)
47
+ entry = ElementTree.SubElement(root, "entry")
48
+ ElementTree.SubElement(entry, "title").text = page.title
49
+ ElementTree.SubElement(entry, "link", href=url)
50
+ ElementTree.SubElement(entry, "id").text = url
51
+ ElementTree.SubElement(entry, "updated").text = _timestamp(page.date)
52
+ summary = str(page.meta.get("summary", "")).strip()
53
+ if summary:
54
+ ElementTree.SubElement(entry, "summary").text = summary
55
+ content = ElementTree.SubElement(entry, "content", type="html")
56
+ content.text = str(page.content)
57
+
58
+ return _write(root, output / "feed.xml")
59
+
60
+
61
+ def absolute_url(config: Config, path: str) -> str:
62
+ return config.base_url.rstrip("/") + path
63
+
64
+
65
+ def _timestamp(value: date) -> str:
66
+ return datetime.combine(value, time.min, tzinfo=UTC).isoformat()
67
+
68
+
69
+ def _write(root: ElementTree.Element, destination: Path) -> Path:
70
+ ElementTree.indent(root, space=" ")
71
+ destination.parent.mkdir(parents=True, exist_ok=True)
72
+ destination.write_bytes(
73
+ ElementTree.tostring(root, encoding="utf-8", xml_declaration=True) + b"\n"
74
+ )
75
+ return destination
jamstack/render.py ADDED
@@ -0,0 +1,12 @@
1
+ from pathlib import Path
2
+
3
+ from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
4
+
5
+
6
+ def create_environment(template_dirs: list[Path]) -> Environment:
7
+ return Environment(
8
+ loader=FileSystemLoader([str(path) for path in template_dirs]),
9
+ autoescape=select_autoescape(["html", "xml"]),
10
+ undefined=StrictUndefined,
11
+ keep_trailing_newline=True,
12
+ )
jamstack/server.py ADDED
@@ -0,0 +1,92 @@
1
+ import functools
2
+ import threading
3
+ from collections.abc import Iterable
4
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
5
+ from pathlib import Path
6
+
7
+ from jamstack.builder import build
8
+ from jamstack.config import Config
9
+ from jamstack.theme import resolve_theme
10
+
11
+
12
+ class _QuietHandler(SimpleHTTPRequestHandler):
13
+ def log_message(self, format: str, *args: object) -> None:
14
+ pass
15
+
16
+
17
+ def create_server(config: Config, port: int = 8000) -> ThreadingHTTPServer:
18
+ handler = functools.partial(_QuietHandler, directory=str(config.output.resolve()))
19
+ return ThreadingHTTPServer(("127.0.0.1", port), handler)
20
+
21
+
22
+ def watched_paths(config: Config) -> list[Path]:
23
+ theme = resolve_theme(config.theme, config.root)
24
+ return [
25
+ config.root / "site.toml",
26
+ config.root / "content",
27
+ config.root / "templates",
28
+ config.root / "static",
29
+ theme.templates,
30
+ theme.static,
31
+ ]
32
+
33
+
34
+ def snapshot(paths: Iterable[Path]) -> dict[str, tuple[int, int]]:
35
+ state: dict[str, tuple[int, int]] = {}
36
+ for path in paths:
37
+ if path.is_file():
38
+ files: Iterable[Path] = [path]
39
+ elif path.is_dir():
40
+ files = path.rglob("*")
41
+ else:
42
+ continue
43
+ for file in files:
44
+ if file.is_file():
45
+ stat = file.stat()
46
+ state[str(file)] = (stat.st_mtime_ns, stat.st_size)
47
+ return state
48
+
49
+
50
+ def watch(config: Config, drafts: bool, poll_interval: float, stop: threading.Event) -> None:
51
+ paths = watched_paths(config)
52
+ previous = snapshot(paths)
53
+ while not stop.wait(poll_interval):
54
+ current = snapshot(paths)
55
+ if current == previous:
56
+ continue
57
+ stop.wait(poll_interval) # let editors finish writing
58
+ settled = snapshot(paths)
59
+ if settled != current:
60
+ continue
61
+ previous = settled
62
+ try:
63
+ result = build(config, drafts=drafts)
64
+ except Exception as error: # a bad build must not kill the watcher
65
+ print(f"Build failed: {error}", flush=True)
66
+ else:
67
+ print(f"Rebuilt {result.page_count} page(s)", flush=True)
68
+
69
+
70
+ def serve(
71
+ config: Config, port: int = 8000, drafts: bool = False, poll_interval: float = 0.5
72
+ ) -> None:
73
+ result = build(config, drafts=drafts)
74
+ print(f"Built {result.page_count} page(s) into {result.output}", flush=True)
75
+
76
+ server = create_server(config, port)
77
+ address, actual_port = server.server_address[:2]
78
+ print(f"Serving at http://{address}:{actual_port}/ (Ctrl+C to stop)", flush=True)
79
+
80
+ stop = threading.Event()
81
+ watcher = threading.Thread(
82
+ target=watch, args=(config, drafts, poll_interval, stop), daemon=True
83
+ )
84
+ watcher.start()
85
+ try:
86
+ server.serve_forever()
87
+ except KeyboardInterrupt:
88
+ pass
89
+ finally:
90
+ stop.set()
91
+ server.server_close()
92
+ watcher.join(timeout=5)
jamstack/theme.py ADDED
@@ -0,0 +1,84 @@
1
+ import tomllib
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+
5
+ from jamstack.config import JamstackError
6
+
7
+ PACKAGED_THEMES = Path(__file__).parent / "themes"
8
+
9
+
10
+ class ThemeError(JamstackError):
11
+ """Raised when a theme cannot be resolved or is malformed."""
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Theme:
16
+ name: str
17
+ description: str
18
+ root: Path
19
+ templates: Path
20
+ static: Path
21
+ scaffold: Path | None
22
+ bundled: bool
23
+
24
+
25
+ def load_theme(root: Path, *, bundled: bool) -> Theme:
26
+ manifest = root / "theme.toml"
27
+ if not manifest.is_file():
28
+ raise ThemeError(f"{root} is not a theme: theme.toml is missing")
29
+
30
+ try:
31
+ data = tomllib.loads(manifest.read_text(encoding="utf-8"))
32
+ except tomllib.TOMLDecodeError as error:
33
+ raise ThemeError(f"{manifest}: {error}") from error
34
+
35
+ name = str(data.get("name", "")).strip()
36
+ if not name:
37
+ raise ThemeError(f"{manifest}: name is required")
38
+ if name != root.name:
39
+ raise ThemeError(f"{manifest}: name {name!r} does not match directory {root.name!r}")
40
+
41
+ templates = root / "templates"
42
+ if not templates.is_dir():
43
+ raise ThemeError(f"{manifest}: templates directory is missing")
44
+
45
+ scaffold = root / "scaffold"
46
+ return Theme(
47
+ name=name,
48
+ description=str(data.get("description", "")).strip(),
49
+ root=root,
50
+ templates=templates,
51
+ static=root / "static",
52
+ scaffold=scaffold if scaffold.is_dir() else None,
53
+ bundled=bundled,
54
+ )
55
+
56
+
57
+ def resolve_theme(name: str, project_root: Path) -> Theme:
58
+ candidates = (
59
+ (project_root / "themes" / name, False),
60
+ (PACKAGED_THEMES / name, True),
61
+ )
62
+ for root, bundled in candidates:
63
+ if root.is_dir():
64
+ return load_theme(root, bundled=bundled)
65
+
66
+ available = ", ".join(theme.name for theme in discover_themes(project_root))
67
+ raise ThemeError(f"Theme {name!r} not found. Available themes: {available or 'none'}")
68
+
69
+
70
+ def discover_themes(project_root: Path) -> list[Theme]:
71
+ themes: dict[str, Theme] = {}
72
+ for root in sorted(PACKAGED_THEMES.iterdir()):
73
+ if root.is_dir():
74
+ theme = load_theme(root, bundled=True)
75
+ themes[theme.name] = theme
76
+
77
+ local_root = project_root / "themes"
78
+ if local_root.is_dir():
79
+ for root in sorted(local_root.iterdir()):
80
+ if root.is_dir():
81
+ theme = load_theme(root, bundled=False)
82
+ themes[theme.name] = theme
83
+
84
+ return sorted(themes.values(), key=lambda theme: theme.name)
@@ -0,0 +1,9 @@
1
+ +++
2
+ title = "Hello, world"
3
+ date = 2026-11-06
4
+ template = "post"
5
+ summary = "The first post on your new site."
6
+ +++
7
+
8
+ This is your first post. Edit `content/posts/hello-world.md` and run
9
+ `jamstack build` to see the changes.