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 +1 -0
- jamstack/__init__.py +3 -0
- jamstack/__main__.py +4 -0
- jamstack/builder.py +70 -0
- jamstack/cli.py +113 -0
- jamstack/config.py +60 -0
- jamstack/content.py +131 -0
- jamstack/feed.py +75 -0
- jamstack/render.py +12 -0
- jamstack/server.py +92 -0
- jamstack/theme.py +84 -0
- jamstack/themes/blog/scaffold/content/posts/hello-world.md +9 -0
- jamstack/themes/blog/static/style.css +186 -0
- jamstack/themes/blog/templates/404.html +15 -0
- jamstack/themes/blog/templates/base.html +22 -0
- jamstack/themes/blog/templates/index.html +21 -0
- jamstack/themes/blog/templates/page.html +3 -0
- jamstack/themes/blog/templates/post.html +16 -0
- jamstack/themes/blog/theme.toml +2 -0
- jamstack/themes/minimal/static/style.css +137 -0
- jamstack/themes/minimal/templates/404.html +11 -0
- jamstack/themes/minimal/templates/base.html +21 -0
- jamstack/themes/minimal/templates/index.html +17 -0
- jamstack/themes/minimal/templates/page.html +11 -0
- jamstack/themes/minimal/theme.toml +2 -0
- jamstack-2.0.0rc1.dist-info/METADATA +226 -0
- jamstack-2.0.0rc1.dist-info/RECORD +30 -0
- jamstack-2.0.0rc1.dist-info/WHEEL +4 -0
- jamstack-2.0.0rc1.dist-info/entry_points.txt +2 -0
- jamstack-2.0.0rc1.dist-info/licenses/LICENSE +201 -0
jamstack/__about__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.0.0rc1"
|
jamstack/__init__.py
ADDED
jamstack/__main__.py
ADDED
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)
|