Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 375eb6fb05 | |||
| 69347dde9a | |||
| b623e17c5a | |||
| cc1cc4639c | |||
| eb0f4cf5a5 | |||
| 1b8f77cb4b | |||
| af2f800321 | |||
| 581563e9e0 | |||
| f369431c0f |
10 changed files with 151 additions and 12 deletions
10
README.md
10
README.md
|
|
@ -1,8 +1,11 @@
|
||||||
|

|
||||||
|
|
||||||
# picopaper
|
# picopaper
|
||||||
|
|
||||||
A minimal static site generator for blogs built with Python 3 and Jinja2
|
A minimal static site generator for blogs built with Python 3 and Jinja2
|
||||||
|
|
||||||
- Status: alpha - expect many changes
|
- Status: alpha - expect many changes
|
||||||
|
- [Issue Tracker](https://git.uphillsecurity.com/cf7/picopaper/issues)
|
||||||
- Goals: keeping it simple and easy to understand
|
- Goals: keeping it simple and easy to understand
|
||||||
- Demo: [picopaper.com](https://picopaper.com/)
|
- Demo: [picopaper.com](https://picopaper.com/)
|
||||||
|
|
||||||
|
|
@ -23,6 +26,7 @@ Show cases:
|
||||||
- separate feeds (used for categories, tagging, etc) `/feed/{tag}`
|
- separate feeds (used for categories, tagging, etc) `/feed/{tag}`
|
||||||
- exclusion of feeds from main feed (drafts or system notes)
|
- exclusion of feeds from main feed (drafts or system notes)
|
||||||
- HTML anchors for headers
|
- HTML anchors for headers
|
||||||
|
- list random posts at the bottom
|
||||||
|
|
||||||
**Ideas**:
|
**Ideas**:
|
||||||
- RSS
|
- RSS
|
||||||
|
|
@ -157,6 +161,12 @@ To switch themes, change the `THEME` setting in `config.py`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- [Github Mirror](https://github.com/CaffeineFueled1/picopaper)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
For security concerns or reports, please contact via `hello a t uphillsecurity d o t com` [gpg](https://uphillsecurity.com/gpg).
|
For security concerns or reports, please contact via `hello a t uphillsecurity d o t com` [gpg](https://uphillsecurity.com/gpg).
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Configuration file for picopaper blog"""
|
"""Configuration file for picopaper blog"""
|
||||||
|
|
||||||
BLOG_TITLE = "picopaper"
|
BLOG_TITLE = "PicoPaper.com"
|
||||||
BLOG_DESCRIPTION = "we like simple."
|
BLOG_DESCRIPTION = "we like simple."
|
||||||
THEME = "default"
|
THEME = "default"
|
||||||
|
|
||||||
|
|
@ -10,6 +10,12 @@ EXCLUDE_FEEDS_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
|
||||||
NAVBAR_ITEMS = [
|
NAVBAR_ITEMS = [
|
||||||
{'text': 'Home', 'url': '/'},
|
{'text': 'Home', 'url': '/'},
|
||||||
|
{'text': 'Feeds', 'url': '/feed/'},
|
||||||
{'text': 'About', 'url': '/about/'}
|
{'text': 'About', 'url': '/about/'}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Logo settings
|
||||||
|
HIDE_LOGO = False
|
||||||
|
HIDE_TITLE = True
|
||||||
|
LOGO_PATH = "/images/logo.png"
|
||||||
|
|
||||||
|
|
|
||||||
BIN
images/logo.png
Normal file
BIN
images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
|
|
@ -7,6 +7,7 @@
|
||||||
- excluding feeds in config file like [/feed/draft/](/feed/draft/)
|
- excluding feeds in config file like [/feed/draft/](/feed/draft/)
|
||||||
- static files like [/LICENSE](/LICENSE)
|
- static files like [/LICENSE](/LICENSE)
|
||||||
- html anchors for headers like [#second-header](/your-first-article/#second-header)
|
- html anchors for headers like [#second-header](/your-first-article/#second-header)
|
||||||
|
- show random posts at the end of the page
|
||||||
|
|
||||||
Everything is **[open-source](https://git.uphillsecurity.com/cf7/picopaper)**!
|
Everything is **[open-source](https://git.uphillsecurity.com/cf7/picopaper)**!
|
||||||
|
|
||||||
|
|
|
||||||
75
picopaper.py
75
picopaper.py
|
|
@ -6,7 +6,7 @@ from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
import markdown
|
import markdown
|
||||||
from config import BLOG_TITLE, BLOG_DESCRIPTION, THEME, EXCLUDE_FEEDS_FROM_MAIN, NAVBAR_ITEMS
|
from config import BLOG_TITLE, BLOG_DESCRIPTION, THEME, EXCLUDE_FEEDS_FROM_MAIN, NAVBAR_ITEMS, HIDE_LOGO, HIDE_TITLE, LOGO_PATH
|
||||||
|
|
||||||
class SSGGGenerator:
|
class SSGGGenerator:
|
||||||
def __init__(self, items_dir='items', output_dir='output', theme=None, blog_title=None, blog_description=None):
|
def __init__(self, items_dir='items', output_dir='output', theme=None, blog_title=None, blog_description=None):
|
||||||
|
|
@ -20,10 +20,21 @@ class SSGGGenerator:
|
||||||
self.blog_description = blog_description or BLOG_DESCRIPTION
|
self.blog_description = blog_description or BLOG_DESCRIPTION
|
||||||
self.exclude_feeds = EXCLUDE_FEEDS_FROM_MAIN
|
self.exclude_feeds = EXCLUDE_FEEDS_FROM_MAIN
|
||||||
self.navbar_items = NAVBAR_ITEMS
|
self.navbar_items = NAVBAR_ITEMS
|
||||||
|
self.hide_logo = HIDE_LOGO
|
||||||
|
self.hide_title = HIDE_TITLE
|
||||||
|
self.logo_path = LOGO_PATH
|
||||||
|
|
||||||
# Setup Jinja2
|
# Setup Jinja2
|
||||||
self.env = Environment(loader=FileSystemLoader(self.templates_dir))
|
self.env = Environment(loader=FileSystemLoader(self.templates_dir))
|
||||||
|
|
||||||
|
# Add custom filter for random sampling
|
||||||
|
def random_sample(items, count):
|
||||||
|
import random
|
||||||
|
items_list = list(items)
|
||||||
|
return random.sample(items_list, min(count, len(items_list)))
|
||||||
|
|
||||||
|
self.env.filters['random_sample'] = random_sample
|
||||||
|
|
||||||
# Setup markdown with toc extension for header anchors
|
# Setup markdown with toc extension for header anchors
|
||||||
self.md = markdown.Markdown(extensions=['extra', 'toc'])
|
self.md = markdown.Markdown(extensions=['extra', 'toc'])
|
||||||
|
|
||||||
|
|
@ -106,7 +117,7 @@ class SSGGGenerator:
|
||||||
'title': title,
|
'title': title,
|
||||||
'content': content,
|
'content': content,
|
||||||
'slug': parsed['name'],
|
'slug': parsed['name'],
|
||||||
'url': f"{parsed['name']}/",
|
'url': f"/{parsed['name']}/",
|
||||||
'feed': parsed['feed'],
|
'feed': parsed['feed'],
|
||||||
'source': filepath.name
|
'source': filepath.name
|
||||||
}
|
}
|
||||||
|
|
@ -118,7 +129,7 @@ class SSGGGenerator:
|
||||||
|
|
||||||
return posts
|
return posts
|
||||||
|
|
||||||
def generate_index(self, posts, feed_name=None):
|
def generate_index(self, posts, feed_name=None, all_posts=None):
|
||||||
"""Generate index.html with all posts (or feed-specific index)"""
|
"""Generate index.html with all posts (or feed-specific index)"""
|
||||||
template = self.env.get_template('index.tmpl')
|
template = self.env.get_template('index.tmpl')
|
||||||
|
|
||||||
|
|
@ -134,7 +145,11 @@ class SSGGGenerator:
|
||||||
blog_title=self.blog_title,
|
blog_title=self.blog_title,
|
||||||
blog_description=self.blog_description,
|
blog_description=self.blog_description,
|
||||||
navbar_items=self.navbar_items,
|
navbar_items=self.navbar_items,
|
||||||
posts=posts
|
posts=posts,
|
||||||
|
all_posts=all_posts or posts,
|
||||||
|
hide_logo=self.hide_logo,
|
||||||
|
hide_title=self.hide_title,
|
||||||
|
logo_path=self.logo_path
|
||||||
)
|
)
|
||||||
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -143,7 +158,41 @@ class SSGGGenerator:
|
||||||
|
|
||||||
print(f"✓ Generated {output_path}")
|
print(f"✓ Generated {output_path}")
|
||||||
|
|
||||||
def generate_post_page(self, post):
|
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')
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
'count': len(posts)
|
||||||
|
})
|
||||||
|
|
||||||
|
title = f"Feeds - {self.blog_title}"
|
||||||
|
output_path = self.output_dir / 'feed' / '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,
|
||||||
|
all_posts=all_posts or [],
|
||||||
|
hide_logo=self.hide_logo,
|
||||||
|
hide_title=self.hide_title,
|
||||||
|
logo_path=self.logo_path
|
||||||
|
)
|
||||||
|
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(output_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(html)
|
||||||
|
|
||||||
|
print(f"✓ Generated {output_path}")
|
||||||
|
|
||||||
|
def generate_post_page(self, post, all_posts=None):
|
||||||
"""Generate individual post page for 'long' posts"""
|
"""Generate individual post page for 'long' posts"""
|
||||||
template = self.env.get_template('post.tmpl')
|
template = self.env.get_template('post.tmpl')
|
||||||
|
|
||||||
|
|
@ -152,7 +201,11 @@ class SSGGGenerator:
|
||||||
blog_title=self.blog_title,
|
blog_title=self.blog_title,
|
||||||
blog_description=self.blog_description,
|
blog_description=self.blog_description,
|
||||||
navbar_items=self.navbar_items,
|
navbar_items=self.navbar_items,
|
||||||
post=post
|
post=post,
|
||||||
|
all_posts=all_posts or [],
|
||||||
|
hide_logo=self.hide_logo,
|
||||||
|
hide_title=self.hide_title,
|
||||||
|
logo_path=self.logo_path
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create directory for the post slug
|
# Create directory for the post slug
|
||||||
|
|
@ -216,7 +269,7 @@ class SSGGGenerator:
|
||||||
and p['feed'] not in self.exclude_feeds]
|
and p['feed'] not in self.exclude_feeds]
|
||||||
|
|
||||||
# Generate main index with filtered feed posts
|
# Generate main index with filtered feed posts
|
||||||
self.generate_index(feed_posts)
|
self.generate_index(feed_posts, all_posts=feed_posts)
|
||||||
|
|
||||||
# Group posts by feed (include all posts, not just those in main feed)
|
# Group posts by feed (include all posts, not just those in main feed)
|
||||||
feeds = {}
|
feeds = {}
|
||||||
|
|
@ -226,12 +279,16 @@ class SSGGGenerator:
|
||||||
|
|
||||||
# Generate feed-specific pages
|
# Generate feed-specific pages
|
||||||
for feed_name, posts in feeds.items():
|
for feed_name, posts in feeds.items():
|
||||||
self.generate_index(posts, feed_name)
|
self.generate_index(posts, feed_name, all_posts=feed_posts)
|
||||||
|
|
||||||
|
# Generate feeds overview page
|
||||||
|
if feeds:
|
||||||
|
self.generate_feeds_overview(feeds, all_posts=feed_posts)
|
||||||
|
|
||||||
# Generate individual pages for long posts, short posts, and pages
|
# Generate individual pages for long posts, short posts, and pages
|
||||||
for post in all_posts:
|
for post in all_posts:
|
||||||
if post['type'] in ['long', 'short', 'page']:
|
if post['type'] in ['long', 'short', 'page']:
|
||||||
self.generate_post_page(post)
|
self.generate_post_page(post, all_posts=feed_posts)
|
||||||
|
|
||||||
# Copy assets
|
# Copy assets
|
||||||
self.copy_assets()
|
self.copy_assets()
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ header {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
|
|
@ -22,7 +23,13 @@ hr {
|
||||||
border: 1px solid #efefef;
|
border: 1px solid #efefef;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 { margin: 0; }
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
.blog-description {
|
.blog-description {
|
||||||
margin: 10px 0 0 0;
|
margin: 10px 0 0 0;
|
||||||
|
|
@ -33,6 +40,10 @@ h1 { margin: 0; }
|
||||||
.main-nav {
|
.main-nav {
|
||||||
margin-top: 15px;
|
margin-top: 15px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
|
|
|
||||||
20
theme/default/templates/feeds.tmpl
Normal file
20
theme/default/templates/feeds.tmpl
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
{% include 'meta.tmpl' %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include 'header.tmpl' %}
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h2>Feeds</h2>
|
||||||
|
<ul>
|
||||||
|
{% for feed in feeds %}
|
||||||
|
<li><a href="/feed/{{ feed.name }}/">{{ feed.name }}</a> ({{ feed.count }} posts)</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{% include 'footer.tmpl' %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
<header>
|
<header>
|
||||||
<h1><a href="/" style="color: #333; text-decoration: none;">{{ blog_title }}</a></h1>
|
<h1>
|
||||||
|
<a href="/" style="color: #333; text-decoration: none;">
|
||||||
|
{% if not hide_logo %}
|
||||||
|
<img src="{{ logo_path }}" alt="{{ blog_title }}" class="header-logo">
|
||||||
|
{% endif %}
|
||||||
|
{% if not hide_title %}
|
||||||
|
{{ blog_title }}
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</h1>
|
||||||
<p class="blog-description">{{ blog_description }}</p>
|
<p class="blog-description">{{ blog_description }}</p>
|
||||||
<nav class="main-nav">
|
<nav class="main-nav">
|
||||||
{% for item in navbar_items %}
|
{% for item in navbar_items %}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@
|
||||||
{{ post.content | safe }}
|
{{ post.content | safe }}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
{% include 'random_posts.tmpl' %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{% include 'footer.tmpl' %}
|
{% include 'footer.tmpl' %}
|
||||||
|
|
|
||||||
23
theme/default/templates/random_posts.tmpl
Normal file
23
theme/default/templates/random_posts.tmpl
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{% if all_posts %}
|
||||||
|
{# Filter out pages and current post, then randomly select 5 #}
|
||||||
|
{% set available_posts = all_posts | selectattr('type', 'ne', 'page') | list %}
|
||||||
|
{% if post %}
|
||||||
|
{% set available_posts = available_posts | rejectattr('slug', 'equalto', post.slug) | list %}
|
||||||
|
{% endif %}
|
||||||
|
{% if available_posts %}
|
||||||
|
{% set random_posts = available_posts | random_sample(5) %}
|
||||||
|
{% if random_posts %}
|
||||||
|
<div class="random-posts">
|
||||||
|
<h3>More Posts</h3>
|
||||||
|
<ul>
|
||||||
|
{% for p in random_posts %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ p.url }}">{{ p.title }}</a>
|
||||||
|
<span class="date">{{ p.date }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue