oxie 0.3.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.
oxie/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ '''
2
+ oxie — a small static site/blog generator.
3
+
4
+ Converts Markdown content into a styled, SEO-friendly static website
5
+ using Jinja2 templates. A site is described by a SiteConfig (paths and
6
+ optional features) and built by a Site:
7
+
8
+ from oxie import Site, SiteConfig
9
+
10
+ Site(SiteConfig()).build()
11
+
12
+ To start a new site from scratch, use the command line instead:
13
+
14
+ oxie init myblog --title "My Blog"
15
+ '''
16
+ from .config import SiteConfig
17
+ from .content import Post, Category
18
+ from .scaffold import init_site
19
+ from .site import Site, BlogIndex, IndexPage, generate_posts_jsonld
20
+ from .updates import Update, UpdateReader
21
+
22
+ __version__ = "0.3.1"
23
+
24
+ __all__ = [
25
+ "Site", "SiteConfig", "Post", "Category", "BlogIndex", "IndexPage",
26
+ "generate_posts_jsonld", "init_site", "Update", "UpdateReader",
27
+ "__version__",
28
+ ]
oxie/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ '''Entry point for `python -m oxie`.'''
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
oxie/cli.py ADDED
@@ -0,0 +1,83 @@
1
+ '''
2
+ Command line interface for oxie.
3
+
4
+ oxie init myblog --title "My Blog" --author "Me"
5
+ python -m oxie init myblog
6
+
7
+ `init` is the only command: a site's own build.py is its build entry point.
8
+ '''
9
+ import argparse
10
+ import sys
11
+
12
+ from . import __version__
13
+ from .scaffold import init_site
14
+
15
+
16
+ def _cmd_init(args) -> int:
17
+ result = init_site(
18
+ target=args.path,
19
+ title=args.title,
20
+ author=args.author,
21
+ link=args.link,
22
+ description=args.description,
23
+ force=args.force,
24
+ )
25
+
26
+ for path in result["created"]:
27
+ print(f" created {path}")
28
+ for path in result["skipped"]:
29
+ print(f" skipped {path} (exists — use --force to overwrite)")
30
+
31
+ if not result["created"]:
32
+ print("\nNothing to do: every file already exists.")
33
+ return 0
34
+
35
+ steps = []
36
+ if args.path not in (".", ""):
37
+ steps.append(f"cd {args.path}")
38
+ steps.append("npm install # Tailwind CSS v4")
39
+ steps.append("python build.py # writes docs/")
40
+
41
+ print(f"\nSite created in {result['root']}\n")
42
+ print("Next steps:")
43
+ for step in steps:
44
+ print(f" {step}")
45
+ print("\nThen open docs/index.html, or serve docs/ with a static file server.")
46
+ return 0
47
+
48
+
49
+ def main(argv=None) -> int:
50
+ parser = argparse.ArgumentParser(
51
+ prog="oxie",
52
+ description="oxie — a small static site/blog generator.",
53
+ )
54
+ parser.add_argument("--version", action="version", version=f"oxie {__version__}")
55
+ subparsers = parser.add_subparsers(dest="command")
56
+
57
+ init_parser = subparsers.add_parser(
58
+ "init",
59
+ help="create a new site (templates, Tailwind setup, sample content)",
60
+ )
61
+ init_parser.add_argument(
62
+ "path", nargs="?", default=".",
63
+ help="directory to create the site in (default: current directory)")
64
+ init_parser.add_argument("--title", default="My Site", help="site title")
65
+ init_parser.add_argument("--author", default="", help="author name")
66
+ init_parser.add_argument(
67
+ "--link", default="https://example.com",
68
+ help="public base URL, used for absolute links in structured data")
69
+ init_parser.add_argument(
70
+ "--description", default="A site built with oxie.", help="site description")
71
+ init_parser.add_argument(
72
+ "--force", action="store_true", help="overwrite existing files")
73
+ init_parser.set_defaults(func=_cmd_init)
74
+
75
+ args = parser.parse_args(argv)
76
+ if not getattr(args, "command", None):
77
+ parser.print_help()
78
+ return 1
79
+ return args.func(args)
80
+
81
+
82
+ if __name__ == "__main__":
83
+ sys.exit(main())
oxie/config.py ADDED
@@ -0,0 +1,93 @@
1
+ '''
2
+ Site configuration for the oxie static site generator.
3
+
4
+ A SiteConfig describes one site: where its content, templates and output
5
+ live, and which optional features (photography page, thumbnails, extra
6
+ template pages, CSS pipeline) are enabled. The defaults match the classic
7
+ oxie layout:
8
+
9
+ source/ markdown content (index.md, post/, page/, image/, static/)
10
+ src/ Jinja2 templates + meta_data.json + styles.css
11
+ docs/ generated output
12
+
13
+ All relative paths are resolved against the current working directory,
14
+ so a build is normally run from the site's root folder.
15
+ '''
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Dict, List, Optional, Sequence
19
+
20
+ DEFAULT_MARKDOWN_EXTENSIONS = [
21
+ 'pymdownx.tilde', 'pymdownx.emoji', 'tables', 'meta', 'footnotes',
22
+ 'md_in_html', 'extra',
23
+ 'pymdownx.arithmatex', 'pymdownx.highlight', 'pymdownx.superfences',
24
+ ]
25
+
26
+ DEFAULT_MARKDOWN_EXTENSION_CONFIGS = {
27
+ 'pymdownx.arithmatex': {
28
+ 'generic': True
29
+ },
30
+ 'pymdownx.highlight': {
31
+ 'use_pygments': True,
32
+ 'noclasses': False,
33
+ },
34
+ }
35
+
36
+
37
+ @dataclass
38
+ class SiteConfig:
39
+ # Core layout
40
+ source_dir: Path = Path('source')
41
+ template_dir: Path = Path('src')
42
+ output_dir: Path = Path('docs')
43
+ # Site metadata JSON (title, link, phrases, default image, ...)
44
+ meta_data_file: Path = Path('src/meta_data.json')
45
+
46
+ # Fall back to the templates bundled with oxie for any template the site
47
+ # does not define itself. Set False to fail loudly on a missing template.
48
+ use_bundled_templates: bool = True
49
+
50
+ # Markdown pipeline
51
+ markdown_extensions: List[str] = field(
52
+ default_factory=lambda: list(DEFAULT_MARKDOWN_EXTENSIONS))
53
+ markdown_extension_configs: Dict[str, dict] = field(
54
+ default_factory=lambda: dict(DEFAULT_MARKDOWN_EXTENSION_CONFIGS))
55
+
56
+ # Static assets: source directory -> output directory. None means the
57
+ # classic default: source/image -> docs/image, source/static -> docs/page.
58
+ collect_dirs: Optional[Dict[str, str]] = None
59
+
60
+ # Page titles excluded from the index page listing
61
+ index_excluded_titles: Sequence[str] = ('Terms of Service', 'Privacy Policy')
62
+
63
+ # Extra standalone pages rendered once with the site context:
64
+ # template file name -> output file name (relative to output_dir)
65
+ simple_pages: Dict[str, str] = field(default_factory=dict)
66
+
67
+ # Photography page: parses photos_md into albums and renders
68
+ # photography_template.html to photography.html
69
+ photography: bool = False
70
+ photos_md: Optional[Path] = None # default: source_dir/photo/photos.md
71
+ thumbnails: bool = False
72
+ thumbnail_dir: Optional[Path] = None # default: output_dir/image/photo
73
+ thumbnail_width: int = 600
74
+
75
+ # CSS pipeline: command run from the site root, or None to skip
76
+ css_build_command: Optional[Sequence[str]] = ('npm', 'run', 'build:css')
77
+ # Pygments highlight style written to output_dir/pygments.css, None to skip
78
+ pygments_style: Optional[str] = None
79
+
80
+ def __post_init__(self) -> None:
81
+ self.source_dir = Path(self.source_dir)
82
+ self.template_dir = Path(self.template_dir)
83
+ self.output_dir = Path(self.output_dir)
84
+ self.meta_data_file = Path(self.meta_data_file)
85
+ if self.collect_dirs is None:
86
+ self.collect_dirs = {
87
+ str(self.source_dir / 'image'): str(self.output_dir / 'image'),
88
+ str(self.source_dir / 'static'): str(self.output_dir / 'page'),
89
+ }
90
+ if self.photos_md is None:
91
+ self.photos_md = self.source_dir / 'photo' / 'photos.md'
92
+ if self.thumbnail_dir is None:
93
+ self.thumbnail_dir = self.output_dir / 'image' / 'photo'
oxie/content.py ADDED
@@ -0,0 +1,122 @@
1
+ '''
2
+ Content models for the oxie static site generator.
3
+
4
+ Post represents a single markdown document (a blog post or a static page)
5
+ and Category a per-category listing page. Both are created by a Site,
6
+ which supplies configuration, site metadata and templates.
7
+ '''
8
+ from pathlib import Path
9
+
10
+ import frontmatter
11
+
12
+
13
+ class Post:
14
+ '''
15
+ A blog post or page.
16
+ Posts and pages are the same, but they live in different directories
17
+ and appear in different areas of index.html.
18
+ '''
19
+
20
+ def __init__(self, md_path, html_path, site):
21
+ self.site = site
22
+ self.md_path = str(md_path)
23
+ self.html_path = str(html_path)
24
+ self.title = None
25
+ self.content = None
26
+ self.date = None
27
+ self.author = None
28
+ self.category = None
29
+ self.tags = []
30
+ self.read_time = None
31
+ self.link = None
32
+ self.summary = None
33
+ self.full_link = None
34
+ # Image is the cover image for the post, a path relative to the web
35
+ # root. The default comes from the site's meta_data.json.
36
+ self.image = site.meta_data["image"]
37
+ self.post_meta_data = None
38
+
39
+ def parse(self) -> None:
40
+ '''
41
+ Parse the markdown file and extract metadata and content
42
+ '''
43
+ with open(self.md_path, 'r') as md_file:
44
+ md_content = md_file.read()
45
+ post = frontmatter.loads(md_content)
46
+ self.post_meta_data = post.metadata
47
+ self.content = self.site.markdown().convert(post.content)
48
+
49
+ # link relative to the web root, and the full link with domain
50
+ self.link = '/' + Path(self.html_path).relative_to(
51
+ self.site.config.output_dir).as_posix()
52
+ self.full_link = self.site.meta_data["link"] + self.link
53
+
54
+ try:
55
+ self.title = self.post_meta_data["Title"]
56
+ self.author = self.post_meta_data["Authors"]
57
+ self.summary = self.post_meta_data["Summary"]
58
+ self.category = self.post_meta_data["Category"]
59
+ self.date = self.post_meta_data['Date']
60
+ # Last_modified is optional and defaults to the post date
61
+ self.last_modified = self.post_meta_data.get('Last_modified', self.date)
62
+ print(f"Processing {self.md_path} with last modified date {self.last_modified}")
63
+ self.tags = self.post_meta_data.get('Tags', [])
64
+ # cover image from the metadata, falling back to the default
65
+ self.image = self.post_meta_data.get("Image", self.image)
66
+ except KeyError:
67
+ print(f"Metadata not found in {self.md_path}")
68
+ pass
69
+
70
+ def render(self) -> None:
71
+ '''
72
+ Render the post to HTML
73
+ '''
74
+ rendered_html = self.site.template("template.html").render(
75
+ meta_data=self.site.meta_data,
76
+ post_meta_data=self.post_meta_data,
77
+ title=self.title,
78
+ author=self.author,
79
+ summary=self.summary,
80
+ category=self.category,
81
+ date=self.date,
82
+ last_modified=self.last_modified,
83
+ content=self.content,
84
+ phrases=self.site.meta_data["phrases"],
85
+ image=self.image,
86
+ tags=self.tags,
87
+ link=self.link,
88
+ )
89
+ with open(self.html_path, "w") as html_file:
90
+ html_file.write(rendered_html)
91
+
92
+
93
+ class Category:
94
+ '''
95
+ A category listing page, rendered once per category.
96
+ Also carries the list of all tags used within the category.
97
+ '''
98
+
99
+ def __init__(self, site, posts, category) -> None:
100
+ self.site = site
101
+ self.meta_data = site.meta_data
102
+ self.posts = posts
103
+ self.category = category
104
+ self.tags = [tag for post in self.posts for tag in post.tags]
105
+ self.link = f"blog_{category}.html"
106
+ self.count = len(self.posts)
107
+ self.last_post = self.posts[0]
108
+
109
+ def render(self) -> None:
110
+ '''
111
+ Render the category page
112
+ '''
113
+ rendered_html = self.site.template("category_template.html").render(
114
+ title=self.meta_data["title"],
115
+ phrases=self.meta_data["phrases"],
116
+ posts=self.posts,
117
+ category=self.category,
118
+ tags=self.tags
119
+ )
120
+ output_path = self.site.config.output_dir / f"blog_{self.category}.html"
121
+ with open(output_path, "w") as html_file:
122
+ html_file.write(rendered_html)
oxie/photos.py ADDED
@@ -0,0 +1,100 @@
1
+ '''
2
+ Photography support for the oxie static site generator: parsing a
3
+ photos.md album file and generating thumbnails for the photo gallery.
4
+ '''
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from PIL import Image
9
+
10
+
11
+ def resolve_photo_src(src: str, md_filepath: str, source_root) -> str:
12
+ '''
13
+ Resolve an image path written relative to the .md file into a path
14
+ relative to the web root (the output directory).
15
+ e.g. ../image/photo/file.jpg (relative to source/photo/) → image/photo/file.jpg
16
+ '''
17
+ md_dir = Path(md_filepath).parent
18
+ resolved = (md_dir / src).resolve()
19
+ return str(resolved.relative_to(Path(source_root).resolve()))
20
+
21
+
22
+ def parse_photos_md(filepath: str, source_root) -> list:
23
+ '''
24
+ Parse photos.md into a list of albums.
25
+ Each album has a name and a list of photos with src, alt, and description.
26
+
27
+ Supported format:
28
+ ## Album Name
29
+
30
+ ![alt text](image/path.jpg)
31
+ Optional description paragraph on the next line.
32
+ '''
33
+ albums = []
34
+ current_album = {'name': None, 'photos': []}
35
+ current_photo = None
36
+
37
+ with open(filepath, 'r') as f:
38
+ lines = f.readlines()
39
+
40
+ for line in lines:
41
+ line = line.rstrip('\n')
42
+
43
+ # Album heading
44
+ if line.startswith('## '):
45
+ if current_photo:
46
+ current_album['photos'].append(current_photo)
47
+ current_photo = None
48
+ if current_album['photos'] or current_album['name']:
49
+ albums.append(current_album)
50
+ current_album = {'name': line[3:].strip(), 'photos': []}
51
+
52
+ # Image line
53
+ elif line.startswith('!['):
54
+ if current_photo:
55
+ current_album['photos'].append(current_photo)
56
+ m = re.match(r'!\[([^\]]*)\]\(([^)]+)\)', line)
57
+ if m:
58
+ web_src = resolve_photo_src(m.group(2), filepath, source_root)
59
+ current_photo = {'alt': m.group(1), 'src': web_src, 'description': ''}
60
+
61
+ # Description: non-empty line after an image, not a heading or image itself
62
+ elif current_photo and line.strip() and not line.startswith('#') and not line.startswith('!['):
63
+ current_photo['description'] = line.strip()
64
+
65
+ # Blank line flushes the current photo
66
+ elif not line.strip() and current_photo:
67
+ current_album['photos'].append(current_photo)
68
+ current_photo = None
69
+
70
+ # Flush remaining
71
+ if current_photo:
72
+ current_album['photos'].append(current_photo)
73
+ if current_album['photos'] or current_album['name']:
74
+ albums.append(current_album)
75
+
76
+ return albums
77
+
78
+
79
+ def generate_thumbnails(photo_dir, thumb_width: int = 600) -> None:
80
+ '''
81
+ Generate thumbnails for all images in photo_dir.
82
+ Thumbnails are written to photo_dir/thumb/ at thumb_width pixels wide,
83
+ preserving aspect ratio. Skips non-image files.
84
+ '''
85
+ photo_path = Path(photo_dir)
86
+ thumb_path = photo_path / 'thumb'
87
+ thumb_path.mkdir(parents=True, exist_ok=True)
88
+
89
+ image_exts = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
90
+ for img_file in photo_path.iterdir():
91
+ if img_file.suffix.lower() not in image_exts:
92
+ continue
93
+ dest = thumb_path / img_file.name
94
+ with Image.open(img_file) as img:
95
+ img = img.convert('RGB')
96
+ ratio = thumb_width / img.width
97
+ new_size = (thumb_width, int(img.height * ratio))
98
+ thumb = img.resize(new_size, Image.LANCZOS)
99
+ thumb.save(dest, quality=80, optimize=True)
100
+ print(f"Thumbnails written to {thumb_path}")
oxie/scaffold.py ADDED
@@ -0,0 +1,236 @@
1
+ '''
2
+ Site scaffolding for oxie: create a complete, buildable site in one command.
3
+
4
+ `init_site()` writes the default templates, a Tailwind CSS v4 setup, sample
5
+ content and a `build.py`, so a new site goes from empty directory to built
6
+ HTML with `npm install && python build.py`.
7
+ '''
8
+ import json
9
+ import shutil
10
+ from datetime import date
11
+ from pathlib import Path
12
+ from typing import List
13
+
14
+ TEMPLATE_PACKAGE_DIR = Path(__file__).parent / "templates" / "default"
15
+
16
+ # Template files copied verbatim into the new site's template directory.
17
+ TEMPLATE_FILES = [
18
+ "base.html",
19
+ "index.html",
20
+ "template.html",
21
+ "blog_template.html",
22
+ "category_template.html",
23
+ ]
24
+
25
+ DEFAULT_PHRASES = [
26
+ "Well begun is half done.",
27
+ "Write drunk, edit sober.",
28
+ "A journey of a thousand miles begins with a single step.",
29
+ ]
30
+
31
+ BUILD_PY = '''\
32
+ """Build script for {title}.
33
+
34
+ Run with: python build.py
35
+ Requires: pip install -r requirements.txt && npm install
36
+ """
37
+ from oxie import Site, SiteConfig
38
+
39
+ config = SiteConfig(
40
+ # source/, src/ and docs/ are the defaults; override them if you move things.
41
+ collect_dirs={{
42
+ # Everything in source/image is copied to docs/image ...
43
+ "source/image": "docs/image",
44
+ # ... and source/static lands at the site root, next to index.html.
45
+ "source/static": "docs",
46
+ }},
47
+ # Syntax highlighting stylesheet written to docs/pygments.css.
48
+ pygments_style="github-dark",
49
+ # Compiles src/styles.css into docs/styles.css with Tailwind.
50
+ # Set to None if you want to manage CSS yourself.
51
+ css_build_command=("npm", "run", "build:css"),
52
+ )
53
+
54
+ if __name__ == "__main__":
55
+ Site(config).build()
56
+ '''
57
+
58
+ PACKAGE_JSON = {
59
+ "name": "site",
60
+ "private": True,
61
+ "scripts": {
62
+ "build:css": "tailwindcss -i src/styles.css -o docs/styles.css --minify",
63
+ "watch:css": "tailwindcss -i src/styles.css -o docs/styles.css --watch",
64
+ },
65
+ "devDependencies": {
66
+ "@tailwindcss/cli": "^4.3.3",
67
+ "@tailwindcss/typography": "^0.5.20",
68
+ },
69
+ }
70
+
71
+ REQUIREMENTS_TXT = "oxie==0.3.1\n"
72
+
73
+ GITIGNORE = """\
74
+ __pycache__/
75
+ *.pyc
76
+ .venv/
77
+ node_modules/
78
+ """
79
+
80
+ INDEX_MD = '''\
81
+ Title: {title}
82
+ Summary: {description}
83
+ Authors: {author}
84
+ Date: {today}
85
+
86
+ ## Hello
87
+
88
+ This is your home page. It is written in `source/index.md`.
89
+
90
+ Unlike posts, this file uses simple `Key: value` lines at the top instead of
91
+ `---` fenced frontmatter — that is the one format asymmetry in oxie.
92
+
93
+ Edit it, run `python build.py`, and open `docs/index.html`.
94
+ '''
95
+
96
+ POST_MD = '''\
97
+ ---
98
+ Title: Hello World
99
+ Summary: The first post on {title} — how posts work in oxie.
100
+ Authors: {author}
101
+ Date: {today}
102
+ Category: Blog
103
+ Tags: [oxie, writing]
104
+ ---
105
+
106
+ # Hello World
107
+
108
+ Every file in `source/post/` becomes a post. The frontmatter above uses
109
+ **capitalised keys** (`Title`, `Authors`, `Date`, `Category`, `Tags`), which
110
+ oxie reads to build the blog index and per-category pages.
111
+
112
+ ## Things that work out of the box
113
+
114
+ - Tables, footnotes[^1] and `~~strikethrough~~`
115
+ - Fenced code with syntax highlighting:
116
+
117
+ ```python
118
+ from oxie import Site, SiteConfig
119
+
120
+ Site(SiteConfig()).build()
121
+ ```
122
+
123
+ - Maths, via `pymdownx.arithmatex`
124
+ - Category pages: this post is in *Blog*, so it appears on `blog_Blog.html`
125
+
126
+ [^1]: Footnotes look like this.
127
+
128
+ ## Next steps
129
+
130
+ Delete this file, write your own, and rebuild.
131
+ '''
132
+
133
+ ABOUT_MD = '''\
134
+ ---
135
+ Title: About
136
+ Summary: About {title}.
137
+ Authors: {author}
138
+ Date: {today}
139
+ Category: Page
140
+ Tags: [about]
141
+ ---
142
+
143
+ # About
144
+
145
+ Files in `source/page/` are pages rather than posts: they are listed on the
146
+ home page but stay out of the blog index.
147
+
148
+ Tell your readers who you are here.
149
+ '''
150
+
151
+
152
+ def _write(path: Path, content: str, force: bool, created: List[str], skipped: List[str]) -> None:
153
+ '''Write content to path unless it exists and force is False.'''
154
+ if path.exists() and not force:
155
+ skipped.append(str(path))
156
+ return
157
+ path.parent.mkdir(parents=True, exist_ok=True)
158
+ path.write_text(content)
159
+ created.append(str(path))
160
+
161
+
162
+ def init_site(
163
+ target: str = ".",
164
+ title: str = "My Site",
165
+ author: str = "",
166
+ link: str = "https://example.com",
167
+ description: str = "A site built with oxie.",
168
+ force: bool = False,
169
+ ) -> dict:
170
+ '''
171
+ Create a new oxie site in `target`.
172
+
173
+ Existing files are left alone unless `force` is True, so running this in
174
+ a directory that already has content is safe.
175
+
176
+ Returns a dict with 'created' and 'skipped' file lists.
177
+ '''
178
+ root = Path(target)
179
+ created: List[str] = []
180
+ skipped: List[str] = []
181
+ today = date.today().isoformat()
182
+ fields = {
183
+ "title": title,
184
+ "author": author or "Anonymous",
185
+ "today": today,
186
+ "description": description,
187
+ }
188
+
189
+ # Content directories, including the ones the build copies verbatim
190
+ for sub in ("source/post", "source/page", "source/image", "source/static", "docs"):
191
+ (root / sub).mkdir(parents=True, exist_ok=True)
192
+
193
+ # Jinja templates
194
+ for name in TEMPLATE_FILES:
195
+ destination = root / "src" / name
196
+ if destination.exists() and not force:
197
+ skipped.append(str(destination))
198
+ continue
199
+ destination.parent.mkdir(parents=True, exist_ok=True)
200
+ shutil.copyfile(TEMPLATE_PACKAGE_DIR / name, destination)
201
+ created.append(str(destination))
202
+
203
+ # Tailwind v4 entry point
204
+ _write(root / "src" / "styles.css",
205
+ (TEMPLATE_PACKAGE_DIR / "styles.css").read_text(), force, created, skipped)
206
+
207
+ # Site metadata
208
+ meta_data = {
209
+ "title": title,
210
+ "description": description,
211
+ "author": author or "Anonymous",
212
+ "date": today,
213
+ "link": link.rstrip("/"),
214
+ "image": "image/default.png",
215
+ "phrases": DEFAULT_PHRASES,
216
+ }
217
+ _write(root / "src" / "meta_data.json",
218
+ json.dumps(meta_data, indent=4, ensure_ascii=False) + "\n",
219
+ force, created, skipped)
220
+
221
+ # Content
222
+ _write(root / "source" / "index.md", INDEX_MD.format(**fields), force, created, skipped)
223
+ _write(root / "source" / "post" / "hello-world.md", POST_MD.format(**fields), force, created, skipped)
224
+ _write(root / "source" / "page" / "about.md", ABOUT_MD.format(**fields), force, created, skipped)
225
+
226
+ # Build script and project files
227
+ _write(root / "build.py", BUILD_PY.format(title=title), force, created, skipped)
228
+ package_json = dict(PACKAGE_JSON)
229
+ package_json["name"] = "".join(
230
+ c if c.isalnum() or c in "-_" else "-" for c in title.lower()
231
+ ).strip("-") or "site"
232
+ _write(root / "package.json", json.dumps(package_json, indent=2) + "\n", force, created, skipped)
233
+ _write(root / "requirements.txt", REQUIREMENTS_TXT, force, created, skipped)
234
+ _write(root / ".gitignore", GITIGNORE, force, created, skipped)
235
+
236
+ return {"created": created, "skipped": skipped, "root": str(root)}