Compare commits
12 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c661f3ea07 | |||
| 0cbd56b689 | |||
| 8e91571121 | |||
| c3ef992a89 | |||
| 7a8ecafc3a | |||
| 5b6edac236 | |||
| cdbdc7e392 | |||
| a99d1db1cb | |||
| 98ab0ab419 | |||
| a08a01517d | |||
| 8a966ec072 | |||
| e31624d03a |
11 changed files with 585 additions and 108 deletions
17
Dockerfile
17
Dockerfile
|
|
@ -15,6 +15,16 @@ RUN python3 -m pip install --no-cache-dir -r /tmp/requirements.txt --break-syste
|
||||||
# Copy Python script to /usr/local/bin so it survives volume mounts
|
# Copy Python script to /usr/local/bin so it survives volume mounts
|
||||||
COPY picopaper.py /usr/local/bin/picopaper.py
|
COPY picopaper.py /usr/local/bin/picopaper.py
|
||||||
|
|
||||||
|
# Bake the default blog content into the image. The `new` command copies these
|
||||||
|
# into the working directory to scaffold a fresh blog without cloning the repo.
|
||||||
|
# Only user content is included — not picopaper.py/requirements.txt/.gitignore.
|
||||||
|
ENV PICOPAPER_SKELETON=/usr/local/share/picopaper/skeleton
|
||||||
|
COPY config.py /usr/local/share/picopaper/skeleton/
|
||||||
|
COPY theme/ /usr/local/share/picopaper/skeleton/theme/
|
||||||
|
COPY items/ /usr/local/share/picopaper/skeleton/items/
|
||||||
|
COPY images/ /usr/local/share/picopaper/skeleton/images/
|
||||||
|
COPY static/ /usr/local/share/picopaper/skeleton/static/
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
@ -24,5 +34,8 @@ ENV PYTHONPATH=/app
|
||||||
# Run as root to allow writing to mounted volumes
|
# Run as root to allow writing to mounted volumes
|
||||||
# (The mounted volume will have host user permissions)
|
# (The mounted volume will have host user permissions)
|
||||||
|
|
||||||
# Generate the site on container start
|
# With no command the site is generated; pass `new` to scaffold a blog instead:
|
||||||
CMD ["python3", "/usr/local/bin/picopaper.py"]
|
# podman run ... picopaper:latest -> build the site (default)
|
||||||
|
# podman run ... picopaper:latest new -> create a new blog in the current dir
|
||||||
|
ENTRYPOINT ["python3", "/usr/local/bin/picopaper.py"]
|
||||||
|
CMD []
|
||||||
|
|
|
||||||
58
README.md
58
README.md
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
A minimal static site generator for blogs built with Python 3 and Jinja2
|
A minimal static site generator for blogs built with Python 3 and Jinja2
|
||||||
|
|
||||||
- Status: alpha - expect many changes
|
- Status: beta - expect many changes
|
||||||
- [Issue Tracker](https://git.uphillsecurity.com/cf7/picopaper/issues)
|
- [Issue Tracker](https://git.uphillsecurity.com/cf7/picopaper/issues)
|
||||||
- Goals: keeping it simple and easy to understand
|
- Goals: keeping it simple and easy to understand
|
||||||
- Demo: [picopaper.com](https://picopaper.com/)
|
- Demo: [picopaper.com](https://picopaper.com/)
|
||||||
|
|
@ -17,21 +17,20 @@ Show cases:
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
**Available**:
|
**Available**:
|
||||||
- Simple use, easy to understand and modify
|
- simple use, easy to understand and modify
|
||||||
- config file for settings
|
- config file for settings
|
||||||
- Themes
|
- Themes
|
||||||
- Long- and short form content
|
- long- and short form content
|
||||||
- Pages
|
- pages
|
||||||
- Static files
|
- static files
|
||||||
- separate feeds (used for categories, tagging, etc) `/feed/{tag}`
|
- separate sections (used for categories, tagging, etc) `/section/{tag}`
|
||||||
- exclusion of feeds from main feed (drafts or system notes)
|
- exclusion of sections from main index (drafts or system notes)
|
||||||
- HTML anchors for headers
|
- HTML anchors for headers
|
||||||
- list random posts at the bottom
|
- list random posts at the bottom
|
||||||
|
- optional RSS feeds
|
||||||
|
- automatic EXIF/metadata stripping from images (privacy)
|
||||||
|
|
||||||
**Ideas**:
|
**Ideas**:
|
||||||
- RSS
|
|
||||||
- Dark mode
|
|
||||||
- logo
|
|
||||||
- custom error pages (404, etc)
|
- custom error pages (404, etc)
|
||||||
|
|
||||||
**Not planned**:
|
**Not planned**:
|
||||||
|
|
@ -49,33 +48,52 @@ Put markdown file into `items` dir. **Important naming convention**:
|
||||||
2025-10-05_short_quick-update_draft.md
|
2025-10-05_short_quick-update_draft.md
|
||||||
```
|
```
|
||||||
|
|
||||||
Format: `YYYY-MM-DD_type_slug[_feed].md`
|
Format: `YYYY-MM-DD_type_slug[_section].md`
|
||||||
|
|
||||||
- `2025-10-03` - date of the article
|
- `2025-10-03` - date of the article
|
||||||
- `_long_` - type of content: `long`, `short`, or `page`
|
- `_long_` - type of content: `long`, `short`, or `page`
|
||||||
- `building-a-static-site-generator` - slug/path for the URL
|
- `building-a-static-site-generator` - slug/path for the URL
|
||||||
- `_draft` (optional) - feed tag for categorization
|
- `_draft` (optional) - section tag for categorization
|
||||||
|
|
||||||
The first `#` header is the title of the article - no frontmatter needed.
|
The first `#` header is the title of the article - no frontmatter needed.
|
||||||
|
|
||||||
**Types of content**:
|
**Types of content**:
|
||||||
- `long` - only title with link to articles will be displayed in feed
|
- `long` - only title with link to articles will be displayed in the main index
|
||||||
- `short` - title and all content will be displayed
|
- `short` - title and all content will be displayed
|
||||||
- `page` - won't be displayed in feed at all
|
- `page` - won't be displayed in the main index at all
|
||||||
|
|
||||||
### Feeds
|
### Sections
|
||||||
|
|
||||||
Posts can be tagged with an optional feed category (e.g., `_python`, `_webdev`). Posts with feed tags:
|
Posts can be tagged with an optional section (e.g., `_python`, `_webdev`). Posts with section tags:
|
||||||
- Appear on the main page (unless excluded in config)
|
- Appear on the main page (unless excluded in config)
|
||||||
- Have their own feed page at `/feed/{tag}/`
|
- Have their own section page at `/section/{tag}/`
|
||||||
|
|
||||||
**Configuration in `config.py`:**
|
**Configuration in `config.py`:**
|
||||||
```python
|
```python
|
||||||
# Exclude specific feeds from main page (they'll still have /feed/name/ pages)
|
# Exclude specific sections from main page (they'll still have /section/name/ pages)
|
||||||
EXCLUDE_FEEDS_FROM_MAIN = ['draft', 'private']
|
EXCLUDE_SECTIONS_FROM_MAIN = ['draft', 'private']
|
||||||
```
|
```
|
||||||
|
|
||||||
This is useful for draft posts or topic-specific content you want separated from the main feed.
|
This is useful for draft posts or topic-specific content you want separated from the main index.
|
||||||
|
|
||||||
|
### Images
|
||||||
|
|
||||||
|
Images placed in `images/` are copied to `output/images/`. By default picopaper
|
||||||
|
strips metadata (EXIF, GPS location, XMP, PNG text chunks) from them during the
|
||||||
|
build so nothing like camera model or GPS coordinates is published. The source
|
||||||
|
files in `images/` are never modified — only the copies in `output/`.
|
||||||
|
|
||||||
|
JPEGs are re-encoded with their original quantization tables (`quality="keep"`),
|
||||||
|
so there is no quality loss. Animated images (GIF/WebP) and unsupported formats
|
||||||
|
(e.g. SVG) are copied as-is.
|
||||||
|
|
||||||
|
**Configuration in `config.py`:**
|
||||||
|
```python
|
||||||
|
STRIP_IMAGE_EXIF = True # set to False to copy images byte-for-byte
|
||||||
|
```
|
||||||
|
|
||||||
|
Metadata stripping uses [Pillow](https://python-pillow.org/). If Pillow is not
|
||||||
|
installed, picopaper prints a warning and copies images unchanged.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
34
config.py
34
config.py
|
|
@ -1,21 +1,45 @@
|
||||||
"""Configuration file for picopaper blog"""
|
"""Configuration file for picopaper blog"""
|
||||||
|
|
||||||
|
# General site settings
|
||||||
BLOG_TITLE = "PicoPaper.com"
|
BLOG_TITLE = "PicoPaper.com"
|
||||||
BLOG_DESCRIPTION = "we like simple."
|
BLOG_DESCRIPTION = "we like simple."
|
||||||
|
BASE_URL = "https://picopaper.com" # Your site's base URL (no trailing slash)
|
||||||
|
AUTHOR_NAME = "PicoPaper"
|
||||||
|
AUTHOR_EMAIL = "hello@picopaper.com" # Optional
|
||||||
THEME = "default"
|
THEME = "default"
|
||||||
|
|
||||||
# Exclude specific feeds from the main page (they'll still have their own /feed/name/ pages)
|
# Exclude specific sections from the main page (they'll still have their own /section/name/ pages)
|
||||||
EXCLUDE_FEEDS_FROM_MAIN = ['draft','private'] # e.g., ['python', 'drafts']
|
EXCLUDE_SECTIONS_FROM_MAIN = ['draft','private'] # e.g., ['python', 'drafts']
|
||||||
|
|
||||||
# Navigation bar items - list of dictionaries with 'text' and 'url' keys
|
# Navigation bar items - list of dictionaries with 'text' and 'url' keys.
|
||||||
|
# Add a 'children' list to turn an item into a one-level dropdown menu.
|
||||||
|
# A dropdown parent is a clickable link if it has a 'url'; omit 'url' to make
|
||||||
|
# it a label that only opens the menu.
|
||||||
NAVBAR_ITEMS = [
|
NAVBAR_ITEMS = [
|
||||||
{'text': 'Home', 'url': '/'},
|
{'text': 'Home', 'url': '/'},
|
||||||
{'text': 'Feeds', 'url': '/feed/'},
|
{'text': 'Sections', 'url': '/section/'},
|
||||||
{'text': 'About', 'url': '/about/'}
|
{'text': 'Projects', 'url': '/projects/', 'children': [
|
||||||
|
{'text': 'All Projects', 'url': '/projects/'},
|
||||||
|
{'text': 'Another Project', 'url': '/projects/another-project/'},
|
||||||
|
]},
|
||||||
|
{'text': 'About', 'url': '/about/'},
|
||||||
|
{'text': 'RSS', 'url': '/rss.xml'}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Path settings
|
||||||
|
BLOGROLL_PATH = "" # Path for the blog roll, e.g. "" (root) or "articles"
|
||||||
|
ROOT_PAGE = "" # Slug of a page item to use as root index, e.g. "home" (leave empty to use blogroll) - example "home" for "items/2026-01-01_page_home.md"
|
||||||
|
|
||||||
# Logo settings
|
# Logo settings
|
||||||
HIDE_LOGO = False
|
HIDE_LOGO = False
|
||||||
HIDE_TITLE = True
|
HIDE_TITLE = True
|
||||||
LOGO_PATH = "/images/logo.png"
|
LOGO_PATH = "/images/logo.png"
|
||||||
|
|
||||||
|
# Image settings
|
||||||
|
STRIP_IMAGE_EXIF = True # Remove EXIF/GPS/XMP/text metadata from images/ when building (requires pillow)
|
||||||
|
|
||||||
|
# Feed settings
|
||||||
|
ENABLE_RSS_FEED = True
|
||||||
|
RSS_FEED_PATH = "rss.xml" # Path relative to site root (e.g., "rss.xml" or "feed/rss.xml")
|
||||||
|
FEED_MAX_ITEMS = 20 # Maximum number of items to include in feeds
|
||||||
|
|
||||||
|
|
|
||||||
3
items/projects/2025-10-27_page_another-project.md
Normal file
3
items/projects/2025-10-27_page_another-project.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Another Project
|
||||||
|
|
||||||
|
Just a placeholer.
|
||||||
487
picopaper.py
487
picopaper.py
|
|
@ -2,11 +2,35 @@
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
import markdown
|
import markdown
|
||||||
from config import BLOG_TITLE, BLOG_DESCRIPTION, THEME, EXCLUDE_FEEDS_FROM_MAIN, NAVBAR_ITEMS, HIDE_LOGO, HIDE_TITLE, LOGO_PATH
|
# Blog configuration lives in config.py in the working directory. It is absent
|
||||||
|
# when scaffolding a new blog into an empty directory (the `new` command), so a
|
||||||
|
# missing config.py must not stop this module from loading — the build command
|
||||||
|
# checks CONFIG_AVAILABLE below and reports it clearly.
|
||||||
|
try:
|
||||||
|
from config import (BLOG_TITLE, BLOG_DESCRIPTION, THEME, EXCLUDE_SECTIONS_FROM_MAIN,
|
||||||
|
NAVBAR_ITEMS, HIDE_LOGO, HIDE_TITLE, LOGO_PATH,
|
||||||
|
ENABLE_RSS_FEED, RSS_FEED_PATH,
|
||||||
|
BASE_URL, AUTHOR_NAME, AUTHOR_EMAIL, FEED_MAX_ITEMS,
|
||||||
|
BLOGROLL_PATH, ROOT_PAGE)
|
||||||
|
CONFIG_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
CONFIG_AVAILABLE = False
|
||||||
|
|
||||||
|
# STRIP_IMAGE_EXIF is a newer setting; older config.py files may not define it.
|
||||||
|
# Default to enabled so the privacy feature works without a config update.
|
||||||
|
if CONFIG_AVAILABLE:
|
||||||
|
try:
|
||||||
|
from config import STRIP_IMAGE_EXIF
|
||||||
|
except ImportError:
|
||||||
|
STRIP_IMAGE_EXIF = True
|
||||||
|
|
||||||
|
# Raster image formats whose metadata (EXIF/GPS/XMP/text chunks) is stripped on copy
|
||||||
|
STRIP_IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.tiff', '.tif', '.bmp'}
|
||||||
|
|
||||||
class SSGGGenerator:
|
class SSGGGenerator:
|
||||||
def __init__(self, items_dir='items', output_dir='output', theme=None, blog_title=None, blog_description=None):
|
def __init__(self, items_dir='items', output_dir='output', theme=None, blog_title=None, blog_description=None):
|
||||||
|
|
@ -18,11 +42,15 @@ class SSGGGenerator:
|
||||||
self.assets_dir = self.theme_dir / 'assets'
|
self.assets_dir = self.theme_dir / 'assets'
|
||||||
self.blog_title = blog_title or BLOG_TITLE
|
self.blog_title = blog_title or BLOG_TITLE
|
||||||
self.blog_description = blog_description or BLOG_DESCRIPTION
|
self.blog_description = blog_description or BLOG_DESCRIPTION
|
||||||
self.exclude_feeds = EXCLUDE_FEEDS_FROM_MAIN
|
self.exclude_sections = EXCLUDE_SECTIONS_FROM_MAIN
|
||||||
|
self.blogroll_path = BLOGROLL_PATH.strip('/')
|
||||||
|
self.root_page = ROOT_PAGE
|
||||||
self.navbar_items = NAVBAR_ITEMS
|
self.navbar_items = NAVBAR_ITEMS
|
||||||
self.hide_logo = HIDE_LOGO
|
self.hide_logo = HIDE_LOGO
|
||||||
self.hide_title = HIDE_TITLE
|
self.hide_title = HIDE_TITLE
|
||||||
self.logo_path = LOGO_PATH
|
self.logo_path = LOGO_PATH
|
||||||
|
self.strip_image_exif = STRIP_IMAGE_EXIF
|
||||||
|
self.base_url = BASE_URL.rstrip('/')
|
||||||
|
|
||||||
# Setup Jinja2
|
# Setup Jinja2
|
||||||
self.env = Environment(loader=FileSystemLoader(self.templates_dir))
|
self.env = Environment(loader=FileSystemLoader(self.templates_dir))
|
||||||
|
|
@ -38,15 +66,20 @@ class SSGGGenerator:
|
||||||
# Setup markdown with toc extension for header anchors
|
# Setup markdown with toc extension for header anchors
|
||||||
self.md = markdown.Markdown(extensions=['extra', 'toc'])
|
self.md = markdown.Markdown(extensions=['extra', 'toc'])
|
||||||
|
|
||||||
def parse_filename(self, filename):
|
def parse_filename(self, filename, subpath=''):
|
||||||
"""Parse filename format: YYYY-MM-DD_type_name[_feed].md"""
|
"""Parse filename format: YYYY-MM-DD_type_name[_section].md
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: The markdown filename
|
||||||
|
subpath: Optional subdirectory path (e.g., 'notes' for items/notes/)
|
||||||
|
"""
|
||||||
pattern = r'(\d{4}-\d{2}-\d{2})_(short|long|page)_(.+?)(?:_([a-z0-9-]+))?\.md'
|
pattern = r'(\d{4}-\d{2}-\d{2})_(short|long|page)_(.+?)(?:_([a-z0-9-]+))?\.md'
|
||||||
match = re.match(pattern, filename)
|
match = re.match(pattern, filename)
|
||||||
|
|
||||||
if not match:
|
if not match:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
date_str, post_type, name, feed = match.groups()
|
date_str, post_type, name, section = match.groups()
|
||||||
date = datetime.strptime(date_str, '%Y-%m-%d')
|
date = datetime.strptime(date_str, '%Y-%m-%d')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -54,8 +87,9 @@ class SSGGGenerator:
|
||||||
'date_str': date.strftime('%Y-%m-%d'),
|
'date_str': date.strftime('%Y-%m-%d'),
|
||||||
'type': post_type,
|
'type': post_type,
|
||||||
'name': name,
|
'name': name,
|
||||||
'feed': feed,
|
'section': section,
|
||||||
'filename': filename
|
'filename': filename,
|
||||||
|
'subpath': subpath
|
||||||
}
|
}
|
||||||
|
|
||||||
def add_header_anchors(self, html_content):
|
def add_header_anchors(self, html_content):
|
||||||
|
|
@ -94,32 +128,46 @@ class SSGGGenerator:
|
||||||
return title, html_content
|
return title, html_content
|
||||||
|
|
||||||
def collect_posts(self):
|
def collect_posts(self):
|
||||||
"""Collect and parse all posts from items directory"""
|
"""Collect and parse all posts from items directory, including subdirectories"""
|
||||||
posts = []
|
posts = []
|
||||||
|
|
||||||
if not self.items_dir.exists():
|
if not self.items_dir.exists():
|
||||||
print(f"Warning: {self.items_dir} does not exist")
|
print(f"Warning: {self.items_dir} does not exist")
|
||||||
return posts
|
return posts
|
||||||
|
|
||||||
for filepath in self.items_dir.glob('*.md'):
|
# Use rglob to recursively find all .md files
|
||||||
parsed = self.parse_filename(filepath.name)
|
for filepath in self.items_dir.rglob('*.md'):
|
||||||
|
# Calculate subpath relative to items_dir
|
||||||
|
relative_path = filepath.relative_to(self.items_dir)
|
||||||
|
subpath = str(relative_path.parent) if relative_path.parent != Path('.') else ''
|
||||||
|
|
||||||
|
parsed = self.parse_filename(filepath.name, subpath)
|
||||||
|
|
||||||
if not parsed:
|
if not parsed:
|
||||||
print(f"Skipping {filepath.name}: doesn't match naming convention")
|
print(f"Skipping {filepath}: doesn't match naming convention")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
title, content = self.read_post(filepath)
|
title, content = self.read_post(filepath)
|
||||||
|
|
||||||
|
# Build slug and URL with subpath
|
||||||
|
if parsed['subpath']:
|
||||||
|
slug = f"{parsed['subpath']}/{parsed['name']}"
|
||||||
|
url = f"/{parsed['subpath']}/{parsed['name']}/"
|
||||||
|
else:
|
||||||
|
slug = parsed['name']
|
||||||
|
url = f"/{parsed['name']}/"
|
||||||
|
|
||||||
post = {
|
post = {
|
||||||
'date': parsed['date_str'],
|
'date': parsed['date_str'],
|
||||||
'type': parsed['type'],
|
'type': parsed['type'],
|
||||||
'name': parsed['name'],
|
'name': parsed['name'],
|
||||||
'title': title,
|
'title': title,
|
||||||
'content': content,
|
'content': content,
|
||||||
'slug': parsed['name'],
|
'slug': slug,
|
||||||
'url': f"/{parsed['name']}/",
|
'url': url,
|
||||||
'feed': parsed['feed'],
|
'section': parsed['section'],
|
||||||
'source': filepath.name
|
'source': str(relative_path),
|
||||||
|
'subpath': parsed['subpath']
|
||||||
}
|
}
|
||||||
|
|
||||||
posts.append(post)
|
posts.append(post)
|
||||||
|
|
@ -129,27 +177,49 @@ class SSGGGenerator:
|
||||||
|
|
||||||
return posts
|
return posts
|
||||||
|
|
||||||
def generate_index(self, posts, feed_name=None, all_posts=None):
|
def common_context(self, page_url):
|
||||||
"""Generate index.html with all posts (or feed-specific index)"""
|
"""Template variables shared by every page.
|
||||||
|
|
||||||
|
page_url is the current page's site-relative path (leading slash).
|
||||||
|
Templates can build absolute links from it, e.g. a share button:
|
||||||
|
{{ base_url }}{{ page_url }} or {{ page_absolute_url }}
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'blog_title': self.blog_title,
|
||||||
|
'blog_description': self.blog_description,
|
||||||
|
'navbar_items': self.navbar_items,
|
||||||
|
'hide_logo': self.hide_logo,
|
||||||
|
'hide_title': self.hide_title,
|
||||||
|
'logo_path': self.logo_path,
|
||||||
|
'rss_feed_enabled': ENABLE_RSS_FEED,
|
||||||
|
'rss_feed_path': RSS_FEED_PATH,
|
||||||
|
'base_url': self.base_url,
|
||||||
|
'page_url': page_url,
|
||||||
|
'page_absolute_url': f"{self.base_url}{page_url}",
|
||||||
|
}
|
||||||
|
|
||||||
|
def generate_index(self, posts, section_name=None, all_posts=None):
|
||||||
|
"""Generate index.html with all posts (or section-specific index)"""
|
||||||
template = self.env.get_template('index.tmpl')
|
template = self.env.get_template('index.tmpl')
|
||||||
|
|
||||||
if feed_name:
|
if section_name:
|
||||||
title = f"{feed_name} - {self.blog_title}"
|
title = f"{section_name} - {self.blog_title}"
|
||||||
output_path = self.output_dir / 'feed' / feed_name / 'index.html'
|
output_path = self.output_dir / 'section' / section_name / 'index.html'
|
||||||
|
page_url = f"/section/{section_name}/"
|
||||||
|
elif self.blogroll_path:
|
||||||
|
title = self.blog_title
|
||||||
|
output_path = self.output_dir / self.blogroll_path / 'index.html'
|
||||||
|
page_url = f"/{self.blogroll_path}/"
|
||||||
else:
|
else:
|
||||||
title = self.blog_title
|
title = self.blog_title
|
||||||
output_path = self.output_dir / 'index.html'
|
output_path = self.output_dir / 'index.html'
|
||||||
|
page_url = "/"
|
||||||
|
|
||||||
html = template.render(
|
html = template.render(
|
||||||
title=title,
|
title=title,
|
||||||
blog_title=self.blog_title,
|
|
||||||
blog_description=self.blog_description,
|
|
||||||
navbar_items=self.navbar_items,
|
|
||||||
posts=posts,
|
posts=posts,
|
||||||
all_posts=all_posts or posts,
|
all_posts=all_posts or posts,
|
||||||
hide_logo=self.hide_logo,
|
**self.common_context(page_url)
|
||||||
hide_title=self.hide_title,
|
|
||||||
logo_path=self.logo_path
|
|
||||||
)
|
)
|
||||||
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -158,32 +228,49 @@ class SSGGGenerator:
|
||||||
|
|
||||||
print(f"✓ Generated {output_path}")
|
print(f"✓ Generated {output_path}")
|
||||||
|
|
||||||
def generate_feeds_overview(self, feeds, all_posts=None):
|
def generate_sections_overview(self, sections, all_posts=None):
|
||||||
"""Generate /feed/index.html with list of all non-excluded feeds"""
|
"""Generate /section/index.html with list of all non-excluded sections"""
|
||||||
template = self.env.get_template('feeds.tmpl')
|
template = self.env.get_template('sections.tmpl')
|
||||||
|
|
||||||
# Prepare feed data with counts, excluding feeds in EXCLUDE_FEEDS_FROM_MAIN
|
# Prepare section data with counts, excluding sections in EXCLUDE_SECTIONS_FROM_MAIN
|
||||||
feed_list = []
|
section_list = []
|
||||||
for feed_name, posts in sorted(feeds.items()):
|
for section_name, posts in sorted(sections.items()):
|
||||||
if feed_name not in self.exclude_feeds:
|
if section_name not in self.exclude_sections:
|
||||||
feed_list.append({
|
section_list.append({
|
||||||
'name': feed_name,
|
'name': section_name,
|
||||||
'count': len(posts)
|
'count': len(posts)
|
||||||
})
|
})
|
||||||
|
|
||||||
title = f"Feeds - {self.blog_title}"
|
title = f"Sections - {self.blog_title}"
|
||||||
output_path = self.output_dir / 'feed' / 'index.html'
|
output_path = self.output_dir / 'section' / 'index.html'
|
||||||
|
|
||||||
html = template.render(
|
html = template.render(
|
||||||
title=title,
|
title=title,
|
||||||
blog_title=self.blog_title,
|
sections=section_list,
|
||||||
blog_description=self.blog_description,
|
|
||||||
navbar_items=self.navbar_items,
|
|
||||||
feeds=feed_list,
|
|
||||||
all_posts=all_posts or [],
|
all_posts=all_posts or [],
|
||||||
hide_logo=self.hide_logo,
|
**self.common_context("/section/")
|
||||||
hide_title=self.hide_title,
|
)
|
||||||
logo_path=self.logo_path
|
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(output_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(html)
|
||||||
|
|
||||||
|
print(f"✓ Generated {output_path}")
|
||||||
|
|
||||||
|
def generate_subdir_index(self, subpath, posts, all_posts=None):
|
||||||
|
"""Generate index page for a subdirectory (e.g., /projects/)"""
|
||||||
|
template = self.env.get_template('index.tmpl')
|
||||||
|
|
||||||
|
# Use the subpath as the title (capitalize first letter)
|
||||||
|
subpath_title = subpath.replace('/', ' / ').title()
|
||||||
|
title = f"{subpath_title} - {self.blog_title}"
|
||||||
|
output_path = self.output_dir / subpath / 'index.html'
|
||||||
|
|
||||||
|
html = template.render(
|
||||||
|
title=title,
|
||||||
|
posts=posts,
|
||||||
|
all_posts=all_posts or posts,
|
||||||
|
**self.common_context(f"/{subpath}/")
|
||||||
)
|
)
|
||||||
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -198,19 +285,14 @@ class SSGGGenerator:
|
||||||
|
|
||||||
html = template.render(
|
html = template.render(
|
||||||
title=f"{post['title']} - {self.blog_title}",
|
title=f"{post['title']} - {self.blog_title}",
|
||||||
blog_title=self.blog_title,
|
|
||||||
blog_description=self.blog_description,
|
|
||||||
navbar_items=self.navbar_items,
|
|
||||||
post=post,
|
post=post,
|
||||||
all_posts=all_posts or [],
|
all_posts=all_posts or [],
|
||||||
hide_logo=self.hide_logo,
|
**self.common_context(post['url'])
|
||||||
hide_title=self.hide_title,
|
|
||||||
logo_path=self.logo_path
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create directory for the post slug
|
# Create directory for the post slug (with parents for nested paths)
|
||||||
post_dir = self.output_dir / post['slug']
|
post_dir = self.output_dir / post['slug']
|
||||||
post_dir.mkdir(exist_ok=True)
|
post_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Generate index.html inside the slug directory
|
# Generate index.html inside the slug directory
|
||||||
output_path = post_dir / 'index.html'
|
output_path = post_dir / 'index.html'
|
||||||
|
|
@ -219,6 +301,131 @@ class SSGGGenerator:
|
||||||
|
|
||||||
print(f"✓ Generated {output_path}")
|
print(f"✓ Generated {output_path}")
|
||||||
|
|
||||||
|
def generate_root_page(self, post, all_posts=None):
|
||||||
|
"""Render a page item at /index.html (used when ROOT_PAGE is set)"""
|
||||||
|
template = self.env.get_template('post.tmpl')
|
||||||
|
|
||||||
|
html = template.render(
|
||||||
|
title=f"{post['title']} - {self.blog_title}",
|
||||||
|
post=post,
|
||||||
|
all_posts=all_posts or [],
|
||||||
|
**self.common_context("/")
|
||||||
|
)
|
||||||
|
|
||||||
|
output_path = self.output_dir / 'index.html'
|
||||||
|
with open(output_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(html)
|
||||||
|
|
||||||
|
print(f"✓ Generated {output_path} (root page: {post['slug']})")
|
||||||
|
|
||||||
|
def generate_rss_feed(self, posts):
|
||||||
|
"""Generate RSS 2.0 feed for main feed posts"""
|
||||||
|
from xml.etree.ElementTree import Element, SubElement, tostring, register_namespace
|
||||||
|
from xml.dom import minidom
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Register atom namespace to avoid ns0 prefix
|
||||||
|
register_namespace('atom', 'http://www.w3.org/2005/Atom')
|
||||||
|
|
||||||
|
# Limit posts
|
||||||
|
posts = posts[:FEED_MAX_ITEMS]
|
||||||
|
|
||||||
|
# Build feed URL correctly - ensure no double slashes
|
||||||
|
feed_path = RSS_FEED_PATH.lstrip('/')
|
||||||
|
# Remove trailing slash from BASE_URL if present for clean URL construction
|
||||||
|
base_url_clean = BASE_URL.rstrip('/')
|
||||||
|
feed_url = f"{base_url_clean}/{feed_path}"
|
||||||
|
|
||||||
|
# Create RSS element (namespace will be added automatically when we use atom:link)
|
||||||
|
rss = Element('rss', version='2.0')
|
||||||
|
channel = SubElement(rss, 'channel')
|
||||||
|
|
||||||
|
# Channel metadata
|
||||||
|
SubElement(channel, 'title').text = self.blog_title
|
||||||
|
SubElement(channel, 'description').text = self.blog_description
|
||||||
|
SubElement(channel, 'link').text = base_url_clean
|
||||||
|
|
||||||
|
# Add atom:link with rel="self" (required by RSS best practices)
|
||||||
|
atom_link = SubElement(channel, '{http://www.w3.org/2005/Atom}link')
|
||||||
|
atom_link.set('href', feed_url)
|
||||||
|
atom_link.set('rel', 'self')
|
||||||
|
atom_link.set('type', 'application/rss+xml')
|
||||||
|
|
||||||
|
SubElement(channel, 'lastBuildDate').text = datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S +0000')
|
||||||
|
|
||||||
|
# Add author information (managingEditor format: email (name))
|
||||||
|
if AUTHOR_EMAIL and AUTHOR_NAME:
|
||||||
|
SubElement(channel, 'managingEditor').text = f"{AUTHOR_EMAIL} ({AUTHOR_NAME})"
|
||||||
|
elif AUTHOR_EMAIL:
|
||||||
|
SubElement(channel, 'managingEditor').text = AUTHOR_EMAIL
|
||||||
|
|
||||||
|
# Helper function to convert relative URLs to absolute
|
||||||
|
def make_absolute_urls(html_content):
|
||||||
|
# Replace relative URLs with absolute ones
|
||||||
|
html_content = re.sub(r'href="/', f'href="{base_url_clean}/', html_content)
|
||||||
|
html_content = re.sub(r'src="/', f'src="{base_url_clean}/', html_content)
|
||||||
|
return html_content
|
||||||
|
|
||||||
|
# Add items
|
||||||
|
for post in posts:
|
||||||
|
item = SubElement(channel, 'item')
|
||||||
|
SubElement(item, 'title').text = post['title']
|
||||||
|
SubElement(item, 'link').text = f"{base_url_clean}{post['url']}"
|
||||||
|
SubElement(item, 'guid', isPermaLink='true').text = f"{base_url_clean}{post['url']}"
|
||||||
|
SubElement(item, 'pubDate').text = datetime.strptime(post['date'], '%Y-%m-%d').strftime('%a, %d %b %Y 00:00:00 +0000')
|
||||||
|
|
||||||
|
# Content type based on post type
|
||||||
|
if post['type'] == 'long':
|
||||||
|
# For long posts, just show title/summary
|
||||||
|
SubElement(item, 'description').text = f"Read more at {base_url_clean}{post['url']}"
|
||||||
|
else:
|
||||||
|
# For short posts, include full content with absolute URLs
|
||||||
|
content_absolute = make_absolute_urls(post['content'])
|
||||||
|
SubElement(item, 'description').text = content_absolute
|
||||||
|
|
||||||
|
# Pretty print XML
|
||||||
|
xml_str = minidom.parseString(tostring(rss, encoding='utf-8')).toprettyxml(indent=' ', encoding='utf-8')
|
||||||
|
|
||||||
|
# Write to file
|
||||||
|
output_path = self.output_dir / RSS_FEED_PATH
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(output_path, 'wb') as f:
|
||||||
|
f.write(xml_str)
|
||||||
|
|
||||||
|
print(f"✓ Generated {output_path}")
|
||||||
|
|
||||||
|
def strip_image_metadata(self, src_path, dest_path):
|
||||||
|
"""Re-encode an image to dest_path without any metadata (EXIF/GPS/XMP/text).
|
||||||
|
|
||||||
|
Pillow does not carry metadata across a save unless it is passed
|
||||||
|
explicitly, so re-saving produces a clean image. Returns True on
|
||||||
|
success, or False if the image should be copied verbatim instead
|
||||||
|
(unsupported/animated) or re-encoding failed.
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
try:
|
||||||
|
with Image.open(src_path) as img:
|
||||||
|
img.load()
|
||||||
|
|
||||||
|
# Animated images (GIF/WebP/APNG) are copied as-is to avoid
|
||||||
|
# dropping frames; they don't carry camera EXIF/GPS anyway.
|
||||||
|
if getattr(img, 'is_animated', False):
|
||||||
|
return False
|
||||||
|
|
||||||
|
save_kwargs = {}
|
||||||
|
if img.format == 'JPEG':
|
||||||
|
# 'keep' reuses the original quantization tables, so there
|
||||||
|
# is no generational quality loss from re-encoding.
|
||||||
|
save_kwargs['quality'] = 'keep'
|
||||||
|
|
||||||
|
# Saving without exif=/pnginfo= drops all metadata.
|
||||||
|
img.save(dest_path, format=img.format, **save_kwargs)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠ Could not strip metadata from {src_path.name} ({e}); copying as-is")
|
||||||
|
return False
|
||||||
|
|
||||||
def copy_assets(self):
|
def copy_assets(self):
|
||||||
"""Copy theme assets and images to output directory"""
|
"""Copy theme assets and images to output directory"""
|
||||||
import shutil
|
import shutil
|
||||||
|
|
@ -231,14 +438,43 @@ class SSGGGenerator:
|
||||||
shutil.copytree(self.assets_dir, dest_dir)
|
shutil.copytree(self.assets_dir, dest_dir)
|
||||||
print(f"✓ Copied theme assets to output")
|
print(f"✓ Copied theme assets to output")
|
||||||
|
|
||||||
# Copy images
|
# Copy images (stripping EXIF/metadata when enabled)
|
||||||
images_dir = Path('images')
|
images_dir = Path('images')
|
||||||
if images_dir.exists():
|
if images_dir.exists():
|
||||||
dest_dir = self.output_dir / 'images'
|
dest_dir = self.output_dir / 'images'
|
||||||
if dest_dir.exists():
|
if dest_dir.exists():
|
||||||
shutil.rmtree(dest_dir)
|
shutil.rmtree(dest_dir)
|
||||||
shutil.copytree(images_dir, dest_dir)
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
print(f"✓ Copied images/ to output")
|
|
||||||
|
# Only strip if enabled and Pillow is importable; otherwise copy as-is.
|
||||||
|
pillow_available = self.strip_image_exif
|
||||||
|
if self.strip_image_exif:
|
||||||
|
try:
|
||||||
|
import PIL # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
pillow_available = False
|
||||||
|
print(" ⚠ STRIP_IMAGE_EXIF is enabled but Pillow is not installed; "
|
||||||
|
"copying images without stripping (pip install pillow)")
|
||||||
|
|
||||||
|
stripped = 0
|
||||||
|
for item in images_dir.rglob('*'):
|
||||||
|
if not item.is_file():
|
||||||
|
continue
|
||||||
|
rel_path = item.relative_to(images_dir)
|
||||||
|
dest_path = dest_dir / rel_path
|
||||||
|
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if (pillow_available
|
||||||
|
and item.suffix.lower() in STRIP_IMAGE_EXTENSIONS
|
||||||
|
and self.strip_image_metadata(item, dest_path)):
|
||||||
|
stripped += 1
|
||||||
|
else:
|
||||||
|
shutil.copy2(item, dest_path)
|
||||||
|
|
||||||
|
if pillow_available:
|
||||||
|
print(f"✓ Copied images/ to output ({stripped} stripped of EXIF/metadata)")
|
||||||
|
else:
|
||||||
|
print(f"✓ Copied images/ to output")
|
||||||
|
|
||||||
# Copy static files (GPG keys, .well-known, etc.)
|
# Copy static files (GPG keys, .well-known, etc.)
|
||||||
static_dir = Path('static')
|
static_dir = Path('static')
|
||||||
|
|
@ -263,41 +499,146 @@ class SSGGGenerator:
|
||||||
all_posts = self.collect_posts()
|
all_posts = self.collect_posts()
|
||||||
print(f"Found {len(all_posts)} posts")
|
print(f"Found {len(all_posts)} posts")
|
||||||
|
|
||||||
# Filter out pages and excluded feeds from main feed
|
# Filter out pages and excluded sections from main index
|
||||||
feed_posts = [p for p in all_posts
|
main_posts = [p for p in all_posts
|
||||||
if p['type'] != 'page'
|
if p['type'] != 'page'
|
||||||
and p['feed'] not in self.exclude_feeds]
|
and p['section'] not in self.exclude_sections]
|
||||||
|
|
||||||
# Generate main index with filtered feed posts
|
# Generate blogroll (at root or at BLOGROLL_PATH)
|
||||||
self.generate_index(feed_posts, all_posts=feed_posts)
|
self.generate_index(main_posts, all_posts=main_posts)
|
||||||
|
|
||||||
# Group posts by feed (include all posts, not just those in main feed)
|
# Generate root index from a page item if ROOT_PAGE is set
|
||||||
feeds = {}
|
if self.root_page:
|
||||||
|
root_post = next((p for p in all_posts if p['slug'] == self.root_page), None)
|
||||||
|
if root_post:
|
||||||
|
self.generate_root_page(root_post, all_posts=main_posts)
|
||||||
|
else:
|
||||||
|
print(f"Warning: ROOT_PAGE '{self.root_page}' not found, skipping root index")
|
||||||
|
|
||||||
|
# Group posts by section (include all posts, not just those in main index)
|
||||||
|
sections = {}
|
||||||
for post in all_posts:
|
for post in all_posts:
|
||||||
if post['feed'] and post['type'] != 'page':
|
if post['section'] and post['type'] != 'page':
|
||||||
feeds.setdefault(post['feed'], []).append(post)
|
sections.setdefault(post['section'], []).append(post)
|
||||||
|
|
||||||
# Generate feed-specific pages
|
# Generate section-specific pages
|
||||||
for feed_name, posts in feeds.items():
|
for section_name, posts in sections.items():
|
||||||
self.generate_index(posts, feed_name, all_posts=feed_posts)
|
self.generate_index(posts, section_name, all_posts=main_posts)
|
||||||
|
|
||||||
# Generate feeds overview page
|
# Generate sections overview page
|
||||||
if feeds:
|
if sections:
|
||||||
self.generate_feeds_overview(feeds, all_posts=feed_posts)
|
self.generate_sections_overview(sections, all_posts=main_posts)
|
||||||
|
|
||||||
|
# Group posts by subdirectory
|
||||||
|
subdirs = {}
|
||||||
|
for post in all_posts:
|
||||||
|
if post['subpath']: # Only posts in subdirectories
|
||||||
|
subdirs.setdefault(post['subpath'], []).append(post)
|
||||||
|
|
||||||
|
# Generate subdirectory index pages (e.g., /projects/)
|
||||||
|
for subpath, subdir_posts in subdirs.items():
|
||||||
|
self.generate_subdir_index(subpath, subdir_posts, all_posts=main_posts)
|
||||||
|
|
||||||
# Generate individual pages for long posts, short posts, and pages
|
# Generate individual pages for long posts, short posts, and pages
|
||||||
for post in all_posts:
|
for post in all_posts:
|
||||||
if post['type'] in ['long', 'short', 'page']:
|
if post['type'] in ['long', 'short', 'page']:
|
||||||
self.generate_post_page(post, all_posts=feed_posts)
|
self.generate_post_page(post, all_posts=main_posts)
|
||||||
|
|
||||||
|
# Generate RSS feed
|
||||||
|
if ENABLE_RSS_FEED:
|
||||||
|
self.generate_rss_feed(main_posts)
|
||||||
|
|
||||||
# Copy assets
|
# Copy assets
|
||||||
self.copy_assets()
|
self.copy_assets()
|
||||||
|
|
||||||
print(f"\n✓ Site generated successfully in {self.output_dir}/")
|
print(f"\n✓ Site generated successfully in {self.output_dir}/")
|
||||||
|
|
||||||
|
# Location of the default project files baked into the container image.
|
||||||
|
DEFAULT_SKELETON_DIR = '/usr/local/share/picopaper/skeleton'
|
||||||
|
|
||||||
|
# The blog content that `new` scaffolds. Only these are copied — the CLI itself
|
||||||
|
# (picopaper.py), requirements.txt and .gitignore belong to the tool/image, not
|
||||||
|
# the user's blog, so they are intentionally excluded.
|
||||||
|
SCAFFOLD_ITEMS = ('config.py', 'theme', 'items', 'images', 'static')
|
||||||
|
|
||||||
|
|
||||||
|
def scaffold_new(target_dir='.'):
|
||||||
|
"""Create a new picopaper blog in target_dir by copying the default files.
|
||||||
|
|
||||||
|
The source ("skeleton") defaults to the path baked into the container image
|
||||||
|
and can be overridden with the PICOPAPER_SKELETON environment variable.
|
||||||
|
Existing files in the target are never overwritten.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
skeleton = Path(os.environ.get('PICOPAPER_SKELETON', DEFAULT_SKELETON_DIR))
|
||||||
|
target = Path(target_dir)
|
||||||
|
|
||||||
|
if not skeleton.is_dir():
|
||||||
|
print(f"Error: default project files not found at {skeleton}")
|
||||||
|
print("Run this inside the picopaper container, or point PICOPAPER_SKELETON "
|
||||||
|
"at a picopaper source directory.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
created, skipped = [], []
|
||||||
|
for name in SCAFFOLD_ITEMS:
|
||||||
|
source = skeleton / name
|
||||||
|
if not source.exists():
|
||||||
|
continue
|
||||||
|
dest = target / name
|
||||||
|
if dest.exists():
|
||||||
|
# Never overwrite the user's files — leave anything already there.
|
||||||
|
skipped.append(name)
|
||||||
|
continue
|
||||||
|
if source.is_dir():
|
||||||
|
shutil.copytree(source, dest)
|
||||||
|
else:
|
||||||
|
shutil.copy2(source, dest)
|
||||||
|
created.append(name)
|
||||||
|
|
||||||
|
if not created:
|
||||||
|
print("Nothing to create — this directory already contains a picopaper blog.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print("✓ Created a new picopaper blog in the current directory:")
|
||||||
|
for name in created:
|
||||||
|
print(f" {name}")
|
||||||
|
if skipped:
|
||||||
|
print("\nLeft untouched (already present):")
|
||||||
|
for name in skipped:
|
||||||
|
print(f" {name}")
|
||||||
|
|
||||||
|
print("\nNext steps:")
|
||||||
|
print(" 1. Edit config.py (blog title, description, navbar).")
|
||||||
|
print(" 2. Add or edit Markdown posts in items/.")
|
||||||
|
print(" 3. Build the site by running the container with no command:")
|
||||||
|
print(" podman run --rm --userns=keep-id -v $(pwd):/app git.uphillsecurity.com/cf7/picopaper:latest")
|
||||||
|
print("\nYour generated site will appear in output/.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
generator = SSGGGenerator()
|
args = sys.argv[1:]
|
||||||
generator.generate()
|
command = args[0] if args else 'build'
|
||||||
|
|
||||||
|
if command in ('build', 'generate'):
|
||||||
|
if not CONFIG_AVAILABLE:
|
||||||
|
print("Error: no config.py found in the current directory.")
|
||||||
|
print("Run 'picopaper.py new' to scaffold a blog first, then edit config.py.")
|
||||||
|
sys.exit(1)
|
||||||
|
generator = SSGGGenerator()
|
||||||
|
generator.generate()
|
||||||
|
elif command == 'new':
|
||||||
|
sys.exit(scaffold_new())
|
||||||
|
elif command in ('help', '-h', '--help'):
|
||||||
|
print("Usage: picopaper.py [command]\n")
|
||||||
|
print("Commands:")
|
||||||
|
print(" build Generate the site from the current directory (default)")
|
||||||
|
print(" new Scaffold a new picopaper blog into the current directory")
|
||||||
|
else:
|
||||||
|
print(f"Unknown command: {command!r}")
|
||||||
|
print("Usage: picopaper.py [build|new] (run 'picopaper.py help' for details)")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
jinja2>=3.0.0
|
jinja2>=3.0.0
|
||||||
markdown>=3.4.0
|
markdown>=3.4.0
|
||||||
|
pillow>=10.0.0
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ h1 {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
@ -61,6 +62,63 @@ h1 {
|
||||||
color: #0066cc;
|
color: #0066cc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dropdown (one level, CSS-only, opens on hover / keyboard focus) */
|
||||||
|
.nav-dropdown {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Match the plain .nav-item box exactly (it would otherwise be an inline
|
||||||
|
element here) and vertically center the label with its caret. */
|
||||||
|
.nav-dropdown-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-caret {
|
||||||
|
font-size: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-submenu {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 6px;
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 6px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #efefef;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Invisible bridge across the gap so hover isn't lost between toggle and menu */
|
||||||
|
.nav-submenu::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown:hover > .nav-submenu,
|
||||||
|
.nav-dropdown:focus-within > .nav-submenu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-submenu .nav-item {
|
||||||
|
display: block;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #0066cc;
|
color: #0066cc;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
@ -107,3 +165,10 @@ footer {
|
||||||
footer p {
|
footer p {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background-color: #f1f1f1;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #efefef;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,22 @@
|
||||||
<p class="blog-description">{{ blog_description }}</p>
|
<p class="blog-description">{{ blog_description }}</p>
|
||||||
<nav class="main-nav">
|
<nav class="main-nav">
|
||||||
{% for item in navbar_items %}
|
{% for item in navbar_items %}
|
||||||
|
{% if item.children %}
|
||||||
|
<div class="nav-dropdown">
|
||||||
|
{% if item.url %}
|
||||||
|
<a href="{{ item.url }}" class="nav-item nav-dropdown-toggle">{{ item.text }}<span class="nav-caret">▾</span></a>
|
||||||
|
{% else %}
|
||||||
|
<span class="nav-item nav-dropdown-toggle" tabindex="0" role="button" aria-haspopup="true">{{ item.text }}<span class="nav-caret">▾</span></span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="nav-submenu">
|
||||||
|
{% for child in item.children %}
|
||||||
|
<a href="{{ child.url }}" class="nav-item">{{ child.text }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
<a href="{{ item.url }}" class="nav-item">{{ item.text }}</a>
|
<a href="{{ item.url }}" class="nav-item">{{ item.text }}</a>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,7 @@
|
||||||
<article class="post">
|
<article class="post">
|
||||||
<div class="post-meta">{{ post.date }}</div>
|
<div class="post-meta">{{ post.date }}</div>
|
||||||
<h2 class="post-title">
|
<h2 class="post-title">
|
||||||
{% if post.type in ['long', 'short'] %}
|
|
||||||
<a href="{{ post.url }}">{{ post.title }}</a>
|
<a href="{{ post.url }}">{{ post.title }}</a>
|
||||||
{% else %}
|
|
||||||
{{ post.title }}
|
|
||||||
{% endif %}
|
|
||||||
</h2>
|
</h2>
|
||||||
{% if post.type == 'short' %}
|
{% if post.type == 'short' %}
|
||||||
<div class="post-content">
|
<div class="post-content">
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,4 @@
|
||||||
<title>{{ title }}</title>
|
<title>{{ title }}</title>
|
||||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico">
|
||||||
<link rel="stylesheet" href="/assets/style.css">
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
|
{% if rss_feed_enabled %}<link rel="alternate" type="application/rss+xml" title="{{ blog_title }} RSS Feed" href="/{{ rss_feed_path }}">{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@
|
||||||
{% include 'header.tmpl' %}
|
{% include 'header.tmpl' %}
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<h2>Feeds</h2>
|
<h2>Sections</h2>
|
||||||
<ul>
|
<ul>
|
||||||
{% for feed in feeds %}
|
{% for section in sections %}
|
||||||
<li><a href="/feed/{{ feed.name }}/">{{ feed.name }}</a> ({{ feed.count }} posts)</li>
|
<li><a href="/section/{{ section.name }}/">{{ section.name }}</a> ({{ section.count }} posts)</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</main>
|
</main>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue