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

View file

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

View file

@ -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"
# 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 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,13 +438,42 @@ 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)
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.)
@ -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():
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()

View file

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

View file

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

View file

@ -12,7 +12,22 @@
<p class="blog-description">{{ blog_description }}</p>
<nav class="main-nav">
{% 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>
{% endif %}
{% endfor %}
</nav>
</header>

View file

@ -11,11 +11,7 @@
<article class="post">
<div class="post-meta">{{ post.date }}</div>
<h2 class="post-title">
{% if post.type in ['long', 'short'] %}
<a href="{{ post.url }}">{{ post.title }}</a>
{% else %}
{{ post.title }}
{% endif %}
</h2>
{% if post.type == 'short' %}
<div class="post-content">

View file

@ -4,3 +4,4 @@
<title>{{ title }}</title>
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico">
<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' %}
<main>
<h2>Feeds</h2>
<h2>Sections</h2>
<ul>
{% for feed in feeds %}
<li><a href="/feed/{{ feed.name }}/">{{ feed.name }}</a> ({{ feed.count }} posts)</li>
{% for section in sections %}
<li><a href="/section/{{ section.name }}/">{{ section.name }}</a> ({{ section.count }} posts)</li>
{% endfor %}
</ul>
</main>