Compare commits

...

12 commits
v0.3.0 ... main

11 changed files with 585 additions and 108 deletions

View file

@ -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 []

View file

@ -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.
--- ---

View file

@ -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

View file

@ -0,0 +1,3 @@
# Another Project
Just a placeholer.

View file

@ -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,13 +438,42 @@ 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)
# 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") print(f"✓ Copied images/ to output")
# Copy static files (GPG keys, .well-known, etc.) # Copy static files (GPG keys, .well-known, etc.)
@ -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():
args = sys.argv[1:]
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 = SSGGGenerator()
generator.generate() 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()

View file

@ -1,2 +1,3 @@
jinja2>=3.0.0 jinja2>=3.0.0
markdown>=3.4.0 markdown>=3.4.0
pillow>=10.0.0

View file

@ -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;
}

View file

@ -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>

View file

@ -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">

View file

@ -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 %}

View file

@ -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>