From a99d1db1cb7d373c2f47d7ea4c30c342a82a11a2 Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Sun, 22 Mar 2026 20:30:05 +0100 Subject: [PATCH 05/12] ux: CHANGE change 'feeds' to 'sections' - BREAKING - short guide in release notes --- README.md | 24 +++--- config.py | 6 +- picopaper.py | 82 +++++++++---------- .../templates/{feeds.tmpl => sections.tmpl} | 6 +- 4 files changed, 59 insertions(+), 59 deletions(-) rename theme/default/templates/{feeds.tmpl => sections.tmpl} (57%) diff --git a/README.md b/README.md index 634e18b..db60989 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ Show cases: - 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) +- 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 @@ -47,33 +47,33 @@ 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. --- diff --git a/config.py b/config.py index 66cb25c..0bf0d15 100644 --- a/config.py +++ b/config.py @@ -8,13 +8,13 @@ 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 NAVBAR_ITEMS = [ {'text': 'Home', 'url': '/'}, - {'text': 'Feeds', 'url': '/feed/'}, + {'text': 'Sections', 'url': '/section/'}, {'text': 'About', 'url': '/about/'}, {'text': 'RSS', 'url': '/rss.xml'} ] diff --git a/picopaper.py b/picopaper.py index bef764e..1448e80 100644 --- a/picopaper.py +++ b/picopaper.py @@ -6,7 +6,7 @@ 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, +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) @@ -21,7 +21,7 @@ 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.navbar_items = NAVBAR_ITEMS self.hide_logo = HIDE_LOGO self.hide_title = HIDE_TITLE @@ -42,7 +42,7 @@ class SSGGGenerator: self.md = markdown.Markdown(extensions=['extra', 'toc']) 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 @@ -54,7 +54,7 @@ class SSGGGenerator: 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 { @@ -62,7 +62,7 @@ class SSGGGenerator: 'date_str': date.strftime('%Y-%m-%d'), 'type': post_type, 'name': name, - 'feed': feed, + 'section': section, 'filename': filename, 'subpath': subpath } @@ -140,7 +140,7 @@ class SSGGGenerator: 'content': content, 'slug': slug, 'url': url, - 'feed': parsed['feed'], + 'section': parsed['section'], 'source': str(relative_path), 'subpath': parsed['subpath'] } @@ -152,13 +152,13 @@ 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 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' else: title = self.blog_title output_path = self.output_dir / 'index.html' @@ -183,28 +183,28 @@ 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, @@ -397,27 +397,27 @@ 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 main index with filtered posts + self.generate_index(main_posts, all_posts=main_posts) - # Group posts by feed (include all posts, not just those in main feed) - feeds = {} + # 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 = {} @@ -427,16 +427,16 @@ class SSGGGenerator: # Generate subdirectory index pages (e.g., /projects/) for subpath, subdir_posts in subdirs.items(): - self.generate_subdir_index(subpath, subdir_posts, all_posts=feed_posts) + 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(feed_posts) + self.generate_rss_feed(main_posts) # Copy assets self.copy_assets() diff --git a/theme/default/templates/feeds.tmpl b/theme/default/templates/sections.tmpl similarity index 57% rename from theme/default/templates/feeds.tmpl rename to theme/default/templates/sections.tmpl index c03aae7..046a356 100644 --- a/theme/default/templates/feeds.tmpl +++ b/theme/default/templates/sections.tmpl @@ -7,10 +7,10 @@ {% include 'header.tmpl' %}
-

Feeds

+

Sections

