diff --git a/Dockerfile b/Dockerfile
index 78f1da5..c7838dc 100644
--- a/Dockerfile
+++ b/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 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
WORKDIR /app
@@ -24,5 +34,8 @@ ENV PYTHONPATH=/app
# Run as root to allow writing to mounted volumes
# (The mounted volume will have host user permissions)
-# Generate the site on container start
-CMD ["python3", "/usr/local/bin/picopaper.py"]
+# With no command the site is generated; pass `new` to scaffold a blog instead:
+# 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 []
diff --git a/README.md b/README.md
index 549d0e8..ea16cc5 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
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)
- Goals: keeping it simple and easy to understand
- Demo: [picopaper.com](https://picopaper.com/)
@@ -17,21 +17,20 @@ Show cases:
## Features
**Available**:
-- Simple use, easy to understand and modify
+- simple use, easy to understand and modify
- config file for settings
- Themes
-- Long- and short form content
-- Pages
-- Static files
-- separate feeds (used for categories, tagging, etc) `/feed/{tag}`
-- exclusion of feeds from main feed (drafts or system notes)
+- long- and short form content
+- pages
+- static files
+- separate sections (used for categories, tagging, etc) `/section/{tag}`
+- exclusion of sections from main index (drafts or system notes)
- HTML anchors for headers
- list random posts at the bottom
+- optional RSS feeds
+- automatic EXIF/metadata stripping from images (privacy)
**Ideas**:
-- RSS
-- Dark mode
-- logo
- custom error pages (404, etc)
**Not planned**:
@@ -49,33 +48,52 @@ Put markdown file into `items` dir. **Important naming convention**:
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
- `_long_` - type of content: `long`, `short`, or `page`
- `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.
**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
-- `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)
-- Have their own feed page at `/feed/{tag}/`
+- Have their own section page at `/section/{tag}/`
**Configuration in `config.py`:**
```python
-# Exclude specific feeds from main page (they'll still have /feed/name/ pages)
-EXCLUDE_FEEDS_FROM_MAIN = ['draft', 'private']
+# Exclude specific sections from main page (they'll still have /section/name/ pages)
+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.
---
diff --git a/config.py b/config.py
index 2f193d3..29d0fc1 100644
--- a/config.py
+++ b/config.py
@@ -1,21 +1,45 @@
"""Configuration file for picopaper blog"""
+# General site settings
BLOG_TITLE = "PicoPaper.com"
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"
-# Exclude specific feeds from the main page (they'll still have their own /feed/name/ pages)
-EXCLUDE_FEEDS_FROM_MAIN = ['draft','private'] # e.g., ['python', 'drafts']
+# Exclude specific sections from the main page (they'll still have their own /section/name/ pages)
+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 = [
{'text': 'Home', 'url': '/'},
- {'text': 'Feeds', 'url': '/feed/'},
- {'text': 'About', 'url': '/about/'}
+ {'text': 'Sections', 'url': '/section/'},
+ {'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
HIDE_LOGO = False
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
diff --git a/items/projects/2025-10-27_page_another-project.md b/items/projects/2025-10-27_page_another-project.md
new file mode 100644
index 0000000..cecf35f
--- /dev/null
+++ b/items/projects/2025-10-27_page_another-project.md
@@ -0,0 +1,3 @@
+# Another Project
+
+Just a placeholer.
diff --git a/picopaper.py b/picopaper.py
index 700bc67..5435fa3 100644
--- a/picopaper.py
+++ b/picopaper.py
@@ -2,11 +2,35 @@
import os
import re
+import sys
from datetime import datetime
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
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:
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.blog_title = blog_title or BLOG_TITLE
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.hide_logo = HIDE_LOGO
self.hide_title = HIDE_TITLE
self.logo_path = LOGO_PATH
+ self.strip_image_exif = STRIP_IMAGE_EXIF
+ self.base_url = BASE_URL.rstrip('/')
# Setup Jinja2
self.env = Environment(loader=FileSystemLoader(self.templates_dir))
@@ -38,15 +66,20 @@ class SSGGGenerator:
# Setup markdown with toc extension for header anchors
self.md = markdown.Markdown(extensions=['extra', 'toc'])
- def parse_filename(self, filename):
- """Parse filename format: YYYY-MM-DD_type_name[_feed].md"""
+ def parse_filename(self, filename, subpath=''):
+ """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'
match = re.match(pattern, filename)
if not match:
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')
return {
@@ -54,8 +87,9 @@ class SSGGGenerator:
'date_str': date.strftime('%Y-%m-%d'),
'type': post_type,
'name': name,
- 'feed': feed,
- 'filename': filename
+ 'section': section,
+ 'filename': filename,
+ 'subpath': subpath
}
def add_header_anchors(self, html_content):
@@ -94,32 +128,46 @@ class SSGGGenerator:
return title, html_content
def collect_posts(self):
- """Collect and parse all posts from items directory"""
+ """Collect and parse all posts from items directory, including subdirectories"""
posts = []
if not self.items_dir.exists():
print(f"Warning: {self.items_dir} does not exist")
return posts
- for filepath in self.items_dir.glob('*.md'):
- parsed = self.parse_filename(filepath.name)
+ # Use rglob to recursively find all .md files
+ 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:
- print(f"Skipping {filepath.name}: doesn't match naming convention")
+ print(f"Skipping {filepath}: doesn't match naming convention")
continue
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 = {
'date': parsed['date_str'],
'type': parsed['type'],
'name': parsed['name'],
'title': title,
'content': content,
- 'slug': parsed['name'],
- 'url': f"/{parsed['name']}/",
- 'feed': parsed['feed'],
- 'source': filepath.name
+ 'slug': slug,
+ 'url': url,
+ 'section': parsed['section'],
+ 'source': str(relative_path),
+ 'subpath': parsed['subpath']
}
posts.append(post)
@@ -129,27 +177,49 @@ class SSGGGenerator:
return posts
- def generate_index(self, posts, feed_name=None, all_posts=None):
- """Generate index.html with all posts (or feed-specific index)"""
+ def common_context(self, page_url):
+ """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')
- if feed_name:
- title = f"{feed_name} - {self.blog_title}"
- output_path = self.output_dir / 'feed' / feed_name / 'index.html'
+ if section_name:
+ title = f"{section_name} - {self.blog_title}"
+ 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:
title = self.blog_title
output_path = self.output_dir / 'index.html'
+ page_url = "/"
html = template.render(
title=title,
- blog_title=self.blog_title,
- blog_description=self.blog_description,
- navbar_items=self.navbar_items,
posts=posts,
all_posts=all_posts or posts,
- hide_logo=self.hide_logo,
- hide_title=self.hide_title,
- logo_path=self.logo_path
+ **self.common_context(page_url)
)
output_path.parent.mkdir(parents=True, exist_ok=True)
@@ -158,32 +228,49 @@ class SSGGGenerator:
print(f"✓ Generated {output_path}")
- def generate_feeds_overview(self, feeds, all_posts=None):
- """Generate /feed/index.html with list of all non-excluded feeds"""
- template = self.env.get_template('feeds.tmpl')
+ def generate_sections_overview(self, sections, all_posts=None):
+ """Generate /section/index.html with list of all non-excluded sections"""
+ template = self.env.get_template('sections.tmpl')
- # Prepare feed data with counts, excluding feeds in EXCLUDE_FEEDS_FROM_MAIN
- feed_list = []
- for feed_name, posts in sorted(feeds.items()):
- if feed_name not in self.exclude_feeds:
- feed_list.append({
- 'name': feed_name,
+ # Prepare section data with counts, excluding sections in EXCLUDE_SECTIONS_FROM_MAIN
+ section_list = []
+ for section_name, posts in sorted(sections.items()):
+ if section_name not in self.exclude_sections:
+ section_list.append({
+ 'name': section_name,
'count': len(posts)
})
- title = f"Feeds - {self.blog_title}"
- output_path = self.output_dir / 'feed' / 'index.html'
+ title = f"Sections - {self.blog_title}"
+ output_path = self.output_dir / 'section' / 'index.html'
html = template.render(
title=title,
- blog_title=self.blog_title,
- blog_description=self.blog_description,
- navbar_items=self.navbar_items,
- feeds=feed_list,
+ sections=section_list,
all_posts=all_posts or [],
- hide_logo=self.hide_logo,
- hide_title=self.hide_title,
- logo_path=self.logo_path
+ **self.common_context("/section/")
+ )
+
+ 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)
@@ -198,19 +285,14 @@ class SSGGGenerator:
html = template.render(
title=f"{post['title']} - {self.blog_title}",
- blog_title=self.blog_title,
- blog_description=self.blog_description,
- navbar_items=self.navbar_items,
post=post,
all_posts=all_posts or [],
- hide_logo=self.hide_logo,
- hide_title=self.hide_title,
- logo_path=self.logo_path
+ **self.common_context(post['url'])
)
- # 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.mkdir(exist_ok=True)
+ post_dir.mkdir(parents=True, exist_ok=True)
# Generate index.html inside the slug directory
output_path = post_dir / 'index.html'
@@ -219,6 +301,131 @@ class SSGGGenerator:
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):
"""Copy theme assets and images to output directory"""
import shutil
@@ -231,14 +438,43 @@ class SSGGGenerator:
shutil.copytree(self.assets_dir, dest_dir)
print(f"✓ Copied theme assets to output")
- # Copy images
+ # Copy images (stripping EXIF/metadata when enabled)
images_dir = Path('images')
if images_dir.exists():
dest_dir = self.output_dir / 'images'
if dest_dir.exists():
shutil.rmtree(dest_dir)
- shutil.copytree(images_dir, dest_dir)
- print(f"✓ Copied images/ to output")
+ 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")
# Copy static files (GPG keys, .well-known, etc.)
static_dir = Path('static')
@@ -263,41 +499,146 @@ class SSGGGenerator:
all_posts = self.collect_posts()
print(f"Found {len(all_posts)} posts")
- # Filter out pages and excluded feeds from main feed
- feed_posts = [p for p in all_posts
+ # Filter out pages and excluded sections from main index
+ main_posts = [p for p in all_posts
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
- self.generate_index(feed_posts, all_posts=feed_posts)
+ # Generate blogroll (at root or at BLOGROLL_PATH)
+ self.generate_index(main_posts, all_posts=main_posts)
- # Group posts by feed (include all posts, not just those in main feed)
- feeds = {}
+ # Generate root index from a page item if ROOT_PAGE is set
+ 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:
- if post['feed'] and post['type'] != 'page':
- feeds.setdefault(post['feed'], []).append(post)
+ if post['section'] and post['type'] != 'page':
+ sections.setdefault(post['section'], []).append(post)
- # Generate feed-specific pages
- for feed_name, posts in feeds.items():
- self.generate_index(posts, feed_name, all_posts=feed_posts)
+ # Generate section-specific pages
+ for section_name, posts in sections.items():
+ self.generate_index(posts, section_name, all_posts=main_posts)
- # Generate feeds overview page
- if feeds:
- self.generate_feeds_overview(feeds, all_posts=feed_posts)
+ # Generate sections overview page
+ if sections:
+ 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
for post in all_posts:
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
self.copy_assets()
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():
- generator = SSGGGenerator()
- generator.generate()
+ 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.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__':
main()
diff --git a/requirements.txt b/requirements.txt
index ea5dec4..bfa5ffa 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,3 @@
jinja2>=3.0.0
markdown>=3.4.0
+pillow>=10.0.0
diff --git a/theme/default/assets/style.css b/theme/default/assets/style.css
index 9b276fc..2aaea72 100644
--- a/theme/default/assets/style.css
+++ b/theme/default/assets/style.css
@@ -42,6 +42,7 @@ h1 {
font-weight: bold;
display: flex;
justify-content: center;
+ align-items: center;
gap: 10px;
flex-wrap: wrap;
}
@@ -61,6 +62,63 @@ h1 {
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 {
color: #0066cc;
text-decoration: none;
@@ -107,3 +165,10 @@ footer {
footer p {
font-size: 0.7rem;
}
+
+pre {
+ background-color: #f1f1f1;
+ padding: 10px;
+ border: 1px solid #efefef;
+ white-space: pre-wrap;
+}
diff --git a/theme/default/templates/header.tmpl b/theme/default/templates/header.tmpl
index e92c6f6..dc9faf8 100644
--- a/theme/default/templates/header.tmpl
+++ b/theme/default/templates/header.tmpl
@@ -12,7 +12,22 @@