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 +28 -0
- oxie/__main__.py +7 -0
- oxie/cli.py +83 -0
- oxie/config.py +93 -0
- oxie/content.py +122 -0
- oxie/photos.py +100 -0
- oxie/scaffold.py +236 -0
- oxie/site.py +408 -0
- oxie/templates/default/base.html +46 -0
- oxie/templates/default/blog_template.html +48 -0
- oxie/templates/default/category_template.html +35 -0
- oxie/templates/default/index.html +58 -0
- oxie/templates/default/styles.css +20 -0
- oxie/templates/default/template.html +48 -0
- oxie/updates.py +198 -0
- oxie-0.3.1.dist-info/METADATA +228 -0
- oxie-0.3.1.dist-info/RECORD +21 -0
- oxie-0.3.1.dist-info/WHEEL +5 -0
- oxie-0.3.1.dist-info/entry_points.txt +2 -0
- oxie-0.3.1.dist-info/licenses/LICENSE +201 -0
- oxie-0.3.1.dist-info/top_level.txt +1 -0
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
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
|
+

|
|
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)}
|