From cdbdc7e392b34734977f92d167eac6b6344dc4f4 Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Sun, 22 Mar 2026 20:36:02 +0100 Subject: [PATCH 06/12] ux: CHANGE pre and code format --- theme/default/assets/style.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/theme/default/assets/style.css b/theme/default/assets/style.css index 9b276fc..4c4743c 100644 --- a/theme/default/assets/style.css +++ b/theme/default/assets/style.css @@ -107,3 +107,10 @@ footer { footer p { font-size: 0.7rem; } + +pre { + background-color: #f1f1f1; + padding: 10px; + border: 1px solid #efefef; + white-space: pre-wrap; +} From 5b6edac23604b3f8b2b34088cb070a0523a3ae2e Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Sun, 22 Mar 2026 20:44:30 +0100 Subject: [PATCH 07/12] tech: ADD allow explicit root index page and path for articles --- config.py | 4 ++++ picopaper.py | 42 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/config.py b/config.py index 0bf0d15..17af7af 100644 --- a/config.py +++ b/config.py @@ -19,6 +19,10 @@ NAVBAR_ITEMS = [ {'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 diff --git a/picopaper.py b/picopaper.py index 1448e80..8069f6e 100644 --- a/picopaper.py +++ b/picopaper.py @@ -9,7 +9,8 @@ import markdown 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) + BASE_URL, AUTHOR_NAME, AUTHOR_EMAIL, FEED_MAX_ITEMS, + BLOGROLL_PATH, ROOT_PAGE) class SSGGGenerator: def __init__(self, items_dir='items', output_dir='output', theme=None, blog_title=None, blog_description=None): @@ -22,6 +23,8 @@ class SSGGGenerator: self.blog_title = blog_title or BLOG_TITLE self.blog_description = blog_description or BLOG_DESCRIPTION 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 @@ -159,6 +162,9 @@ class SSGGGenerator: if section_name: title = f"{section_name} - {self.blog_title}" output_path = self.output_dir / 'section' / section_name / 'index.html' + elif self.blogroll_path: + title = self.blog_title + output_path = self.output_dir / self.blogroll_path / 'index.html' else: title = self.blog_title output_path = self.output_dir / 'index.html' @@ -277,6 +283,30 @@ 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}", + 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, + rss_feed_enabled=ENABLE_RSS_FEED, + rss_feed_path=RSS_FEED_PATH + ) + + 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 @@ -402,9 +432,17 @@ class SSGGGenerator: if p['type'] != 'page' and p['section'] not in self.exclude_sections] - # Generate main index with filtered posts + # Generate blogroll (at root or at BLOGROLL_PATH) self.generate_index(main_posts, all_posts=main_posts) + # 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: From 7a8ecafc3a7a3d2384436d4bb2718a5a44cf61e9 Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Sat, 4 Jul 2026 07:59:35 +0200 Subject: [PATCH 08/12] feat: ADD optional exif data removable in files #18 --- README.md | 20 +++++++++++++ config.py | 3 ++ picopaper.py | 78 ++++++++++++++++++++++++++++++++++++++++++++++-- requirements.txt | 1 + 4 files changed, 99 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index db60989..ea16cc5 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Show cases: - HTML anchors for headers - list random posts at the bottom - optional RSS feeds +- automatic EXIF/metadata stripping from images (privacy) **Ideas**: - custom error pages (404, etc) @@ -75,6 +76,25 @@ EXCLUDE_SECTIONS_FROM_MAIN = ['draft', 'private'] 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. + --- ## Installation diff --git a/config.py b/config.py index 17af7af..b5e2c4d 100644 --- a/config.py +++ b/config.py @@ -28,6 +28,9 @@ 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") diff --git a/picopaper.py b/picopaper.py index 8069f6e..47b43ef 100644 --- a/picopaper.py +++ b/picopaper.py @@ -12,6 +12,16 @@ from config import (BLOG_TITLE, BLOG_DESCRIPTION, THEME, EXCLUDE_SECTIONS_FROM_M BASE_URL, AUTHOR_NAME, AUTHOR_EMAIL, FEED_MAX_ITEMS, BLOGROLL_PATH, ROOT_PAGE) +# 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. +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): self.items_dir = Path(items_dir) @@ -29,6 +39,7 @@ class SSGGGenerator: self.hide_logo = HIDE_LOGO self.hide_title = HIDE_TITLE self.logo_path = LOGO_PATH + self.strip_image_exif = STRIP_IMAGE_EXIF # Setup Jinja2 self.env = Environment(loader=FileSystemLoader(self.templates_dir)) @@ -383,6 +394,38 @@ class SSGGGenerator: 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 @@ -395,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') 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 From c3ef992a89c0f56843e20ede23226743941d81b7 Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Sat, 4 Jul 2026 08:13:23 +0200 Subject: [PATCH 09/12] feat: ADD variable for URLs #29 --- picopaper.py | 70 ++++++++++++++++++++++------------------------------ 1 file changed, 30 insertions(+), 40 deletions(-) diff --git a/picopaper.py b/picopaper.py index 47b43ef..206aca0 100644 --- a/picopaper.py +++ b/picopaper.py @@ -40,6 +40,7 @@ class SSGGGenerator: 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)) @@ -166,6 +167,27 @@ class SSGGGenerator: return posts + 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') @@ -173,25 +195,21 @@ class SSGGGenerator: 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, - rss_feed_enabled=ENABLE_RSS_FEED, - rss_feed_path=RSS_FEED_PATH + **self.common_context(page_url) ) output_path.parent.mkdir(parents=True, exist_ok=True) @@ -218,16 +236,9 @@ class SSGGGenerator: html = template.render( title=title, - blog_title=self.blog_title, - blog_description=self.blog_description, - navbar_items=self.navbar_items, sections=section_list, all_posts=all_posts or [], - 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 + **self.common_context("/section/") ) output_path.parent.mkdir(parents=True, exist_ok=True) @@ -247,16 +258,9 @@ class SSGGGenerator: 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, - rss_feed_enabled=ENABLE_RSS_FEED, - rss_feed_path=RSS_FEED_PATH + **self.common_context(f"/{subpath}/") ) output_path.parent.mkdir(parents=True, exist_ok=True) @@ -271,16 +275,9 @@ 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, - rss_feed_enabled=ENABLE_RSS_FEED, - rss_feed_path=RSS_FEED_PATH + **self.common_context(post['url']) ) # Create directory for the post slug (with parents for nested paths) @@ -300,16 +297,9 @@ 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, - rss_feed_enabled=ENABLE_RSS_FEED, - rss_feed_path=RSS_FEED_PATH + **self.common_context("/") ) output_path = self.output_dir / 'index.html' From 8e915711211438bfac9e9e8760d9c189923fc285 Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Sat, 4 Jul 2026 08:27:13 +0200 Subject: [PATCH 10/12] feat: ADD dropdown field for the menu #28 --- config.py | 9 ++++- theme/default/assets/style.css | 52 +++++++++++++++++++++++++++++ theme/default/templates/header.tmpl | 15 +++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/config.py b/config.py index b5e2c4d..29d0fc1 100644 --- a/config.py +++ b/config.py @@ -11,10 +11,17 @@ THEME = "default" # 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': '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'} ] diff --git a/theme/default/assets/style.css b/theme/default/assets/style.css index 4c4743c..df9be7e 100644 --- a/theme/default/assets/style.css +++ b/theme/default/assets/style.css @@ -61,6 +61,58 @@ h1 { color: #0066cc; } +/* Dropdown (one level, CSS-only, opens on hover / keyboard focus) */ +.nav-dropdown { + position: relative; + display: inline-block; +} + +.nav-dropdown-toggle { + 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; diff --git a/theme/default/templates/header.tmpl b/theme/default/templates/header.tmpl index e92c6f6..b335f66 100644 --- a/theme/default/templates/header.tmpl +++ b/theme/default/templates/header.tmpl @@ -12,7 +12,22 @@

{{ blog_description }}

From 0cbd56b6895da0e8b26abe6e3711630b179b3d89 Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Sat, 4 Jul 2026 08:54:05 +0200 Subject: [PATCH 11/12] feat: ADD 'new' function to generate new instance to avoid git clone #1 --- Dockerfile | 17 +++++++- picopaper.py | 115 ++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 119 insertions(+), 13 deletions(-) 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/picopaper.py b/picopaper.py index 206aca0..5435fa3 100644 --- a/picopaper.py +++ b/picopaper.py @@ -2,22 +2,32 @@ 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_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) +# 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. -try: - from config import STRIP_IMAGE_EXIF -except ImportError: - STRIP_IMAGE_EXIF = True +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'} @@ -543,9 +553,92 @@ class SSGGGenerator: 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() From c661f3ea071d34c5bc1296e4c4ef280d1ed369b1 Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Sat, 4 Jul 2026 09:07:57 +0200 Subject: [PATCH 12/12] ux: FIX parent menu item - correct size --- theme/default/assets/style.css | 8 +++++++- theme/default/templates/header.tmpl | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/theme/default/assets/style.css b/theme/default/assets/style.css index df9be7e..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; } @@ -64,10 +65,15 @@ h1 { /* Dropdown (one level, CSS-only, opens on hover / keyboard focus) */ .nav-dropdown { position: relative; - display: inline-block; + 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; } diff --git a/theme/default/templates/header.tmpl b/theme/default/templates/header.tmpl index b335f66..dc9faf8 100644 --- a/theme/default/templates/header.tmpl +++ b/theme/default/templates/header.tmpl @@ -15,9 +15,9 @@ {% if item.children %}