Compare commits
39 Commits
0.2.0
...
ad81d7f3db
| Author | SHA1 | Date | |
|---|---|---|---|
| ad81d7f3db | |||
| c9a3a21f07 | |||
| c99bced56e | |||
| c12c8b0a89 | |||
| 17145628a0 | |||
| 195c353710 | |||
| 8c23e9d811 | |||
| 5a56496538 | |||
| 9c06401557 | |||
| 9b1b84e5be | |||
| 23cc4c3876 | |||
| 9e62a84843 | |||
| dda3be0101 | |||
| 3fd24c75fc | |||
| 07bb33006e | |||
| aab53f1e54 | |||
| 0e6ca5859a | |||
| 7986ad2f88 | |||
| 7c4c20b3ce | |||
| b407497713 | |||
| 90d20978b1 | |||
| 1a26b0b3fb | |||
| 71efbfcc83 | |||
| 27ef2d4ca3 | |||
| 1aa1964853 | |||
| aae43a0001 | |||
| 61392e296c | |||
| 997afcdd9e | |||
| 5a611dd893 | |||
| 2adde253c9 | |||
| 744693a5f1 | |||
| 102c7a2b94 | |||
| 74a010e82a | |||
| 23ce3be362 | |||
| f12247b0b1 | |||
| 2605f7db37 | |||
| 3898a198bd | |||
| d901f00c1f | |||
| fc211edc77 |
@ -7,15 +7,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Datadog Static Analyzer
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Check code for comitted secrets
|
||||
id: datadog-static-analysis
|
||||
uses: DataDog/datadog-static-analyzer-github-action@v1
|
||||
with:
|
||||
dd_api_key: ${{ secrets.DD_API_KEY }}
|
||||
dd_app_key: ${{ secrets.DD_APP_KEY }}
|
||||
dd_site: datadoghq.com
|
||||
secrets_enabled: true
|
||||
static_analysis_enabled: false
|
||||
cpu_count: 2
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Check code for comitted secrets
|
||||
id: datadog-static-analysis
|
||||
uses: DataDog/datadog-static-analyzer-github-action@v1
|
||||
with:
|
||||
dd_api_key: ${{ secrets.DD_API_KEY }}
|
||||
dd_app_key: ${{ secrets.DD_APP_KEY }}
|
||||
dd_site: datadoghq.com
|
||||
secrets_enabled: true
|
||||
static_analysis_enabled: false
|
||||
cpu_count: 8
|
||||
|
||||
@ -17,3 +17,25 @@ jobs:
|
||||
dd_app_key: ${{ secrets.DD_APP_KEY }}
|
||||
dd_site: datadoghq.com
|
||||
cpu_count: 2
|
||||
- name: Run Semgrep
|
||||
run: |
|
||||
python3 -m pip install --break-system-package semgrep
|
||||
semgrep scan --sarif -o /tmp/semgrep.sarif
|
||||
cat /tmp/semgrep.sarif
|
||||
# Download and install nvm:
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash
|
||||
# in lieu of restarting the shell
|
||||
\. "$HOME/.nvm/nvm.sh"
|
||||
# Download and install Node.js:
|
||||
nvm install 22
|
||||
# Verify the Node.js version:
|
||||
node -v # Should print "v22.14.0".
|
||||
nvm current # Should print "v22.14.0".
|
||||
# Verify npm version:
|
||||
npm -v # Should print "10.9.2".
|
||||
npm install -g @datadog/datadog-ci
|
||||
datadog-ci sarif upload /tmp/semgrep.sarif
|
||||
env:
|
||||
DD_API_KEY: ${{ secrets.DD_API_KEY }}
|
||||
DD_APP_KEY: ${{ secrets.DD_APP_KEY }}
|
||||
DD_SITE: datadoghq.com
|
||||
@ -3,6 +3,9 @@ name: Release
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@ -42,3 +45,28 @@ jobs:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
publish_head:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: git.dws.rip
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GLOBAL_KEY }}
|
||||
|
||||
- name: Build and push "head" image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: git.dws.rip/${{ github.repository }}:head
|
||||
86
CLAUDE.md
Normal file
86
CLAUDE.md
Normal file
@ -0,0 +1,86 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Foldsite is a dynamic site generator built with Python and Flask. It serves Markdown content as HTML pages using Jinja2 templates and CSS styles. The application follows a modular architecture with clear separation of concerns.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Running the Application
|
||||
```bash
|
||||
python main.py --config config.toml
|
||||
```
|
||||
|
||||
### Managing Dependencies
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Or using uv (if available)
|
||||
uv pip install -r requirements.txt
|
||||
|
||||
# Update dependencies from pyproject.toml
|
||||
uv pip compile pyproject.toml -o requirements.txt
|
||||
```
|
||||
|
||||
### Docker Development
|
||||
```bash
|
||||
# Build Docker image
|
||||
docker build -t foldsite .
|
||||
|
||||
# Run with Docker Compose
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **Server** (`src/server/server.py`): Flask application wrapped with Gunicorn for production serving. Handles template function registration and route management.
|
||||
|
||||
2. **Configuration** (`src/config/`): TOML-based configuration system managing paths, server settings, and application options.
|
||||
|
||||
3. **Route Management** (`src/routes/routes.py`): Handles URL routing with path validation and security. Serves content, styles, and static files with thumbnail generation for images.
|
||||
|
||||
4. **Rendering System** (`src/rendering/`):
|
||||
- `renderer.py`: Main page rendering logic
|
||||
- `markdown.py`: Markdown to HTML conversion with frontmatter support
|
||||
- `helpers.py`: Template helper functions for content discovery
|
||||
- `image.py`: Thumbnail generation for images
|
||||
|
||||
5. **File Manager** (`src/server/file_manager.py`): Optional admin interface for content management (when `admin_browser` is enabled).
|
||||
|
||||
### Template System
|
||||
|
||||
The application uses Jinja2 templates with custom helper functions:
|
||||
- `get_sibling_content_files(path)`: Returns list of sibling content files
|
||||
- `get_text_document_preview(path)`: Generates text document previews
|
||||
- `get_sibling_content_folders(path)`: Returns list of sibling folders
|
||||
- `get_folder_contents(path)`: Retrieves folder contents as TemplateFile objects
|
||||
|
||||
### Directory Structure
|
||||
|
||||
- `src/`: Main application source code
|
||||
- `config/`: Configuration handling
|
||||
- `rendering/`: Content rendering and processing
|
||||
- `routes/`: URL routing and request handling
|
||||
- `server/`: Flask server and file management
|
||||
- `docs/content/`: Site content (Markdown files)
|
||||
- `docs/templates/`: Jinja2 HTML templates
|
||||
- `docs/styles/`: CSS stylesheets
|
||||
- `config.toml`: Application configuration
|
||||
|
||||
### Configuration
|
||||
|
||||
The application uses TOML configuration with sections for:
|
||||
- `[paths]`: Directory paths for content, templates, and styles
|
||||
- `[server]`: Server settings including address, port, debug mode, and admin options
|
||||
|
||||
### Security Features
|
||||
|
||||
- Path traversal protection in route handlers
|
||||
- Hidden file/folder access restrictions
|
||||
- Configurable admin interface with password protection
|
||||
- Input validation and sanitization for file paths
|
||||
186
README.md
186
README.md
@ -0,0 +1,186 @@
|
||||
# Foldsite
|
||||
|
||||
Foldsite is a dynamic site generator built with Python and Flask. It allows you to create and manage a website using Markdown content, HTML templates, and CSS styles.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Foldsite](#foldsite)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Configuration](#configuration)
|
||||
- [Template Setup](#template-setup)
|
||||
- [Site Setup](#site-setup)
|
||||
- [Style Setup](#style-setup)
|
||||
- [Template and Style Search](#template-and-style-search)
|
||||
- [How a Template is Written](#how-a-template-is-written)
|
||||
- [Jinja Primer](#jinja-primer)
|
||||
- [Added Tools for the Template](#added-tools-for-the-template)
|
||||
- [Tool Input and Return Types](#tool-input-and-return-types)
|
||||
- [`get_sibling_content_files(path: str) -> list`](#get_sibling_content_filespath-str---list)
|
||||
- [`get_text_document_preview(path: str) -> str`](#get_text_document_previewpath-str---str)
|
||||
- [`get_sibling_content_folders(path: str) -> list`](#get_sibling_content_folderspath-str---list)
|
||||
- [`get_folder_contents(path: str) -> list`](#get_folder_contentspath-str---list)
|
||||
- [Example Usages for Tools and Types](#example-usages-for-tools-and-types)
|
||||
- [Example Usage of `get_sibling_content_files`](#example-usage-of-get_sibling_content_files)
|
||||
- [Example Usage of `get_text_document_preview`](#example-usage-of-get_text_document_preview)
|
||||
- [Example Usage of `get_sibling_content_folders`](#example-usage-of-get_sibling_content_folders)
|
||||
- [Example Usage of `get_folder_contents`](#example-usage-of-get_folder_contents)
|
||||
- [Deployment](#deployment)
|
||||
- [Docker Compose Example](#docker-compose-example)
|
||||
|
||||
## Configuration
|
||||
|
||||
The configuration file is written in TOML format and contains various settings for the application. Below is an example configuration file (`config.toml`):
|
||||
|
||||
```toml
|
||||
[paths]
|
||||
content_dir = "example/content"
|
||||
templates_dir = "templates"
|
||||
styles_dir = "styles"
|
||||
|
||||
[server]
|
||||
listen_address = "127.0.0.1"
|
||||
listen_port = 8080
|
||||
debug = false
|
||||
access_log = true
|
||||
max_threads = 4
|
||||
admin_browser = false
|
||||
admin_password = "your_admin_password"
|
||||
```
|
||||
|
||||
## Template Setup
|
||||
|
||||
Templates are HTML files that define the structure of your web pages. They are stored in the `templates` directory. Each template can include other templates and use Jinja2 syntax for dynamic content.
|
||||
|
||||
## Site Setup
|
||||
|
||||
The site content is stored in the `content` directory. Each Markdown file represents a page on your site. The directory structure of the `content` directory determines the URL structure of your site.
|
||||
|
||||
## Style Setup
|
||||
|
||||
Styles are CSS files that define the appearance of your web pages. They are stored in the `styles` directory. You can create specific styles for different types of content and categories.
|
||||
|
||||
## Template and Style Search
|
||||
|
||||
Templates and styles are searched in a specific order to apply the most specific styles first, followed by more general styles, and finally the base style.
|
||||
|
||||
## How a Template is Written
|
||||
|
||||
Templates are written in HTML and use Jinja2 syntax for dynamic content. Below is an example template (`base.html`):
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ title }}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='base.css') }}">
|
||||
{% for style in styles %}
|
||||
<link rel="stylesheet" href="{{ style }}">
|
||||
{% endfor %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="content">
|
||||
{{ content }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Jinja Primer
|
||||
|
||||
Jinja2 is a templating engine for Python. It allows you to include dynamic content in your HTML templates. Below are some basic Jinja2 syntax examples:
|
||||
|
||||
- Variables: `{{ variable }}`
|
||||
- Loops: `{% for item in list %} ... {% endfor %}`
|
||||
- Conditionals: `{% if condition %} ... {% endif %}`
|
||||
- Includes: `{% include 'template.html' %}`
|
||||
|
||||
## Added Tools for the Template
|
||||
|
||||
Foldsite provides additional tools for templates, such as functions to get sibling content files, text document previews, and folder contents.
|
||||
|
||||
## Tool Input and Return Types
|
||||
|
||||
### `get_sibling_content_files(path: str) -> list`
|
||||
Returns a list of sibling content files in the specified directory.
|
||||
|
||||
### `get_text_document_preview(path: str) -> str`
|
||||
Generates a preview of the text document located at the given path.
|
||||
|
||||
### `get_sibling_content_folders(path: str) -> list`
|
||||
Returns a list of sibling content folders within a specified directory.
|
||||
|
||||
### `get_folder_contents(path: str) -> list`
|
||||
Retrieves the contents of a folder and returns a list of `TemplateFile` objects.
|
||||
|
||||
## Example Usages for Tools and Types
|
||||
|
||||
### Example Usage of `get_sibling_content_files`
|
||||
|
||||
```html
|
||||
<ul>
|
||||
{% for file in get_sibling_content_files('path/to/directory') %}
|
||||
<li>{{ file[0] }} - {{ file[1] }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
```
|
||||
|
||||
### Example Usage of `get_text_document_preview`
|
||||
|
||||
```html
|
||||
<div>
|
||||
{{ get_text_document_preview('path/to/document.md') }}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Example Usage of `get_sibling_content_folders`
|
||||
|
||||
```html
|
||||
<ul>
|
||||
{% for folder in get_sibling_content_folders('path/to/directory') %}
|
||||
<li>{{ folder[0] }} - {{ folder[1] }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
```
|
||||
|
||||
### Example Usage of `get_folder_contents`
|
||||
|
||||
```html
|
||||
<ul>
|
||||
{% for item in get_folder_contents('path/to/directory') %}
|
||||
<li>{{ item.name }} - {{ item.path }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
To deploy Foldsite, you can use Docker. Below is an example Dockerfile:
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.13.2-bookworm
|
||||
WORKDIR /app
|
||||
COPY requirements.txt requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
CMD ["python", "main.py"]
|
||||
```
|
||||
|
||||
|
||||
## Docker Compose Example
|
||||
|
||||
Below is an example `docker-compose.yml` file to deploy Foldsite using Docker Compose:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
foldsite:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- .:/app
|
||||
environment:
|
||||
- CONFIG_PATH=config.toml
|
||||
```
|
||||
|
||||
16
config.toml
Normal file
16
config.toml
Normal file
@ -0,0 +1,16 @@
|
||||
[paths]
|
||||
content_dir = "/home/dubey/projects/foldsite/docs/content"
|
||||
templates_dir = "/home/dubey/projects/foldsite/docs/templates"
|
||||
styles_dir = "/home/dubey/projects/foldsite/docs/styles"
|
||||
|
||||
[server]
|
||||
listen_address = "0.0.0.0"
|
||||
listen_port = 8081
|
||||
admin_browser = true
|
||||
admin_password = "password"
|
||||
max_threads = 4
|
||||
debug = false
|
||||
access_log = true
|
||||
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
[paths]
|
||||
content_dir = "/home/dubey/projects/foldsite/example/content"
|
||||
templates_dir = "/home/dubey/projects/foldsite/example/templates"
|
||||
styles_dir = "/home/dubey/projects/foldsite/example/styles"
|
||||
content_dir = "/home/dubey/projects/foldsite/docs/content"
|
||||
templates_dir = "/home/dubey/projects/foldsite/docs/templates"
|
||||
styles_dir = "/home/dubey/projects/foldsite/docs/styles"
|
||||
|
||||
[server]
|
||||
listen_address = "0.0.0.0"
|
||||
|
||||
219
docs/content/about.md
Normal file
219
docs/content/about.md
Normal file
@ -0,0 +1,219 @@
|
||||
---
|
||||
version: "1.0"
|
||||
date: "2025-01-15"
|
||||
author: "DWS Foldsite Team"
|
||||
title: "About Foldsite"
|
||||
description: "The philosophy and story behind Foldsite"
|
||||
summary: "Learn why Foldsite was created and how it empowers you to reclaim your corner of the internet with simple, file-based content management."
|
||||
quick_tips:
|
||||
- "Foldsite is part of the DWS mission to help people own their web presence"
|
||||
- "Your content stays as simple files and folders - no database lock-in"
|
||||
- "Built for people who want to focus on content, not configuration"
|
||||
---
|
||||
|
||||
# About Foldsite
|
||||
|
||||
## The Vision
|
||||
|
||||
**Foldsite** exists to make creating and hosting your own website as simple as organizing files on your computer. It's part of the broader [DWS (Dubey Web Services)](https://dws.rip) mission: **"It's your Internet. Take it back."**
|
||||
|
||||
In an era where social media platforms control your content and complex CMSs require constant maintenance, Foldsite offers a refreshing alternative: your website is just folders and files on your filesystem.
|
||||
|
||||
## Why Foldsite Was Created
|
||||
|
||||
### The Problem
|
||||
|
||||
Modern web development has become unnecessarily complex:
|
||||
|
||||
- **Content Management Systems** require databases, constant updates, and security patches
|
||||
- **Static Site Generators** force you to learn specific frameworks and build processes
|
||||
- **Social Media Platforms** own your content and can remove it at any time
|
||||
- **Blog Platforms** lock you into their ecosystem with proprietary formats
|
||||
|
||||
### The Solution
|
||||
|
||||
Foldsite strips away the complexity:
|
||||
|
||||
1. **Your content is just files** - Markdown, images, PDFs - organize them however makes sense
|
||||
2. **Your structure is your site** - Folders become URLs automatically
|
||||
3. **Templates are optional** - Start with defaults, customize when you need
|
||||
4. **No build step required** - Dynamic server or export to static files
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. Content Ownership
|
||||
|
||||
Your content lives in simple text files using standard markdown format. No proprietary databases, no vendor lock-in. Take your files anywhere - they'll work with any markdown tool.
|
||||
|
||||
### 2. Convention Over Configuration
|
||||
|
||||
Foldsite uses sensible defaults based on file types and folder structure:
|
||||
|
||||
- Markdown files become pages
|
||||
- Image folders become galleries
|
||||
- Folder names become navigation
|
||||
- No routing configuration needed
|
||||
|
||||
### 3. Progressive Enhancement
|
||||
|
||||
Start with the simplest possible setup:
|
||||
|
||||
```
|
||||
content/
|
||||
└── index.md
|
||||
```
|
||||
|
||||
Add complexity only when you need it:
|
||||
|
||||
```
|
||||
content/
|
||||
├── index.md
|
||||
├── blog/
|
||||
│ └── post.md
|
||||
templates/
|
||||
├── base.html
|
||||
└── __file.md.html
|
||||
styles/
|
||||
└── base.css
|
||||
```
|
||||
|
||||
### 4. Developer Friendly
|
||||
|
||||
When things don't work as expected:
|
||||
- Clear error messages explain what went wrong
|
||||
- Debug mode shows template discovery process
|
||||
- File-based structure makes troubleshooting intuitive
|
||||
|
||||
## Who Foldsite Is For
|
||||
|
||||
### Content Creators
|
||||
|
||||
Writers, photographers, artists who want to share their work without fighting with technology. Focus on creating, not configuring.
|
||||
|
||||
### Personal Website Owners
|
||||
|
||||
People who want a simple blog, portfolio, or personal site without the overhead of WordPress or the limitations of site builders.
|
||||
|
||||
### Documentation Writers
|
||||
|
||||
Technical writers and project maintainers who need clean, navigable documentation that mirrors their mental model.
|
||||
|
||||
### Privacy-Conscious Individuals
|
||||
|
||||
Anyone who wants to control their web presence without relying on platforms that monetize user data.
|
||||
|
||||
### Hobbyist Developers
|
||||
|
||||
Developers who appreciate simple, understandable tools and want to hack on their personal sites without complex build pipelines.
|
||||
|
||||
## What Foldsite Is NOT
|
||||
|
||||
To set clear expectations:
|
||||
|
||||
- **Not a CMS replacement** - No admin UI for non-technical users (though an optional file manager exists)
|
||||
- **Not optimized for huge sites** - Works best with hundreds to thousands of pages, not millions
|
||||
- **Not a full application framework** - It renders content, doesn't handle complex application logic
|
||||
- **Not trying to replace everything** - It's a focused tool for a specific use case
|
||||
|
||||
## The Technology
|
||||
|
||||
Foldsite is built with:
|
||||
|
||||
- **Python & Flask** - Proven, stable web framework
|
||||
- **Jinja2** - Powerful templating with familiar syntax
|
||||
- **Markdown** - Universal content format
|
||||
- **No JavaScript required** - Works with JS disabled (progressive enhancement)
|
||||
|
||||
It can run as:
|
||||
- **Dynamic server** - Live rendering with Python
|
||||
- **Static site** - Export to HTML files
|
||||
- **Docker container** - Easy deployment anywhere
|
||||
|
||||
## The DWS Philosophy
|
||||
|
||||
DWS (Dubey Web Services) believes the internet should be:
|
||||
|
||||
### Decentralized
|
||||
|
||||
Not controlled by a handful of mega-platforms. Everyone should be able to host their own corner of the web.
|
||||
|
||||
### Simple
|
||||
|
||||
Technology should serve users, not the other way around. Complexity is often unnecessary.
|
||||
|
||||
### Yours
|
||||
|
||||
You should own your content, your presentation, and your digital presence.
|
||||
|
||||
### Open
|
||||
|
||||
Open source tools, open standards, open community. No lock-in, no secrets.
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Personal Blog
|
||||
|
||||
Write in markdown, organize by topic or date, let Foldsite handle the rest. Built-in helpers for recent posts, tags, and related content.
|
||||
|
||||
### Photography Portfolio
|
||||
|
||||
Upload images to folders, automatic EXIF extraction, thumbnail generation, and gallery views. Organize by project, date, or theme.
|
||||
|
||||
### Documentation Site
|
||||
|
||||
Mirror your project structure in folders, automatic navigation and breadcrumbs, searchable content hierarchy.
|
||||
|
||||
### Digital Garden
|
||||
|
||||
Non-linear, interconnected notes and thoughts. Flexible organization, easy linking between pages.
|
||||
|
||||
### Project Showcase
|
||||
|
||||
Portfolio of work with custom templates per project. Mix markdown descriptions with galleries and downloads.
|
||||
|
||||
## The Name
|
||||
|
||||
**Foldsite** = **Fold**er + Web**site**
|
||||
|
||||
Your folders become your site. Simple as that.
|
||||
|
||||
## Community & Contribution
|
||||
|
||||
Foldsite is open source and welcomes contributions:
|
||||
|
||||
- **Use it** - Build your site and share what you create
|
||||
- **Improve it** - Submit bug fixes and features
|
||||
- **Extend it** - Create themes and templates
|
||||
- **Share it** - Tell others about the project
|
||||
|
||||
See the [Develop Section](develop/) for contribution guidelines.
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **Documentation** - You're reading it! Check other sections for specific topics
|
||||
- **Examples** - See [Explore Foldsites](explore.md) for real-world sites
|
||||
- **Support** - Visit [Support](support.md) for help channels
|
||||
- **Source Code** - [GitHub Repository](https://github.com/DWSresearch/foldsite)
|
||||
|
||||
## Future Vision
|
||||
|
||||
Foldsite aims to remain focused and simple while adding:
|
||||
|
||||
- More template helpers for common use cases
|
||||
- Better performance optimization
|
||||
- Enhanced developer tooling
|
||||
- Growing library of themes and recipes
|
||||
- Strong community of Foldsite users
|
||||
|
||||
The goal is never to become a monolithic CMS, but to remain a focused tool that does one thing well: turns folders into websites.
|
||||
|
||||
## Start Building
|
||||
|
||||
Ready to create your own site?
|
||||
|
||||
- [Quick Start Guide](index.md#quick-start)
|
||||
- [Directory Structure](directory-structure.md)
|
||||
- [Deployment Options](deployment/)
|
||||
- [Template Recipes](recipes/)
|
||||
|
||||
**It's your internet. Take it back.**
|
||||
735
docs/content/deployment/docker.md
Normal file
735
docs/content/deployment/docker.md
Normal file
@ -0,0 +1,735 @@
|
||||
---
|
||||
version: "1.0"
|
||||
date: "2025-01-15"
|
||||
author: "DWS Foldsite Team"
|
||||
title: "Docker Deployment"
|
||||
description: "Running Foldsite in Docker containers"
|
||||
summary: "Complete guide to deploying Foldsite with Docker for consistent, isolated, and portable deployments."
|
||||
quick_tips:
|
||||
- "Docker ensures consistent environments across development and production"
|
||||
- "Use docker-compose for easy multi-container setup"
|
||||
- "Mount content as volumes for live updates without rebuilding"
|
||||
---
|
||||
|
||||
# Docker Deployment
|
||||
|
||||
Docker provides isolated, reproducible deployments of Foldsite. Perfect for testing, staging, and production environments.
|
||||
|
||||
## Why Docker?
|
||||
|
||||
**Benefits:**
|
||||
- **Consistency** - Same environment everywhere
|
||||
- **Isolation** - Dependencies don't conflict with system
|
||||
- **Portability** - Run anywhere Docker runs
|
||||
- **Easy deployment** - Single command to start
|
||||
- **Version control** - Docker images are versioned
|
||||
|
||||
**Use cases:**
|
||||
- Team development (everyone has same environment)
|
||||
- Staging environments before production
|
||||
- Production deployments
|
||||
- CI/CD pipelines
|
||||
- Cloud platform deployments
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Install Docker
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
# Download Docker Desktop from docker.com
|
||||
# Or use Homebrew
|
||||
brew install --cask docker
|
||||
```
|
||||
|
||||
**Linux (Ubuntu/Debian):**
|
||||
```bash
|
||||
# Update package index
|
||||
sudo apt update
|
||||
|
||||
# Install Docker
|
||||
sudo apt install docker.io docker-compose
|
||||
|
||||
# Add your user to docker group
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# Log out and back in for group changes
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
# Download Docker Desktop from docker.com
|
||||
# Requires WSL2
|
||||
```
|
||||
|
||||
**Verify installation:**
|
||||
```bash
|
||||
docker --version
|
||||
docker-compose --version
|
||||
```
|
||||
|
||||
## Quick Start with Docker Compose
|
||||
|
||||
### Step 1: Create docker-compose.yml
|
||||
|
||||
In your Foldsite directory:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
foldsite:
|
||||
image: python:3.11-slim
|
||||
container_name: foldsite
|
||||
working_dir: /app
|
||||
command: >
|
||||
sh -c "pip install -r requirements.txt &&
|
||||
python main.py --config config.toml"
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./my-site/content:/app/content
|
||||
- ./my-site/templates:/app/templates
|
||||
- ./my-site/styles:/app/styles
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### Step 2: Create config.toml for Docker
|
||||
|
||||
```toml
|
||||
[paths]
|
||||
content_dir = "/app/content"
|
||||
templates_dir = "/app/templates"
|
||||
styles_dir = "/app/styles"
|
||||
|
||||
[server]
|
||||
listen_address = "0.0.0.0" # Important: bind to all interfaces
|
||||
listen_port = 8081
|
||||
admin_browser = false
|
||||
max_threads = 4
|
||||
debug = false
|
||||
access_log = true
|
||||
```
|
||||
|
||||
### Step 3: Start Container
|
||||
|
||||
```bash
|
||||
# Start in background
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stop
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
Visit `http://localhost:8081` to see your site!
|
||||
|
||||
## Building a Custom Docker Image
|
||||
|
||||
For production, build a dedicated Foldsite image:
|
||||
|
||||
### Create Dockerfile
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first (for caching)
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create directories for content
|
||||
RUN mkdir -p /content /templates /styles
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8081
|
||||
|
||||
# Run application
|
||||
CMD ["python", "main.py", "--config", "/app/config.toml"]
|
||||
```
|
||||
|
||||
### Build Image
|
||||
|
||||
```bash
|
||||
# Build image
|
||||
docker build -t foldsite:latest .
|
||||
|
||||
# Tag for versioning
|
||||
docker tag foldsite:latest foldsite:1.0.0
|
||||
```
|
||||
|
||||
### Run Container
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name foldsite \
|
||||
-p 8081:8081 \
|
||||
-v $(pwd)/my-site/content:/content \
|
||||
-v $(pwd)/my-site/templates:/templates \
|
||||
-v $(pwd)/my-site/styles:/styles \
|
||||
foldsite:latest
|
||||
```
|
||||
|
||||
## Development with Docker
|
||||
|
||||
### Hot Reload Setup
|
||||
|
||||
Mount your code as volumes for live updates:
|
||||
|
||||
```yaml
|
||||
# docker-compose.dev.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
foldsite:
|
||||
build: .
|
||||
container_name: foldsite-dev
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
# Mount everything for development
|
||||
- .:/app
|
||||
- ./my-site/content:/app/content
|
||||
- ./my-site/templates:/app/templates
|
||||
- ./my-site/styles:/app/styles
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- FLASK_ENV=development
|
||||
command: >
|
||||
sh -c "pip install -r requirements.txt &&
|
||||
python main.py --config config.toml"
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Start development environment
|
||||
docker-compose -f docker-compose.dev.yml up
|
||||
|
||||
# Changes to content, templates, and styles appear immediately
|
||||
# Code changes require restart
|
||||
```
|
||||
|
||||
### Interactive Development
|
||||
|
||||
Run commands inside container:
|
||||
|
||||
```bash
|
||||
# Start bash session in running container
|
||||
docker exec -it foldsite bash
|
||||
|
||||
# Inside container, you can:
|
||||
python main.py --config config.toml
|
||||
pip install new-package
|
||||
ls /app/content
|
||||
```
|
||||
|
||||
## Production Docker Setup
|
||||
|
||||
### Multi-Stage Build
|
||||
|
||||
Optimize image size with multi-stage build:
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile.production
|
||||
# Stage 1: Build dependencies
|
||||
FROM python:3.11-slim as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --user --no-cache-dir -r requirements.txt
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy Python dependencies from builder
|
||||
COPY --from=builder /root/.local /root/.local
|
||||
|
||||
# Copy application
|
||||
COPY . .
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -u 1000 foldsite && \
|
||||
chown -R foldsite:foldsite /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER foldsite
|
||||
|
||||
# Make sure scripts in .local are usable
|
||||
ENV PATH=/root/.local/bin:$PATH
|
||||
|
||||
EXPOSE 8081
|
||||
|
||||
# Use Gunicorn for production
|
||||
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8081", "main:app"]
|
||||
```
|
||||
|
||||
### Production docker-compose.yml
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
foldsite:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.production
|
||||
container_name: foldsite-prod
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
- ./content:/app/content:ro # Read-only
|
||||
- ./templates:/app/templates:ro
|
||||
- ./styles:/app/styles:ro
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
restart: always
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8081/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
```
|
||||
|
||||
## Docker Compose Examples
|
||||
|
||||
### With Nginx Reverse Proxy
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
foldsite:
|
||||
build: .
|
||||
container_name: foldsite
|
||||
expose:
|
||||
- "8081"
|
||||
volumes:
|
||||
- ./content:/app/content:ro
|
||||
- ./templates:/app/templates:ro
|
||||
- ./styles:/app/styles:ro
|
||||
restart: always
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./ssl:/etc/nginx/ssl:ro
|
||||
depends_on:
|
||||
- foldsite
|
||||
restart: always
|
||||
```
|
||||
|
||||
**nginx.conf:**
|
||||
```nginx
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
upstream foldsite {
|
||||
server foldsite:8081;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://foldsite;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
|
||||
proxy_pass http://foldsite;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Sites
|
||||
|
||||
Run multiple Foldsite instances:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
blog:
|
||||
build: .
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
- ./blog/content:/app/content
|
||||
- ./blog/templates:/app/templates
|
||||
- ./blog/styles:/app/styles
|
||||
restart: always
|
||||
|
||||
portfolio:
|
||||
build: .
|
||||
ports:
|
||||
- "8082:8081"
|
||||
volumes:
|
||||
- ./portfolio/content:/app/content
|
||||
- ./portfolio/templates:/app/templates
|
||||
- ./portfolio/styles:/app/styles
|
||||
restart: always
|
||||
```
|
||||
|
||||
## Volume Management
|
||||
|
||||
### Content Volumes
|
||||
|
||||
**Development** - Mount host directories:
|
||||
```yaml
|
||||
volumes:
|
||||
- ./my-site/content:/app/content
|
||||
- ./my-site/templates:/app/templates
|
||||
- ./my-site/styles:/app/styles
|
||||
```
|
||||
|
||||
**Production** - Read-only mounts:
|
||||
```yaml
|
||||
volumes:
|
||||
- ./content:/app/content:ro
|
||||
- ./templates:/app/templates:ro
|
||||
- ./styles:/app/styles:ro
|
||||
```
|
||||
|
||||
### Named Volumes
|
||||
|
||||
For persistent data:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
foldsite:
|
||||
volumes:
|
||||
- content-data:/app/content
|
||||
- templates-data:/app/templates
|
||||
|
||||
volumes:
|
||||
content-data:
|
||||
templates-data:
|
||||
```
|
||||
|
||||
**Backup named volumes:**
|
||||
```bash
|
||||
# Backup
|
||||
docker run --rm \
|
||||
-v foldsite_content-data:/data \
|
||||
-v $(pwd):/backup \
|
||||
alpine tar czf /backup/content-backup.tar.gz -C /data .
|
||||
|
||||
# Restore
|
||||
docker run --rm \
|
||||
-v foldsite_content-data:/data \
|
||||
-v $(pwd):/backup \
|
||||
alpine tar xzf /backup/content-backup.tar.gz -C /data
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Pass configuration via environment:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
foldsite:
|
||||
environment:
|
||||
- FOLDSITE_DEBUG=false
|
||||
- FOLDSITE_PORT=8081
|
||||
- FOLDSITE_MAX_THREADS=4
|
||||
```
|
||||
|
||||
**Use in config:**
|
||||
```python
|
||||
# In a config loader
|
||||
import os
|
||||
|
||||
debug = os.getenv('FOLDSITE_DEBUG', 'false').lower() == 'true'
|
||||
port = int(os.getenv('FOLDSITE_PORT', '8081'))
|
||||
```
|
||||
|
||||
## Common Docker Commands
|
||||
|
||||
### Container Management
|
||||
|
||||
```bash
|
||||
# Start containers
|
||||
docker-compose up -d
|
||||
|
||||
# Stop containers
|
||||
docker-compose down
|
||||
|
||||
# Restart
|
||||
docker-compose restart
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# View logs for specific service
|
||||
docker-compose logs -f foldsite
|
||||
|
||||
# Exec into running container
|
||||
docker exec -it foldsite bash
|
||||
|
||||
# View running containers
|
||||
docker ps
|
||||
|
||||
# View all containers (including stopped)
|
||||
docker ps -a
|
||||
```
|
||||
|
||||
### Image Management
|
||||
|
||||
```bash
|
||||
# Build image
|
||||
docker-compose build
|
||||
|
||||
# Pull images
|
||||
docker-compose pull
|
||||
|
||||
# List images
|
||||
docker images
|
||||
|
||||
# Remove unused images
|
||||
docker image prune
|
||||
|
||||
# Remove all unused data
|
||||
docker system prune -a
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
```bash
|
||||
# Check container logs
|
||||
docker logs foldsite
|
||||
|
||||
# Follow logs
|
||||
docker logs -f foldsite
|
||||
|
||||
# Inspect container
|
||||
docker inspect foldsite
|
||||
|
||||
# View container stats
|
||||
docker stats foldsite
|
||||
|
||||
# Check container health
|
||||
docker ps --filter health=healthy
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port Already in Use
|
||||
|
||||
```
|
||||
Error: port is already allocated
|
||||
```
|
||||
|
||||
**Solution:** Change port mapping:
|
||||
```yaml
|
||||
ports:
|
||||
- "8082:8081" # Map to different host port
|
||||
```
|
||||
|
||||
### Permission Errors
|
||||
|
||||
```
|
||||
PermissionError: [Errno 13] Permission denied
|
||||
```
|
||||
|
||||
**Solution:** Fix volume permissions:
|
||||
```bash
|
||||
# Fix ownership
|
||||
sudo chown -R $USER:$USER ./my-site
|
||||
|
||||
# Or run container as your user
|
||||
docker run --user $(id -u):$(id -g) ...
|
||||
```
|
||||
|
||||
### Container Won't Start
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker-compose logs
|
||||
|
||||
# Common issues:
|
||||
# 1. Missing requirements.txt
|
||||
# 2. Wrong working directory
|
||||
# 3. Port conflicts
|
||||
# 4. Volume mount errors
|
||||
```
|
||||
|
||||
### Changes Not Appearing
|
||||
|
||||
**Content changes:**
|
||||
- Should appear immediately (volumes mounted)
|
||||
- Try hard refresh in browser
|
||||
|
||||
**Code changes:**
|
||||
- Require container restart: `docker-compose restart`
|
||||
|
||||
**Template changes:**
|
||||
- Should appear immediately
|
||||
- Check volume mounts are correct
|
||||
|
||||
### Container Crashes
|
||||
|
||||
```bash
|
||||
# View exit reason
|
||||
docker ps -a
|
||||
|
||||
# Check logs
|
||||
docker logs foldsite
|
||||
|
||||
# Try running interactively
|
||||
docker run -it foldsite bash
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Resource Limits
|
||||
|
||||
Limit container resources:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
foldsite:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
```
|
||||
|
||||
### Build Cache
|
||||
|
||||
Speed up builds:
|
||||
|
||||
```bash
|
||||
# Use BuildKit
|
||||
DOCKER_BUILDKIT=1 docker build .
|
||||
|
||||
# Cache from registry
|
||||
docker build --cache-from myregistry/foldsite:latest .
|
||||
```
|
||||
|
||||
### Layer Optimization
|
||||
|
||||
Order Dockerfile for better caching:
|
||||
|
||||
```dockerfile
|
||||
# Dependencies first (change rarely)
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
# Code last (changes often)
|
||||
COPY . .
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions Example
|
||||
|
||||
```yaml
|
||||
# .github/workflows/docker.yml
|
||||
name: Build Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Build image
|
||||
run: docker build -t foldsite:${{ github.sha }} .
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
docker run -d --name test foldsite:${{ github.sha }}
|
||||
docker logs test
|
||||
docker stop test
|
||||
```
|
||||
|
||||
## Cloud Platform Deployment
|
||||
|
||||
### Deploy to AWS ECS
|
||||
|
||||
```bash
|
||||
# Build and push to ECR
|
||||
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin 123456789.dkr.ecr.us-east-1.amazonaws.com
|
||||
docker tag foldsite:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/foldsite:latest
|
||||
docker push 123456789.dkr.ecr.us-east-1.amazonaws.com/foldsite:latest
|
||||
```
|
||||
|
||||
### Deploy to Google Cloud Run
|
||||
|
||||
```bash
|
||||
# Build and push to GCR
|
||||
gcloud builds submit --tag gcr.io/PROJECT_ID/foldsite
|
||||
gcloud run deploy --image gcr.io/PROJECT_ID/foldsite --platform managed
|
||||
```
|
||||
|
||||
### Deploy to Azure Container Instances
|
||||
|
||||
```bash
|
||||
# Create container instance
|
||||
az container create \
|
||||
--resource-group myResourceGroup \
|
||||
--name foldsite \
|
||||
--image myregistry.azurecr.io/foldsite:latest \
|
||||
--ports 8081
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Production Deployment](production.md)** - Production-grade setup
|
||||
- **[Local Development](local-development.md)** - Development workflow
|
||||
- **[Support](../support.md)** - Get help
|
||||
|
||||
Docker provides a solid foundation for deploying Foldsite anywhere. From development to production, containers ensure consistency and reliability.
|
||||
380
docs/content/deployment/index.md
Normal file
380
docs/content/deployment/index.md
Normal file
@ -0,0 +1,380 @@
|
||||
---
|
||||
version: "1.0"
|
||||
date: "2025-01-15"
|
||||
author: "DWS Foldsite Team"
|
||||
title: "Deployment Overview"
|
||||
description: "Getting Foldsite running - from local development to production"
|
||||
summary: "Learn how to deploy Foldsite in various environments: local development, Docker containers, or production servers."
|
||||
quick_tips:
|
||||
- "Start with local development for fastest iteration"
|
||||
- "Docker provides consistent environments across machines"
|
||||
- "Production deployment supports both dynamic and static modes"
|
||||
---
|
||||
|
||||
# Deployment Overview
|
||||
|
||||
Foldsite is flexible in how you run it. Choose the deployment method that fits your needs:
|
||||
|
||||
## Deployment Options
|
||||
|
||||
### [Local Development](local-development.md)
|
||||
|
||||
**Best for:** Content creation, theme development, testing
|
||||
|
||||
Run Foldsite directly with Python for the fastest development cycle. Changes to content and templates appear immediately (no rebuild needed).
|
||||
|
||||
```bash
|
||||
python main.py --config config.toml
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Fastest iteration - see changes instantly
|
||||
- Easy debugging with Python stack traces
|
||||
- Full access to logs and error messages
|
||||
|
||||
**Cons:**
|
||||
- Requires Python environment
|
||||
- Manual dependency management
|
||||
- Not suitable for production
|
||||
|
||||
**When to use:** Always use this during development. It's the fastest way to see your changes.
|
||||
|
||||
### [Docker Deployment](docker.md)
|
||||
|
||||
**Best for:** Consistent environments, easy deployment, testing production builds
|
||||
|
||||
Run Foldsite in a Docker container for isolated, reproducible deployments.
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Consistent across development and production
|
||||
- Isolated dependencies
|
||||
- Easy to share with team members
|
||||
- Simplifies deployment to cloud platforms
|
||||
|
||||
**Cons:**
|
||||
- Slight overhead from containerization
|
||||
- Requires Docker knowledge
|
||||
- Extra layer to debug
|
||||
|
||||
**When to use:** Use Docker when you need consistency across environments, or when deploying to platforms that support containers.
|
||||
|
||||
### [Production Deployment](production.md)
|
||||
|
||||
**Best for:** Public-facing websites, high-traffic sites, static hosting
|
||||
|
||||
Deploy Foldsite as either a dynamic Python server or export to static files.
|
||||
|
||||
**Dynamic Mode:**
|
||||
```bash
|
||||
gunicorn -w 4 -b 0.0.0.0:8081 main:app
|
||||
```
|
||||
|
||||
**Static Export:**
|
||||
```bash
|
||||
# Generate static HTML files
|
||||
python export.py --output ./dist
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Optimized for production workloads
|
||||
- Can use static hosting (cheap/free)
|
||||
- Supports CDN caching
|
||||
- Professional-grade performance
|
||||
|
||||
**Cons:**
|
||||
- More complex setup
|
||||
- Requires understanding of web servers
|
||||
- Static mode requires rebuilds for updates
|
||||
|
||||
**When to use:** Use this for your live website once development is complete.
|
||||
|
||||
## Quick Comparison
|
||||
|
||||
| Method | Speed | Complexity | Best For |
|
||||
|--------|-------|------------|----------|
|
||||
| **Local Development** | ⚡⚡⚡ | ⭐ | Creating content and themes |
|
||||
| **Docker** | ⚡⚡ | ⭐⭐ | Team collaboration, staging |
|
||||
| **Production (Dynamic)** | ⚡⚡ | ⭐⭐⭐ | Live sites with frequent updates |
|
||||
| **Production (Static)** | ⚡⚡⚡ | ⭐⭐⭐ | Live sites, maximum performance |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### For All Deployment Methods
|
||||
|
||||
1. **Git** (to clone the repository)
|
||||
```bash
|
||||
git --version
|
||||
```
|
||||
|
||||
2. **A text editor** (VS Code, Sublime, vim, etc.)
|
||||
|
||||
### For Local Development
|
||||
|
||||
1. **Python 3.10+**
|
||||
```bash
|
||||
python3 --version
|
||||
```
|
||||
|
||||
2. **pip** (Python package manager)
|
||||
```bash
|
||||
pip --version
|
||||
```
|
||||
|
||||
### For Docker Deployment
|
||||
|
||||
1. **Docker** and **Docker Compose**
|
||||
```bash
|
||||
docker --version
|
||||
docker-compose --version
|
||||
```
|
||||
|
||||
### For Production Deployment
|
||||
|
||||
Choose based on your hosting strategy:
|
||||
|
||||
- **Dynamic mode:** Python 3.10+, web server (nginx/apache)
|
||||
- **Static mode:** Any web server or static host (GitHub Pages, Netlify, etc.)
|
||||
|
||||
## Getting the Source Code
|
||||
|
||||
All deployment methods start by getting Foldsite:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/DWSresearch/foldsite.git
|
||||
cd foldsite
|
||||
|
||||
# Or download and extract the latest release
|
||||
wget https://github.com/DWSresearch/foldsite/archive/main.zip
|
||||
unzip main.zip
|
||||
cd foldsite-main
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Every deployment uses a `config.toml` file to specify paths and settings:
|
||||
|
||||
```toml
|
||||
[paths]
|
||||
content_dir = "/path/to/your/content"
|
||||
templates_dir = "/path/to/your/templates"
|
||||
styles_dir = "/path/to/your/styles"
|
||||
|
||||
[server]
|
||||
listen_address = "0.0.0.0"
|
||||
listen_port = 8081
|
||||
admin_browser = false
|
||||
admin_password = "change-me"
|
||||
max_threads = 4
|
||||
debug = false
|
||||
access_log = true
|
||||
```
|
||||
|
||||
**Important:** Adjust these paths before running Foldsite!
|
||||
|
||||
### Development Config Example
|
||||
|
||||
```toml
|
||||
[paths]
|
||||
content_dir = "./my-site/content"
|
||||
templates_dir = "./my-site/templates"
|
||||
styles_dir = "./my-site/styles"
|
||||
|
||||
[server]
|
||||
listen_address = "127.0.0.1" # Local only
|
||||
listen_port = 8081
|
||||
debug = true # Enable debug mode
|
||||
access_log = true
|
||||
```
|
||||
|
||||
### Production Config Example
|
||||
|
||||
```toml
|
||||
[paths]
|
||||
content_dir = "/var/www/site/content"
|
||||
templates_dir = "/var/www/site/templates"
|
||||
styles_dir = "/var/www/site/styles"
|
||||
|
||||
[server]
|
||||
listen_address = "0.0.0.0" # All interfaces
|
||||
listen_port = 8081
|
||||
debug = false # Disable debug mode
|
||||
access_log = true
|
||||
max_threads = 8 # More workers for production
|
||||
```
|
||||
|
||||
## Typical Deployment Workflow
|
||||
|
||||
### 1. Development Phase
|
||||
|
||||
Use **local development** for content creation and theme building:
|
||||
|
||||
```bash
|
||||
# Edit content
|
||||
vim content/my-post.md
|
||||
|
||||
# Run server
|
||||
python main.py --config config.toml
|
||||
|
||||
# Visit http://localhost:8081
|
||||
# See changes immediately
|
||||
```
|
||||
|
||||
### 2. Testing Phase
|
||||
|
||||
Use **Docker** to test in a production-like environment:
|
||||
|
||||
```bash
|
||||
# Build and run
|
||||
docker-compose up
|
||||
|
||||
# Test on http://localhost:8081
|
||||
# Verify everything works in container
|
||||
```
|
||||
|
||||
### 3. Deployment Phase
|
||||
|
||||
Deploy to **production** using your preferred method:
|
||||
|
||||
```bash
|
||||
# Option A: Dynamic server
|
||||
gunicorn -w 4 main:app
|
||||
|
||||
# Option B: Static export
|
||||
python export.py --output ./dist
|
||||
rsync -avz ./dist/ user@server:/var/www/site/
|
||||
```
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### Scenario: Personal Blog
|
||||
|
||||
**Best deployment:** Local development + static export to GitHub Pages
|
||||
|
||||
```bash
|
||||
# Develop locally
|
||||
python main.py --config config.toml
|
||||
|
||||
# Export to static files
|
||||
python export.py --output ./docs
|
||||
|
||||
# Push to GitHub (Pages serves from /docs)
|
||||
git add docs/
|
||||
git commit -m "Update site"
|
||||
git push
|
||||
```
|
||||
|
||||
### Scenario: Team Documentation
|
||||
|
||||
**Best deployment:** Docker for everyone, dynamic server in production
|
||||
|
||||
```bash
|
||||
# Everyone on the team uses Docker
|
||||
docker-compose up
|
||||
|
||||
# Production uses dynamic Python server
|
||||
# for real-time updates when docs change
|
||||
```
|
||||
|
||||
### Scenario: Photography Portfolio
|
||||
|
||||
**Best deployment:** Local development, Docker for staging, static export for production
|
||||
|
||||
High-performance static site with CDN for fast image delivery.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port Already in Use
|
||||
|
||||
```
|
||||
OSError: [Errno 48] Address already in use
|
||||
```
|
||||
|
||||
**Solution:** Change `listen_port` in config.toml or stop the other service using that port.
|
||||
|
||||
### Module Not Found
|
||||
|
||||
```
|
||||
ModuleNotFoundError: No module named 'flask'
|
||||
```
|
||||
|
||||
**Solution:** Install dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Permission Denied
|
||||
|
||||
```
|
||||
PermissionError: [Errno 13] Permission denied
|
||||
```
|
||||
|
||||
**Solution:** Check directory permissions:
|
||||
```bash
|
||||
chmod -R 755 content/ templates/ styles/
|
||||
```
|
||||
|
||||
### Template Not Found
|
||||
|
||||
```
|
||||
Exception: Base template not found
|
||||
```
|
||||
|
||||
**Solution:** Ensure `base.html` exists in templates directory:
|
||||
```bash
|
||||
ls templates/base.html
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
Choose your deployment path:
|
||||
|
||||
- **[Local Development Guide](deployment/local-development.md)** - Start here for development
|
||||
- **[Docker Deployment Guide](deployment/docker.md)** - Containerized deployment
|
||||
- **[Production Deployment Guide](deployment/production.md)** - Go live with your site
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Development
|
||||
|
||||
- **Only bind to localhost** (`listen_address = "127.0.0.1"`)
|
||||
- **Never commit config.toml** with sensitive data to version control
|
||||
|
||||
### Production
|
||||
|
||||
- **Use HTTPS** - Always use TLS/SSL in production
|
||||
- **Strong passwords** - If using admin interface, use strong passwords
|
||||
- **Firewall rules** - Only expose necessary ports
|
||||
- **Regular updates** - Keep Foldsite and dependencies updated
|
||||
- **Content validation** - Be careful with user-uploaded content
|
||||
|
||||
See [Production Deployment](production.md) for detailed security guidance.
|
||||
|
||||
## Performance Tips
|
||||
|
||||
### For All Deployments
|
||||
|
||||
- **Use hidden files** for drafts (`___draft.md`) to avoid processing
|
||||
- **Optimize images** before uploading (Foldsite generates thumbnails, but smaller source = faster)
|
||||
- **Minimize template complexity** - Simple templates render faster
|
||||
|
||||
### For Production
|
||||
|
||||
- **Enable caching** at the web server level
|
||||
- **Use a CDN** for static assets
|
||||
- **Compress responses** with gzip/brotli
|
||||
- **Monitor resource usage** and scale as needed
|
||||
|
||||
## Getting Help
|
||||
|
||||
Need assistance with deployment?
|
||||
|
||||
- **[Support](../support.md)** - Community help and resources
|
||||
- **GitHub Issues** - Report bugs or ask questions
|
||||
- **Example Configs** - See `/examples` directory in repository
|
||||
|
||||
Happy deploying!
|
||||
629
docs/content/deployment/local-development.md
Normal file
629
docs/content/deployment/local-development.md
Normal file
@ -0,0 +1,629 @@
|
||||
---
|
||||
version: "1.0"
|
||||
date: "2025-01-15"
|
||||
author: "DWS Foldsite Team"
|
||||
title: "Local Development"
|
||||
description: "Running Foldsite on your local machine for development"
|
||||
summary: "Complete guide to setting up and running Foldsite locally for the fastest development workflow."
|
||||
quick_tips:
|
||||
- "Local development gives instant feedback - no build step needed"
|
||||
- "Use debug mode to see template discovery and errors clearly"
|
||||
- "Changes to content and templates appear immediately on refresh"
|
||||
---
|
||||
|
||||
# Local Development
|
||||
|
||||
Running Foldsite locally is the fastest way to develop your site. Changes to content and templates appear instantly without any build process.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Required Software
|
||||
|
||||
**Python 3.10 or higher**
|
||||
```bash
|
||||
# Check your Python version
|
||||
python3 --version
|
||||
|
||||
# Should output: Python 3.10.x or higher
|
||||
```
|
||||
|
||||
If you don't have Python 3.10+:
|
||||
- **macOS:** `brew install python3`
|
||||
- **Ubuntu/Debian:** `sudo apt install python3.10`
|
||||
- **Windows:** Download from [python.org](https://www.python.org/downloads/)
|
||||
|
||||
**pip (Python package manager)**
|
||||
```bash
|
||||
# Check pip version
|
||||
pip --version
|
||||
|
||||
# If missing, install
|
||||
python3 -m ensurepip --upgrade
|
||||
```
|
||||
|
||||
**Git (recommended)**
|
||||
```bash
|
||||
git --version
|
||||
```
|
||||
|
||||
### Optional but Recommended
|
||||
|
||||
**Virtual environment support**
|
||||
```bash
|
||||
python3 -m venv --help
|
||||
```
|
||||
|
||||
**Text editor with syntax highlighting**
|
||||
- VS Code (recommended)
|
||||
- Sublime Text
|
||||
- Vim/Neovim
|
||||
- Any editor you prefer
|
||||
|
||||
## Installation
|
||||
|
||||
### Step 1: Get Foldsite
|
||||
|
||||
**Option A: Clone with Git (recommended)**
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/DWSresearch/foldsite.git
|
||||
cd foldsite
|
||||
```
|
||||
|
||||
**Option B: Download ZIP**
|
||||
```bash
|
||||
# Download latest release
|
||||
wget https://github.com/DWSresearch/foldsite/archive/main.zip
|
||||
unzip main.zip
|
||||
cd foldsite-main
|
||||
```
|
||||
|
||||
### Step 2: Create Virtual Environment
|
||||
|
||||
Using a virtual environment keeps dependencies isolated:
|
||||
|
||||
```bash
|
||||
# Create virtual environment
|
||||
python3 -m venv venv
|
||||
|
||||
# Activate it
|
||||
# On macOS/Linux:
|
||||
source venv/bin/activate
|
||||
|
||||
# On Windows:
|
||||
venv\Scripts\activate
|
||||
|
||||
# Your prompt should now show (venv)
|
||||
```
|
||||
|
||||
**Why virtual environment?**
|
||||
- Isolates project dependencies
|
||||
- Prevents conflicts with system Python
|
||||
- Easy to recreate or remove
|
||||
- Professional Python practice
|
||||
|
||||
### Step 3: Install Dependencies
|
||||
|
||||
```bash
|
||||
# Ensure pip is up to date
|
||||
pip install --upgrade pip
|
||||
|
||||
# Install Foldsite dependencies
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
**Dependencies installed:**
|
||||
- Flask - Web framework
|
||||
- Jinja2 - Template engine
|
||||
- mistune - Markdown parser
|
||||
- python-frontmatter - YAML frontmatter parsing
|
||||
- Pillow - Image processing
|
||||
- Gunicorn - WSGI server
|
||||
|
||||
**Troubleshooting:**
|
||||
|
||||
If you see compilation errors:
|
||||
```bash
|
||||
# Install build tools
|
||||
|
||||
# macOS:
|
||||
xcode-select --install
|
||||
|
||||
# Ubuntu/Debian:
|
||||
sudo apt install python3-dev build-essential
|
||||
|
||||
# Then retry:
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Step 4: Set Up Your Content
|
||||
|
||||
Create your site directory structure:
|
||||
|
||||
```bash
|
||||
# Create your site directories
|
||||
mkdir -p my-site/content
|
||||
mkdir -p my-site/templates
|
||||
mkdir -p my-site/styles
|
||||
|
||||
# Create a basic index page
|
||||
echo "# Welcome to My Site" > my-site/content/index.md
|
||||
|
||||
# Copy example templates to start
|
||||
cp -r example_site/template/* my-site/templates/
|
||||
cp -r example_site/style/* my-site/styles/
|
||||
```
|
||||
|
||||
### Step 5: Configure Foldsite
|
||||
|
||||
Create a configuration file:
|
||||
|
||||
```bash
|
||||
# Copy example config
|
||||
cp config.toml my-site-config.toml
|
||||
```
|
||||
|
||||
Edit `my-site-config.toml`:
|
||||
|
||||
```toml
|
||||
[paths]
|
||||
content_dir = "/absolute/path/to/my-site/content"
|
||||
templates_dir = "/absolute/path/to/my-site/templates"
|
||||
styles_dir = "/absolute/path/to/my-site/styles"
|
||||
|
||||
[server]
|
||||
listen_address = "127.0.0.1" # Only accessible from your machine
|
||||
listen_port = 8081
|
||||
admin_browser = false # Disable admin interface for now
|
||||
max_threads = 4
|
||||
debug = true # Enable debug mode for development
|
||||
access_log = true
|
||||
```
|
||||
|
||||
**Important:** Use absolute paths in development to avoid confusion.
|
||||
|
||||
**Find absolute path:**
|
||||
```bash
|
||||
# macOS/Linux:
|
||||
cd my-site && pwd
|
||||
|
||||
# Windows:
|
||||
cd my-site && cd
|
||||
```
|
||||
|
||||
## Running Foldsite
|
||||
|
||||
### Start the Server
|
||||
|
||||
```bash
|
||||
# Make sure virtual environment is activated
|
||||
source venv/bin/activate # or venv\Scripts\activate on Windows
|
||||
|
||||
# Run Foldsite
|
||||
python main.py --config my-site-config.toml
|
||||
```
|
||||
|
||||
**Expected output:**
|
||||
```
|
||||
[2025-01-15 10:30:45] [INFO] Starting Foldsite server
|
||||
[2025-01-15 10:30:45] [INFO] Content directory: /path/to/my-site/content
|
||||
[2025-01-15 10:30:45] [INFO] Templates directory: /path/to/my-site/templates
|
||||
[2025-01-15 10:30:45] [INFO] Listening on http://127.0.0.1:8081
|
||||
```
|
||||
|
||||
### Visit Your Site
|
||||
|
||||
Open your browser to:
|
||||
```
|
||||
http://localhost:8081
|
||||
```
|
||||
|
||||
You should see your site!
|
||||
|
||||
### Stop the Server
|
||||
|
||||
Press `Ctrl+C` in the terminal where Foldsite is running.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### The Edit-Refresh Cycle
|
||||
|
||||
Foldsite has **no build step**. Changes appear immediately:
|
||||
|
||||
1. **Edit content** - Modify markdown files
|
||||
2. **Save file** - Ctrl+S / Cmd+S
|
||||
3. **Refresh browser** - F5 or Cmd+R
|
||||
4. **See changes instantly**
|
||||
|
||||
**What updates live:**
|
||||
- Content (markdown files)
|
||||
- Templates (HTML files)
|
||||
- Styles (CSS files)
|
||||
- Configuration (requires restart)
|
||||
|
||||
### Example Workflow
|
||||
|
||||
**Scenario:** Adding a blog post
|
||||
|
||||
```bash
|
||||
# 1. Create new post
|
||||
vim my-site/content/blog/my-new-post.md
|
||||
```
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "My New Blog Post"
|
||||
date: "2025-01-15"
|
||||
tags: ["tutorial", "foldsite"]
|
||||
---
|
||||
|
||||
# My New Blog Post
|
||||
|
||||
This is my latest post!
|
||||
```
|
||||
|
||||
```bash
|
||||
# 2. Save file and switch to browser
|
||||
# 3. Visit http://localhost:8081/blog/my-new-post.md
|
||||
# 4. See your post rendered immediately
|
||||
```
|
||||
|
||||
**No restart needed!**
|
||||
|
||||
### Working with Templates
|
||||
|
||||
```bash
|
||||
# 1. Edit template
|
||||
vim my-site/templates/__file.md.html
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- Add a new section -->
|
||||
<article>
|
||||
<header>
|
||||
<h1>{{ metadata.title }}</h1>
|
||||
<time>{{ metadata.date }}</time>
|
||||
</header>
|
||||
|
||||
{{ content|safe }}
|
||||
|
||||
<!-- NEW: Add related posts -->
|
||||
{% set related = get_related_posts(currentPath, limit=3) %}
|
||||
{% if related %}
|
||||
<aside class="related">
|
||||
<h3>Related Posts</h3>
|
||||
{% for post in related %}
|
||||
<a href="{{ post.url }}">{{ post.title }}</a>
|
||||
{% endfor %}
|
||||
</aside>
|
||||
{% endif %}
|
||||
</article>
|
||||
```
|
||||
|
||||
```bash
|
||||
# 2. Save and refresh browser
|
||||
# 3. See related posts section appear
|
||||
```
|
||||
|
||||
### Working with Styles
|
||||
|
||||
```bash
|
||||
# 1. Edit CSS
|
||||
vim my-site/styles/base.css
|
||||
```
|
||||
|
||||
```css
|
||||
/* Add new styles */
|
||||
.related {
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.related h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# 2. Save and hard refresh (Cmd+Shift+R / Ctrl+Shift+R)
|
||||
# 3. See styled related posts
|
||||
```
|
||||
|
||||
## Debug Mode
|
||||
|
||||
Enable debug mode for helpful development information:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
debug = true
|
||||
```
|
||||
|
||||
**What debug mode shows:**
|
||||
- Template discovery process
|
||||
- Which templates were considered
|
||||
- Which template was chosen
|
||||
- Detailed error messages with stack traces
|
||||
- Template variables and context
|
||||
|
||||
**Example debug output:**
|
||||
|
||||
When visiting a page, console shows:
|
||||
```
|
||||
[DEBUG] Template search for: blog/my-post.md
|
||||
[DEBUG] Checking: templates/blog/my-post.html - Not found
|
||||
[DEBUG] Checking: templates/blog/__file.md.html - Found!
|
||||
[DEBUG] Using template: templates/blog/__file.md.html
|
||||
[DEBUG] Available variables: content, metadata, currentPath, styles
|
||||
```
|
||||
|
||||
**View in browser:**
|
||||
|
||||
With debug mode, error pages show:
|
||||
- Full Python stack trace
|
||||
- Template rendering context
|
||||
- What went wrong and where
|
||||
- Suggestions for fixes
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Creating New Pages
|
||||
|
||||
```bash
|
||||
# Simple page
|
||||
echo "# About Me\n\nI'm a web developer." > my-site/content/about.md
|
||||
|
||||
# With frontmatter
|
||||
cat > my-site/content/projects.md << 'EOF'
|
||||
---
|
||||
title: "My Projects"
|
||||
description: "Things I've built"
|
||||
---
|
||||
|
||||
# My Projects
|
||||
|
||||
Here are some things I've worked on...
|
||||
EOF
|
||||
```
|
||||
|
||||
### Organizing Content
|
||||
|
||||
```bash
|
||||
# Create a blog section
|
||||
mkdir -p my-site/content/blog
|
||||
|
||||
# Add posts
|
||||
for i in {1..5}; do
|
||||
cat > my-site/content/blog/post-$i.md << EOF
|
||||
---
|
||||
title: "Blog Post $i"
|
||||
date: "2024-01-$i"
|
||||
tags: ["example"]
|
||||
---
|
||||
|
||||
# Post $i
|
||||
|
||||
Content here...
|
||||
EOF
|
||||
done
|
||||
```
|
||||
|
||||
### Testing Template Helpers
|
||||
|
||||
Create a test page to experiment:
|
||||
|
||||
```bash
|
||||
cat > my-site/content/test.md << 'EOF'
|
||||
---
|
||||
title: "Template Helper Test"
|
||||
---
|
||||
|
||||
# Testing Helpers
|
||||
|
||||
## Recent Posts
|
||||
{% for post in get_recent_posts(limit=5) %}
|
||||
- [{{ post.title }}]({{ post.url }}) - {{ post.date }}
|
||||
{% endfor %}
|
||||
|
||||
## All Tags
|
||||
{% for tag in get_all_tags() %}
|
||||
- {{ tag.name }} ({{ tag.count }})
|
||||
{% endfor %}
|
||||
|
||||
## Current Path Info
|
||||
- Path: {{ currentPath }}
|
||||
- Metadata: {{ metadata }}
|
||||
EOF
|
||||
```
|
||||
|
||||
Visit `/test.md` to see helper output.
|
||||
|
||||
### Hiding Work in Progress
|
||||
|
||||
Use the `___` prefix:
|
||||
|
||||
```bash
|
||||
# Hidden draft
|
||||
vim my-site/content/___draft-post.md
|
||||
|
||||
# Hidden development folder
|
||||
mkdir my-site/content/___testing
|
||||
```
|
||||
|
||||
These won't appear in navigation or listings.
|
||||
|
||||
## Editor Setup
|
||||
|
||||
### VS Code
|
||||
|
||||
Recommended extensions:
|
||||
- **Python** (Microsoft)
|
||||
- **Jinja** (wholroyd.jinja)
|
||||
- **Markdown All in One** (yzhang.markdown-all-in-one)
|
||||
|
||||
**Settings:**
|
||||
```json
|
||||
{
|
||||
"files.associations": {
|
||||
"*.html": "jinja-html"
|
||||
},
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
```
|
||||
|
||||
### Vim/Neovim
|
||||
|
||||
Add to `.vimrc` or `init.vim`:
|
||||
```vim
|
||||
" Jinja syntax for .html files
|
||||
autocmd BufNewFile,BufRead */templates/*.html set filetype=jinja
|
||||
|
||||
" Markdown settings
|
||||
autocmd FileType markdown setlocal spell spelllang=en_us
|
||||
autocmd FileType markdown setlocal textwidth=80
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port Already in Use
|
||||
|
||||
```
|
||||
Error: [Errno 48] Address already in use
|
||||
```
|
||||
|
||||
**Solution 1:** Change port in config:
|
||||
```toml
|
||||
[server]
|
||||
listen_port = 8082
|
||||
```
|
||||
|
||||
**Solution 2:** Find and kill process:
|
||||
```bash
|
||||
# Find process using port 8081
|
||||
lsof -i :8081
|
||||
|
||||
# Kill it
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
### Module Not Found
|
||||
|
||||
```
|
||||
ModuleNotFoundError: No module named 'flask'
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Ensure virtual environment is activated
|
||||
source venv/bin/activate
|
||||
|
||||
# Reinstall dependencies
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Template Not Found
|
||||
|
||||
```
|
||||
Exception: Base template not found
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check templates directory exists and has base.html
|
||||
ls my-site/templates/base.html
|
||||
|
||||
# If missing, copy from example
|
||||
cp example_site/template/base.html my-site/templates/
|
||||
```
|
||||
|
||||
### Changes Not Appearing
|
||||
|
||||
**Possible causes:**
|
||||
|
||||
1. **Browser cache** - Hard refresh (Cmd/Ctrl + Shift + R)
|
||||
2. **Wrong file** - Check you're editing the file Foldsite is using
|
||||
3. **Configuration issue** - Verify config.toml paths
|
||||
4. **Syntax error** - Check console for error messages
|
||||
|
||||
**Debug:**
|
||||
```bash
|
||||
# Enable debug mode
|
||||
# Edit config.toml
|
||||
[server]
|
||||
debug = true
|
||||
|
||||
# Restart Foldsite
|
||||
# Check console output
|
||||
```
|
||||
|
||||
### Permission Denied
|
||||
|
||||
```
|
||||
PermissionError: [Errno 13] Permission denied
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Fix permissions
|
||||
chmod -R 755 my-site/
|
||||
|
||||
# Or run with correct user
|
||||
sudo chown -R $USER:$USER my-site/
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
### Development Speed
|
||||
|
||||
**Fast iteration:**
|
||||
- Keep browser DevTools open
|
||||
- Use multiple browser windows for comparison
|
||||
- Enable auto-reload browser extensions
|
||||
- Use terminal multiplexer (tmux/screen)
|
||||
|
||||
**Organize workspace:**
|
||||
```
|
||||
Terminal 1: Foldsite server
|
||||
Terminal 2: Content editing
|
||||
Terminal 3: Git operations
|
||||
Browser: Live preview
|
||||
Editor: Code/templates
|
||||
```
|
||||
|
||||
### Working with Large Sites
|
||||
|
||||
If your site has many files:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
max_threads = 8 # Increase workers
|
||||
```
|
||||
|
||||
**Cache considerations:**
|
||||
- Template helpers are cached automatically
|
||||
- Folder contents cached for 5 minutes
|
||||
- Recent posts cached for 10 minutes
|
||||
- Restart server to clear caches
|
||||
|
||||
## Next Steps
|
||||
|
||||
Now that you have local development running:
|
||||
|
||||
- **[Templates Guide](../templates/)** - Learn the template system
|
||||
- **[Template Helpers](../templates/template-helpers.md)** - Explore helper functions
|
||||
- **[Recipes](../recipes/)** - Copy working examples
|
||||
- **[Docker Deployment](docker.md)** - Test in container
|
||||
- **[Production Deployment](production.md)** - Go live
|
||||
|
||||
## Tips from Developers
|
||||
|
||||
> "Keep the server running in a dedicated terminal. Switch to it to see errors immediately." — Foldsite contributor
|
||||
|
||||
> "Use `___testing/` folder for experimenting. It's ignored so you can mess around without affecting the site." — Content creator
|
||||
|
||||
> "Debug mode is your friend. Always enable it in development." — Theme developer
|
||||
|
||||
> "Create test pages to try helper functions. Much faster than reading docs." — Documentation writer
|
||||
|
||||
Happy developing! Local development is where the magic happens. 🚀
|
||||
810
docs/content/deployment/production.md
Normal file
810
docs/content/deployment/production.md
Normal file
@ -0,0 +1,810 @@
|
||||
---
|
||||
version: "1.0"
|
||||
date: "2025-01-15"
|
||||
author: "DWS Foldsite Team"
|
||||
title: "Production Deployment"
|
||||
description: "Deploying Foldsite for production use"
|
||||
summary: "Complete guide to deploying Foldsite in production - from VPS servers to static hosting, with security, performance, and reliability best practices."
|
||||
quick_tips:
|
||||
- "Use Gunicorn with multiple workers for dynamic deployments"
|
||||
- "Static export is fastest and cheapest for sites that don't change frequently"
|
||||
- "Always use HTTPS in production with proper SSL certificates"
|
||||
---
|
||||
|
||||
# Production Deployment
|
||||
|
||||
Deploy Foldsite to serve real traffic with reliability, security, and performance.
|
||||
|
||||
## Deployment Options
|
||||
|
||||
### Option 1: Dynamic Server (Python)
|
||||
|
||||
**Best for:**
|
||||
- Frequently updated content
|
||||
- Sites needing template helpers in real-time
|
||||
- Admin file browser interface
|
||||
- Dynamic content generation
|
||||
|
||||
**Characteristics:**
|
||||
- Runs Python/Gunicorn server
|
||||
- Content updates appear immediately
|
||||
- Template helpers work dynamically
|
||||
- Requires server with Python
|
||||
|
||||
**Hosting:** VPS, dedicated server, PaaS platforms
|
||||
|
||||
### Option 2: Static Export
|
||||
|
||||
**Best for:**
|
||||
- Infrequently updated sites
|
||||
- Maximum performance
|
||||
- Minimal cost
|
||||
- CDN delivery
|
||||
|
||||
**Characteristics:**
|
||||
- Pre-rendered HTML files
|
||||
- Blazing fast delivery
|
||||
- Can host anywhere (GitHub Pages, Netlify, S3)
|
||||
- Rebuild required for updates
|
||||
|
||||
**Hosting:** Static hosts, CDN, object storage
|
||||
|
||||
## Dynamic Server Deployment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Linux server (Ubuntu 20.04+ recommended)
|
||||
- Python 3.10+
|
||||
- Domain name
|
||||
- SSH access
|
||||
|
||||
### Step 1: Server Setup
|
||||
|
||||
**Update system:**
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt upgrade -y
|
||||
```
|
||||
|
||||
**Install dependencies:**
|
||||
```bash
|
||||
# Python and pip
|
||||
sudo apt install -y python3.10 python3-pip python3-venv
|
||||
|
||||
# Build tools
|
||||
sudo apt install -y build-essential python3-dev
|
||||
|
||||
# Nginx
|
||||
sudo apt install -y nginx
|
||||
|
||||
# Certbot for SSL
|
||||
sudo apt install -y certbot python3-certbot-nginx
|
||||
```
|
||||
|
||||
### Step 2: Deploy Foldsite
|
||||
|
||||
**Create user:**
|
||||
```bash
|
||||
sudo useradd -m -s /bin/bash foldsite
|
||||
sudo su - foldsite
|
||||
```
|
||||
|
||||
**Clone and setup:**
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/DWSresearch/foldsite.git
|
||||
cd foldsite
|
||||
|
||||
# Create virtual environment
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
pip install gunicorn # Production WSGI server
|
||||
```
|
||||
|
||||
**Setup your content:**
|
||||
```bash
|
||||
# Create site structure
|
||||
mkdir -p ~/site/content
|
||||
mkdir -p ~/site/templates
|
||||
mkdir -p ~/site/styles
|
||||
|
||||
# Copy your content
|
||||
# (upload via SCP, rsync, or git)
|
||||
```
|
||||
|
||||
**Create production config:**
|
||||
```bash
|
||||
vim ~/foldsite/production-config.toml
|
||||
```
|
||||
|
||||
```toml
|
||||
[paths]
|
||||
content_dir = "/home/foldsite/site/content"
|
||||
templates_dir = "/home/foldsite/site/templates"
|
||||
styles_dir = "/home/foldsite/site/styles"
|
||||
|
||||
[server]
|
||||
listen_address = "127.0.0.1" # Only accept local connections
|
||||
listen_port = 8081
|
||||
admin_browser = false # Disable for security
|
||||
max_threads = 8 # Adjust based on server
|
||||
debug = false # Never enable in production
|
||||
access_log = true
|
||||
```
|
||||
|
||||
### Step 3: Systemd Service
|
||||
|
||||
Create service file:
|
||||
|
||||
```bash
|
||||
sudo vim /etc/systemd/system/foldsite.service
|
||||
```
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Foldsite Web Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=foldsite
|
||||
Group=foldsite
|
||||
WorkingDirectory=/home/foldsite/foldsite
|
||||
Environment="PATH=/home/foldsite/foldsite/venv/bin"
|
||||
ExecStart=/home/foldsite/foldsite/venv/bin/gunicorn \
|
||||
--workers 4 \
|
||||
--bind 127.0.0.1:8081 \
|
||||
--access-logfile /var/log/foldsite/access.log \
|
||||
--error-logfile /var/log/foldsite/error.log \
|
||||
--config /home/foldsite/foldsite/production-config.toml \
|
||||
main:app
|
||||
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
**Create log directory:**
|
||||
```bash
|
||||
sudo mkdir -p /var/log/foldsite
|
||||
sudo chown foldsite:foldsite /var/log/foldsite
|
||||
```
|
||||
|
||||
**Enable and start service:**
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable foldsite
|
||||
sudo systemctl start foldsite
|
||||
|
||||
# Check status
|
||||
sudo systemctl status foldsite
|
||||
```
|
||||
|
||||
### Step 4: Nginx Reverse Proxy
|
||||
|
||||
**Create Nginx config:**
|
||||
```bash
|
||||
sudo vim /etc/nginx/sites-available/foldsite
|
||||
```
|
||||
|
||||
```nginx
|
||||
upstream foldsite {
|
||||
server 127.0.0.1:8081;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com www.your-domain.com;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
|
||||
# Logs
|
||||
access_log /var/log/nginx/foldsite-access.log;
|
||||
error_log /var/log/nginx/foldsite-error.log;
|
||||
|
||||
# Max upload size
|
||||
client_max_body_size 10M;
|
||||
|
||||
# Proxy to Foldsite
|
||||
location / {
|
||||
proxy_pass http://foldsite;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
|
||||
proxy_pass http://foldsite;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Security: deny access to hidden files
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Enable site:**
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/foldsite /etc/nginx/sites-enabled/
|
||||
sudo nginx -t # Test configuration
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### Step 5: SSL Certificate
|
||||
|
||||
**Get certificate with Certbot:**
|
||||
```bash
|
||||
sudo certbot --nginx -d your-domain.com -d www.your-domain.com
|
||||
```
|
||||
|
||||
Follow prompts. Certbot will:
|
||||
- Obtain certificate from Let's Encrypt
|
||||
- Modify Nginx config for HTTPS
|
||||
- Setup auto-renewal
|
||||
|
||||
**Verify auto-renewal:**
|
||||
```bash
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
**Final Nginx config (with SSL):**
|
||||
Certbot updates your config to include:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-domain.com www.your-domain.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
# ... rest of config
|
||||
}
|
||||
|
||||
# Redirect HTTP to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com www.your-domain.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Firewall
|
||||
|
||||
**Configure UFW:**
|
||||
```bash
|
||||
sudo ufw allow OpenSSH
|
||||
sudo ufw allow 'Nginx Full'
|
||||
sudo ufw enable
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
Visit your domain:
|
||||
```
|
||||
https://your-domain.com
|
||||
```
|
||||
|
||||
Should see your Foldsite with valid SSL!
|
||||
|
||||
## Static Export Deployment
|
||||
|
||||
### Generate Static Files
|
||||
|
||||
*Note: Static export functionality may need to be implemented or use a static site generator mode*
|
||||
|
||||
**Conceptual approach:**
|
||||
|
||||
```python
|
||||
# export.py
|
||||
import os
|
||||
from pathlib import Path
|
||||
from src.rendering.renderer import render_page
|
||||
from src.config.config import Configuration
|
||||
|
||||
def export_static(config_path, output_dir):
|
||||
"""Export all pages to static HTML"""
|
||||
config = Configuration(config_path)
|
||||
config.load_config()
|
||||
|
||||
output = Path(output_dir)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Walk content directory
|
||||
for content_file in config.content_dir.rglob('*'):
|
||||
if content_file.is_file() and not content_file.name.startswith('___'):
|
||||
# Generate output path
|
||||
rel_path = content_file.relative_to(config.content_dir)
|
||||
out_path = output / rel_path.with_suffix('.html')
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Render and save
|
||||
html = render_page(
|
||||
content_file,
|
||||
base_path=config.content_dir,
|
||||
template_path=config.templates_dir,
|
||||
style_path=config.styles_dir
|
||||
)
|
||||
|
||||
with open(out_path, 'w') as f:
|
||||
f.write(html)
|
||||
|
||||
# Copy styles
|
||||
import shutil
|
||||
shutil.copytree(config.styles_dir, output / 'styles')
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
export_static(sys.argv[1], sys.argv[2])
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
python export.py config.toml ./dist
|
||||
```
|
||||
|
||||
### Deploy Static Files
|
||||
|
||||
#### GitHub Pages
|
||||
|
||||
```bash
|
||||
# Export to docs/
|
||||
python export.py config.toml ./docs
|
||||
|
||||
# Commit and push
|
||||
git add docs/
|
||||
git commit -m "Update site"
|
||||
git push
|
||||
|
||||
# Enable Pages in GitHub repo settings
|
||||
# Source: docs/ folder
|
||||
```
|
||||
|
||||
#### Netlify
|
||||
|
||||
```bash
|
||||
# Export
|
||||
python export.py config.toml ./dist
|
||||
|
||||
# Install Netlify CLI
|
||||
npm install -g netlify-cli
|
||||
|
||||
# Deploy
|
||||
netlify deploy --prod --dir=dist
|
||||
```
|
||||
|
||||
**Or use continuous deployment:**
|
||||
|
||||
```yaml
|
||||
# netlify.toml
|
||||
[build]
|
||||
command = "pip install -r requirements.txt && python export.py config.toml dist"
|
||||
publish = "dist"
|
||||
```
|
||||
|
||||
#### AWS S3 + CloudFront
|
||||
|
||||
```bash
|
||||
# Export
|
||||
python export.py config.toml ./dist
|
||||
|
||||
# Sync to S3
|
||||
aws s3 sync ./dist s3://your-bucket-name --delete
|
||||
|
||||
# Invalidate CloudFront cache
|
||||
aws cloudfront create-invalidation --distribution-id YOUR_DIST_ID --paths "/*"
|
||||
```
|
||||
|
||||
#### Vercel
|
||||
|
||||
```bash
|
||||
# Export
|
||||
python export.py config.toml ./dist
|
||||
|
||||
# Deploy
|
||||
vercel --prod ./dist
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Nginx Caching
|
||||
|
||||
Add to Nginx config:
|
||||
|
||||
```nginx
|
||||
# Define cache path
|
||||
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=foldsite_cache:10m max_size=1g inactive=60m use_temp_path=off;
|
||||
|
||||
server {
|
||||
# ...
|
||||
|
||||
location / {
|
||||
proxy_cache foldsite_cache;
|
||||
proxy_cache_valid 200 10m;
|
||||
proxy_cache_valid 404 1m;
|
||||
proxy_cache_bypass $cookie_session;
|
||||
proxy_no_cache $cookie_session;
|
||||
add_header X-Cache-Status $upstream_cache_status;
|
||||
|
||||
proxy_pass http://foldsite;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Gzip Compression
|
||||
|
||||
```nginx
|
||||
# In /etc/nginx/nginx.conf
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
|
||||
```
|
||||
|
||||
### Image Optimization
|
||||
|
||||
Foldsite automatically generates thumbnails, but optimize source images:
|
||||
|
||||
```bash
|
||||
# Install optimization tools
|
||||
sudo apt install jpegoptim optipng
|
||||
|
||||
# Optimize JPEGs
|
||||
find content/ -name "*.jpg" -exec jpegoptim --strip-all {} \;
|
||||
|
||||
# Optimize PNGs
|
||||
find content/ -name "*.png" -exec optipng -o2 {} \;
|
||||
```
|
||||
|
||||
### CDN Integration
|
||||
|
||||
Use CDN for static assets:
|
||||
|
||||
```nginx
|
||||
# Separate static assets
|
||||
location /styles/ {
|
||||
alias /home/foldsite/site/styles/;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location /download/ {
|
||||
# Proxy to Foldsite for thumbnails
|
||||
proxy_pass http://foldsite;
|
||||
expires 30d;
|
||||
}
|
||||
```
|
||||
|
||||
Then point DNS for `static.yourdomain.com` to CDN origin.
|
||||
|
||||
## Monitoring & Logging
|
||||
|
||||
### Application Logs
|
||||
|
||||
**View logs:**
|
||||
```bash
|
||||
# Systemd logs
|
||||
sudo journalctl -u foldsite -f
|
||||
|
||||
# Application logs
|
||||
tail -f /var/log/foldsite/error.log
|
||||
tail -f /var/log/foldsite/access.log
|
||||
```
|
||||
|
||||
### Nginx Logs
|
||||
|
||||
```bash
|
||||
tail -f /var/log/nginx/foldsite-access.log
|
||||
tail -f /var/log/nginx/foldsite-error.log
|
||||
```
|
||||
|
||||
### Log Rotation
|
||||
|
||||
Create `/etc/logrotate.d/foldsite`:
|
||||
|
||||
```
|
||||
/var/log/foldsite/*.log {
|
||||
daily
|
||||
missingok
|
||||
rotate 14
|
||||
compress
|
||||
delaycompress
|
||||
notifempty
|
||||
create 0640 foldsite foldsite
|
||||
sharedscripts
|
||||
postrotate
|
||||
systemctl reload foldsite
|
||||
endscript
|
||||
}
|
||||
```
|
||||
|
||||
### Monitoring Tools
|
||||
|
||||
**Install basic monitoring:**
|
||||
```bash
|
||||
# Netdata (system monitoring)
|
||||
bash <(curl -Ss https://my-netdata.io/kickstart.sh)
|
||||
|
||||
# Access at http://your-server:19999
|
||||
```
|
||||
|
||||
**Check application health:**
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# health-check.sh
|
||||
response=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8081/)
|
||||
|
||||
if [ $response -eq 200 ]; then
|
||||
echo "Foldsite is healthy"
|
||||
exit 0
|
||||
else
|
||||
echo "Foldsite is down (HTTP $response)"
|
||||
systemctl restart foldsite
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
Run via cron:
|
||||
```bash
|
||||
*/5 * * * * /usr/local/bin/health-check.sh >> /var/log/foldsite/health.log 2>&1
|
||||
```
|
||||
|
||||
## Backup Strategy
|
||||
|
||||
### Content Backup
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# backup.sh
|
||||
BACKUP_DIR="/backups/foldsite"
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
# Backup content, templates, styles
|
||||
tar czf $BACKUP_DIR/site-$DATE.tar.gz \
|
||||
/home/foldsite/site/
|
||||
|
||||
# Keep only last 30 days
|
||||
find $BACKUP_DIR -name "site-*.tar.gz" -mtime +30 -delete
|
||||
|
||||
echo "Backup completed: site-$DATE.tar.gz"
|
||||
```
|
||||
|
||||
**Run daily via cron:**
|
||||
```bash
|
||||
0 2 * * * /usr/local/bin/backup.sh
|
||||
```
|
||||
|
||||
### Remote Backup
|
||||
|
||||
```bash
|
||||
# Sync to remote server
|
||||
rsync -avz /home/foldsite/site/ user@backup-server:/backups/foldsite/
|
||||
|
||||
# Or sync to S3
|
||||
aws s3 sync /home/foldsite/site/ s3://your-backup-bucket/foldsite/
|
||||
```
|
||||
|
||||
## Updating Foldsite
|
||||
|
||||
### Update Process
|
||||
|
||||
```bash
|
||||
# As foldsite user
|
||||
cd ~/foldsite
|
||||
|
||||
# Pull latest code
|
||||
git pull
|
||||
|
||||
# Activate venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Update dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Restart service
|
||||
sudo systemctl restart foldsite
|
||||
|
||||
# Check logs
|
||||
sudo journalctl -u foldsite -n 50
|
||||
```
|
||||
|
||||
### Zero-Downtime Updates
|
||||
|
||||
Use multiple Gunicorn workers and graceful reloading:
|
||||
|
||||
```bash
|
||||
# Graceful reload (workers restart one by one)
|
||||
sudo systemctl reload foldsite
|
||||
|
||||
# Or send HUP signal to Gunicorn
|
||||
sudo pkill -HUP gunicorn
|
||||
```
|
||||
|
||||
## Security Hardening
|
||||
|
||||
### Disable Directory Listing
|
||||
|
||||
Nginx automatically prevents this, but verify:
|
||||
|
||||
```nginx
|
||||
autoindex off;
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Add to Nginx config:
|
||||
|
||||
```nginx
|
||||
limit_req_zone $binary_remote_addr zone=foldsite_limit:10m rate=10r/s;
|
||||
|
||||
server {
|
||||
location / {
|
||||
limit_req zone=foldsite_limit burst=20 nodelay;
|
||||
# ... rest of config
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fail2ban
|
||||
|
||||
Protect against brute force:
|
||||
|
||||
```bash
|
||||
sudo apt install fail2ban
|
||||
|
||||
# Create /etc/fail2ban/jail.local
|
||||
[nginx-foldsite]
|
||||
enabled = true
|
||||
port = http,https
|
||||
filter = nginx-foldsite
|
||||
logpath = /var/log/nginx/foldsite-access.log
|
||||
maxretry = 5
|
||||
bantime = 3600
|
||||
```
|
||||
|
||||
### Security Headers
|
||||
|
||||
Already in Nginx config, but verify:
|
||||
|
||||
```nginx
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Content-Security-Policy "default-src 'self' https:; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline' https:;" always;
|
||||
```
|
||||
|
||||
### File Permissions
|
||||
|
||||
Ensure proper permissions:
|
||||
|
||||
```bash
|
||||
# Content should be readable by foldsite user
|
||||
chmod -R 755 ~/site/content
|
||||
chmod -R 755 ~/site/templates
|
||||
chmod -R 755 ~/site/styles
|
||||
|
||||
# Application should not be writable
|
||||
chmod -R 555 ~/foldsite/src
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Service Won't Start
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
sudo journalctl -u foldsite -xe
|
||||
|
||||
# Common issues:
|
||||
# - Wrong Python path
|
||||
# - Missing dependencies
|
||||
# - Port already in use
|
||||
# - Permission errors
|
||||
```
|
||||
|
||||
### 502 Bad Gateway
|
||||
|
||||
Nginx can't reach Foldsite:
|
||||
|
||||
```bash
|
||||
# Check if Foldsite is running
|
||||
sudo systemctl status foldsite
|
||||
|
||||
# Check if port is listening
|
||||
sudo netstat -tulpn | grep 8081
|
||||
|
||||
# Check Nginx error log
|
||||
sudo tail /var/log/nginx/error.log
|
||||
```
|
||||
|
||||
### High Memory Usage
|
||||
|
||||
```bash
|
||||
# Check process memory
|
||||
ps aux | grep gunicorn
|
||||
|
||||
# Reduce workers or add swap
|
||||
sudo fallocate -l 2G /swapfile
|
||||
sudo chmod 600 /swapfile
|
||||
sudo mkswap /swapfile
|
||||
sudo swapon /swapfile
|
||||
```
|
||||
|
||||
### Slow Response Times
|
||||
|
||||
```bash
|
||||
# Check Nginx access logs for slow requests
|
||||
sudo tail -f /var/log/nginx/foldsite-access.log
|
||||
|
||||
# Enable query logging in Foldsite
|
||||
# Check for expensive template helpers
|
||||
# Consider caching with Redis/Memcached
|
||||
```
|
||||
|
||||
## Platform-Specific Guides
|
||||
|
||||
### DigitalOcean
|
||||
|
||||
```bash
|
||||
# Create droplet (Ubuntu 22.04)
|
||||
# Follow server setup steps above
|
||||
|
||||
# Use DigitalOcean firewall for security
|
||||
# Enable backups in control panel
|
||||
```
|
||||
|
||||
### AWS EC2
|
||||
|
||||
```bash
|
||||
# Launch Ubuntu instance
|
||||
# Setup security groups (ports 22, 80, 443)
|
||||
# Use Elastic IP for static IP
|
||||
# Consider RDS for database if needed
|
||||
```
|
||||
|
||||
### Hetzner Cloud
|
||||
|
||||
```bash
|
||||
# Create CX11 or larger instance
|
||||
# Follow server setup
|
||||
# Use Hetzner firewall
|
||||
# Consider Hetzner volumes for storage
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Local Development](local-development.md)** - Development workflow
|
||||
- **[Docker Deployment](docker.md)** - Container deployment
|
||||
- **[Support](../support.md)** - Get help
|
||||
|
||||
Your Foldsite is now production-ready! Monitor it regularly, keep it updated, and enjoy your self-hosted corner of the internet.
|
||||
594
docs/content/develop/index.md
Normal file
594
docs/content/develop/index.md
Normal file
@ -0,0 +1,594 @@
|
||||
---
|
||||
version: "1.0"
|
||||
date: "2025-01-15"
|
||||
author: "DWS Foldsite Team"
|
||||
title: "Develop Foldsite"
|
||||
description: "Contributing to Foldsite development"
|
||||
summary: "Guidelines for contributing code, documentation, themes, and ideas to the Foldsite project."
|
||||
quick_tips:
|
||||
- "Start with documentation or small bug fixes"
|
||||
- "Discuss major changes in issues before implementing"
|
||||
- "Follow existing code style and patterns"
|
||||
---
|
||||
|
||||
# Develop Foldsite
|
||||
|
||||
Want to contribute to Foldsite? We welcome contributions of all kinds!
|
||||
|
||||
## Ways to Contribute
|
||||
|
||||
### 1. Documentation
|
||||
|
||||
**Impact:** High | **Difficulty:** Low
|
||||
|
||||
Improving documentation helps everyone:
|
||||
|
||||
- Fix typos and unclear explanations
|
||||
- Add missing examples
|
||||
- Create tutorials
|
||||
- Translate to other languages
|
||||
|
||||
**How to start:**
|
||||
1. Find documentation that confused you
|
||||
2. Fork the repository
|
||||
3. Edit markdown files in `docs/content/`
|
||||
4. Submit pull request
|
||||
|
||||
No coding required!
|
||||
|
||||
### 2. Bug Fixes
|
||||
|
||||
**Impact:** High | **Difficulty:** Low to Medium
|
||||
|
||||
Fix issues others are experiencing:
|
||||
|
||||
- Browse [GitHub Issues](https://github.com/DWSresearch/foldsite/issues)
|
||||
- Look for "good first issue" label
|
||||
- Fix the bug
|
||||
- Add test if possible
|
||||
- Submit pull request
|
||||
|
||||
### 3. New Features
|
||||
|
||||
**Impact:** High | **Difficulty:** Medium to High
|
||||
|
||||
Add new capabilities:
|
||||
|
||||
- Discuss in issue first
|
||||
- Get feedback on approach
|
||||
- Implement feature
|
||||
- Add documentation
|
||||
- Add tests
|
||||
- Submit pull request
|
||||
|
||||
### 4. Templates & Themes
|
||||
|
||||
**Impact:** Medium | **Difficulty:** Medium
|
||||
|
||||
Create reusable designs:
|
||||
|
||||
- Build complete theme
|
||||
- Document installation
|
||||
- Submit to theme gallery
|
||||
- Help others customize
|
||||
|
||||
### 5. Testing
|
||||
|
||||
**Impact:** Medium | **Difficulty:** Low
|
||||
|
||||
Help ensure quality:
|
||||
|
||||
- Test new releases
|
||||
- Report bugs with details
|
||||
- Verify fixes work
|
||||
- Test on different platforms
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.10 or higher
|
||||
- Git
|
||||
- Text editor or IDE
|
||||
|
||||
### Getting Started
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/DWSresearch/foldsite.git
|
||||
cd foldsite
|
||||
|
||||
# Create virtual environment
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Install development dependencies
|
||||
pip install -r requirements-dev.txt # If exists
|
||||
|
||||
# Run tests
|
||||
python -m pytest # If tests exist
|
||||
|
||||
# Run Foldsite
|
||||
python main.py --config config.toml
|
||||
```
|
||||
|
||||
### Development Workflow
|
||||
|
||||
1. **Create branch** for your changes:
|
||||
```bash
|
||||
git checkout -b feature/my-feature
|
||||
```
|
||||
|
||||
2. **Make changes** following code style
|
||||
|
||||
3. **Test changes**:
|
||||
```bash
|
||||
# Manual testing
|
||||
python main.py --config config.toml
|
||||
|
||||
# Run tests if available
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
4. **Commit changes**:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Add feature: description"
|
||||
```
|
||||
|
||||
5. **Push and create PR**:
|
||||
```bash
|
||||
git push origin feature/my-feature
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
### Python
|
||||
|
||||
Follow [PEP 8](https://pep8.org/) with these specifics:
|
||||
|
||||
**Formatting:**
|
||||
- 4 spaces for indentation
|
||||
- Max line length: 100 characters
|
||||
- Use type hints where helpful
|
||||
- Docstrings for public functions
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
def render_page(
|
||||
path: Path,
|
||||
base_path: Path = Path("./"),
|
||||
template_path: Path = Path("./"),
|
||||
) -> str:
|
||||
"""
|
||||
Renders a web page based on the provided path.
|
||||
|
||||
Args:
|
||||
path: The path to the target file or directory
|
||||
base_path: The base path for relative paths
|
||||
template_path: Path to directory containing templates
|
||||
|
||||
Returns:
|
||||
Rendered HTML content of the page
|
||||
"""
|
||||
# Implementation...
|
||||
```
|
||||
|
||||
**Naming:**
|
||||
- `snake_case` for functions and variables
|
||||
- `PascalCase` for classes
|
||||
- `UPPER_CASE` for constants
|
||||
|
||||
### HTML Templates
|
||||
|
||||
- 2 spaces for indentation
|
||||
- Close all tags
|
||||
- Use semantic HTML
|
||||
- Comment complex logic
|
||||
|
||||
```html
|
||||
<article class="post">
|
||||
{% if metadata %}
|
||||
<h1>{{ metadata.title }}</h1>
|
||||
|
||||
{# Loop through tags #}
|
||||
{% if metadata.tags %}
|
||||
<div class="tags">
|
||||
{% for tag in metadata.tags %}
|
||||
<span class="tag">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</article>
|
||||
```
|
||||
|
||||
### CSS
|
||||
|
||||
- 2 spaces for indentation
|
||||
- Alphabetical property order (within reason)
|
||||
- Mobile-first responsive design
|
||||
- BEM naming for classes (optional but encouraged)
|
||||
|
||||
```css
|
||||
.post {
|
||||
margin: 2rem 0;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.post__title {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.post {
|
||||
margin: 3rem 0;
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Key Components
|
||||
|
||||
**main.py**
|
||||
- Entry point
|
||||
- Initializes configuration
|
||||
- Registers template helpers
|
||||
- Starts server
|
||||
|
||||
**src/config/**
|
||||
- Configuration loading
|
||||
- Command-line argument parsing
|
||||
|
||||
**src/server/**
|
||||
- Flask application setup
|
||||
- Route registration
|
||||
- File manager (admin interface)
|
||||
|
||||
**src/routes/**
|
||||
- URL routing logic
|
||||
- Path validation and security
|
||||
- Content serving
|
||||
|
||||
**src/rendering/**
|
||||
- Template discovery
|
||||
- Markdown rendering
|
||||
- Page rendering pipeline
|
||||
- Helper functions
|
||||
|
||||
### Request Flow
|
||||
|
||||
```
|
||||
1. HTTP Request
|
||||
↓
|
||||
2. Flask routes (/src/routes/routes.py)
|
||||
↓
|
||||
3. Path validation & security checks
|
||||
↓
|
||||
4. Template discovery (/src/rendering/template_discovery.py)
|
||||
↓
|
||||
5. Content rendering (/src/rendering/renderer.py)
|
||||
↓
|
||||
6. Template rendering with Jinja2
|
||||
↓
|
||||
7. HTTP Response
|
||||
```
|
||||
|
||||
### Adding a New Template Helper
|
||||
|
||||
Template helpers are functions available in all templates.
|
||||
|
||||
**1. Add to TemplateHelpers class** (`src/rendering/helpers.py`):
|
||||
|
||||
```python
|
||||
class TemplateHelpers:
|
||||
def __init__(self, config: Configuration):
|
||||
self.config = config
|
||||
|
||||
def get_my_helper(self, param: str) -> list:
|
||||
"""
|
||||
Description of what this helper does.
|
||||
|
||||
Args:
|
||||
param: Description of parameter
|
||||
|
||||
Returns:
|
||||
Description of return value
|
||||
"""
|
||||
# Implementation
|
||||
result = []
|
||||
# ... logic ...
|
||||
return result
|
||||
```
|
||||
|
||||
**2. Register in main.py**:
|
||||
|
||||
```python
|
||||
server.register_template_function("get_my_helper", t.get_my_helper)
|
||||
```
|
||||
|
||||
**3. Use in templates**:
|
||||
|
||||
```jinja
|
||||
{% for item in get_my_helper('value') %}
|
||||
{{ item }}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
**4. Document** in `docs/content/templates/template-helpers.md`
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing
|
||||
|
||||
Create test content:
|
||||
|
||||
```
|
||||
test-site/
|
||||
├── content/
|
||||
│ ├── index.md
|
||||
│ ├── test-post.md
|
||||
│ └── photos/
|
||||
│ └── test.jpg
|
||||
├── templates/
|
||||
│ ├── base.html
|
||||
│ └── __file.md.html
|
||||
└── styles/
|
||||
└── base.css
|
||||
```
|
||||
|
||||
Run and verify:
|
||||
```bash
|
||||
python main.py --config test-config.toml
|
||||
```
|
||||
|
||||
### Writing Tests
|
||||
|
||||
*Note: Test infrastructure may need to be set up*
|
||||
|
||||
Example test structure:
|
||||
|
||||
```python
|
||||
# tests/test_renderer.py
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from src.rendering.renderer import render_page
|
||||
|
||||
def test_render_simple_markdown():
|
||||
"""Test rendering a basic markdown file"""
|
||||
# Setup
|
||||
content = Path("test-content/simple.md")
|
||||
templates = Path("test-templates")
|
||||
|
||||
# Execute
|
||||
result = render_page(content, template_path=templates)
|
||||
|
||||
# Assert
|
||||
assert "<h1>" in result
|
||||
assert "<!DOCTYPE html>" in result
|
||||
```
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
### Before Submitting
|
||||
|
||||
- [ ] Code follows style guidelines
|
||||
- [ ] Changes are focused (one feature/fix per PR)
|
||||
- [ ] Commit messages are clear
|
||||
- [ ] Documentation updated if needed
|
||||
- [ ] Manual testing completed
|
||||
- [ ] No merge conflicts
|
||||
|
||||
### PR Description Template
|
||||
|
||||
```markdown
|
||||
## Description
|
||||
Brief description of changes
|
||||
|
||||
## Type of Change
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Documentation
|
||||
- [ ] Refactoring
|
||||
- [ ] Other (describe)
|
||||
|
||||
## Testing
|
||||
How you tested the changes
|
||||
|
||||
## Screenshots
|
||||
If UI changes, add screenshots
|
||||
|
||||
## Checklist
|
||||
- [ ] Code follows project style
|
||||
- [ ] Self-reviewed code
|
||||
- [ ] Commented complex code
|
||||
- [ ] Updated documentation
|
||||
- [ ] No breaking changes (or documented)
|
||||
```
|
||||
|
||||
### Review Process
|
||||
|
||||
1. **Maintainer reviews** code and design
|
||||
2. **Feedback provided** if changes needed
|
||||
3. **Discussion** on approach if necessary
|
||||
4. **Approval** when ready
|
||||
5. **Merge** by maintainer
|
||||
|
||||
Be patient - maintainers are volunteers.
|
||||
|
||||
## Issue Guidelines
|
||||
|
||||
### Bug Reports
|
||||
|
||||
Use this template:
|
||||
|
||||
```markdown
|
||||
**Foldsite Version:** X.X.X
|
||||
**Python Version:** X.X.X
|
||||
**OS:** macOS/Linux/Windows
|
||||
|
||||
**Description:**
|
||||
Clear description of the bug
|
||||
|
||||
**Steps to Reproduce:**
|
||||
1. Step one
|
||||
2. Step two
|
||||
3. See error
|
||||
|
||||
**Expected Behavior:**
|
||||
What should happen
|
||||
|
||||
**Actual Behavior:**
|
||||
What actually happens
|
||||
|
||||
**Error Messages:**
|
||||
```
|
||||
Paste full error/traceback
|
||||
```
|
||||
|
||||
**Additional Context:**
|
||||
Any other relevant information
|
||||
```
|
||||
|
||||
### Feature Requests
|
||||
|
||||
Use this template:
|
||||
|
||||
```markdown
|
||||
**Feature Description:**
|
||||
Clear description of the feature
|
||||
|
||||
**Use Case:**
|
||||
Why you need this feature
|
||||
|
||||
**Proposed Implementation:**
|
||||
Ideas for how it could work (optional)
|
||||
|
||||
**Alternatives Considered:**
|
||||
Other solutions you've thought about
|
||||
|
||||
**Additional Context:**
|
||||
Examples, mockups, etc.
|
||||
```
|
||||
|
||||
## Communication
|
||||
|
||||
### GitHub Issues
|
||||
|
||||
- **Bugs** - Report problems
|
||||
- **Features** - Request improvements
|
||||
- **Questions** - Ask for help (or use Discussions)
|
||||
|
||||
### GitHub Discussions
|
||||
|
||||
- **Q&A** - Get help using Foldsite
|
||||
- **Ideas** - Discuss potential features
|
||||
- **Show and Tell** - Share what you built
|
||||
- **General** - Everything else
|
||||
|
||||
### Response Times
|
||||
|
||||
- **Critical bugs** - Few days
|
||||
- **Other issues** - 1-2 weeks
|
||||
- **Feature requests** - Varies
|
||||
- **PRs** - 1-2 weeks
|
||||
|
||||
## Security
|
||||
|
||||
### Reporting Vulnerabilities
|
||||
|
||||
**Do not** open public issues for security problems.
|
||||
|
||||
Email: security@dws.rip
|
||||
|
||||
Include:
|
||||
- Description of vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested fix (if you have one)
|
||||
|
||||
### Security Considerations
|
||||
|
||||
When contributing:
|
||||
|
||||
- Validate all file paths
|
||||
- Sanitize user inputs
|
||||
- Be careful with path traversal
|
||||
- Don't expose sensitive info in errors
|
||||
- Follow principle of least privilege
|
||||
|
||||
## Project Philosophy
|
||||
|
||||
Keep these in mind when contributing:
|
||||
|
||||
### 1. Simplicity
|
||||
|
||||
Prefer simple solutions over complex ones. Add complexity only when necessary.
|
||||
|
||||
### 2. Convention Over Configuration
|
||||
|
||||
Sensible defaults that work for most cases. Configuration for edge cases.
|
||||
|
||||
### 3. User Focus
|
||||
|
||||
Optimize for content creators, not developers. Make common tasks easy.
|
||||
|
||||
### 4. Clear Over Clever
|
||||
|
||||
Code should be understandable. Clever tricks make maintenance harder.
|
||||
|
||||
### 5. Backward Compatibility
|
||||
|
||||
Don't break existing sites without very good reason and clear migration path.
|
||||
|
||||
## Resources
|
||||
|
||||
### Learning
|
||||
|
||||
- **Python** - [docs.python.org](https://docs.python.org/)
|
||||
- **Flask** - [flask.palletsprojects.com](https://flask.palletsprojects.com/)
|
||||
- **Jinja2** - [jinja.palletsprojects.com](https://jinja.palletsprojects.com/)
|
||||
|
||||
### Tools
|
||||
|
||||
- **VS Code** - Great Python support with extensions
|
||||
- **PyCharm** - Full-featured Python IDE
|
||||
- **Git** - Version control
|
||||
- **GitHub CLI** - Command-line GitHub interface
|
||||
|
||||
## Recognition
|
||||
|
||||
Contributors are recognized:
|
||||
|
||||
- **README** - Listed in contributors section
|
||||
- **Release notes** - Mentioned in changelogs
|
||||
- **Commits** - Your name in git history
|
||||
- **Gratitude** - Appreciation from the community!
|
||||
|
||||
## Getting Help
|
||||
|
||||
Stuck on contribution?
|
||||
|
||||
- **Read existing code** - Learn from examples
|
||||
- **Ask in Discussions** - Community can help
|
||||
- **Open draft PR** - Get early feedback
|
||||
- **Reach out** - DWS team is friendly!
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **Browse issues** - Find something to work on
|
||||
- **Read the code** - Understand how it works
|
||||
- **Make a change** - Start small
|
||||
- **Submit PR** - Share your contribution!
|
||||
|
||||
Thank you for helping make Foldsite better!
|
||||
|
||||
**Remember:** Every contribution matters, from fixing a typo to adding major features. We appreciate all help!
|
||||
441
docs/content/directory-structure.md
Normal file
441
docs/content/directory-structure.md
Normal file
@ -0,0 +1,441 @@
|
||||
---
|
||||
version: "1.0"
|
||||
date: "2025-01-15"
|
||||
author: "DWS Foldsite Team"
|
||||
title: "Directory Structure Guide"
|
||||
description: "Understanding how to organize your Foldsite project"
|
||||
summary: "Learn how Foldsite's directory structure works - where to put content, templates, and styles for maximum flexibility and power."
|
||||
quick_tips:
|
||||
- "content/ is where all your pages and media live"
|
||||
- "templates/ cascade down through subdirectories for hierarchical theming"
|
||||
- "Hidden files (starting with ___) are ignored by Foldsite"
|
||||
---
|
||||
|
||||
# Directory Structure Guide
|
||||
|
||||
Understanding Foldsite's directory structure is essential to using it effectively. The beauty of Foldsite is that **your folder structure IS your site structure**.
|
||||
|
||||
## The Three Core Directories
|
||||
|
||||
Every Foldsite project has three main directories:
|
||||
|
||||
```
|
||||
my-site/
|
||||
├── content/ # Your pages, posts, images, and files
|
||||
├── templates/ # HTML templates using Jinja2
|
||||
└── styles/ # CSS stylesheets
|
||||
```
|
||||
|
||||
### content/ - Your Website Content
|
||||
|
||||
This is where everything your visitors see lives. Every file and folder in here becomes part of your website.
|
||||
|
||||
**Example structure:**
|
||||
|
||||
```
|
||||
content/
|
||||
├── index.md # Homepage (/)
|
||||
├── about.md # About page (/about.md)
|
||||
├── contact.md # Contact page (/contact.md)
|
||||
├── blog/ # Blog section (/blog)
|
||||
│ ├── 2024-01-15-first-post.md
|
||||
│ ├── 2024-02-20-second-post.md
|
||||
│ └── archives/
|
||||
│ └── 2023-recap.md
|
||||
├── photos/ # Photo galleries
|
||||
│ ├── vacation/
|
||||
│ │ ├── IMG_001.jpg
|
||||
│ │ ├── IMG_002.jpg
|
||||
│ │ └── IMG_003.jpg
|
||||
│ └── family/
|
||||
│ ├── portrait.jpg
|
||||
│ └── gathering.jpg
|
||||
└── downloads/ # Files for download
|
||||
├── resume.pdf
|
||||
└── presentation.pptx
|
||||
```
|
||||
|
||||
**How URLs work:**
|
||||
|
||||
- `content/index.md` → `http://yoursite.com/`
|
||||
- `content/about.md` → `http://yoursite.com/about.md`
|
||||
- `content/blog/first-post.md` → `http://yoursite.com/blog/first-post.md`
|
||||
- `content/photos/vacation/` → `http://yoursite.com/photos/vacation`
|
||||
|
||||
### templates/ - HTML Templates
|
||||
|
||||
Templates define how your content is displayed. They mirror your content structure and cascade down through subdirectories.
|
||||
|
||||
**Example structure:**
|
||||
|
||||
```
|
||||
templates/
|
||||
├── base.html # Required: Main page wrapper
|
||||
├── __error.html # Optional: Custom error page
|
||||
├── __file.md.html # Template for all markdown files
|
||||
├── __folder.md.html # Template for folders with markdown
|
||||
├── __folder.image.html # Template for image galleries
|
||||
├── blog/
|
||||
│ └── __file.md.html # Custom template for blog posts
|
||||
└── photos/
|
||||
└── __folder.image.html # Custom gallery template for photos
|
||||
```
|
||||
|
||||
**Key template files:**
|
||||
|
||||
- `base.html` - **REQUIRED** - Wraps all pages (header, navigation, footer)
|
||||
- `__file.{category}.html` - Templates for individual files
|
||||
- `__folder.{category}.html` - Templates for folder views
|
||||
- `__error.html` - Custom error pages (404, etc.)
|
||||
|
||||
### styles/ - CSS Stylesheets
|
||||
|
||||
Stylesheets follow the same cascading logic as templates.
|
||||
|
||||
**Example structure:**
|
||||
|
||||
```
|
||||
styles/
|
||||
├── base.css # Required: Base styles
|
||||
├── __file.md.css # Styles for markdown files
|
||||
├── __folder.image.css # Styles for image galleries
|
||||
├── blog/
|
||||
│ └── __file.md.css # Blog-specific styles
|
||||
└── layouts/
|
||||
├── document.css # Layout styles
|
||||
└── gallery.css # Gallery layout styles
|
||||
```
|
||||
|
||||
## File Naming Conventions
|
||||
|
||||
### Content Files
|
||||
|
||||
- **Regular names**: `about.md`, `contact.md`, `my-post.md`
|
||||
- **Hidden files**: Prefix with `___` to hide from navigation
|
||||
- `___draft-post.md` - Won't appear in listings
|
||||
- `___notes.md` - Private notes not rendered
|
||||
|
||||
### Template Files
|
||||
|
||||
Templates use a special naming pattern:
|
||||
|
||||
- `__file.{extension}.html` - For individual files
|
||||
- `__file.md.html` - All markdown files
|
||||
- `__file.jpg.html` - Individual images (rare)
|
||||
|
||||
- `__folder.{category}.html` - For folders
|
||||
- `__folder.md.html` - Folders containing mostly markdown
|
||||
- `__folder.image.html` - Photo gallery folders
|
||||
|
||||
- `{specific-name}.html` - For specific pages
|
||||
- `index.html` - Only for index.md
|
||||
- `about.html` - Only for about.md
|
||||
|
||||
### Style Files
|
||||
|
||||
Styles follow the same pattern as templates:
|
||||
|
||||
- `base.css` - Always loaded
|
||||
- `__file.md.css` - Loaded for markdown files
|
||||
- `__folder.image.css` - Loaded for image galleries
|
||||
- `{specific-path}.css` - Loaded for specific pages
|
||||
|
||||
## How File Categories Work
|
||||
|
||||
Foldsite automatically categorizes files by extension:
|
||||
|
||||
| Category | Extensions | Template Pattern | Use Case |
|
||||
|----------|-----------|------------------|----------|
|
||||
| **document** | `.md`, `.txt`, `.html` | `__file.document.html` | Articles, pages |
|
||||
| **image** | `.jpg`, `.png`, `.gif`, `.svg` | `__file.image.html` | Photos |
|
||||
| **multimedia** | `.mp4`, `.mp3`, `.webm` | `__file.multimedia.html` | Videos, audio |
|
||||
| **other** | Everything else | `__file.other.html` | PDFs, downloads |
|
||||
|
||||
Folders are categorized by their **most common file type**:
|
||||
|
||||
```
|
||||
photos/ # Mostly images → "image" category
|
||||
IMG_001.jpg
|
||||
IMG_002.jpg
|
||||
notes.txt # Ignored for categorization
|
||||
|
||||
blog/ # Mostly markdown → "document" category
|
||||
post-1.md
|
||||
post-2.md
|
||||
header.jpg # Ignored for categorization
|
||||
```
|
||||
|
||||
## Template and Style Discovery
|
||||
|
||||
Foldsite uses a **hierarchical discovery system**. When rendering a page, it searches for templates from most specific to most general.
|
||||
|
||||
### Example: Rendering `content/blog/my-post.md`
|
||||
|
||||
**Template search order:**
|
||||
|
||||
1. `templates/blog/my-post.html` - Specific file template
|
||||
2. `templates/blog/__file.md.html` - Blog folder markdown template
|
||||
3. `templates/blog/__file.document.html` - Blog folder document template
|
||||
4. `templates/__file.md.html` - Root markdown template
|
||||
5. `templates/__file.document.html` - Root document template
|
||||
6. `templates/__file.html` - Generic file template
|
||||
|
||||
**First match wins.**
|
||||
|
||||
### Example: Rendering `content/photos/vacation/` folder
|
||||
|
||||
**Template search order:**
|
||||
|
||||
1. `templates/photos/vacation/__folder.html` - Specific folder template
|
||||
2. `templates/photos/__folder.image.html` - Photo folder image template
|
||||
3. `templates/__folder.image.html` - Root image folder template
|
||||
4. `templates/__folder.html` - Generic folder template
|
||||
|
||||
**Style discovery works the same way**, building a list of all matching styles from most specific to most general.
|
||||
|
||||
## Configuration File
|
||||
|
||||
The `config.toml` file tells Foldsite where your directories are:
|
||||
|
||||
```toml
|
||||
[paths]
|
||||
content_dir = "/path/to/content"
|
||||
templates_dir = "/path/to/templates"
|
||||
styles_dir = "/path/to/styles"
|
||||
|
||||
[server]
|
||||
listen_address = "0.0.0.0"
|
||||
listen_port = 8081
|
||||
admin_browser = false
|
||||
admin_password = "change-me"
|
||||
max_threads = 4
|
||||
debug = false
|
||||
access_log = true
|
||||
```
|
||||
|
||||
### Key Configuration Options
|
||||
|
||||
**paths:**
|
||||
- `content_dir` - Absolute path to content folder
|
||||
- `templates_dir` - Absolute path to templates folder
|
||||
- `styles_dir` - Absolute path to styles folder
|
||||
|
||||
**server:**
|
||||
- `listen_address` - IP to bind to (`0.0.0.0` for all interfaces)
|
||||
- `listen_port` - Port number (default 8081)
|
||||
- `admin_browser` - Enable file manager UI (true/false)
|
||||
- `admin_password` - Password for file manager (if enabled)
|
||||
- `max_threads` - Number of worker threads
|
||||
- `debug` - Enable debug mode (shows template discovery)
|
||||
- `access_log` - Log HTTP requests
|
||||
|
||||
## Special Files and Folders
|
||||
|
||||
### Hidden Content (`___` prefix)
|
||||
|
||||
Files and folders starting with three underscores are ignored:
|
||||
|
||||
```
|
||||
content/
|
||||
├── public-post.md # ✓ Visible
|
||||
├── ___draft-post.md # ✗ Hidden
|
||||
├── blog/ # ✓ Visible
|
||||
│ └── article.md
|
||||
└── ___notes/ # ✗ Hidden (entire folder)
|
||||
└── ideas.md
|
||||
```
|
||||
|
||||
Use this for:
|
||||
- Draft posts
|
||||
- Private notes
|
||||
- Work-in-progress content
|
||||
- Template testing
|
||||
|
||||
### Index Files
|
||||
|
||||
`index.md` files serve as folder landing pages:
|
||||
|
||||
```
|
||||
content/
|
||||
├── index.md # Homepage: /
|
||||
└── blog/
|
||||
├── index.md # Blog index: /blog or /blog/
|
||||
├── post-1.md # Post: /blog/post-1.md
|
||||
└── post-2.md # Post: /blog/post-2.md
|
||||
```
|
||||
|
||||
Without an `index.md`, folders display using the `__folder.*` template.
|
||||
|
||||
## Practical Examples
|
||||
|
||||
### Minimal Setup
|
||||
|
||||
Simplest possible Foldsite:
|
||||
|
||||
```
|
||||
my-site/
|
||||
├── content/
|
||||
│ └── index.md
|
||||
├── templates/
|
||||
│ ├── base.html
|
||||
│ └── __file.md.html
|
||||
└── styles/
|
||||
└── base.css
|
||||
```
|
||||
|
||||
### Blog Site
|
||||
|
||||
Typical blog structure:
|
||||
|
||||
```
|
||||
blog-site/
|
||||
├── content/
|
||||
│ ├── index.md # Homepage
|
||||
│ ├── about.md # About page
|
||||
│ └── posts/
|
||||
│ ├── 2024-01-post.md
|
||||
│ ├── 2024-02-post.md
|
||||
│ └── ___draft.md # Hidden draft
|
||||
├── templates/
|
||||
│ ├── base.html
|
||||
│ ├── __file.md.html # Default post template
|
||||
│ ├── __folder.md.html # Post listing
|
||||
│ └── index.html # Custom homepage
|
||||
└── styles/
|
||||
├── base.css
|
||||
├── __file.md.css
|
||||
└── __folder.md.css
|
||||
```
|
||||
|
||||
### Photo Portfolio
|
||||
|
||||
Photography site structure:
|
||||
|
||||
```
|
||||
portfolio/
|
||||
├── content/
|
||||
│ ├── index.md
|
||||
│ └── galleries/
|
||||
│ ├── landscape/
|
||||
│ │ ├── IMG_001.jpg
|
||||
│ │ └── IMG_002.jpg
|
||||
│ ├── portrait/
|
||||
│ │ └── photos...
|
||||
│ └── urban/
|
||||
│ └── photos...
|
||||
├── templates/
|
||||
│ ├── base.html
|
||||
│ ├── __folder.image.html # Gallery template
|
||||
│ └── galleries/
|
||||
│ └── __folder.image.html # Custom for galleries
|
||||
└── styles/
|
||||
├── base.css
|
||||
└── __folder.image.css
|
||||
```
|
||||
|
||||
### Documentation Site
|
||||
|
||||
Hierarchical documentation:
|
||||
|
||||
```
|
||||
docs-site/
|
||||
├── content/
|
||||
│ ├── index.md
|
||||
│ ├── getting-started/
|
||||
│ │ ├── installation.md
|
||||
│ │ ├── configuration.md
|
||||
│ │ └── first-steps.md
|
||||
│ ├── guides/
|
||||
│ │ ├── basics.md
|
||||
│ │ └── advanced.md
|
||||
│ └── api/
|
||||
│ ├── overview.md
|
||||
│ └── reference.md
|
||||
├── templates/
|
||||
│ ├── base.html
|
||||
│ ├── __file.md.html
|
||||
│ └── __folder.md.html
|
||||
└── styles/
|
||||
├── base.css
|
||||
└── layouts/
|
||||
└── document.css
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Organize Content Logically
|
||||
|
||||
Your folder structure should make sense to humans:
|
||||
|
||||
```
|
||||
✓ Good:
|
||||
content/
|
||||
├── blog/
|
||||
├── projects/
|
||||
└── about.md
|
||||
|
||||
✗ Confusing:
|
||||
content/
|
||||
├── stuff/
|
||||
├── things/
|
||||
└── misc/
|
||||
```
|
||||
|
||||
### 2. Use Descriptive Names
|
||||
|
||||
File and folder names become URLs and navigation labels:
|
||||
|
||||
```
|
||||
✓ Good:
|
||||
getting-started.md
|
||||
python-tutorial.md
|
||||
2024-year-review.md
|
||||
|
||||
✗ Poor:
|
||||
page1.md
|
||||
doc.md
|
||||
tmp.md
|
||||
```
|
||||
|
||||
### 3. Keep Templates DRY
|
||||
|
||||
Don't duplicate templates. Use inheritance and the cascade:
|
||||
|
||||
```
|
||||
✓ Good:
|
||||
templates/
|
||||
├── base.html # Shared structure
|
||||
├── __file.md.html # All markdown files
|
||||
└── blog/
|
||||
└── __file.md.html # Only blog-specific changes
|
||||
|
||||
✗ Redundant:
|
||||
templates/
|
||||
├── base.html
|
||||
├── about.html # Unnecessary if using __file.md.html
|
||||
├── contact.html # Unnecessary if using __file.md.html
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 4. Use Hidden Files for Work in Progress
|
||||
|
||||
Keep drafts and private notes out of production:
|
||||
|
||||
```
|
||||
content/
|
||||
├── published-post.md
|
||||
├── ___draft-post.md # Hidden
|
||||
└── ___notes/ # Hidden folder
|
||||
└── ideas.md
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
Now that you understand the directory structure:
|
||||
|
||||
- [Learn about Templates](templates/) - Master the template system
|
||||
- [Explore Styles](styles/) - Understand CSS cascading
|
||||
- [See Recipes](recipes/) - Ready-to-use examples
|
||||
- [Deploy Your Site](deployment/) - Get it online
|
||||
|
||||
The directory structure is the foundation of Foldsite. Master it, and everything else becomes intuitive.
|
||||
356
docs/content/explore.md
Normal file
356
docs/content/explore.md
Normal file
@ -0,0 +1,356 @@
|
||||
---
|
||||
version: "1.0"
|
||||
date: "2025-01-15"
|
||||
author: "DWS Foldsite Team"
|
||||
title: "Explore Foldsites"
|
||||
description: "Real-world Foldsite examples and inspiration"
|
||||
summary: "Discover websites built with Foldsite - from personal blogs to photo portfolios and documentation sites."
|
||||
quick_tips:
|
||||
- "Real sites provide the best learning examples"
|
||||
- "View source to see how they're built"
|
||||
- "Steal ideas (with attribution) and make them your own"
|
||||
---
|
||||
|
||||
# Explore Foldsites
|
||||
|
||||
See what people are building with Foldsite. These real-world examples demonstrate different approaches, designs, and use cases.
|
||||
|
||||
## Featured Sites
|
||||
|
||||
### Personal Sites & Blogs
|
||||
|
||||
#### Tanishq Dubey's Site
|
||||
**URL:** [https://tanishq.page](https://tanishq.page)
|
||||
**Type:** Personal blog + photo gallery
|
||||
**Highlights:**
|
||||
- Clean, minimal design
|
||||
- Mix of blog posts and photo galleries
|
||||
- Breadcrumb navigation
|
||||
- Responsive layout
|
||||
|
||||
**What you can learn:**
|
||||
- How to combine blog and gallery in one site
|
||||
- Sidebar navigation pattern
|
||||
- Photo organization by folders
|
||||
|
||||
---
|
||||
|
||||
### Documentation Sites
|
||||
|
||||
#### Foldsite Documentation
|
||||
**URL:** (This site!)
|
||||
**Type:** Product documentation
|
||||
**Highlights:**
|
||||
- Hierarchical content organization
|
||||
- Sibling page navigation
|
||||
- Code examples with syntax highlighting
|
||||
- Comprehensive frontmatter usage
|
||||
|
||||
**What you can learn:**
|
||||
- Documentation site structure
|
||||
- Template organization
|
||||
- Content hierarchy best practices
|
||||
|
||||
---
|
||||
|
||||
## Community Showcase
|
||||
|
||||
*This section is community-curated. Add your site below!*
|
||||
|
||||
### How to Add Your Site
|
||||
|
||||
Built something with Foldsite? Share it with the community!
|
||||
|
||||
**To add your site:**
|
||||
|
||||
1. Fork the Foldsite repository
|
||||
2. Edit `docs/content/explore.md`
|
||||
3. Add your site using this template:
|
||||
```markdown
|
||||
#### Your Site Name
|
||||
**URL:** https://your-site.com
|
||||
**Type:** Blog / Portfolio / Gallery / Docs / Other
|
||||
**Highlights:**
|
||||
- Key feature 1
|
||||
- Key feature 2
|
||||
- Key feature 3
|
||||
|
||||
**What makes it special:**
|
||||
Brief description of unique aspects or interesting implementation details.
|
||||
```
|
||||
4. Submit a pull request
|
||||
|
||||
**Guidelines:**
|
||||
- Your site must be publicly accessible
|
||||
- Must be built with Foldsite
|
||||
- Keep description concise
|
||||
- No commercial promotion (personal/hobby sites only)
|
||||
- Family-friendly content
|
||||
|
||||
---
|
||||
|
||||
## Inspiration Gallery
|
||||
|
||||
### Blog Designs
|
||||
|
||||
**Minimalist Blog**
|
||||
- Clean typography
|
||||
- Lots of whitespace
|
||||
- Focus on content
|
||||
- Tag-based navigation
|
||||
|
||||
**Magazine Style**
|
||||
- Grid layout
|
||||
- Featured images
|
||||
- Multi-column
|
||||
- Category sections
|
||||
|
||||
**Tech Blog**
|
||||
- Syntax highlighting
|
||||
- Code-focused
|
||||
- Dark mode
|
||||
- Technical diagrams
|
||||
|
||||
### Photo Galleries
|
||||
|
||||
**Travel Photography**
|
||||
- Location-based organization
|
||||
- EXIF data display
|
||||
- Lightbox viewing
|
||||
- Map integration (possible)
|
||||
|
||||
**Portfolio Site**
|
||||
- Project-based galleries
|
||||
- About/contact pages
|
||||
- Custom landing page
|
||||
- Client testimonials
|
||||
|
||||
**Photo Blog**
|
||||
- Mix of photos and text
|
||||
- Chronological posts
|
||||
- Photo series
|
||||
- Behind-the-scenes content
|
||||
|
||||
### Documentation Styles
|
||||
|
||||
**API Reference**
|
||||
- Code examples
|
||||
- Parameter tables
|
||||
- Return value documentation
|
||||
- Search functionality
|
||||
|
||||
**User Guide**
|
||||
- Step-by-step tutorials
|
||||
- Screenshots/diagrams
|
||||
- Prerequisites sections
|
||||
- Troubleshooting guides
|
||||
|
||||
**Knowledge Base**
|
||||
- FAQ style
|
||||
- Search by category
|
||||
- Related articles
|
||||
- Quick answers
|
||||
|
||||
## Design Patterns
|
||||
|
||||
Common patterns seen across successful Foldsites:
|
||||
|
||||
### Navigation
|
||||
|
||||
**Sidebar Navigation** (Like example_site)
|
||||
```
|
||||
┌─────────────┬──────────────────────────┐
|
||||
│ │ │
|
||||
│ Sidebar │ Main Content │
|
||||
│ Nav │ │
|
||||
│ │ │
|
||||
│ - Home │ Page content here... │
|
||||
│ - About │ │
|
||||
│ - Blog │ │
|
||||
│ - Photos │ │
|
||||
│ │ │
|
||||
└─────────────┴──────────────────────────┘
|
||||
```
|
||||
|
||||
**Top Navigation**
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ Logo Home About Blog Contact │
|
||||
├────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Main Content │
|
||||
│ │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Breadcrumb Navigation**
|
||||
```
|
||||
Home / Blog / 2024 / My Post
|
||||
|
||||
────────────────────────────────────────
|
||||
Post Content
|
||||
````
|
||||
|
||||
### Layouts
|
||||
|
||||
**Single Column**
|
||||
- Best for reading
|
||||
- Focus on content
|
||||
- Mobile-friendly by default
|
||||
|
||||
**Two Column**
|
||||
- Content + sidebar
|
||||
- Related posts/navigation
|
||||
- Additional information
|
||||
|
||||
**Grid**
|
||||
- Photo galleries
|
||||
- Post previews
|
||||
- Portfolio items
|
||||
|
||||
## Learning from Examples
|
||||
|
||||
### View Source
|
||||
|
||||
All Foldsites are just HTML, CSS, and markdown. View source to learn:
|
||||
|
||||
1. **Right-click → View Page Source** - See rendered HTML
|
||||
2. **Check `/styles/` URLs** - View CSS files
|
||||
3. **Look at URL structure** - Understand content organization
|
||||
|
||||
### Clone and Experiment
|
||||
|
||||
For open source Foldsites:
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/user/their-site.git
|
||||
|
||||
# Copy their templates
|
||||
cp -r their-site/templates my-site/templates
|
||||
|
||||
# Customize and make it your own
|
||||
```
|
||||
|
||||
### Adaptation Guidelines
|
||||
|
||||
**Do:**
|
||||
- Study patterns and techniques
|
||||
- Adapt ideas to your needs
|
||||
- Give credit for inspiration
|
||||
- Make it your own
|
||||
|
||||
**Don't:**
|
||||
- Copy entire sites wholesale
|
||||
- Use someone else's content
|
||||
- Steal unique designs exactly
|
||||
- Claim others' work as yours
|
||||
|
||||
## Themes & Templates
|
||||
|
||||
### Official Examples
|
||||
|
||||
**example_site/** (in repository)
|
||||
- Typical blog/gallery site
|
||||
- Shows common patterns
|
||||
- Breadcrumb navigation
|
||||
- Sidebar layout
|
||||
|
||||
### Community Themes
|
||||
|
||||
*Coming soon! Community-contributed themes will be listed here.*
|
||||
|
||||
Want to contribute a theme? See [Theme Gallery](theme-gallery.md).
|
||||
|
||||
## Case Studies
|
||||
|
||||
### From Idea to Site
|
||||
|
||||
**Case Study 1: Personal Blog**
|
||||
|
||||
**Goal:** Simple blog to share thoughts
|
||||
**Time:** 2 hours
|
||||
**Approach:**
|
||||
1. Started with `example_site` templates
|
||||
2. Customized colors and fonts
|
||||
3. Added personal branding
|
||||
4. Deployed to GitHub Pages
|
||||
|
||||
**Lessons learned:**
|
||||
- Start simple, iterate
|
||||
- Templates cascade saves time
|
||||
- Custom homepage makes it special
|
||||
|
||||
---
|
||||
|
||||
**Case Study 2: Photography Portfolio**
|
||||
|
||||
**Goal:** Showcase travel photography
|
||||
**Time:** 4 hours
|
||||
**Approach:**
|
||||
1. Organized photos by location/trip
|
||||
2. Used `__folder.image.html` template
|
||||
3. Added EXIF data display
|
||||
4. Integrated lightbox library
|
||||
|
||||
**Lessons learned:**
|
||||
- Folder structure = site structure
|
||||
- EXIF data adds context
|
||||
- Thumbnail generation is automatic
|
||||
|
||||
---
|
||||
|
||||
**Case Study 3: Project Documentation**
|
||||
|
||||
**Goal:** Document open source project
|
||||
**Time:** 6 hours
|
||||
**Approach:**
|
||||
1. Mirrored code structure in content
|
||||
2. Created hierarchical docs
|
||||
3. Added code examples
|
||||
4. Set up sibling navigation
|
||||
|
||||
**Lessons learned:**
|
||||
- Mirror mental model in folders
|
||||
- Breadcrumbs essential for deep hierarchies
|
||||
- Code blocks need good syntax highlighting
|
||||
|
||||
## Tips from the Community
|
||||
|
||||
> "Start with one template and iterate. Don't try to build everything at once."
|
||||
> — Early Foldsite user
|
||||
|
||||
> "The folder structure IS the site structure. Once I understood that, everything clicked."
|
||||
> — Documentation writer
|
||||
|
||||
> "Use hidden files (`___`) for drafts. Game changer for my workflow."
|
||||
> — Blogger
|
||||
|
||||
> "Template helpers like `get_recent_posts()` saved me so much time. No database needed!"
|
||||
> — Former WordPress user
|
||||
|
||||
## Your Site Here
|
||||
|
||||
Built something with Foldsite? We'd love to feature it!
|
||||
|
||||
**Submission criteria:**
|
||||
- Publicly accessible
|
||||
- Built with Foldsite
|
||||
- Demonstrates interesting pattern or design
|
||||
- Well-executed (doesn't have to be perfect!)
|
||||
|
||||
**How to submit:**
|
||||
1. Open issue on GitHub with "Showcase" label
|
||||
2. Include URL, description, and what makes it special
|
||||
3. We'll review and add to this page
|
||||
|
||||
## Explore More
|
||||
|
||||
- **[Recipes](recipes/)** - Template code you can copy
|
||||
- **[Theme Gallery](theme-gallery.md)** - Downloadable themes
|
||||
- **[Templates Guide](templates/)** - Learn the system
|
||||
- **[Support](support.md)** - Get help building your site
|
||||
|
||||
**Ready to build?** [Get Started](index.md#quick-start)
|
||||
|
||||
*Remember: Every great Foldsite started as an empty folder. Your site could be featured here next!*
|
||||
165
docs/content/index.md
Normal file
165
docs/content/index.md
Normal file
@ -0,0 +1,165 @@
|
||||
---
|
||||
version: "1.0"
|
||||
date: "2025-01-15"
|
||||
author: "DWS Foldsite Team"
|
||||
title: "Foldsite Documentation"
|
||||
description: "Turn your folders into beautiful websites with zero configuration"
|
||||
summary: "Welcome to Foldsite - a modern static/dynamic site generator that transforms your file structure into a navigable website. Focus on content, not configuration."
|
||||
quick_tips:
|
||||
- "Your folder structure IS your site structure - no complex routing needed"
|
||||
- "Templates cascade automatically - create them where you need them"
|
||||
- "Start with just markdown files - add templates and styles later"
|
||||
---
|
||||
|
||||
# Welcome to Foldsite
|
||||
|
||||
**Foldsite** is a static/dynamic site generator that lets you focus on what matters: **your content**. Drop markdown files into folders, add templates for customization, and Foldsite handles the rest.
|
||||
|
||||
> *"It's your Internet. Take it back."*
|
||||
> — [DWS (Dubey Web Services)](https://dws.rip)
|
||||
|
||||
## What Makes Foldsite Different?
|
||||
|
||||
### Folders → Site Structure
|
||||
Your directory layout becomes your website structure automatically. No routing configuration, no complex build steps. Create a folder, drop in a markdown file, and it's live.
|
||||
|
||||
```
|
||||
content/
|
||||
├── about.md → /about
|
||||
├── blog/
|
||||
│ ├── post-1.md → /blog/post-1.md
|
||||
│ └── post-2.md → /blog/post-2.md
|
||||
└── photos/
|
||||
└── vacation/ → /photos/vacation
|
||||
```
|
||||
|
||||
### Template System That Makes Sense
|
||||
Templates cascade through your directory tree. Create specific templates for individual files, or general templates that apply to entire sections:
|
||||
|
||||
- `__file.md.html` - Template for all markdown files
|
||||
- `__folder.md.html` - Template for folders containing markdown
|
||||
- `__folder.image.html` - Template for photo galleries
|
||||
- Custom templates for specific pages
|
||||
|
||||
### Powerful Helper Functions
|
||||
Access content dynamically from your templates using built-in Jinja2 helpers:
|
||||
|
||||
```jinja
|
||||
{# List recent blog posts #}
|
||||
{% for post in get_recent_posts(limit=5, folder='blog') %}
|
||||
<a href="/{{ post.path }}">{{ post.title }}</a>
|
||||
{% endfor %}
|
||||
|
||||
{# Show navigation breadcrumbs #}
|
||||
{% for crumb in generate_breadcrumbs(currentPath) %}
|
||||
<a href="{{ crumb.url }}">{{ crumb.title }}</a>
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install and Run
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/DWSresearch/foldsite
|
||||
cd foldsite
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run the development server
|
||||
python main.py --config config.toml
|
||||
```
|
||||
|
||||
Visit `http://localhost:8081` to see your site!
|
||||
|
||||
### 2. Create Your First Page
|
||||
|
||||
```bash
|
||||
# Create a content directory
|
||||
mkdir -p my-site/content
|
||||
|
||||
# Write your first page
|
||||
echo "# Hello World" > my-site/content/index.md
|
||||
```
|
||||
|
||||
### 3. Customize with Templates
|
||||
|
||||
```bash
|
||||
# Create a basic template structure
|
||||
mkdir -p my-site/templates my-site/styles
|
||||
|
||||
# Add a base template (see Templates section for examples)
|
||||
```
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### Personal Blog
|
||||
Perfect for sharing your thoughts with automatic post discovery, tagging, and chronological sorting.
|
||||
|
||||
### Photo Gallery
|
||||
Built-in image handling with EXIF data extraction, thumbnail generation, and gallery views.
|
||||
|
||||
### Documentation Site
|
||||
Hierarchical content organization with automatic navigation and sibling page discovery.
|
||||
|
||||
### Portfolio Site
|
||||
Showcase projects with flexible templates that adapt to your content type.
|
||||
|
||||
## Documentation Sections
|
||||
|
||||
### [About Foldsite](about.md)
|
||||
Learn about the philosophy behind Foldsite and why it was created.
|
||||
|
||||
### [Directory Structure](directory-structure.md)
|
||||
Understanding how to organize your content, templates, and styles.
|
||||
|
||||
### [Deployment](deployment/)
|
||||
Get your Foldsite running locally, in Docker, or production environments.
|
||||
|
||||
### [Templates](templates/)
|
||||
Master the template system - from basics to advanced hierarchical templates.
|
||||
|
||||
### [Styles](styles/)
|
||||
Learn how CSS cascades through your site structure.
|
||||
|
||||
### [Template Recipes](recipes/)
|
||||
Ready-to-use examples for blogs, galleries, documentation sites, and more.
|
||||
|
||||
### [Theme Gallery](theme-gallery.md)
|
||||
Explore community-created themes and templates.
|
||||
|
||||
### [Explore Foldsites](explore.md)
|
||||
See real-world examples of Foldsite in action.
|
||||
|
||||
### [Develop Foldsite](develop/)
|
||||
Contributing to Foldsite development.
|
||||
|
||||
### [Support](support.md)
|
||||
Get help and connect with the community.
|
||||
|
||||
## Why Foldsite Exists
|
||||
|
||||
Foldsite is part of the **DWS (Dubey Web Services)** mission to help people reclaim their corner of the internet. In an era of complex CMSs and heavy frameworks, Foldsite brings simplicity back:
|
||||
|
||||
- **Own your content** - Just files and folders on your filesystem
|
||||
- **Control your presentation** - Templates and styles that make sense
|
||||
- **Host anywhere** - Static files or dynamic Python server
|
||||
- **Zero lock-in** - Your markdown works everywhere
|
||||
|
||||
## Philosophy
|
||||
|
||||
1. **Content is king** - Your folders and files are the source of truth
|
||||
2. **Convention over configuration** - Sensible defaults, customize when needed
|
||||
3. **Progressive enhancement** - Start simple, add complexity only where needed
|
||||
4. **Developer friendly** - Clear APIs, helpful error messages, debug tools
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **New users**: Start with [Directory Structure](directory-structure.md) to understand the basics
|
||||
- **Building a blog**: Jump to [Blog Site Recipe](recipes/blog-site.md)
|
||||
- **Creating themes**: Read the [Templates Guide](templates/)
|
||||
- **Deploying**: Check [Deployment Options](deployment/)
|
||||
|
||||
Ready to turn your folders into a website? Let's get started!
|
||||
682
docs/content/recipes/index.md
Normal file
682
docs/content/recipes/index.md
Normal file
@ -0,0 +1,682 @@
|
||||
---
|
||||
version: "1.0"
|
||||
date: "2025-01-15"
|
||||
author: "DWS Foldsite Team"
|
||||
title: "Template Recipes"
|
||||
description: "Ready-to-use examples for common Foldsite patterns"
|
||||
summary: "Complete, working examples for building blogs, photo galleries, documentation sites, and more with Foldsite."
|
||||
quick_tips:
|
||||
- "All recipes are complete and ready to use - just copy and customize"
|
||||
- "Recipes demonstrate real-world patterns used in production Foldsites"
|
||||
- "Mix and match components from different recipes"
|
||||
---
|
||||
|
||||
# Template Recipes
|
||||
|
||||
Ready-to-use templates and patterns for common Foldsite use cases. Copy these examples and customize them for your needs.
|
||||
|
||||
## Recipe Collection
|
||||
|
||||
### Blog Site
|
||||
Complete blog setup with recent posts, tag filtering, and related content.
|
||||
|
||||
### Photo Gallery
|
||||
Beautiful image galleries with EXIF data, breadcrumbs, and responsive layout.
|
||||
|
||||
### Documentation Site
|
||||
Hierarchical documentation with navigation, breadcrumbs, and sibling pages.
|
||||
|
||||
### Portfolio Site
|
||||
Project showcase with custom layouts per project.
|
||||
|
||||
---
|
||||
|
||||
## Recipe 1: Personal Blog
|
||||
|
||||
A complete blog with post listings, tags, and related posts.
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
my-blog/
|
||||
├── content/
|
||||
│ ├── index.md
|
||||
│ ├── about.md
|
||||
│ └── posts/
|
||||
│ ├── 2024-01-15-first-post.md
|
||||
│ ├── 2024-02-20-python-tips.md
|
||||
│ └── 2024-03-10-web-development.md
|
||||
├── templates/
|
||||
│ ├── base.html
|
||||
│ ├── index.html
|
||||
│ ├── __file.md.html
|
||||
│ └── posts/
|
||||
│ ├── __file.md.html
|
||||
│ └── __folder.md.html
|
||||
└── styles/
|
||||
├── base.css
|
||||
└── posts/
|
||||
└── __file.md.css
|
||||
```
|
||||
|
||||
### Post Frontmatter Format
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "My First Blog Post"
|
||||
date: "2024-01-15"
|
||||
author: "Your Name"
|
||||
tags: ["python", "tutorial", "beginners"]
|
||||
description: "A brief introduction to Python for beginners"
|
||||
---
|
||||
|
||||
# Your content here...
|
||||
```
|
||||
|
||||
### Template: `base.html`
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% if metadata and metadata.title %}{{ metadata.title }} - {% endif %}My Blog</title>
|
||||
|
||||
{% for style in styles %}
|
||||
<link rel="stylesheet" href="/styles{{ style }}">
|
||||
{% endfor %}
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav class="main-nav">
|
||||
<a href="/" class="logo">My Blog</a>
|
||||
<div class="nav-links">
|
||||
<a href="/">Home</a>
|
||||
<a href="/posts">Posts</a>
|
||||
<a href="/about.md">About</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
{{ content|safe }}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2025 My Blog. Built with <a href="https://github.com/DWSresearch/foldsite">Foldsite</a>.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Template: `index.html` (Homepage)
|
||||
|
||||
```html
|
||||
<div class="homepage">
|
||||
<section class="hero">
|
||||
<h1>Welcome to My Blog</h1>
|
||||
<p>Thoughts on coding, life, and everything in between.</p>
|
||||
</section>
|
||||
|
||||
<section class="recent-posts">
|
||||
<h2>Recent Posts</h2>
|
||||
<div class="post-grid">
|
||||
{% for post in get_recent_posts(limit=6, folder='posts') %}
|
||||
<article class="post-card">
|
||||
<h3><a href="{{ post.url }}">{{ post.title }}</a></h3>
|
||||
<time datetime="{{ post.date }}">{{ post.date }}</time>
|
||||
|
||||
{% if post.metadata.description %}
|
||||
<p class="excerpt">{{ post.metadata.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if post.metadata.tags %}
|
||||
<div class="tags">
|
||||
{% for tag in post.metadata.tags[:3] %}
|
||||
<a href="/tags/{{ tag|lower }}.md" class="tag">{{ tag }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<a href="/posts" class="view-all">View All Posts →</a>
|
||||
</section>
|
||||
|
||||
<aside class="sidebar">
|
||||
<section class="popular-tags">
|
||||
<h3>Popular Tags</h3>
|
||||
<div class="tag-cloud">
|
||||
{% for tag in get_all_tags()|sort(attribute='count', reverse=True)[:15] %}
|
||||
<a href="/tags/{{ tag.name|lower }}.md"
|
||||
class="tag"
|
||||
style="font-size: {{ 0.9 + (tag.count * 0.15) }}rem;">
|
||||
{{ tag.name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Template: `posts/__file.md.html` (Blog Post)
|
||||
|
||||
```html
|
||||
<article class="blog-post">
|
||||
<header class="post-header">
|
||||
{% if metadata %}
|
||||
<h1>{{ metadata.title }}</h1>
|
||||
|
||||
<div class="post-meta">
|
||||
<time datetime="{{ metadata.date }}">{{ metadata.date }}</time>
|
||||
{% if metadata.author %}
|
||||
<span class="author">by {{ metadata.author }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if metadata.tags %}
|
||||
<div class="post-tags">
|
||||
{% for tag in metadata.tags %}
|
||||
<a href="/tags/{{ tag|lower }}.md" class="tag">{{ tag }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<div class="post-content">
|
||||
{{ content|safe }}
|
||||
</div>
|
||||
|
||||
<footer class="post-footer">
|
||||
{% set related = get_related_posts(currentPath, limit=3) %}
|
||||
{% if related %}
|
||||
<section class="related-posts">
|
||||
<h3>Related Posts</h3>
|
||||
<div class="related-grid">
|
||||
{% for post in related %}
|
||||
<a href="{{ post.url }}" class="related-card">
|
||||
<h4>{{ post.title }}</h4>
|
||||
<time>{{ post.date }}</time>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<nav class="post-navigation">
|
||||
{% set siblings = get_sibling_content_files(currentPath) %}
|
||||
{% if siblings|length > 1 %}
|
||||
{% set current_index = namespace(value=-1) %}
|
||||
{% for idx, (name, path) in enumerate(siblings) %}
|
||||
{% if path == currentPath %}
|
||||
{% set current_index.value = idx %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if current_index.value > 0 %}
|
||||
{% set prev = siblings[current_index.value - 1] %}
|
||||
<a href="/{{ prev[1] }}" class="nav-prev">← {{ prev[0].replace('.md', '') }}</a>
|
||||
{% endif %}
|
||||
|
||||
{% if current_index.value < siblings|length - 1 %}
|
||||
{% set next = siblings[current_index.value + 1] %}
|
||||
<a href="/{{ next[1] }}" class="nav-next">{{ next[0].replace('.md', '') }} →</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</nav>
|
||||
</footer>
|
||||
</article>
|
||||
```
|
||||
|
||||
### Template: `posts/__folder.md.html` (Post Index)
|
||||
|
||||
```html
|
||||
<div class="post-index">
|
||||
<header class="index-header">
|
||||
<h1>All Posts</h1>
|
||||
<p>{{ get_recent_posts(limit=1000, folder='posts')|length }} posts and counting</p>
|
||||
</header>
|
||||
|
||||
<div class="post-list">
|
||||
{% for post in get_recent_posts(limit=100, folder='posts') %}
|
||||
<article class="post-item">
|
||||
<time datetime="{{ post.date }}">{{ post.date }}</time>
|
||||
<h2><a href="{{ post.url }}">{{ post.title }}</a></h2>
|
||||
|
||||
{% if post.metadata.description %}
|
||||
<p class="description">{{ post.metadata.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if post.metadata.tags %}
|
||||
<div class="tags">
|
||||
{% for tag in post.metadata.tags %}
|
||||
<a href="/tags/{{ tag|lower }}.md" class="tag">{{ tag }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recipe 2: Photo Gallery
|
||||
|
||||
Beautiful, responsive photo galleries with EXIF data extraction.
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
photo-site/
|
||||
├── content/
|
||||
│ ├── index.md
|
||||
│ └── galleries/
|
||||
│ ├── vacation-2024/
|
||||
│ │ ├── IMG_001.jpg
|
||||
│ │ ├── IMG_002.jpg
|
||||
│ │ └── IMG_003.jpg
|
||||
│ └── family/
|
||||
│ └── photos...
|
||||
├── templates/
|
||||
│ ├── base.html
|
||||
│ └── galleries/
|
||||
│ └── __folder.image.html
|
||||
└── styles/
|
||||
├── base.css
|
||||
└── galleries/
|
||||
└── __folder.image.css
|
||||
```
|
||||
|
||||
### Template: `galleries/__folder.image.html`
|
||||
|
||||
```html
|
||||
<div class="photo-gallery">
|
||||
<header class="gallery-header">
|
||||
<nav class="breadcrumbs">
|
||||
{% for crumb in generate_breadcrumbs(currentPath) %}
|
||||
{% if not crumb.is_current %}
|
||||
<a href="{{ crumb.url }}">{{ crumb.title }}</a>
|
||||
{% else %}
|
||||
<span>{{ crumb.title }}</span>
|
||||
{% endif %}
|
||||
{% if not loop.last %} / {% endif %}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
<h1>{{ currentPath.split('/')[-1]|replace('-', ' ')|title }}</h1>
|
||||
|
||||
{% set photos = get_folder_contents(currentPath)|selectattr('categories', 'contains', 'image') %}
|
||||
<p class="photo-count">{{ photos|list|length }} photos</p>
|
||||
|
||||
{# Show sibling galleries #}
|
||||
{% set sibling_folders = get_sibling_content_folders(currentPath) %}
|
||||
{% if sibling_folders %}
|
||||
<nav class="gallery-nav">
|
||||
<h3>Other Galleries:</h3>
|
||||
{% for name, path in sibling_folders %}
|
||||
<a href="/{{ path }}">{{ name|replace('-', ' ')|title }}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<div class="photo-grid">
|
||||
{% for photo in photos|list|sort(attribute='date_created', reverse=True) %}
|
||||
<figure class="photo-item">
|
||||
<a href="/download/{{ photo.path }}" class="photo-link">
|
||||
<img src="/download/{{ photo.path }}?max_width=800"
|
||||
alt="{{ photo.name }}"
|
||||
loading="lazy"
|
||||
width="{{ photo.metadata.width if photo.metadata else 800 }}"
|
||||
height="{{ photo.metadata.height if photo.metadata else 600 }}">
|
||||
</a>
|
||||
|
||||
{% if photo.metadata and photo.metadata.exif %}
|
||||
<figcaption class="photo-caption">
|
||||
{% if photo.metadata.exif.DateTimeOriginal %}
|
||||
<time>{{ photo.metadata.exif.DateTimeOriginal }}</time>
|
||||
{% endif %}
|
||||
{% if photo.metadata.exif.Model %}
|
||||
<span class="camera">{{ photo.metadata.exif.Model }}</span>
|
||||
{% endif %}
|
||||
{% if photo.metadata.exif.LensModel %}
|
||||
<span class="lens">{{ photo.metadata.exif.LensModel }}</span>
|
||||
{% endif %}
|
||||
</figcaption>
|
||||
{% endif %}
|
||||
</figure>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Optional: Include a lightbox library #}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/viewerjs/1.11.7/viewer.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/viewerjs/1.11.7/viewer.min.js"></script>
|
||||
<script>
|
||||
const gallery = new Viewer(document.querySelector('.photo-grid'), {
|
||||
toolbar: true,
|
||||
title: true,
|
||||
navbar: true,
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Styles: `galleries/__folder.image.css`
|
||||
|
||||
```css
|
||||
.photo-gallery {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.gallery-header {
|
||||
margin-bottom: 3rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.breadcrumbs a {
|
||||
color: #0066cc;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.photo-count {
|
||||
color: #888;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.photo-item {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.photo-item:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.photo-link {
|
||||
display: block;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.photo-item img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.photo-caption {
|
||||
padding: 0.75rem;
|
||||
background: white;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.photo-caption time {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.gallery-nav {
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.gallery-nav a {
|
||||
display: inline-block;
|
||||
margin: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.gallery-nav a:hover {
|
||||
background: #e5e5e5;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.photo-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recipe 3: Documentation Site
|
||||
|
||||
Hierarchical documentation with navigation and search.
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
docs-site/
|
||||
├── content/
|
||||
│ ├── index.md
|
||||
│ ├── getting-started/
|
||||
│ │ ├── installation.md
|
||||
│ │ ├── configuration.md
|
||||
│ │ └── quickstart.md
|
||||
│ ├── guides/
|
||||
│ │ ├── basics.md
|
||||
│ │ └── advanced.md
|
||||
│ └── api/
|
||||
│ ├── overview.md
|
||||
│ └── reference.md
|
||||
├── templates/
|
||||
│ ├── base.html
|
||||
│ ├── index.html
|
||||
│ └── __file.md.html
|
||||
└── styles/
|
||||
└── base.css
|
||||
```
|
||||
|
||||
### Template: `__file.md.html` (Documentation Page)
|
||||
|
||||
```html
|
||||
<div class="docs-layout">
|
||||
<aside class="docs-sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<h3>Documentation</h3>
|
||||
|
||||
<section class="nav-section">
|
||||
<h4>Getting Started</h4>
|
||||
<ul>
|
||||
<li><a href="/getting-started/installation.md">Installation</a></li>
|
||||
<li><a href="/getting-started/configuration.md">Configuration</a></li>
|
||||
<li><a href="/getting-started/quickstart.md">Quick Start</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="nav-section">
|
||||
<h4>Guides</h4>
|
||||
<ul>
|
||||
<li><a href="/guides/basics.md">Basics</a></li>
|
||||
<li><a href="/guides/advanced.md">Advanced</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="nav-section">
|
||||
<h4>API Reference</h4>
|
||||
<ul>
|
||||
<li><a href="/api/overview.md">Overview</a></li>
|
||||
<li><a href="/api/reference.md">Reference</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
</nav>
|
||||
|
||||
{# Show sibling pages automatically #}
|
||||
{% set siblings = get_sibling_content_files(currentPath) %}
|
||||
{% if siblings|length > 1 %}
|
||||
<nav class="page-nav">
|
||||
<h4>On This Page:</h4>
|
||||
<ul>
|
||||
{% for name, path in siblings %}
|
||||
<li>
|
||||
<a href="/{{ path }}"
|
||||
{% if path == currentPath %}class="active"{% endif %}>
|
||||
{{ name.replace('.md', '')|replace('-', ' ')|title }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</aside>
|
||||
|
||||
<main class="docs-content">
|
||||
<nav class="breadcrumbs">
|
||||
{% for crumb in generate_breadcrumbs(currentPath) %}
|
||||
<a href="{{ crumb.url }}">{{ crumb.title }}</a>
|
||||
{% if not loop.last %} › {% endif %}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
<article class="documentation">
|
||||
{% if metadata and metadata.title %}
|
||||
<h1>{{ metadata.title }}</h1>
|
||||
{% endif %}
|
||||
|
||||
{{ content|safe }}
|
||||
</article>
|
||||
|
||||
<footer class="docs-footer">
|
||||
{# Previous/Next navigation #}
|
||||
{% set siblings = get_sibling_content_files(currentPath) %}
|
||||
{% if siblings|length > 1 %}
|
||||
<nav class="pagination">
|
||||
{% set current_index = namespace(value=-1) %}
|
||||
{% for idx in range(siblings|length) %}
|
||||
{% if siblings[idx][1] == currentPath %}
|
||||
{% set current_index.value = idx %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if current_index.value > 0 %}
|
||||
{% set prev = siblings[current_index.value - 1] %}
|
||||
<a href="/{{ prev[1] }}" class="prev">
|
||||
← {{ prev[0].replace('.md', '')|replace('-', ' ')|title }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if current_index.value >= 0 and current_index.value < siblings|length - 1 %}
|
||||
{% set next = siblings[current_index.value + 1] %}
|
||||
<a href="/{{ next[1] }}" class="next">
|
||||
{{ next[0].replace('.md', '')|replace('-', ' ')|title }} →
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{# Related pages based on tags #}
|
||||
{% set related = get_related_posts(currentPath, limit=3) %}
|
||||
{% if related %}
|
||||
<section class="related-docs">
|
||||
<h4>Related Documentation:</h4>
|
||||
<ul>
|
||||
{% for doc in related %}
|
||||
<li><a href="{{ doc.url }}">{{ doc.title }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recipe 4: Portfolio Site
|
||||
|
||||
Showcase projects with custom layouts.
|
||||
|
||||
### Template: Portfolio Homepage
|
||||
|
||||
```html
|
||||
<div class="portfolio">
|
||||
<section class="hero">
|
||||
<h1>{{ metadata.title if metadata else "My Portfolio" }}</h1>
|
||||
<p class="tagline">{{ metadata.description if metadata else "Designer & Developer" }}</p>
|
||||
</section>
|
||||
|
||||
<section class="projects">
|
||||
<h2>Featured Projects</h2>
|
||||
<div class="project-grid">
|
||||
{% for folder_name, folder_path in get_sibling_content_folders('projects') %}
|
||||
{% set project_files = get_folder_contents(folder_path) %}
|
||||
{% set cover_image = project_files|selectattr('categories', 'contains', 'image')|first %}
|
||||
|
||||
<a href="/{{ folder_path }}" class="project-card">
|
||||
{% if cover_image %}
|
||||
<img src="/download/{{ cover_image.path }}?max_width=600"
|
||||
alt="{{ folder_name }}">
|
||||
{% endif %}
|
||||
<h3>{{ folder_name|replace('-', ' ')|title }}</h3>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mixing Recipes
|
||||
|
||||
You can combine elements from different recipes:
|
||||
|
||||
### Blog + Gallery
|
||||
|
||||
Add photo galleries to a blog site by including the gallery template in your blog's template directory.
|
||||
|
||||
### Documentation + Blog
|
||||
|
||||
Add a `/blog` section to documentation by including blog templates alongside docs templates.
|
||||
|
||||
### Portfolio + Blog
|
||||
|
||||
Showcase projects and share thoughts by combining portfolio and blog patterns.
|
||||
|
||||
## Customization Tips
|
||||
|
||||
1. **Start with one recipe** - Get it working before adding complexity
|
||||
2. **Modify styles first** - Change colors, fonts, spacing to match your brand
|
||||
3. **Adjust layouts gradually** - Start with structure, refine as you go
|
||||
4. **Add features incrementally** - Don't try to implement everything at once
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Template System](../templates/)** - Understanding how templates work
|
||||
- **[Template Helpers](../templates/template-helpers.md)** - Complete API reference
|
||||
- **[Styles Guide](../styles/)** - Styling your site
|
||||
- **[Explore Foldsites](../explore.md)** - See real examples
|
||||
|
||||
Copy these recipes, make them your own, and build something amazing!
|
||||
770
docs/content/styles/index.md
Normal file
770
docs/content/styles/index.md
Normal file
@ -0,0 +1,770 @@
|
||||
---
|
||||
version: "1.0"
|
||||
date: "2025-01-15"
|
||||
author: "DWS Foldsite Team"
|
||||
title: "Styles Guide"
|
||||
description: "Understanding Foldsite's CSS cascade system"
|
||||
summary: "Learn how CSS styles cascade through your Foldsite project - from base styles to page-specific customizations."
|
||||
quick_tips:
|
||||
- "base.css is required and loaded on every page"
|
||||
- "All matching styles are loaded (unlike templates where first match wins)"
|
||||
- "Styles cascade down through directory structure like templates"
|
||||
---
|
||||
|
||||
# Styles Guide
|
||||
|
||||
Foldsite's style system follows the same hierarchical logic as templates, allowing you to organize CSS in a maintainable, scalable way.
|
||||
|
||||
## The Style System
|
||||
|
||||
### Key Differences from Templates
|
||||
|
||||
| Templates | Styles |
|
||||
|-----------|--------|
|
||||
| **First match wins** | **All matches load** |
|
||||
| One template per page | Multiple stylesheets per page |
|
||||
| Must have at least one | base.css required |
|
||||
|
||||
**Why the difference?**
|
||||
|
||||
CSS is designed to cascade and layer. Loading multiple stylesheets allows you to:
|
||||
- Share base styles
|
||||
- Add section-specific styles
|
||||
- Override with page-specific styles
|
||||
|
||||
## Required Style: base.css
|
||||
|
||||
Every Foldsite project **must have** `styles/base.css`.
|
||||
|
||||
This file is **loaded on every page**, providing:
|
||||
- Typography
|
||||
- Layout basics
|
||||
- Color scheme
|
||||
- Resets/normalizes
|
||||
|
||||
### Minimal base.css
|
||||
|
||||
```css
|
||||
/* styles/base.css */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0066cc;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
```
|
||||
|
||||
## Style Discovery
|
||||
|
||||
Styles follow a **hierarchical discovery pattern** similar to templates.
|
||||
|
||||
### For File: `blog/my-post.md`
|
||||
|
||||
**All matching styles load (in order):**
|
||||
|
||||
1. **Base style** (always)
|
||||
```
|
||||
/styles/base.css
|
||||
```
|
||||
|
||||
2. **Type + extension** styles (from root to specific)
|
||||
```
|
||||
/styles/__file.md.css
|
||||
/styles/blog/__file.md.css
|
||||
```
|
||||
|
||||
3. **Type + category** styles
|
||||
```
|
||||
/styles/__file.document.css
|
||||
/styles/blog/__file.document.css
|
||||
```
|
||||
|
||||
4. **Specific file** style
|
||||
```
|
||||
/styles/blog/my-post.md.css
|
||||
```
|
||||
|
||||
**All found styles are included!**
|
||||
|
||||
### Rendered HTML
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="/styles/base.css">
|
||||
<link rel="stylesheet" href="/styles/__file.md.css">
|
||||
<link rel="stylesheet" href="/styles/blog/__file.md.css">
|
||||
```
|
||||
|
||||
## Style Naming Patterns
|
||||
|
||||
### File Styles
|
||||
|
||||
Pattern: `__file.{extension}.css` or `__file.{category}.css`
|
||||
|
||||
```
|
||||
styles/
|
||||
├── __file.md.css # All markdown files
|
||||
├── __file.document.css # All document files
|
||||
├── __file.image.css # Individual images (rare)
|
||||
└── __file.other.css # Other file types
|
||||
```
|
||||
|
||||
### Folder Styles
|
||||
|
||||
Pattern: `__folder.{category}.css`
|
||||
|
||||
```
|
||||
styles/
|
||||
├── __folder.md.css # Folders with markdown
|
||||
├── __folder.image.css # Photo galleries
|
||||
└── __folder.html # Any folder view
|
||||
```
|
||||
|
||||
### Specific Page Styles
|
||||
|
||||
Pattern: `{path/to/file}.css`
|
||||
|
||||
```
|
||||
styles/
|
||||
├── index.md.css # Only homepage
|
||||
├── about.md.css # Only about page
|
||||
└── blog/
|
||||
└── special-post.md.css # One specific post
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
### Basic Structure
|
||||
|
||||
```
|
||||
styles/
|
||||
├── base.css # Required: Base styles
|
||||
├── __file.md.css # Markdown file styles
|
||||
└── __folder.image.css # Gallery styles
|
||||
```
|
||||
|
||||
### Advanced Structure
|
||||
|
||||
```
|
||||
styles/
|
||||
├── base.css # Base
|
||||
├── __file.md.css # General markdown
|
||||
├── __folder.image.css # General galleries
|
||||
├── blog/
|
||||
│ ├── __file.md.css # Blog posts
|
||||
│ └── __folder.md.css # Blog index
|
||||
├── docs/
|
||||
│ └── __file.md.css # Documentation
|
||||
├── layouts/
|
||||
│ ├── document.css # Document layout
|
||||
│ ├── gallery.css # Gallery layout
|
||||
│ └── landing.css # Landing pages
|
||||
└── components/
|
||||
├── navigation.css # Navigation
|
||||
├── footer.css # Footer
|
||||
└── breadcrumbs.css # Breadcrumbs
|
||||
```
|
||||
|
||||
## Cascade & Specificity
|
||||
|
||||
### CSS Cascade Order
|
||||
|
||||
**Load order matters:**
|
||||
|
||||
1. `base.css` - Loaded first
|
||||
2. General styles (`__file.md.css`)
|
||||
3. Section styles (`blog/__file.md.css`)
|
||||
4. Specific styles (`blog/my-post.md.css`)
|
||||
|
||||
**Later styles override earlier ones** (standard CSS behavior).
|
||||
|
||||
### Example Cascade
|
||||
|
||||
**Given page:** `blog/tutorial.md`
|
||||
|
||||
**Loaded styles:**
|
||||
```html
|
||||
<link rel="stylesheet" href="/styles/base.css">
|
||||
<link rel="stylesheet" href="/styles/__file.md.css">
|
||||
<link rel="stylesheet" href="/styles/blog/__file.md.css">
|
||||
```
|
||||
|
||||
**base.css:**
|
||||
```css
|
||||
h1 {
|
||||
color: black; /* Default */
|
||||
}
|
||||
```
|
||||
|
||||
**__file.md.css:**
|
||||
```css
|
||||
h1 {
|
||||
color: #333; /* Slightly lighter */
|
||||
}
|
||||
```
|
||||
|
||||
**blog/__file.md.css:**
|
||||
```css
|
||||
h1 {
|
||||
color: #0066cc; /* Blue - wins! */
|
||||
}
|
||||
```
|
||||
|
||||
**Result:** Blog headings are blue.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Base + Overrides
|
||||
|
||||
Start with comprehensive base, override as needed:
|
||||
|
||||
**base.css** - Everything
|
||||
```css
|
||||
/* Typography */
|
||||
body { font-family: sans-serif; }
|
||||
h1 { font-size: 2.5rem; }
|
||||
h2 { font-size: 2rem; }
|
||||
|
||||
/* Layout */
|
||||
.container { max-width: 1200px; }
|
||||
|
||||
/* Components */
|
||||
nav { /* navigation styles */ }
|
||||
footer { /* footer styles */ }
|
||||
```
|
||||
|
||||
**blog/__file.md.css** - Blog-specific
|
||||
```css
|
||||
/* Override heading colors for blog */
|
||||
h1 { color: #0066cc; }
|
||||
|
||||
/* Add blog-specific components */
|
||||
.post-meta { /* metadata styles */ }
|
||||
```
|
||||
|
||||
### Pattern 2: Modular Components
|
||||
|
||||
Split styles into reusable modules:
|
||||
|
||||
**base.css** - Minimal
|
||||
```css
|
||||
@import url('components/typography.css');
|
||||
@import url('components/layout.css');
|
||||
@import url('components/navigation.css');
|
||||
```
|
||||
|
||||
**components/typography.css**
|
||||
```css
|
||||
body {
|
||||
font-family: Georgia, serif;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
h1 { font-size: 2.5rem; }
|
||||
/* ... */
|
||||
```
|
||||
|
||||
**components/navigation.css**
|
||||
```css
|
||||
nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
/* ... */
|
||||
```
|
||||
|
||||
### Pattern 3: Layout Variants
|
||||
|
||||
Different layouts for different sections:
|
||||
|
||||
**layouts/document.css**
|
||||
```css
|
||||
.document-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 250px 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.docs-sidebar { /* sidebar styles */ }
|
||||
.docs-content { /* content styles */ }
|
||||
```
|
||||
|
||||
**layouts/gallery.css**
|
||||
```css
|
||||
.gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.photo-item { /* photo card styles */ }
|
||||
```
|
||||
|
||||
**Include in templates:**
|
||||
```html
|
||||
<!-- __file.md.html for docs -->
|
||||
<link rel="stylesheet" href="/styles/layouts/document.css">
|
||||
<div class="document-layout">
|
||||
<!-- content -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### Pattern 4: Responsive Design
|
||||
|
||||
Mobile-first approach:
|
||||
|
||||
```css
|
||||
/* base.css - Mobile first */
|
||||
body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Tablet */
|
||||
@media (min-width: 768px) {
|
||||
body {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop */
|
||||
@media (min-width: 1200px) {
|
||||
body {
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Practical Examples
|
||||
|
||||
### Example 1: Blog Site
|
||||
|
||||
```
|
||||
styles/
|
||||
├── base.css # Site-wide styles
|
||||
├── __file.md.css # General markdown
|
||||
├── __folder.md.css # Folder listings
|
||||
└── blog/
|
||||
├── __file.md.css # Blog posts
|
||||
└── __folder.md.css # Blog index
|
||||
```
|
||||
|
||||
**base.css:**
|
||||
```css
|
||||
/* Basic layout and typography */
|
||||
body {
|
||||
font-family: Georgia, serif;
|
||||
line-height: 1.6;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
a { color: #0066cc; }
|
||||
```
|
||||
|
||||
**__file.md.css:**
|
||||
```css
|
||||
/* Default markdown styles */
|
||||
article {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #f4f4f4;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f4f4f4;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
```
|
||||
|
||||
**blog/__file.md.css:**
|
||||
```css
|
||||
/* Blog post specific */
|
||||
.post-header {
|
||||
border-bottom: 2px solid #eee;
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.post-tags {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: #f0f0f0;
|
||||
border-radius: 3px;
|
||||
margin-right: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
```
|
||||
|
||||
**blog/__folder.md.css:**
|
||||
```css
|
||||
/* Blog index */
|
||||
.post-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.post-item {
|
||||
margin: 2rem 0;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.post-item:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
```
|
||||
|
||||
### Example 2: Photo Gallery
|
||||
|
||||
```
|
||||
styles/
|
||||
├── base.css
|
||||
└── galleries/
|
||||
└── __folder.image.css
|
||||
```
|
||||
|
||||
**galleries/__folder.image.css:**
|
||||
```css
|
||||
.photo-gallery {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.photo-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.photo-item:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.photo-item img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.photo-caption {
|
||||
padding: 0.75rem;
|
||||
background: white;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.photo-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example 3: Documentation Site
|
||||
|
||||
```
|
||||
styles/
|
||||
├── base.css
|
||||
├── layouts/
|
||||
│ └── document.css
|
||||
└── docs/
|
||||
└── __file.md.css
|
||||
```
|
||||
|
||||
**layouts/document.css:**
|
||||
```css
|
||||
.docs-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 250px 1fr;
|
||||
gap: 3rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.docs-sidebar {
|
||||
position: sticky;
|
||||
top: 2rem;
|
||||
height: calc(100vh - 4rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.docs-content {
|
||||
min-width: 0; /* Prevent grid blowout */
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.docs-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.docs-sidebar {
|
||||
position: static;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**docs/__file.md.css:**
|
||||
```css
|
||||
.documentation {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
/* Table of contents */
|
||||
.doc-toc {
|
||||
background: #f8f8f8;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
.documentation pre {
|
||||
background: #282c34;
|
||||
color: #abb2bf;
|
||||
padding: 1.5rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.documentation code {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Callouts */
|
||||
.note,
|
||||
.warning,
|
||||
.tip {
|
||||
padding: 1rem 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
border-left: 4px solid;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.note {
|
||||
background: #e3f2fd;
|
||||
border-color: #2196f3;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: #fff3e0;
|
||||
border-color: #ff9800;
|
||||
}
|
||||
|
||||
.tip {
|
||||
background: #e8f5e9;
|
||||
border-color: #4caf50;
|
||||
}
|
||||
```
|
||||
|
||||
## CSS Variables
|
||||
|
||||
Use CSS custom properties for theming:
|
||||
|
||||
### base.css with Variables
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Colors */
|
||||
--color-primary: #0066cc;
|
||||
--color-secondary: #6c757d;
|
||||
--color-text: #333;
|
||||
--color-background: #fff;
|
||||
--color-border: #dee2e6;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: 'Monaco', 'Courier New', monospace;
|
||||
|
||||
/* Spacing */
|
||||
--space-xs: 0.5rem;
|
||||
--space-sm: 1rem;
|
||||
--space-md: 2rem;
|
||||
--space-lg: 3rem;
|
||||
|
||||
/* Breakpoints (for reference) */
|
||||
/* Use in @media queries */
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ... */
|
||||
```
|
||||
|
||||
### Dark Mode
|
||||
|
||||
```css
|
||||
/* base.css */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-text: #e4e4e4;
|
||||
--color-background: #1a1a1a;
|
||||
--color-border: #333;
|
||||
}
|
||||
}
|
||||
|
||||
/* Or toggle with class */
|
||||
body.dark-mode {
|
||||
--color-text: #e4e4e4;
|
||||
--color-background: #1a1a1a;
|
||||
--color-border: #333;
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
### 1. Minimize File Size
|
||||
|
||||
```css
|
||||
/* Remove unnecessary spaces/newlines in production */
|
||||
/* Use a CSS minifier */
|
||||
```
|
||||
|
||||
### 2. Avoid @import
|
||||
|
||||
```css
|
||||
/* Slow - additional HTTP request */
|
||||
@import url('components/typography.css');
|
||||
|
||||
/* Better - combine files or use build tool */
|
||||
/* Or let browser load multiple <link> tags in parallel */
|
||||
```
|
||||
|
||||
### 3. Optimize Selectors
|
||||
|
||||
```css
|
||||
/* Fast */
|
||||
.class-name { }
|
||||
#id-name { }
|
||||
|
||||
/* Slower */
|
||||
div > ul > li > a { }
|
||||
[data-attribute="value"] { }
|
||||
|
||||
/* Use classes for styling */
|
||||
```
|
||||
|
||||
### 4. Use Will-Change Sparingly
|
||||
|
||||
```css
|
||||
/* Only for elements that will actually animate */
|
||||
.photo-item {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.photo-item:hover {
|
||||
transform: translateY(-4px);
|
||||
will-change: transform; /* Hint to browser */
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging Styles
|
||||
|
||||
### Browser DevTools
|
||||
|
||||
1. **Inspect element** - Right-click → Inspect
|
||||
2. **Check computed styles** - See which rules apply
|
||||
3. **See cascade** - Understand override order
|
||||
4. **Live edit** - Test changes instantly
|
||||
|
||||
### Debug Checklist
|
||||
|
||||
**Styles not loading:**
|
||||
- [ ] File exists in `styles/` directory
|
||||
- [ ] Filename matches expected pattern
|
||||
- [ ] No syntax errors in CSS
|
||||
- [ ] Browser cache cleared
|
||||
|
||||
**Styles not applying:**
|
||||
- [ ] Check CSS specificity
|
||||
- [ ] Check cascade order
|
||||
- [ ] Look for typos in selectors
|
||||
- [ ] Verify HTML classes match CSS
|
||||
|
||||
**Wrong styles applying:**
|
||||
- [ ] Check for conflicting rules
|
||||
- [ ] Verify file loading order
|
||||
- [ ] Look for !important (avoid if possible)
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Templates Guide](../templates/)** - Templates use these styles
|
||||
- **[Template Discovery](../templates/template-discovery.md)** - How styles are discovered
|
||||
- **[Recipes](../recipes/)** - Complete examples with CSS
|
||||
|
||||
Master the style system to create beautiful, maintainable Foldsites!
|
||||
314
docs/content/support.md
Normal file
314
docs/content/support.md
Normal file
@ -0,0 +1,314 @@
|
||||
---
|
||||
version: "1.0"
|
||||
date: "2025-01-15"
|
||||
author: "DWS Foldsite Team"
|
||||
title: "Support & Community"
|
||||
description: "Get help with Foldsite"
|
||||
summary: "Find help, report issues, and connect with the Foldsite community."
|
||||
quick_tips:
|
||||
- "Check documentation first - most questions are answered here"
|
||||
- "Search existing GitHub issues before opening new ones"
|
||||
- "Share your Foldsite creations with the community"
|
||||
---
|
||||
|
||||
# Support & Community
|
||||
|
||||
Need help with Foldsite? Here's how to get assistance and connect with others.
|
||||
|
||||
## Documentation
|
||||
|
||||
**Start here first!** Most questions are answered in the documentation:
|
||||
|
||||
- **[Home](index.md)** - Overview and quick start
|
||||
- **[Directory Structure](directory-structure.md)** - Understanding the basics
|
||||
- **[Templates](templates/)** - Template system guide
|
||||
- **[Template Helpers](templates/template-helpers.md)** - Complete API reference
|
||||
- **[Deployment](deployment/)** - Getting your site running
|
||||
- **[Recipes](recipes/)** - Working examples
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Template Not Found
|
||||
|
||||
**Error:** `Exception: Base template not found`
|
||||
|
||||
**Solution:** Ensure `base.html` exists in your templates directory:
|
||||
```bash
|
||||
ls templates/base.html
|
||||
```
|
||||
|
||||
### Module Not Found
|
||||
|
||||
**Error:** `ModuleNotFoundError: No module named 'flask'`
|
||||
|
||||
**Solution:** Install dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Port Already in Use
|
||||
|
||||
**Error:** `OSError: [Errno 48] Address already in use`
|
||||
|
||||
**Solution:** Change port in `config.toml`:
|
||||
```toml
|
||||
[server]
|
||||
listen_port = 8082 # Use different port
|
||||
```
|
||||
|
||||
Or stop the process using port 8081:
|
||||
```bash
|
||||
# Find process
|
||||
lsof -i :8081
|
||||
|
||||
# Kill it
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
### Images Not Loading
|
||||
|
||||
**Problem:** Images show broken links
|
||||
|
||||
**Solution:** Use the `/download/` route for serving files:
|
||||
```html
|
||||
<!-- Correct -->
|
||||
<img src="/download/{{ photo.path }}">
|
||||
|
||||
<!-- Wrong -->
|
||||
<img src="/{{ photo.path }}">
|
||||
```
|
||||
|
||||
### Template Changes Not Appearing
|
||||
|
||||
**Problem:** Modified templates don't show changes
|
||||
|
||||
**Solutions:**
|
||||
1. Check you're editing the correct file
|
||||
2. Clear browser cache (Cmd/Ctrl + Shift + R)
|
||||
3. Restart the Foldsite server
|
||||
4. Check `config.toml` points to correct templates directory
|
||||
|
||||
## GitHub Repository
|
||||
|
||||
**Repository:** [https://github.com/DWSresearch/foldsite](https://github.com/DWSresearch/foldsite)
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
Found a bug? Please report it on GitHub Issues.
|
||||
|
||||
**Before reporting:**
|
||||
1. Search existing issues to avoid duplicates
|
||||
2. Make sure you're using the latest version
|
||||
3. Try to reproduce with minimal example
|
||||
|
||||
**Good bug report includes:**
|
||||
- Foldsite version
|
||||
- Python version
|
||||
- Operating system
|
||||
- Clear steps to reproduce
|
||||
- Expected vs. actual behavior
|
||||
- Error messages (full stack traces)
|
||||
- Minimal example code if possible
|
||||
|
||||
**Example:**
|
||||
|
||||
```markdown
|
||||
## Bug Report
|
||||
|
||||
**Foldsite version:** 1.0.0
|
||||
**Python version:** 3.11.5
|
||||
**OS:** macOS 14.0
|
||||
|
||||
### Steps to Reproduce
|
||||
1. Create template with `{{ get_recent_posts() }}`
|
||||
2. Run `python main.py --config config.toml`
|
||||
3. Visit homepage
|
||||
|
||||
### Expected
|
||||
Should show 5 recent posts
|
||||
|
||||
### Actual
|
||||
Raises `AttributeError: 'NoneType' object has no attribute 'metadata'`
|
||||
|
||||
### Stack Trace
|
||||
```
|
||||
[paste full error here]
|
||||
```
|
||||
```
|
||||
|
||||
### Feature Requests
|
||||
|
||||
Have an idea for improvement? Open a feature request!
|
||||
|
||||
**Good feature request includes:**
|
||||
- Clear description of the feature
|
||||
- Use case (why you need it)
|
||||
- Proposed implementation (if you have ideas)
|
||||
- Examples from other tools (if applicable)
|
||||
|
||||
### Pull Requests
|
||||
|
||||
Contributions welcome! See [Develop Foldsite](develop/) for guidelines.
|
||||
|
||||
## Community
|
||||
|
||||
### Show & Tell
|
||||
|
||||
Share what you've built with Foldsite:
|
||||
|
||||
- Post in GitHub Discussions
|
||||
- Tag `#foldsite` on social media
|
||||
- Add your site to [Explore Foldsites](explore.md)
|
||||
|
||||
### Discussions
|
||||
|
||||
Have questions or want to chat? Use GitHub Discussions:
|
||||
|
||||
- **Q&A** - Ask questions
|
||||
- **Show and Tell** - Share your site
|
||||
- **Ideas** - Discuss potential features
|
||||
- **General** - Everything else
|
||||
|
||||
## Getting Help
|
||||
|
||||
### Before Asking
|
||||
|
||||
1. **Read the docs** - Your answer might be here
|
||||
2. **Search issues** - Someone might have asked already
|
||||
3. **Check examples** - See working code in `/example_site`
|
||||
4. **Enable debug mode** - See what Foldsite is doing:
|
||||
```toml
|
||||
[server]
|
||||
debug = true
|
||||
```
|
||||
|
||||
### How to Ask
|
||||
|
||||
**Good questions include:**
|
||||
- What you're trying to achieve
|
||||
- What you've tried
|
||||
- Relevant code snippets
|
||||
- Error messages
|
||||
|
||||
**Example:**
|
||||
|
||||
> I'm trying to show recent posts on my homepage, but `get_recent_posts()` returns an empty list.
|
||||
>
|
||||
> My content structure:
|
||||
> ```
|
||||
> content/
|
||||
> ├── index.md
|
||||
> └── blog/
|
||||
> ├── post1.md
|
||||
> └── post2.md
|
||||
> ```
|
||||
>
|
||||
> My template code:
|
||||
> ```jinja
|
||||
> {% for post in get_recent_posts(limit=5) %}
|
||||
> {{ post.title }}
|
||||
> {% endfor %}
|
||||
> ```
|
||||
>
|
||||
> Posts have frontmatter with `title` and `date` fields. What am I missing?
|
||||
|
||||
### Response Times
|
||||
|
||||
Foldsite is maintained by volunteers. Please be patient:
|
||||
|
||||
- **Bugs** - Usually addressed within a few days
|
||||
- **Features** - May take longer depending on complexity
|
||||
- **Questions** - Community often responds quickly
|
||||
|
||||
## Contributing
|
||||
|
||||
Want to help make Foldsite better?
|
||||
|
||||
### Ways to Contribute
|
||||
|
||||
- **Documentation** - Fix typos, clarify confusing sections, add examples
|
||||
- **Bug fixes** - Fix issues and submit pull requests
|
||||
- **Features** - Implement new functionality
|
||||
- **Templates** - Create and share themes
|
||||
- **Testing** - Test new releases, report issues
|
||||
- **Community** - Help others in discussions
|
||||
|
||||
See [Develop Foldsite](develop/) for detailed contribution guidelines.
|
||||
|
||||
## Professional Support
|
||||
|
||||
Need dedicated help with Foldsite?
|
||||
|
||||
**[DWS (Dubey Web Services)](https://dws.rip)** may offer consulting for complex implementations. Contact through the website for availability and rates.
|
||||
|
||||
## Security Issues
|
||||
|
||||
Found a security vulnerability? **Do not open a public issue.**
|
||||
|
||||
Email security@dws.rip with:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested fix (if you have one)
|
||||
|
||||
We'll respond as quickly as possible and coordinate disclosure.
|
||||
|
||||
## Learning Resources
|
||||
|
||||
### Jinja2 (Template Engine)
|
||||
|
||||
Foldsite uses Jinja2 for templates:
|
||||
|
||||
- **[Official Docs](https://jinja.palletsprojects.com/)** - Complete Jinja2 reference
|
||||
- **[Template Designer Docs](https://jinja.palletsprojects.com/en/3.1.x/templates/)** - Template syntax
|
||||
|
||||
### Markdown
|
||||
|
||||
Content is written in Markdown:
|
||||
|
||||
- **[CommonMark](https://commonmark.org/)** - Markdown specification
|
||||
- **[Markdown Guide](https://www.markdownguide.org/)** - Beginner-friendly guide
|
||||
|
||||
### Python (For Development)
|
||||
|
||||
If you want to contribute to Foldsite:
|
||||
|
||||
- **[Python Tutorial](https://docs.python.org/3/tutorial/)** - Official Python tutorial
|
||||
- **[Flask Docs](https://flask.palletsprojects.com/)** - Web framework used by Foldsite
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Foldsite is built with:
|
||||
|
||||
- **[Flask](https://flask.palletsprojects.com/)** - Web framework
|
||||
- **[Jinja2](https://jinja.palletsprojects.com/)** - Template engine
|
||||
- **[mistune](https://github.com/lepture/mistune)** - Markdown parser
|
||||
- **[python-frontmatter](https://github.com/eyeseast/python-frontmatter)** - Frontmatter parsing
|
||||
- **[Pillow](https://python-pillow.org/)** - Image processing
|
||||
- **[Gunicorn](https://gunicorn.org/)** - WSGI HTTP server
|
||||
|
||||
Thank you to all contributors and users!
|
||||
|
||||
## Stay Updated
|
||||
|
||||
- **Watch** the GitHub repository for updates
|
||||
- **Star** the repo if you find Foldsite useful
|
||||
- **Fork** to experiment with your own changes
|
||||
|
||||
## Philosophy
|
||||
|
||||
Foldsite is part of the **DWS mission**: *"It's your Internet. Take it back."*
|
||||
|
||||
We believe in:
|
||||
- **Simple tools** that solve real problems
|
||||
- **User ownership** of content and presentation
|
||||
- **Open source** collaboration
|
||||
- **Privacy** and control
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Explore Foldsites](explore.md)** - See what others have built
|
||||
- **[Develop Foldsite](develop/)** - Contributing guidelines
|
||||
- **[Recipes](recipes/)** - Working examples to learn from
|
||||
|
||||
**Still stuck?** Don't hesitate to ask for help. The Foldsite community is here to support you!
|
||||
578
docs/content/templates/index.md
Normal file
578
docs/content/templates/index.md
Normal file
@ -0,0 +1,578 @@
|
||||
---
|
||||
version: "1.0"
|
||||
date: "2025-01-15"
|
||||
author: "DWS Foldsite Team"
|
||||
title: "Template System"
|
||||
description: "Master Foldsite's powerful cascading template system"
|
||||
summary: "Learn how Foldsite's hierarchical template discovery works - from simple defaults to sophisticated, context-aware layouts."
|
||||
quick_tips:
|
||||
- "base.html is required and wraps every page on your site"
|
||||
- "Templates cascade from specific to general - Foldsite uses the first match"
|
||||
- "Use Jinja2 syntax for dynamic content and logic"
|
||||
---
|
||||
|
||||
# Template System
|
||||
|
||||
Foldsite's template system is both powerful and intuitive. Templates are HTML files with **Jinja2** syntax that define how your content is displayed.
|
||||
|
||||
## The Template Hierarchy
|
||||
|
||||
Foldsite uses a **cascading template discovery system**. When rendering a page, it searches for templates from **most specific** to **most general**, using the first match found.
|
||||
|
||||
###Understanding Template Priority
|
||||
|
||||
Think of it like CSS specificity - more specific selectors override general ones:
|
||||
|
||||
```
|
||||
Specific file > Type + Extension > Type + Category > Generic type
|
||||
```
|
||||
|
||||
### Example: Rendering `content/blog/my-post.md`
|
||||
|
||||
Foldsite searches in this order:
|
||||
|
||||
1. `templates/blog/my-post.html` ← Exact file match
|
||||
2. `templates/blog/__file.md.html` ← Markdown files in blog/
|
||||
3. `templates/blog/__file.document.html` ← Document files in blog/
|
||||
4. `templates/__file.md.html` ← All markdown files
|
||||
5. `templates/__file.document.html` ← All document files
|
||||
6. `templates/__file.html` ← Any file
|
||||
7. **First match wins!**
|
||||
|
||||
## Required Template: base.html
|
||||
|
||||
**Every Foldsite project MUST have a `base.html`** in the templates root. This wraps every page on your site.
|
||||
|
||||
### Minimal base.html
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>My Site</title>
|
||||
{% for style in styles %}
|
||||
<link rel="stylesheet" href="/styles{{ style }}">
|
||||
{% endfor %}
|
||||
</head>
|
||||
<body>
|
||||
{{ content|safe }}
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Available Variables in base.html
|
||||
|
||||
- `{{ content|safe }}` - Rendered page content (required)
|
||||
- `{{ styles }}` - List of CSS files to load
|
||||
- `{{ currentPath }}` - Current page path
|
||||
- `{{ metadata }}` - Frontmatter from markdown files
|
||||
- All template helper functions
|
||||
|
||||
### Complete base.html Example
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% if metadata and metadata.title %}{{ metadata.title }} - {% endif %}My Site</title>
|
||||
|
||||
{% for style in styles %}
|
||||
<link rel="stylesheet" href="/styles{{ style }}">
|
||||
{% endfor %}
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
{% for item in get_navigation_items() %}
|
||||
<a href="{{ item.url }}">{{ item.title }}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{{ content|safe }}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2025 My Site</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Template Naming Patterns
|
||||
|
||||
### File Templates
|
||||
|
||||
Templates for individual files use this pattern:
|
||||
|
||||
**Pattern:** `__file.{extension}.html` or `__file.{category}.html`
|
||||
|
||||
**Examples:**
|
||||
- `__file.md.html` - All markdown files
|
||||
- `__file.document.html` - All document types (md, txt, html)
|
||||
- `__file.image.html` - Individual image pages (rare)
|
||||
- `__file.jpg.html` - Specific to JPG files
|
||||
|
||||
### Folder Templates
|
||||
|
||||
Templates for directory views:
|
||||
|
||||
**Pattern:** `__folder.{category}.html`
|
||||
|
||||
**Examples:**
|
||||
- `__folder.md.html` - Folders containing mostly markdown
|
||||
- `__folder.image.html` - Photo gallery folders
|
||||
- `__folder.html` - Generic folder view
|
||||
|
||||
### Specific Page Templates
|
||||
|
||||
Override for specific pages:
|
||||
|
||||
**Pattern:** `{filename}.html`
|
||||
|
||||
**Examples:**
|
||||
- `index.html` - Only for index.md
|
||||
- `about.html` - Only for about.md
|
||||
- `contact.html` - Only for contact.md
|
||||
|
||||
## File Categories
|
||||
|
||||
Foldsite automatically categorizes files by extension:
|
||||
|
||||
| Category | Extensions | Template | Use Case |
|
||||
|----------|-----------|----------|----------|
|
||||
| **document** | `.md`, `.txt` | `__file.document.html` | Text content |
|
||||
| **image** | `.jpg`, `.png`, `.gif` | `__file.image.html` | Photos |
|
||||
| **multimedia** | `.mp4`, `.mp3` | `__file.multimedia.html` | Video/audio |
|
||||
| **other** | Everything else | `__file.other.html` | Downloads |
|
||||
|
||||
Files can have **multiple categories**. For example, `.md` files are both `md` and `document`.
|
||||
|
||||
## Template Variables
|
||||
|
||||
Every template receives these variables:
|
||||
|
||||
### Always Available
|
||||
|
||||
- `content` - Rendered HTML content
|
||||
- `styles` - List of CSS file paths
|
||||
- `currentPath` - Path relative to content root
|
||||
- `metadata` - Frontmatter dict (for markdown files)
|
||||
|
||||
### Markdown Files Only
|
||||
|
||||
For `.md` files, `metadata` contains frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "My Blog Post"
|
||||
date: "2025-01-15"
|
||||
author: "Your Name"
|
||||
tags: ["python", "web"]
|
||||
---
|
||||
|
||||
# Content here...
|
||||
```
|
||||
|
||||
Access in templates:
|
||||
|
||||
```jinja
|
||||
<h1>{{ metadata.title }}</h1>
|
||||
<time>{{ metadata.date }}</time>
|
||||
<p>By {{ metadata.author }}</p>
|
||||
|
||||
{% for tag in metadata.tags %}
|
||||
<span class="tag">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
## Template Helpers
|
||||
|
||||
Foldsite provides powerful helper functions accessible in all templates:
|
||||
|
||||
### Content Discovery
|
||||
|
||||
```jinja
|
||||
{# Get folder contents #}
|
||||
{% for file in get_folder_contents(currentPath) %}
|
||||
<a href="/{{ file.path }}">{{ file.name }}</a>
|
||||
{% endfor %}
|
||||
|
||||
{# Get sibling files #}
|
||||
{% for sibling in get_sibling_content_files(currentPath) %}
|
||||
<a href="/{{ sibling[1] }}">{{ sibling[0] }}</a>
|
||||
{% endfor %}
|
||||
|
||||
{# Get sibling folders #}
|
||||
{% for folder in get_sibling_content_folders(currentPath) %}
|
||||
<a href="/{{ folder[1] }}">{{ folder[0] }}</a>
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
### Blog Functions
|
||||
|
||||
```jinja
|
||||
{# Recent blog posts #}
|
||||
{% for post in get_recent_posts(limit=5, folder='blog') %}
|
||||
<article>
|
||||
<h2><a href="{{ post.url }}">{{ post.title }}</a></h2>
|
||||
<time>{{ post.date }}</time>
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
{# Posts by tag #}
|
||||
{% for post in get_posts_by_tag('python', limit=10) %}
|
||||
<a href="{{ post.url }}">{{ post.title }}</a>
|
||||
{% endfor %}
|
||||
|
||||
{# All tags #}
|
||||
{% for tag in get_all_tags() %}
|
||||
<a href="/tags/{{ tag.name }}">{{ tag.name }} ({{ tag.count }})</a>
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
### Navigation
|
||||
|
||||
```jinja
|
||||
{# Breadcrumbs #}
|
||||
{% for crumb in generate_breadcrumbs(currentPath) %}
|
||||
{% if not crumb.is_current %}
|
||||
<a href="{{ crumb.url }}">{{ crumb.title }}</a>
|
||||
{% else %}
|
||||
<span>{{ crumb.title }}</span>
|
||||
{% endif %}
|
||||
{% if not loop.last %} / {% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{# Navigation menu #}
|
||||
{% for item in get_navigation_items(max_items=10) %}
|
||||
<a href="{{ item.url }}">{{ item.title }}</a>
|
||||
{% endfor %}
|
||||
|
||||
{# Related posts #}
|
||||
{% for post in get_related_posts(currentPath, limit=3) %}
|
||||
<a href="{{ post.url }}">{{ post.title }}</a>
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
See [Template Helpers Reference](template-helpers.md) for complete documentation.
|
||||
|
||||
## Cascading Through Directories
|
||||
|
||||
Templates cascade down through your directory structure. Place templates in subdirectories to override for specific sections.
|
||||
|
||||
### Example Structure
|
||||
|
||||
```
|
||||
templates/
|
||||
├── base.html # Site-wide wrapper
|
||||
├── __file.md.html # Default for all markdown
|
||||
├── __folder.image.html # Default for galleries
|
||||
├── blog/
|
||||
│ ├── __file.md.html # Override for blog posts
|
||||
│ └── __folder.md.html # Override for blog index
|
||||
└── photos/
|
||||
└── __folder.image.html # Override for photo galleries
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
**Rendering `content/blog/my-post.md`:**
|
||||
1. Looks in `templates/blog/` first
|
||||
2. Finds `blog/__file.md.html` ← **Uses this**
|
||||
3. Never checks root `__file.md.html`
|
||||
|
||||
**Rendering `content/projects/project.md`:**
|
||||
1. Looks in `templates/projects/` first
|
||||
2. Doesn't find specific template
|
||||
3. Falls back to `templates/__file.md.html` ← **Uses this**
|
||||
|
||||
## Practical Examples
|
||||
|
||||
### Simple Blog Post Template
|
||||
|
||||
`templates/__file.md.html`:
|
||||
|
||||
```html
|
||||
<article>
|
||||
{% if metadata %}
|
||||
<header>
|
||||
<h1>{{ metadata.title }}</h1>
|
||||
<time datetime="{{ metadata.date }}">{{ metadata.date }}</time>
|
||||
{% if metadata.author %}
|
||||
<p class="author">By {{ metadata.author }}</p>
|
||||
{% endif %}
|
||||
</header>
|
||||
{% endif %}
|
||||
|
||||
<div class="content">
|
||||
{{ content|safe }}
|
||||
</div>
|
||||
|
||||
{% if metadata and metadata.tags %}
|
||||
<footer>
|
||||
<p>Tags:
|
||||
{% for tag in metadata.tags %}
|
||||
<a href="/tags/{{ tag|lower }}">#{{ tag }}</a>
|
||||
{% endfor %}
|
||||
</p>
|
||||
</footer>
|
||||
{% endif %}
|
||||
</article>
|
||||
```
|
||||
|
||||
### Blog Index Template
|
||||
|
||||
`templates/__folder.md.html`:
|
||||
|
||||
```html
|
||||
<div class="blog-index">
|
||||
<h1>Recent Posts</h1>
|
||||
|
||||
{% for post in get_recent_posts(limit=10, folder=currentPath) %}
|
||||
<article class="post-preview">
|
||||
<h2><a href="{{ post.url }}">{{ post.title }}</a></h2>
|
||||
<time>{{ post.date }}</time>
|
||||
{% if post.metadata.description %}
|
||||
<p>{{ post.metadata.description }}</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Photo Gallery Template
|
||||
|
||||
`templates/__folder.image.html`:
|
||||
|
||||
```html
|
||||
<div class="gallery">
|
||||
{% set breadcrumbs = currentPath.split('/') %}
|
||||
<nav class="breadcrumbs">
|
||||
<a href="/">Home</a>
|
||||
{% for i in range(breadcrumbs|length) %}
|
||||
{% if i+1 == breadcrumbs|length %}
|
||||
/ <span>{{ breadcrumbs[i] }}</span>
|
||||
{% else %}
|
||||
/ <a href="/{{ '/'.join(breadcrumbs[:i+1]) }}">{{ breadcrumbs[i] }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
<div class="photos">
|
||||
{% for photo in get_folder_contents(currentPath)|sort(attribute='date_created', reverse=True) %}
|
||||
{% if 'image' in photo.categories %}
|
||||
<a href="/download/{{ photo.path }}" class="photo">
|
||||
<img src="/download/{{ photo.path }}?max_width=400"
|
||||
alt="{{ photo.name }}"
|
||||
loading="lazy">
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Error Pages
|
||||
|
||||
Custom error template: `templates/__error.html`
|
||||
|
||||
```html
|
||||
<div class="error-page">
|
||||
<h1>Error {{ error_code }}</h1>
|
||||
<p>{{ error_message }}</p>
|
||||
<p>{{ error_description }}</p>
|
||||
<a href="/">Return Home</a>
|
||||
</div>
|
||||
```
|
||||
|
||||
Variables available:
|
||||
- `error_code` - HTTP status code (404, 500, etc.)
|
||||
- `error_message` - Short message ("Not Found")
|
||||
- `error_description` - Detailed description
|
||||
|
||||
## Jinja2 Syntax Quick Reference
|
||||
|
||||
### Variables
|
||||
|
||||
```jinja
|
||||
{{ variable }}
|
||||
{{ metadata.title }}
|
||||
{{ post.url }}
|
||||
```
|
||||
|
||||
### Filters
|
||||
|
||||
```jinja
|
||||
{{ content|safe }} {# Don't escape HTML #}
|
||||
{{ title|upper }} {# Uppercase #}
|
||||
{{ date|default('Unknown') }} {# Default value #}
|
||||
{{ items|length }} {# Count items #}
|
||||
```
|
||||
|
||||
### Conditionals
|
||||
|
||||
```jinja
|
||||
{% if metadata %}
|
||||
<h1>{{ metadata.title }}</h1>
|
||||
{% endif %}
|
||||
|
||||
{% if metadata and metadata.title %}
|
||||
...
|
||||
{% elif metadata %}
|
||||
...
|
||||
{% else %}
|
||||
...
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### Loops
|
||||
|
||||
```jinja
|
||||
{% for item in items %}
|
||||
<p>{{ item }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% for key, value in metadata.items() %}
|
||||
<p>{{ key }}: {{ value }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{# Loop variables #}
|
||||
{% for item in items %}
|
||||
{{ loop.index }} {# 1-indexed #}
|
||||
{{ loop.index0 }} {# 0-indexed #}
|
||||
{{ loop.first }} {# True on first iteration #}
|
||||
{{ loop.last }} {# True on last iteration #}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
### Filters and Functions
|
||||
|
||||
```jinja
|
||||
{% for file in get_folder_contents()|sort(attribute='date') %}
|
||||
...
|
||||
{% endfor %}
|
||||
|
||||
{% for post in get_recent_posts(limit=5)|reverse %}
|
||||
...
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Start Simple, Add Complexity as Needed
|
||||
|
||||
Begin with basic templates:
|
||||
|
||||
```
|
||||
templates/
|
||||
├── base.html
|
||||
├── __file.md.html
|
||||
└── __folder.md.html
|
||||
```
|
||||
|
||||
Add specific overrides only when you need different styling or layout.
|
||||
|
||||
### 2. Keep base.html Minimal
|
||||
|
||||
Your base template should handle:
|
||||
- HTML document structure
|
||||
- CSS loading
|
||||
- Site-wide navigation
|
||||
- Footer
|
||||
|
||||
Leave content-specific layouts to page templates.
|
||||
|
||||
### 3. Use Template Helpers
|
||||
|
||||
Don't manually read files or iterate directories. Use helpers:
|
||||
|
||||
```jinja
|
||||
✓ Good:
|
||||
{% for post in get_recent_posts(limit=5) %}
|
||||
|
||||
✗ Bad:
|
||||
{# Trying to manually list files - won't work #}
|
||||
```
|
||||
|
||||
### 4. Leverage the Cascade
|
||||
|
||||
Put general templates at the root, specific ones in subdirectories:
|
||||
|
||||
```
|
||||
templates/
|
||||
├── __file.md.html # Default for all markdown
|
||||
└── blog/
|
||||
└── __file.md.html # Special layout for blog
|
||||
```
|
||||
|
||||
### 5. Test with Debug Mode
|
||||
|
||||
Enable debug mode in `config.toml` to see template discovery:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
debug = true
|
||||
```
|
||||
|
||||
This shows which templates Foldsite considered and why it chose the one it did.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern: Site Navigation
|
||||
|
||||
In `base.html`:
|
||||
|
||||
```html
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
{% for item in get_navigation_items() %}
|
||||
<a href="{{ item.url }}"
|
||||
{% if currentPath == item.path %}class="active"{% endif %}>
|
||||
{{ item.title }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
```
|
||||
|
||||
### Pattern: Sidebar with Recent Posts
|
||||
|
||||
```html
|
||||
<aside>
|
||||
<h3>Recent Posts</h3>
|
||||
<ul>
|
||||
{% for post in get_recent_posts(limit=5) %}
|
||||
<li>
|
||||
<a href="{{ post.url }}">{{ post.title }}</a>
|
||||
<small>{{ post.date }}</small>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</aside>
|
||||
```
|
||||
|
||||
### Pattern: Tag Cloud
|
||||
|
||||
```html
|
||||
<div class="tag-cloud">
|
||||
{% for tag in get_all_tags() %}
|
||||
<a href="/tags/{{ tag.name|lower }}"
|
||||
style="font-size: {{ 0.8 + (tag.count * 0.1) }}em;">
|
||||
{{ tag.name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Template Discovery](templates/template-discovery.md)** - Deep dive into how templates are found
|
||||
- **[Template Helpers Reference](templates/template-helpers.md)** - Complete API documentation
|
||||
- **[Template Recipes](../recipes/)** - Ready-to-use template examples
|
||||
- **[Styles Guide](../styles/)** - Styling your templates
|
||||
|
||||
Master the template system, and you can build any type of site with Foldsite!
|
||||
558
docs/content/templates/template-discovery.md
Normal file
558
docs/content/templates/template-discovery.md
Normal file
@ -0,0 +1,558 @@
|
||||
---
|
||||
version: "1.0"
|
||||
date: "2025-01-15"
|
||||
author: "DWS Foldsite Team"
|
||||
title: "Template Discovery System"
|
||||
description: "Understanding how Foldsite finds and chooses templates"
|
||||
summary: "Deep dive into Foldsite's hierarchical template discovery algorithm - learn exactly how templates are matched to content."
|
||||
quick_tips:
|
||||
- "Templates are searched from most specific to most general"
|
||||
- "First match wins - more specific templates override general ones"
|
||||
- "Templates cascade down through directory hierarchies"
|
||||
---
|
||||
|
||||
# Template Discovery System
|
||||
|
||||
Understanding how Foldsite discovers and chooses templates is key to building sophisticated sites. This guide explains the complete template resolution algorithm.
|
||||
|
||||
## The Discovery Algorithm
|
||||
|
||||
When Foldsite renders a page, it follows a **hierarchical search pattern** to find the best template.
|
||||
|
||||
### Core Principle
|
||||
|
||||
**Specificity wins.** More specific templates override general ones.
|
||||
|
||||
Think of it like CSS specificity:
|
||||
```
|
||||
#specific-id (most specific)
|
||||
.class-name
|
||||
element (least specific)
|
||||
```
|
||||
|
||||
In Foldsite:
|
||||
```
|
||||
my-post.html (most specific - exact file)
|
||||
__file.md.html (category + extension)
|
||||
__file.document.html (category only)
|
||||
__file.html (least specific - any file)
|
||||
```
|
||||
|
||||
## File Type Detection
|
||||
|
||||
Before template discovery, Foldsite determines the content type:
|
||||
|
||||
### For Files
|
||||
|
||||
**Step 1:** Extract extension
|
||||
```
|
||||
blog/my-post.md → extension: "md"
|
||||
```
|
||||
|
||||
**Step 2:** Map to categories
|
||||
```
|
||||
"md" → categories: ["document", "md"]
|
||||
```
|
||||
|
||||
**Category mapping:**
|
||||
```python
|
||||
GENERIC_FILE_MAPPING = {
|
||||
"document": ["md", "txt", "html"],
|
||||
"image": ["png", "jpg", "jpeg", "gif", "svg"],
|
||||
"multimedia": ["mp4", "mp3", "webm"],
|
||||
"other": [...] # Everything else
|
||||
}
|
||||
```
|
||||
|
||||
### For Folders
|
||||
|
||||
**Step 1:** Count file types in folder
|
||||
```
|
||||
photos/vacation/
|
||||
IMG_001.jpg → jpg: 1
|
||||
IMG_002.jpg → jpg: 2
|
||||
IMG_003.jpg → jpg: 3
|
||||
notes.txt → txt: 1
|
||||
```
|
||||
|
||||
**Step 2:** Most common type wins
|
||||
```
|
||||
Most common: jpg (3 files)
|
||||
→ categories: ["image", "jpg"]
|
||||
```
|
||||
|
||||
## Template Search Order
|
||||
|
||||
### For Files: `blog/my-post.md`
|
||||
|
||||
**Given:**
|
||||
- Path: `blog/my-post.md`
|
||||
- Type: `file`
|
||||
- Categories: `["document", "md"]`
|
||||
- Extension: `md`
|
||||
|
||||
**Search order:**
|
||||
|
||||
1. **Exact file match** in current directory
|
||||
```
|
||||
templates/blog/my-post.html
|
||||
```
|
||||
|
||||
2. **Type + extension** in current directory
|
||||
```
|
||||
templates/blog/__file.md.html
|
||||
```
|
||||
|
||||
3. **Type + category** in current directory (most specific category first)
|
||||
```
|
||||
templates/blog/__file.document.html
|
||||
```
|
||||
|
||||
4. **Generic type** in current directory
|
||||
```
|
||||
templates/blog/__file.html
|
||||
```
|
||||
|
||||
5. **Move up one directory**, repeat steps 2-4
|
||||
```
|
||||
templates/__file.md.html
|
||||
templates/__file.document.html
|
||||
templates/__file.html
|
||||
```
|
||||
|
||||
6. **Default template** (if exists)
|
||||
```
|
||||
templates/default.html
|
||||
```
|
||||
|
||||
**First match wins!**
|
||||
|
||||
### For Folders: `photos/vacation/`
|
||||
|
||||
**Given:**
|
||||
- Path: `photos/vacation/`
|
||||
- Type: `folder`
|
||||
- Categories: `["image"]` (most files are images)
|
||||
|
||||
**Search order:**
|
||||
|
||||
1. **Exact folder match**
|
||||
```
|
||||
templates/photos/vacation/__folder.html
|
||||
```
|
||||
|
||||
2. **Type + category** in current directory
|
||||
```
|
||||
templates/photos/__folder.image.html
|
||||
```
|
||||
|
||||
3. **Generic folder** in current directory
|
||||
```
|
||||
templates/photos/__folder.html
|
||||
```
|
||||
|
||||
4. **Move up one directory**, repeat steps 2-3
|
||||
```
|
||||
templates/__folder.image.html
|
||||
templates/__folder.html
|
||||
```
|
||||
|
||||
5. **Default template**
|
||||
```
|
||||
templates/default.html
|
||||
```
|
||||
|
||||
## Practical Examples
|
||||
|
||||
### Example 1: Blog Post
|
||||
|
||||
**Content:** `content/blog/posts/2024-my-post.md`
|
||||
|
||||
**Template search:**
|
||||
```
|
||||
1. templates/blog/posts/2024-my-post.html ✗ Not found
|
||||
2. templates/blog/posts/__file.md.html ✗ Not found
|
||||
3. templates/blog/posts/__file.document.html ✗ Not found
|
||||
4. templates/blog/posts/__file.html ✗ Not found
|
||||
5. templates/blog/__file.md.html ✓ FOUND!
|
||||
```
|
||||
|
||||
**Result:** Uses `templates/blog/__file.md.html`
|
||||
|
||||
**Why:** Most specific template found in the hierarchy.
|
||||
|
||||
### Example 2: Homepage
|
||||
|
||||
**Content:** `content/index.md`
|
||||
|
||||
**Template search:**
|
||||
```
|
||||
1. templates/index.html ✓ FOUND!
|
||||
```
|
||||
|
||||
**Result:** Uses `templates/index.html`
|
||||
|
||||
**Why:** Exact file match (most specific possible).
|
||||
|
||||
### Example 3: Photo Gallery
|
||||
|
||||
**Content:** `content/photos/vacation/` (folder with 50 JPG files)
|
||||
|
||||
**Categories:** `["image"]`
|
||||
|
||||
**Template search:**
|
||||
```
|
||||
1. templates/photos/vacation/__folder.html ✗ Not found
|
||||
2. templates/photos/__folder.image.html ✓ FOUND!
|
||||
```
|
||||
|
||||
**Result:** Uses `templates/photos/__folder.image.html`
|
||||
|
||||
**Why:** Type + category match in parent directory.
|
||||
|
||||
### Example 4: Deep Nesting
|
||||
|
||||
**Content:** `content/docs/guides/advanced/testing.md`
|
||||
|
||||
**Template search:**
|
||||
```
|
||||
1. templates/docs/guides/advanced/testing.html ✗ Not found
|
||||
2. templates/docs/guides/advanced/__file.md.html ✗ Not found
|
||||
3. templates/docs/guides/__file.md.html ✗ Not found
|
||||
4. templates/docs/__file.md.html ✓ FOUND!
|
||||
```
|
||||
|
||||
**Result:** Uses `templates/docs/__file.md.html`
|
||||
|
||||
**Why:** Climbs directory tree until finding a match.
|
||||
|
||||
## Template Cascade
|
||||
|
||||
Templates **cascade down** through directories:
|
||||
|
||||
```
|
||||
templates/
|
||||
├── __file.md.html ← Default for ALL markdown
|
||||
└── blog/
|
||||
└── __file.md.html ← Override for blog/ only
|
||||
```
|
||||
|
||||
**Rendering `content/about.md`:**
|
||||
- Uses `templates/__file.md.html`
|
||||
|
||||
**Rendering `content/blog/post.md`:**
|
||||
- Uses `templates/blog/__file.md.html` (more specific)
|
||||
|
||||
### Multi-Level Cascade
|
||||
|
||||
```
|
||||
templates/
|
||||
├── __file.md.html ← Level 1: Site default
|
||||
├── blog/
|
||||
│ └── __file.md.html ← Level 2: Blog default
|
||||
└── blog/
|
||||
└── tutorials/
|
||||
└── __file.md.html ← Level 3: Tutorial-specific
|
||||
```
|
||||
|
||||
**Effect:**
|
||||
- `content/about.md` → Level 1 template
|
||||
- `content/blog/news.md` → Level 2 template
|
||||
- `content/blog/tutorials/python.md` → Level 3 template
|
||||
|
||||
## Category Priority
|
||||
|
||||
Files can belong to multiple categories. **More specific categories come first.**
|
||||
|
||||
### Category Hierarchy
|
||||
|
||||
For `my-post.md`:
|
||||
```
|
||||
Categories: ["md", "document"]
|
||||
↑ ↑
|
||||
specific general
|
||||
```
|
||||
|
||||
**Search order:**
|
||||
```
|
||||
1. __file.md.html ← Most specific
|
||||
2. __file.document.html ← More general
|
||||
3. __file.html ← Most general
|
||||
```
|
||||
|
||||
## Debug Mode
|
||||
|
||||
Enable debug mode to see template discovery in action:
|
||||
|
||||
```toml
|
||||
# config.toml
|
||||
[server]
|
||||
debug = true
|
||||
```
|
||||
|
||||
**Console output when visiting a page:**
|
||||
```
|
||||
[DEBUG] Template discovery for: blog/my-post.md
|
||||
[DEBUG] Content type: file
|
||||
[DEBUG] Categories: ['md', 'document']
|
||||
[DEBUG] Extension: md
|
||||
[DEBUG]
|
||||
[DEBUG] Searching templates:
|
||||
[DEBUG] 1. templates/blog/my-post.html - NOT FOUND
|
||||
[DEBUG] 2. templates/blog/__file.md.html - FOUND!
|
||||
[DEBUG]
|
||||
[DEBUG] Using template: templates/blog/__file.md.html
|
||||
```
|
||||
|
||||
### Debug Test Page
|
||||
|
||||
Create a test page to understand discovery:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Template Discovery Test"
|
||||
---
|
||||
|
||||
# Current Template Info
|
||||
|
||||
**Current Path:** {{ currentPath }}
|
||||
|
||||
**Styles Loaded:**
|
||||
{% for style in styles %}
|
||||
- {{ style }}
|
||||
{% endfor %}
|
||||
|
||||
**Metadata:**
|
||||
{{ metadata }}
|
||||
```
|
||||
|
||||
Visit this page and check the console to see which template was used.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### No Template Found
|
||||
|
||||
If no template matches:
|
||||
|
||||
**For files:**
|
||||
- Foldsite serves the file directly (download)
|
||||
- Useful for PDFs, images, etc.
|
||||
|
||||
**For folders:**
|
||||
- Returns 404 error
|
||||
- Need at least one `__folder.html` template
|
||||
|
||||
### Hidden Files
|
||||
|
||||
Files/folders starting with `___` are ignored:
|
||||
|
||||
```
|
||||
content/
|
||||
├── post.md ✓ Will be rendered
|
||||
└── ___draft.md ✗ Ignored completely
|
||||
```
|
||||
|
||||
No template search is performed for hidden content.
|
||||
|
||||
### Multiple Extensions
|
||||
|
||||
Only the last extension is considered:
|
||||
|
||||
```
|
||||
my-file.tar.gz → extension: "gz"
|
||||
```
|
||||
|
||||
Not: `tar.gz` or `tar`
|
||||
|
||||
## Style Discovery
|
||||
|
||||
Styles follow **similar logic** to templates but accumulate instead of first-match:
|
||||
|
||||
### Style Search for `blog/post.md`
|
||||
|
||||
**All matching styles are loaded (in order):**
|
||||
```
|
||||
1. /base.css ← Always loaded
|
||||
2. /blog/__file.md.css ← If exists
|
||||
3. /blog/__file.document.css ← If exists
|
||||
4. /__file.md.css ← If exists (from root)
|
||||
5. /blog/post.md.css ← If exists (specific file)
|
||||
```
|
||||
|
||||
**All found styles are included** (not just first match).
|
||||
|
||||
**Rendering:**
|
||||
```html
|
||||
<link rel="stylesheet" href="/styles/base.css">
|
||||
<link rel="stylesheet" href="/styles/blog/__file.md.css">
|
||||
<link rel="stylesheet" href="/styles/__file.md.css">
|
||||
```
|
||||
|
||||
## Optimization Tips
|
||||
|
||||
### 1. Keep Templates at Appropriate Levels
|
||||
|
||||
**Good:**
|
||||
```
|
||||
templates/
|
||||
├── __file.md.html ← General template
|
||||
└── blog/
|
||||
└── __file.md.html ← Blog-specific customization
|
||||
```
|
||||
|
||||
**Avoid:**
|
||||
```
|
||||
templates/
|
||||
├── post1.html
|
||||
├── post2.html
|
||||
├── post3.html ← Repetitive!
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 2. Use Categories Effectively
|
||||
|
||||
**Good:**
|
||||
```
|
||||
templates/
|
||||
├── __file.md.html ← For markdown
|
||||
├── __folder.image.html ← For galleries
|
||||
└── __file.document.html ← For all documents
|
||||
```
|
||||
|
||||
**Avoid:**
|
||||
```
|
||||
templates/
|
||||
├── __file.html ← Too generic
|
||||
└── (nothing else)
|
||||
```
|
||||
|
||||
### 3. Understand the Cascade
|
||||
|
||||
Place templates where they make sense:
|
||||
|
||||
**Project structure:**
|
||||
```
|
||||
content/
|
||||
├── blog/ → Frequent posts, custom layout
|
||||
├── docs/ → Technical docs, different layout
|
||||
└── about.md → One-off pages
|
||||
```
|
||||
|
||||
**Template structure:**
|
||||
```
|
||||
templates/
|
||||
├── __file.md.html ← Default for one-off pages
|
||||
├── blog/
|
||||
│ └── __file.md.html ← Blog-specific
|
||||
└── docs/
|
||||
└── __file.md.html ← Docs-specific
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern: Section-Specific Layouts
|
||||
|
||||
Different sections need different layouts:
|
||||
|
||||
```
|
||||
templates/
|
||||
├── base.html ← Shared wrapper
|
||||
├── __file.md.html ← Default content
|
||||
├── blog/
|
||||
│ ├── __file.md.html ← Blog posts
|
||||
│ └── __folder.md.html ← Blog index
|
||||
├── portfolio/
|
||||
│ ├── __file.md.html ← Project pages
|
||||
│ └── __folder.md.html ← Portfolio grid
|
||||
└── docs/
|
||||
├── __file.md.html ← Documentation pages
|
||||
└── __folder.md.html ← Docs index
|
||||
```
|
||||
|
||||
### Pattern: Gradual Specialization
|
||||
|
||||
Start general, add specificity as needed:
|
||||
|
||||
**Phase 1: MVP**
|
||||
```
|
||||
templates/
|
||||
├── base.html
|
||||
└── __file.md.html
|
||||
```
|
||||
|
||||
**Phase 2: Add Gallery**
|
||||
```
|
||||
templates/
|
||||
├── base.html
|
||||
├── __file.md.html
|
||||
└── __folder.image.html ← New!
|
||||
```
|
||||
|
||||
**Phase 3: Custom Blog**
|
||||
```
|
||||
templates/
|
||||
├── base.html
|
||||
├── __file.md.html
|
||||
├── __folder.image.html
|
||||
└── blog/
|
||||
└── __file.md.html ← New!
|
||||
```
|
||||
|
||||
### Pattern: Override Single Page
|
||||
|
||||
Override one specific page:
|
||||
|
||||
```
|
||||
templates/
|
||||
├── __file.md.html ← All markdown
|
||||
└── index.html ← Special homepage
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Template Not Being Used
|
||||
|
||||
**Check:**
|
||||
1. **File name** - Is it exactly right?
|
||||
2. **Location** - Is it in the right directory?
|
||||
3. **Extension** - `.html`, not `.htm` or `.jinja`
|
||||
4. **Debug mode** - What does the console say?
|
||||
|
||||
**Debug:**
|
||||
```bash
|
||||
# Enable debug
|
||||
[server]
|
||||
debug = true
|
||||
|
||||
# Restart and check console output
|
||||
```
|
||||
|
||||
### Wrong Template Used
|
||||
|
||||
**Likely cause:** More specific template exists
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Want: templates/__file.md.html
|
||||
Using: templates/blog/__file.md.html ← More specific!
|
||||
```
|
||||
|
||||
**Solution:** Update the more specific template, or remove it.
|
||||
|
||||
### Styles Not Loading
|
||||
|
||||
**Check:**
|
||||
1. **Style file exists** in `styles/` directory
|
||||
2. **Path matches** template expectations
|
||||
3. **Browser dev tools** - Are styles being requested?
|
||||
|
||||
**Remember:** Unlike templates, **all matching styles load**.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Template System Overview](index.md)** - Basics of templates
|
||||
- **[Template Helpers](template-helpers.md)** - Functions available in templates
|
||||
- **[Recipes](../recipes/)** - Working examples
|
||||
- **[Styles Guide](../styles/)** - CSS cascade system
|
||||
|
||||
Understanding template discovery gives you complete control over your site's presentation. Use it wisely!
|
||||
793
docs/content/templates/template-helpers.md
Normal file
793
docs/content/templates/template-helpers.md
Normal file
@ -0,0 +1,793 @@
|
||||
---
|
||||
version: "1.0"
|
||||
date: "2025-01-15"
|
||||
author: "DWS Foldsite Team"
|
||||
title: "Template Helper Functions"
|
||||
description: "Complete reference for all Jinja2 helper functions in Foldsite"
|
||||
summary: "Comprehensive API documentation for Foldsite's template helper functions - discover content, navigate your site, and build dynamic features."
|
||||
quick_tips:
|
||||
- "All helpers are automatically available in every template"
|
||||
- "Helpers return Python objects you can loop over and filter"
|
||||
- "Most helpers are cached for performance - safe to call multiple times"
|
||||
---
|
||||
|
||||
# Template Helper Functions
|
||||
|
||||
Foldsite provides powerful helper functions you can use in any template. These functions access your content dynamically, enabling features like recent posts, navigation menus, breadcrumbs, and more.
|
||||
|
||||
## Content Discovery
|
||||
|
||||
### get_folder_contents(path)
|
||||
|
||||
Get all files and folders in a directory with rich metadata.
|
||||
|
||||
**Parameters:**
|
||||
- `path` (str, optional) - Relative path from content root. Defaults to current directory.
|
||||
|
||||
**Returns:** List of `TemplateFile` objects with attributes:
|
||||
- `name` (str) - Filename with extension
|
||||
- `path` (str) - Relative path from content root
|
||||
- `proper_name` (str) - Filename without extension
|
||||
- `extension` (str) - File extension (`.md`, `.jpg`, etc.)
|
||||
- `categories` (list[str]) - File categories (`['document']`, `['image']`, etc.)
|
||||
- `date_modified` (str) - Last modified date (`YYYY-MM-DD`)
|
||||
- `date_created` (str) - Creation date (`YYYY-MM-DD`)
|
||||
- `size_kb` (int) - File size in kilobytes
|
||||
- `metadata` (dict | None) - Markdown frontmatter or image EXIF data
|
||||
- `dir_item_count` (int) - Number of items if it's a directory
|
||||
- `is_dir` (bool) - True if it's a directory
|
||||
|
||||
**Example:**
|
||||
|
||||
```jinja
|
||||
<h2>Files in this folder:</h2>
|
||||
<ul>
|
||||
{% for file in get_folder_contents(currentPath) %}
|
||||
<li>
|
||||
<a href="/{{ file.path }}">{{ file.proper_name }}</a>
|
||||
({{ file.size_kb }} KB, modified {{ file.date_modified }})
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
```
|
||||
|
||||
**Sort and filter:**
|
||||
|
||||
```jinja
|
||||
{# Sort by date, newest first #}
|
||||
{% for file in get_folder_contents()|sort(attribute='date_created', reverse=True) %}
|
||||
...
|
||||
{% endfor %}
|
||||
|
||||
{# Filter to only documents #}
|
||||
{% for file in get_folder_contents() %}
|
||||
{% if 'document' in file.categories %}
|
||||
<a href="/{{ file.path }}">{{ file.proper_name }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
### get_sibling_content_files(path)
|
||||
|
||||
Get files in the same directory as the current page.
|
||||
|
||||
**Parameters:**
|
||||
- `path` (str, optional) - Relative path. Defaults to current directory.
|
||||
|
||||
**Returns:** List of tuples `(filename, relative_path)`
|
||||
|
||||
**Example:**
|
||||
|
||||
```jinja
|
||||
<nav class="sibling-nav">
|
||||
<h3>Other pages in this section:</h3>
|
||||
{% for name, path in get_sibling_content_files(currentPath) %}
|
||||
<a href="/{{ path }}"
|
||||
{% if path == currentPath %}class="active"{% endif %}>
|
||||
{{ name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
```
|
||||
|
||||
### get_sibling_content_folders(path)
|
||||
|
||||
Get folders in the same directory as the current page.
|
||||
|
||||
**Parameters:**
|
||||
- `path` (str, optional) - Relative path. Defaults to current directory.
|
||||
|
||||
**Returns:** List of tuples `(folder_name, relative_path)`
|
||||
|
||||
**Example:**
|
||||
|
||||
```jinja
|
||||
<nav class="folder-nav">
|
||||
<h3>Sections:</h3>
|
||||
{% for name, path in get_sibling_content_folders(currentPath) %}
|
||||
<a href="/{{ path }}">{{ name }}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
```
|
||||
|
||||
### get_text_document_preview(path)
|
||||
|
||||
Get a preview (first 100 characters) of a text document.
|
||||
|
||||
**Parameters:**
|
||||
- `path` (str) - Relative path to the document
|
||||
|
||||
**Returns:** String (first 100 characters of the file)
|
||||
|
||||
**Example:**
|
||||
|
||||
```jinja
|
||||
{% for file in get_folder_contents() %}
|
||||
{% if 'document' in file.categories %}
|
||||
<article>
|
||||
<h3><a href="/{{ file.path }}">{{ file.proper_name }}</a></h3>
|
||||
<p>{{ get_text_document_preview(file.path) }}...</p>
|
||||
</article>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
### get_rendered_markdown(path)
|
||||
|
||||
Get fully rendered markdown content without Jinja2 templating. Perfect for displaying markdown files (like `index.md`) within folder views or embedding content from one markdown file into a template.
|
||||
|
||||
**Parameters:**
|
||||
- `path` (str) - Relative path to the markdown file
|
||||
|
||||
**Returns:** Dictionary with:
|
||||
- `html` (str | None) - Rendered HTML content from markdown
|
||||
- `metadata` (dict | None) - Frontmatter metadata from the markdown file
|
||||
- `exists` (bool) - True if file was found and rendered successfully
|
||||
|
||||
**Example:**
|
||||
|
||||
```jinja
|
||||
{# Display index.md in a folder view #}
|
||||
{% set index_path = (currentPath + '/index.md') if currentPath else 'index.md' %}
|
||||
{% set index = get_rendered_markdown(index_path) %}
|
||||
{% if index.exists %}
|
||||
<section class="folder-index">
|
||||
{{ index.html | safe }}
|
||||
{% if index.metadata.author %}
|
||||
<p class="author">By {{ index.metadata.author }}</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
**Use in folder templates:**
|
||||
|
||||
```jinja
|
||||
{# Show index.md content at the top of a folder listing #}
|
||||
<div class="folder-view">
|
||||
{% set index = get_rendered_markdown(currentPath + '/index.md') %}
|
||||
{% if index.exists %}
|
||||
<div class="folder-introduction">
|
||||
{{ index.html | safe }}
|
||||
<hr>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Then show other files in the folder #}
|
||||
<div class="folder-contents">
|
||||
{% for file in get_folder_contents(currentPath) %}
|
||||
{% if file.name != 'index.md' %}
|
||||
<a href="/{{ file.path }}">{{ file.proper_name }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Embed content from another file:**
|
||||
|
||||
```jinja
|
||||
{# Include shared content from another markdown file #}
|
||||
{% set about = get_rendered_markdown('about/team-bio.md') %}
|
||||
{% if about.exists %}
|
||||
<aside class="team-bio">
|
||||
{{ about.html | safe }}
|
||||
</aside>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### get_markdown_metadata(path)
|
||||
|
||||
Get metadata (frontmatter) from a markdown file **without rendering the content**. Perfect for displaying static markdown metadata in any location - like showing post titles, descriptions, or custom fields without the overhead of rendering the full markdown.
|
||||
|
||||
**Parameters:**
|
||||
- `path` (str) - Relative path to the markdown file
|
||||
|
||||
**Returns:** Dictionary with:
|
||||
- `metadata` (dict | None) - Frontmatter metadata from the markdown file
|
||||
- `exists` (bool) - True if file was found successfully
|
||||
- `error` (str, optional) - Error message if reading failed
|
||||
|
||||
**Example:**
|
||||
|
||||
```jinja
|
||||
{# Display metadata from a specific post without rendering it #}
|
||||
{% set post_meta = get_markdown_metadata('blog/my-awesome-post.md') %}
|
||||
{% if post_meta.exists %}
|
||||
<div class="featured-post">
|
||||
<h2>{{ post_meta.metadata.title }}</h2>
|
||||
<p class="description">{{ post_meta.metadata.description }}</p>
|
||||
<p class="date">Published: {{ post_meta.metadata.date }}</p>
|
||||
<div class="tags">
|
||||
{% for tag in post_meta.metadata.tags %}
|
||||
<span class="tag">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<a href="/blog/my-awesome-post.md">Read more →</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
**Build a custom post grid using metadata only:**
|
||||
|
||||
```jinja
|
||||
{# Create cards showing metadata from multiple posts #}
|
||||
<div class="post-grid">
|
||||
{% for post_path in ['blog/intro.md', 'blog/tutorial.md', 'blog/advanced.md'] %}
|
||||
{% set meta = get_markdown_metadata(post_path) %}
|
||||
{% if meta.exists %}
|
||||
<article class="post-card">
|
||||
<h3>{{ meta.metadata.title }}</h3>
|
||||
<time datetime="{{ meta.metadata.date }}">{{ meta.metadata.date }}</time>
|
||||
{% if meta.metadata.author %}
|
||||
<p class="author">By {{ meta.metadata.author }}</p>
|
||||
{% endif %}
|
||||
{% if meta.metadata.excerpt %}
|
||||
<p>{{ meta.metadata.excerpt }}</p>
|
||||
{% endif %}
|
||||
<a href="/{{ post_path }}">Read full post</a>
|
||||
</article>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Show custom metadata fields:**
|
||||
|
||||
```jinja
|
||||
{# Display reading time, difficulty, or any custom frontmatter field #}
|
||||
{% set meta = get_markdown_metadata('tutorials/python-basics.md') %}
|
||||
{% if meta.exists %}
|
||||
<div class="tutorial-info">
|
||||
<h2>{{ meta.metadata.title }}</h2>
|
||||
{% if meta.metadata.difficulty %}
|
||||
<span class="badge">{{ meta.metadata.difficulty }}</span>
|
||||
{% endif %}
|
||||
{% if meta.metadata.reading_time %}
|
||||
<span class="time">⏱ {{ meta.metadata.reading_time }} min read</span>
|
||||
{% endif %}
|
||||
{% if meta.metadata.prerequisites %}
|
||||
<div class="prerequisites">
|
||||
<strong>Prerequisites:</strong>
|
||||
<ul>
|
||||
{% for prereq in meta.metadata.prerequisites %}
|
||||
<li>{{ prereq }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
**Performance tip:** Use `get_markdown_metadata` instead of `get_rendered_markdown` when you only need frontmatter data. It's much faster since it doesn't process the markdown content.
|
||||
|
||||
## Blog Functions
|
||||
|
||||
### get_recent_posts(limit, folder)
|
||||
|
||||
Get recent blog posts sorted by date (newest first).
|
||||
|
||||
**Parameters:**
|
||||
- `limit` (int, optional) - Maximum number of posts. Default: 5
|
||||
- `folder` (str, optional) - Search within specific folder. Default: "" (search everywhere)
|
||||
|
||||
**Returns:** List of post dictionaries with:
|
||||
- `title` (str) - From frontmatter or filename
|
||||
- `date` (str) - From frontmatter
|
||||
- `path` (str) - Relative path to post
|
||||
- `url` (str) - Full URL to post
|
||||
- `metadata` (dict) - Full frontmatter including tags, description, etc.
|
||||
|
||||
**Example:**
|
||||
|
||||
```jinja
|
||||
<section class="recent-posts">
|
||||
<h2>Recent Posts</h2>
|
||||
{% for post in get_recent_posts(limit=5, folder='blog') %}
|
||||
<article>
|
||||
<h3><a href="{{ post.url }}">{{ post.title }}</a></h3>
|
||||
<time datetime="{{ post.date }}">{{ post.date }}</time>
|
||||
{% if post.metadata.description %}
|
||||
<p>{{ post.metadata.description }}</p>
|
||||
{% endif %}
|
||||
{% if post.metadata.tags %}
|
||||
<div class="tags">
|
||||
{% for tag in post.metadata.tags %}
|
||||
<span class="tag">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
```
|
||||
|
||||
**Search in specific folder:**
|
||||
|
||||
```jinja
|
||||
{# Only posts from blog/tutorials/ #}
|
||||
{% for post in get_recent_posts(limit=10, folder='blog/tutorials') %}
|
||||
<a href="{{ post.url }}">{{ post.title }}</a>
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
### get_posts_by_tag(tag, limit)
|
||||
|
||||
Get posts filtered by a specific tag.
|
||||
|
||||
**Parameters:**
|
||||
- `tag` (str) - Tag to filter by (case-insensitive)
|
||||
- `limit` (int, optional) - Maximum posts. Default: 10
|
||||
|
||||
**Returns:** List of post dictionaries (same format as `get_recent_posts`)
|
||||
|
||||
**Example:**
|
||||
|
||||
```jinja
|
||||
<h2>Python Posts</h2>
|
||||
{% for post in get_posts_by_tag('python', limit=10) %}
|
||||
<article>
|
||||
<h3><a href="{{ post.url }}">{{ post.title }}</a></h3>
|
||||
<time>{{ post.date }}</time>
|
||||
</article>
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
**Dynamic tag pages:**
|
||||
|
||||
```jinja
|
||||
{# If currentPath is 'tags/python.md' #}
|
||||
{% set tag = currentPath.split('/')[-1].replace('.md', '') %}
|
||||
<h1>Posts tagged: {{ tag }}</h1>
|
||||
{% for post in get_posts_by_tag(tag) %}
|
||||
...
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
### get_related_posts(current_post_path, limit)
|
||||
|
||||
Find posts related to the current post based on shared tags.
|
||||
|
||||
**Parameters:**
|
||||
- `current_post_path` (str) - Path to current post
|
||||
- `limit` (int, optional) - Maximum related posts. Default: 3
|
||||
|
||||
**Returns:** List of post dictionaries with an additional `overlap_score` field (number of shared tags)
|
||||
|
||||
**Example:**
|
||||
|
||||
```jinja
|
||||
{% set related = get_related_posts(currentPath, limit=3) %}
|
||||
{% if related %}
|
||||
<aside class="related-posts">
|
||||
<h3>You might also like:</h3>
|
||||
{% for post in related %}
|
||||
<article>
|
||||
<a href="{{ post.url }}">{{ post.title }}</a>
|
||||
<small>{{ post.overlap_score }} shared tags</small>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</aside>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### get_all_tags()
|
||||
|
||||
Get all tags used across the site with post counts.
|
||||
|
||||
**Returns:** List of tag dictionaries with:
|
||||
- `name` (str) - Tag name
|
||||
- `count` (int) - Number of posts with this tag
|
||||
|
||||
**Example:**
|
||||
|
||||
```jinja
|
||||
<div class="tag-cloud">
|
||||
{% for tag in get_all_tags() %}
|
||||
<a href="/tags/{{ tag.name|lower }}"
|
||||
class="tag"
|
||||
style="font-size: {{ 0.8 + (tag.count * 0.1) }}em;">
|
||||
{{ tag.name }} ({{ tag.count }})
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Sort by popularity:**
|
||||
|
||||
```jinja
|
||||
{% for tag in get_all_tags()|sort(attribute='count', reverse=True) %}
|
||||
<a href="/tags/{{ tag.name|lower }}">
|
||||
{{ tag.name }} <span class="count">{{ tag.count }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
### generate_breadcrumbs(current_path)
|
||||
|
||||
Generate breadcrumb navigation based on URL path.
|
||||
|
||||
**Parameters:**
|
||||
- `current_path` (str) - Current page path
|
||||
|
||||
**Returns:** List of breadcrumb dictionaries with:
|
||||
- `title` (str) - Display title (from metadata or derived from path)
|
||||
- `url` (str) - URL to this level
|
||||
- `is_current` (bool) - True if this is the current page
|
||||
|
||||
**Example:**
|
||||
|
||||
```jinja
|
||||
<nav class="breadcrumbs" aria-label="Breadcrumb">
|
||||
{% for crumb in generate_breadcrumbs(currentPath) %}
|
||||
{% if not crumb.is_current %}
|
||||
<a href="{{ crumb.url }}">{{ crumb.title }}</a>
|
||||
{% else %}
|
||||
<span aria-current="page">{{ crumb.title }}</span>
|
||||
{% endif %}
|
||||
{% if not loop.last %} / {% endif %}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
```
|
||||
|
||||
**Styled example:**
|
||||
|
||||
```jinja
|
||||
<ol class="breadcrumb">
|
||||
{% for crumb in generate_breadcrumbs(currentPath) %}
|
||||
<li class="breadcrumb-item {% if crumb.is_current %}active{% endif %}">
|
||||
{% if not crumb.is_current %}
|
||||
<a href="{{ crumb.url }}">{{ crumb.title }}</a>
|
||||
{% else %}
|
||||
{{ crumb.title }}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
```
|
||||
|
||||
### get_navigation_items(max_items)
|
||||
|
||||
Get top-level pages and folders for site navigation.
|
||||
|
||||
**Parameters:**
|
||||
- `max_items` (int, optional) - Maximum items to return. Default: 10
|
||||
|
||||
**Returns:** List of navigation dictionaries with:
|
||||
- `title` (str) - Display title (from metadata or filename)
|
||||
- `url` (str) - URL to page/folder
|
||||
- `path` (str) - Relative path
|
||||
- `is_folder` (bool, optional) - True if it's a folder
|
||||
|
||||
**Example:**
|
||||
|
||||
```jinja
|
||||
<nav class="main-nav">
|
||||
<a href="/">Home</a>
|
||||
{% for item in get_navigation_items(max_items=10) %}
|
||||
<a href="{{ item.url }}"
|
||||
{% if currentPath.startswith(item.path) %}class="active"{% endif %}>
|
||||
{{ item.title }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
```
|
||||
|
||||
**With icons for folders:**
|
||||
|
||||
```jinja
|
||||
{% for item in get_navigation_items() %}
|
||||
<a href="{{ item.url }}">
|
||||
{% if item.is_folder %}📁{% endif %}
|
||||
{{ item.title }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
## Gallery Functions
|
||||
|
||||
### get_photo_albums()
|
||||
|
||||
Get all photo gallery directories (folders containing mostly images).
|
||||
|
||||
**Returns:** List of album dictionaries with:
|
||||
- `name` (str) - Album name
|
||||
- `path` (str) - Relative path
|
||||
- `url` (str) - Full URL
|
||||
- `image_count` (int) - Number of images
|
||||
- `total_files` (int) - Total files in album
|
||||
|
||||
**Example:**
|
||||
|
||||
```jinja
|
||||
<div class="photo-albums">
|
||||
<h2>Photo Galleries</h2>
|
||||
{% for album in get_photo_albums() %}
|
||||
<a href="{{ album.url }}" class="album-card">
|
||||
<h3>{{ album.name }}</h3>
|
||||
<p>{{ album.image_count }} photos</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Built-in Jinja2 Filters
|
||||
|
||||
In addition to Foldsite helpers, you can use standard Jinja2 filters:
|
||||
|
||||
### String Filters
|
||||
|
||||
```jinja
|
||||
{{ "hello"|upper }} {# HELLO #}
|
||||
{{ "HELLO"|lower }} {# hello #}
|
||||
{{ "hello world"|title }} {# Hello World #}
|
||||
{{ "hello world"|capitalize }} {# Hello world #}
|
||||
{{ " text "|trim }} {# text #}
|
||||
{{ "hello"|reverse }} {# olleh #}
|
||||
```
|
||||
|
||||
### List Filters
|
||||
|
||||
```jinja
|
||||
{{ items|length }} {# Count items #}
|
||||
{{ items|first }} {# First item #}
|
||||
{{ items|last }} {# Last item #}
|
||||
{{ items|join(', ') }} {# Join with comma #}
|
||||
{{ items|sort }} {# Sort list #}
|
||||
{{ items|sort(attribute='date') }} {# Sort by attribute #}
|
||||
{{ items|reverse }} {# Reverse list #}
|
||||
{{ items|unique }} {# Remove duplicates #}
|
||||
```
|
||||
|
||||
### Other Useful Filters
|
||||
|
||||
```jinja
|
||||
{{ value|default('N/A') }} {# Default if falsy #}
|
||||
{{ html|safe }} {# Don't escape HTML #}
|
||||
{{ number|round(2) }} {# Round to 2 decimals #}
|
||||
{{ date|replace('-', '/') }} {# Replace strings #}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern: Blog Homepage with Recent Posts
|
||||
|
||||
```jinja
|
||||
<main>
|
||||
<h1>Welcome to My Blog</h1>
|
||||
|
||||
<section class="recent-posts">
|
||||
{% for post in get_recent_posts(limit=10) %}
|
||||
<article class="post-card">
|
||||
<h2><a href="{{ post.url }}">{{ post.title }}</a></h2>
|
||||
<time datetime="{{ post.date }}">{{ post.date }}</time>
|
||||
|
||||
{% if post.metadata.description %}
|
||||
<p>{{ post.metadata.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if post.metadata.tags %}
|
||||
<div class="tags">
|
||||
{% for tag in post.metadata.tags %}
|
||||
<a href="/tags/{{ tag|lower }}" class="tag">{{ tag }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<aside class="sidebar">
|
||||
<h3>Popular Tags</h3>
|
||||
<div class="tag-cloud">
|
||||
{% for tag in get_all_tags()|sort(attribute='count', reverse=True)[:10] %}
|
||||
<a href="/tags/{{ tag.name|lower }}">{{ tag.name }} ({{ tag.count }})</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
```
|
||||
|
||||
### Pattern: Folder Index with Previews
|
||||
|
||||
```jinja
|
||||
<div class="folder-index">
|
||||
<h1>{{ currentPath.split('/')[-1]|title }}</h1>
|
||||
|
||||
<nav class="breadcrumbs">
|
||||
{% for crumb in generate_breadcrumbs(currentPath) %}
|
||||
{% if not crumb.is_current %}
|
||||
<a href="{{ crumb.url }}">{{ crumb.title }}</a> /
|
||||
{% else %}
|
||||
{{ crumb.title }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
{# Show index.md content if it exists #}
|
||||
{% set index = get_rendered_markdown(currentPath + '/index.md') %}
|
||||
{% if index.exists %}
|
||||
<section class="folder-introduction">
|
||||
{{ index.html | safe }}
|
||||
<hr>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="content-grid">
|
||||
{% for file in get_folder_contents(currentPath)|sort(attribute='date_created', reverse=True) %}
|
||||
{% if 'document' in file.categories and file.name != 'index.md' %}
|
||||
<div class="content-card">
|
||||
<h3><a href="/{{ file.path }}">{{ file.proper_name }}</a></h3>
|
||||
<p class="date">{{ file.date_created }}</p>
|
||||
{% if file.metadata and file.metadata.description %}
|
||||
<p>{{ file.metadata.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Pattern: Photo Gallery with EXIF Data
|
||||
|
||||
```jinja
|
||||
<div class="gallery">
|
||||
{% set photos = get_folder_contents(currentPath)|sort(attribute='date_created', reverse=True) %}
|
||||
|
||||
<h1>{{ currentPath.split('/')[-1]|title }}</h1>
|
||||
<p>{{ photos|length }} photos</p>
|
||||
|
||||
<div class="photo-grid">
|
||||
{% for photo in photos %}
|
||||
{% if 'image' in photo.categories %}
|
||||
<figure class="photo">
|
||||
<a href="/download/{{ photo.path }}">
|
||||
<img src="/download/{{ photo.path }}?max_width=600"
|
||||
alt="{{ photo.name }}"
|
||||
loading="lazy">
|
||||
</a>
|
||||
<figcaption>
|
||||
{% if photo.metadata and photo.metadata.exif %}
|
||||
<p>{{ photo.metadata.exif.DateTimeOriginal }}</p>
|
||||
{% if photo.metadata.exif.Model %}
|
||||
<p>{{ photo.metadata.exif.Model }}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</figcaption>
|
||||
</figure>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Pattern: Documentation with Sibling Pages
|
||||
|
||||
```jinja
|
||||
<article class="doc-page">
|
||||
<nav class="doc-sidebar">
|
||||
<h3>In This Section:</h3>
|
||||
<ul>
|
||||
{% for name, path in get_sibling_content_files(currentPath) %}
|
||||
<li>
|
||||
<a href="/{{ path }}"
|
||||
{% if path == currentPath %}class="active"{% endif %}>
|
||||
{{ name.replace('.md', '')|replace('-', ' ')|title }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="doc-content">
|
||||
<nav class="breadcrumbs">
|
||||
{% for crumb in generate_breadcrumbs(currentPath) %}
|
||||
<a href="{{ crumb.url }}">{{ crumb.title }}</a>
|
||||
{% if not loop.last %} › {% endif %}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
{{ content|safe }}
|
||||
|
||||
{% set related = get_related_posts(currentPath, limit=3) %}
|
||||
{% if related %}
|
||||
<aside class="related-docs">
|
||||
<h4>Related Documentation:</h4>
|
||||
<ul>
|
||||
{% for doc in related %}
|
||||
<li><a href="{{ doc.url }}">{{ doc.title }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</aside>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
### Caching
|
||||
|
||||
Most helper functions are cached automatically. It's safe to call them multiple times:
|
||||
|
||||
```jinja
|
||||
{# These don't cause multiple filesystem scans #}
|
||||
{% set posts = get_recent_posts(limit=5) %}
|
||||
... use posts ...
|
||||
{% set posts_again = get_recent_posts(limit=5) %} {# Cached result #}
|
||||
```
|
||||
|
||||
### Filtering vs. Multiple Calls
|
||||
|
||||
Filter results in templates rather than calling helpers multiple times:
|
||||
|
||||
```jinja
|
||||
✓ Efficient:
|
||||
{% set all_files = get_folder_contents() %}
|
||||
{% set docs = all_files|selectattr('categories', 'contains', 'document') %}
|
||||
{% set images = all_files|selectattr('categories', 'contains', 'image') %}
|
||||
|
||||
✗ Less efficient:
|
||||
{% for file in get_folder_contents() %}
|
||||
{% if 'document' in file.categories %}...{% endif %}
|
||||
{% endfor %}
|
||||
{% for file in get_folder_contents() %} {# Second call #}
|
||||
{% if 'image' in file.categories %}...{% endif %}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
Enable debug mode to see which templates and helpers are being used:
|
||||
|
||||
```toml
|
||||
# config.toml
|
||||
[server]
|
||||
debug = true
|
||||
```
|
||||
|
||||
Add debug output to templates:
|
||||
|
||||
```jinja
|
||||
{# Show what get_recent_posts returns #}
|
||||
{% set posts = get_recent_posts(limit=3) %}
|
||||
<pre>{{ posts|pprint }}</pre>
|
||||
|
||||
{# Show current variables #}
|
||||
<pre>
|
||||
currentPath: {{ currentPath }}
|
||||
metadata: {{ metadata|pprint }}
|
||||
</pre>
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Template Recipes](../recipes/)** - See these helpers in complete working examples
|
||||
- **[Template System Overview](index.md)** - Understand how templates work
|
||||
- **[Template Discovery](template-discovery.md)** - Learn how Foldsite finds templates
|
||||
|
||||
With these helpers, you can build sophisticated, dynamic sites while keeping your content as simple files and folders!
|
||||
314
docs/content/theme-gallery.md
Normal file
314
docs/content/theme-gallery.md
Normal file
@ -0,0 +1,314 @@
|
||||
---
|
||||
version: "1.0"
|
||||
date: "2025-01-15"
|
||||
author: "DWS Foldsite Team"
|
||||
title: "Theme Gallery"
|
||||
description: "Download and use community-created Foldsite themes"
|
||||
summary: "Browse ready-to-use themes for Foldsite. Download complete template and style packages to jumpstart your site."
|
||||
quick_tips:
|
||||
- "Themes are complete template + style packages you can drop into your project"
|
||||
- "All themes are free and open source"
|
||||
- "Customize themes to make them your own"
|
||||
---
|
||||
|
||||
# Theme Gallery
|
||||
|
||||
Ready-to-use themes for Foldsite. Download, customize, and launch your site quickly.
|
||||
|
||||
## What is a Theme?
|
||||
|
||||
A Foldsite theme is a complete package of:
|
||||
- **Templates** (`templates/` directory)
|
||||
- **Styles** (`styles/` directory)
|
||||
- **Example content** (optional)
|
||||
- **Configuration** (optional)
|
||||
|
||||
Simply download and drop into your Foldsite project.
|
||||
|
||||
## Official Themes
|
||||
|
||||
### Default Theme
|
||||
|
||||
**Included in:** Foldsite repository (`example_site/`)
|
||||
**Type:** Blog + Gallery
|
||||
**Best for:** Personal sites, blogs with photos
|
||||
|
||||
**Features:**
|
||||
- Responsive sidebar navigation
|
||||
- Blog post support with metadata
|
||||
- Photo gallery views
|
||||
- Breadcrumb navigation
|
||||
- Clean, minimal design
|
||||
|
||||
**Install:**
|
||||
```bash
|
||||
# Copy from example_site
|
||||
cp -r example_site/template my-site/templates
|
||||
cp -r example_site/style my-site/styles
|
||||
```
|
||||
|
||||
**Preview:** See [Tanishq Dubey's site](https://tanishq.page)
|
||||
|
||||
---
|
||||
|
||||
### Documentation Theme
|
||||
|
||||
**Included in:** Foldsite repository (`docs/`)
|
||||
**Type:** Documentation
|
||||
**Best for:** Project docs, technical writing, knowledge bases
|
||||
|
||||
**Features:**
|
||||
- Hierarchical navigation
|
||||
- Sibling page links
|
||||
- Code syntax highlighting
|
||||
- Breadcrumb trails
|
||||
- Clean, readable typography
|
||||
|
||||
**Install:**
|
||||
```bash
|
||||
# Copy from docs
|
||||
cp -r docs/templates my-site/templates
|
||||
cp -r docs/styles my-site/styles
|
||||
```
|
||||
|
||||
**Preview:** This documentation site!
|
||||
|
||||
---
|
||||
|
||||
## Community Themes
|
||||
|
||||
*Community-contributed themes will appear here as they're created and submitted.*
|
||||
|
||||
### How to Submit a Theme
|
||||
|
||||
Created a theme you want to share?
|
||||
|
||||
**Requirements:**
|
||||
- Complete templates and styles
|
||||
- README with installation instructions
|
||||
- Screenshot or demo site
|
||||
- MIT or similar permissive license
|
||||
- No external dependencies (except common CDNs)
|
||||
|
||||
**Submission process:**
|
||||
1. Create GitHub repository with your theme
|
||||
2. Open issue on Foldsite repo with "Theme Submission" label
|
||||
3. Include:
|
||||
- Theme name and description
|
||||
- Screenshot or demo URL
|
||||
- Repository link
|
||||
- What makes it unique
|
||||
4. We'll review and add to gallery
|
||||
|
||||
**Theme structure:**
|
||||
```
|
||||
my-theme/
|
||||
├── README.md
|
||||
├── LICENSE
|
||||
├── templates/
|
||||
│ ├── base.html
|
||||
│ ├── __file.md.html
|
||||
│ └── ...
|
||||
├── styles/
|
||||
│ ├── base.css
|
||||
│ └── ...
|
||||
├── screenshots/
|
||||
│ └── preview.png
|
||||
└── example-content/ (optional)
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Using a Theme
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Download theme** (clone or download ZIP)
|
||||
2. **Copy to your project:**
|
||||
```bash
|
||||
cp -r theme-name/templates my-site/templates
|
||||
cp -r theme-name/styles my-site/styles
|
||||
```
|
||||
3. **Configure paths** in `config.toml`:
|
||||
```toml
|
||||
[paths]
|
||||
templates_dir = "/path/to/my-site/templates"
|
||||
styles_dir = "/path/to/my-site/styles"
|
||||
```
|
||||
4. **Test:**
|
||||
```bash
|
||||
python main.py --config config.toml
|
||||
```
|
||||
|
||||
### Customization
|
||||
|
||||
Themes are starting points. Make them your own!
|
||||
|
||||
**Easy customizations:**
|
||||
- Change colors in CSS
|
||||
- Modify fonts
|
||||
- Adjust spacing and sizing
|
||||
- Replace logo/branding
|
||||
- Update footer text
|
||||
|
||||
**Advanced customizations:**
|
||||
- Modify templates
|
||||
- Add new template variants
|
||||
- Change layout structure
|
||||
- Add custom helper functions
|
||||
- Integrate JavaScript libraries
|
||||
|
||||
**Best practice:** Keep original theme in git so you can track your changes.
|
||||
|
||||
---
|
||||
|
||||
## Theme Development
|
||||
|
||||
Want to create your own theme?
|
||||
|
||||
### Theme Checklist
|
||||
|
||||
A complete theme should include:
|
||||
|
||||
**Required Templates:**
|
||||
- [ ] `base.html` - Main page wrapper
|
||||
- [ ] `__file.md.html` - Markdown file display
|
||||
- [ ] `__folder.md.html` - Folder index for documents
|
||||
- [ ] `__error.html` - Error pages
|
||||
|
||||
**Optional but Recommended:**
|
||||
- [ ] `index.html` - Custom homepage
|
||||
- [ ] `__folder.image.html` - Photo galleries
|
||||
- [ ] `__file.document.html` - Document-specific layout
|
||||
|
||||
**Styles:**
|
||||
- [ ] `base.css` - Base styles, always loaded
|
||||
- [ ] `__file.md.css` - Markdown file styles
|
||||
- [ ] `__folder.image.css` - Gallery styles
|
||||
- [ ] Responsive design (mobile-friendly)
|
||||
|
||||
**Documentation:**
|
||||
- [ ] README with installation instructions
|
||||
- [ ] Screenshot or demo
|
||||
- [ ] List of features
|
||||
- [ ] Customization guide
|
||||
- [ ] License (MIT recommended)
|
||||
|
||||
### Design Guidelines
|
||||
|
||||
**For consistency and usability:**
|
||||
|
||||
1. **Mobile-first** - Design for small screens first
|
||||
2. **Accessible** - Follow WCAG guidelines
|
||||
3. **Fast** - Minimize CSS, optimize images
|
||||
4. **Semantic HTML** - Use proper elements
|
||||
5. **Print-friendly** - Consider print stylesheets
|
||||
|
||||
**Typography:**
|
||||
- Readable font sizes (16px+ for body)
|
||||
- Good line height (1.5+)
|
||||
- Proper contrast ratios
|
||||
- System fonts or fast-loading web fonts
|
||||
|
||||
**Colors:**
|
||||
- Consistent color palette
|
||||
- Sufficient contrast
|
||||
- Dark mode consideration (optional)
|
||||
|
||||
**Layout:**
|
||||
- Clear visual hierarchy
|
||||
- Consistent spacing
|
||||
- Responsive breakpoints
|
||||
- Touch-friendly (44px+ tap targets)
|
||||
|
||||
### Testing Your Theme
|
||||
|
||||
Before sharing, test with:
|
||||
|
||||
- **Various content types** - Markdown, images, mixed
|
||||
- **Different structures** - Flat vs. deep hierarchies
|
||||
- **Edge cases** - Long titles, no metadata, many tags
|
||||
- **Devices** - Mobile, tablet, desktop
|
||||
- **Browsers** - Chrome, Firefox, Safari
|
||||
|
||||
### Inspiration
|
||||
|
||||
Look at themes from other static site generators:
|
||||
|
||||
- Jekyll themes
|
||||
- Hugo themes
|
||||
- 11ty themes
|
||||
- Gatsby themes
|
||||
|
||||
Adapt patterns (don't copy code) to Foldsite's template system.
|
||||
|
||||
---
|
||||
|
||||
## Coming Soon
|
||||
|
||||
**Future theme additions:**
|
||||
|
||||
- **Minimal Blog** - Ultra-simple, typography-focused
|
||||
- **Photo Portfolio** - Full-screen galleries, minimal UI
|
||||
- **Magazine** - Multi-column, content-rich
|
||||
- **Landing Page** - Single-page, marketing-focused
|
||||
- **Academic** - Papers, publications, research-focused
|
||||
|
||||
Want to help create these? See [Develop Foldsite](develop/).
|
||||
|
||||
---
|
||||
|
||||
## Theme Showcase
|
||||
|
||||
*Once community themes are available, this section will showcase screenshots and live demos.*
|
||||
|
||||
---
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
**Q: Are themes free?**
|
||||
A: Yes! All themes in the official gallery are free and open source.
|
||||
|
||||
**Q: Can I sell themes I create?**
|
||||
A: The themes themselves must be open source if listed here, but you could offer customization services commercially.
|
||||
|
||||
**Q: Do themes work with all Foldsite versions?**
|
||||
A: Themes should specify compatible versions. Most work across versions unless core template system changes.
|
||||
|
||||
**Q: Can I request a specific theme?**
|
||||
A: Open an issue with "Theme Request" label. No guarantees, but community might help!
|
||||
|
||||
**Q: How do I update a theme?**
|
||||
A: If you've customized it, manually merge changes. Otherwise, re-download and replace.
|
||||
|
||||
**Q: Can I mix themes?**
|
||||
A: Yes! Take templates from different themes. Just ensure styles don't conflict.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Help grow the theme ecosystem:
|
||||
|
||||
- **Create themes** - Share your designs
|
||||
- **Improve docs** - Help others use themes
|
||||
- **Test themes** - Report issues
|
||||
- **Showcase sites** - Inspire others
|
||||
|
||||
See [Develop Foldsite](develop/) for contribution guidelines.
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
Need help with themes?
|
||||
|
||||
- **[Support](support.md)** - Get help
|
||||
- **[Templates Guide](templates/)** - Learn the system
|
||||
- **[Recipes](recipes/)** - See examples
|
||||
- **GitHub Issues** - Report theme bugs
|
||||
|
||||
---
|
||||
|
||||
*This gallery is just getting started. As the Foldsite community grows, expect many more themes! Consider contributing the first one!*
|
||||
266
docs/styles/__file.document.css
Normal file
266
docs/styles/__file.document.css
Normal file
@ -0,0 +1,266 @@
|
||||
/* Document Layout - For documentation/blog post pages */
|
||||
/* Based on reference_post.html design */
|
||||
|
||||
:root {
|
||||
/* Document-specific spacing */
|
||||
--document-margin-x: 120px;
|
||||
--document-max-width: 1200px;
|
||||
}
|
||||
|
||||
/* Main content wrapper for document pages */
|
||||
.document-layout {
|
||||
/* max-width: var(--document-max-width); */
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-exp-7) var(--document-margin-x);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.document {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.document-header-holder {
|
||||
display: flex;
|
||||
height: 70vh;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
/* text-align: end; */
|
||||
}
|
||||
|
||||
/* Document header with metadata */
|
||||
.document-header {
|
||||
margin-bottom: var(--spacing-exp-6);
|
||||
max-width: 15%;
|
||||
}
|
||||
|
||||
.document-metadata {
|
||||
font-family: var(--fontFamily-mono);
|
||||
font-size: var(--fontSize-small);
|
||||
color: var(--swatch-4);
|
||||
margin-bottom: var(--spacing-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.document-title {
|
||||
font-size: 3.8rem;
|
||||
font-weight: 420;
|
||||
line-height: 1;
|
||||
margin: var(--spacing-2) 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.document-subtitle {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 500;
|
||||
color: var(--swatch-2);
|
||||
line-height: var(--lineHeight-default);
|
||||
margin-top: var(--spacing-1);
|
||||
}
|
||||
|
||||
/* Document content area */
|
||||
|
||||
.document-quick-tips {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--swatch-2);
|
||||
line-height: var(--lineHeight-default);
|
||||
margin-top: var(--spacing-1);
|
||||
}
|
||||
|
||||
.document-content-wrapper {
|
||||
flex-grow: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
overflow-y: auto;
|
||||
margin-left: 1vw;
|
||||
margin-right: 1vw;
|
||||
}
|
||||
|
||||
.document-content {
|
||||
font-size: var(--fontSize-default);
|
||||
line-height: var(--lineHeight-default);
|
||||
width: fit-content;
|
||||
padding-bottom: var(--spacing-exp-7);
|
||||
}
|
||||
|
||||
.document-content > * + * {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.document-content h1 {
|
||||
font-size: 3.8rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
/* Section headers within document */
|
||||
.document-content h2 {
|
||||
margin-top: var(--spacing-exp-6);
|
||||
margin-bottom: var(--spacing-2);
|
||||
font-size: 2.4rem;
|
||||
}
|
||||
|
||||
.document-content h3 {
|
||||
margin-top: var(--spacing-exp-5);
|
||||
margin-bottom: var(--spacing-1);
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.document-content h4 {
|
||||
margin-top: var(--spacing-2);
|
||||
margin-bottom: var(--spacing-1);
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
/* Enhanced code blocks for documentation */
|
||||
.document-content pre {
|
||||
margin: var(--spacing-2) 0;
|
||||
background: var(--background-3);
|
||||
border-radius: var(--border-radius-small);
|
||||
overflow-x: auto;
|
||||
max-width: 120ch;
|
||||
}
|
||||
|
||||
.document-content pre code {
|
||||
font-family: var(--fontFamily-mono);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Document-specific column layouts */
|
||||
.document-content .column-set {
|
||||
margin: var(--spacing-exp-5) 0;
|
||||
}
|
||||
|
||||
/* Two-column code examples */
|
||||
.code-comparison {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-2);
|
||||
margin: var(--spacing-2) 0;
|
||||
}
|
||||
|
||||
/* Callout boxes for notes/warnings */
|
||||
.callout {
|
||||
padding: var(--spacing-2);
|
||||
margin: var(--spacing-2) 0;
|
||||
border-left: 3px solid var(--swatch-5);
|
||||
background: var(--background-3);
|
||||
}
|
||||
|
||||
.callout.note {
|
||||
border-left-color: var(--swatch-3);
|
||||
}
|
||||
|
||||
.callout.warning {
|
||||
border-left-color: var(--swatch-1);
|
||||
background: rgba(255, 200, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Table of contents navigation */
|
||||
.document-toc {
|
||||
position: sticky;
|
||||
top: var(--spacing-2);
|
||||
font-size: var(--fontSize-secondary);
|
||||
padding: var(--spacing-2);
|
||||
background: var(--background-3);
|
||||
border-radius: var(--border-radius-small);
|
||||
}
|
||||
|
||||
.document-toc ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.document-toc li {
|
||||
margin: var(--spacing-half) 0;
|
||||
}
|
||||
|
||||
.document-toc a {
|
||||
color: var(--swatch-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.document-toc a:hover {
|
||||
color: var(--swatch-1);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Footer navigation (prev/next) */
|
||||
.document-footer {
|
||||
max-width: 15%;
|
||||
}
|
||||
|
||||
.document-footer-holder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
height: 40vh;
|
||||
}
|
||||
|
||||
.document-nav-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-decoration: none;
|
||||
color: var(--swatch-2);
|
||||
}
|
||||
|
||||
.document-nav-link:hover {
|
||||
color: var(--swatch-1);
|
||||
}
|
||||
|
||||
.document-nav-label {
|
||||
font-size: var(--fontSize-small);
|
||||
font-family: var(--fontFamily-mono);
|
||||
color: var(--swatch-4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--spacing-half);
|
||||
}
|
||||
|
||||
.document-nav-label-selected {
|
||||
font-size: var(--fontSize-small);
|
||||
font-family: var(--fontFamily-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--spacing-half);
|
||||
color:rgb(236, 113, 42);
|
||||
}
|
||||
|
||||
.document-nav-title {
|
||||
font-size: var(--fontSize-header);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 1024px) {
|
||||
:root {
|
||||
--document-margin-x: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--document-margin-x: var(--spacing-2);
|
||||
}
|
||||
|
||||
.document-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.code-comparison {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.document-footer {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
}
|
||||
266
docs/styles/__folder.document.css
Normal file
266
docs/styles/__folder.document.css
Normal file
@ -0,0 +1,266 @@
|
||||
/* Document Layout - For documentation/blog post pages */
|
||||
/* Based on reference_post.html design */
|
||||
|
||||
:root {
|
||||
/* Document-specific spacing */
|
||||
--document-margin-x: 120px;
|
||||
--document-max-width: 1200px;
|
||||
}
|
||||
|
||||
/* Main content wrapper for document pages */
|
||||
.document-layout {
|
||||
/* max-width: var(--document-max-width); */
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-exp-7) var(--document-margin-x);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.document {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.document-header-holder {
|
||||
display: flex;
|
||||
height: 70vh;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
/* text-align: end; */
|
||||
}
|
||||
|
||||
/* Document header with metadata */
|
||||
.document-header {
|
||||
margin-bottom: var(--spacing-exp-6);
|
||||
max-width: 15%;
|
||||
}
|
||||
|
||||
.document-metadata {
|
||||
font-family: var(--fontFamily-mono);
|
||||
font-size: var(--fontSize-small);
|
||||
color: var(--swatch-4);
|
||||
margin-bottom: var(--spacing-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.document-title {
|
||||
font-size: 3.8rem;
|
||||
font-weight: 420;
|
||||
line-height: 1;
|
||||
margin: var(--spacing-2) 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.document-subtitle {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 500;
|
||||
color: var(--swatch-2);
|
||||
line-height: var(--lineHeight-default);
|
||||
margin-top: var(--spacing-1);
|
||||
}
|
||||
|
||||
/* Document content area */
|
||||
|
||||
.document-quick-tips {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--swatch-2);
|
||||
line-height: var(--lineHeight-default);
|
||||
margin-top: var(--spacing-1);
|
||||
}
|
||||
|
||||
.document-content-wrapper {
|
||||
flex-grow: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
overflow-y: auto;
|
||||
margin-left: 1vw;
|
||||
margin-right: 1vw;
|
||||
}
|
||||
|
||||
.document-content {
|
||||
font-size: var(--fontSize-default);
|
||||
line-height: var(--lineHeight-default);
|
||||
width: fit-content;
|
||||
padding-bottom: var(--spacing-exp-7);
|
||||
}
|
||||
|
||||
.document-content > * + * {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.document-content h1 {
|
||||
font-size: 3.8rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
/* Section headers within document */
|
||||
.document-content h2 {
|
||||
margin-top: var(--spacing-exp-6);
|
||||
margin-bottom: var(--spacing-2);
|
||||
font-size: 2.4rem;
|
||||
}
|
||||
|
||||
.document-content h3 {
|
||||
margin-top: var(--spacing-exp-5);
|
||||
margin-bottom: var(--spacing-1);
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.document-content h4 {
|
||||
margin-top: var(--spacing-2);
|
||||
margin-bottom: var(--spacing-1);
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
/* Enhanced code blocks for documentation */
|
||||
.document-content pre {
|
||||
margin: var(--spacing-2) 0;
|
||||
background: var(--background-3);
|
||||
border-radius: var(--border-radius-small);
|
||||
overflow-x: auto;
|
||||
max-width: 120ch;
|
||||
}
|
||||
|
||||
.document-content pre code {
|
||||
font-family: var(--fontFamily-mono);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Document-specific column layouts */
|
||||
.document-content .column-set {
|
||||
margin: var(--spacing-exp-5) 0;
|
||||
}
|
||||
|
||||
/* Two-column code examples */
|
||||
.code-comparison {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-2);
|
||||
margin: var(--spacing-2) 0;
|
||||
}
|
||||
|
||||
/* Callout boxes for notes/warnings */
|
||||
.callout {
|
||||
padding: var(--spacing-2);
|
||||
margin: var(--spacing-2) 0;
|
||||
border-left: 3px solid var(--swatch-5);
|
||||
background: var(--background-3);
|
||||
}
|
||||
|
||||
.callout.note {
|
||||
border-left-color: var(--swatch-3);
|
||||
}
|
||||
|
||||
.callout.warning {
|
||||
border-left-color: var(--swatch-1);
|
||||
background: rgba(255, 200, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Table of contents navigation */
|
||||
.document-toc {
|
||||
position: sticky;
|
||||
top: var(--spacing-2);
|
||||
font-size: var(--fontSize-secondary);
|
||||
padding: var(--spacing-2);
|
||||
background: var(--background-3);
|
||||
border-radius: var(--border-radius-small);
|
||||
}
|
||||
|
||||
.document-toc ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.document-toc li {
|
||||
margin: var(--spacing-half) 0;
|
||||
}
|
||||
|
||||
.document-toc a {
|
||||
color: var(--swatch-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.document-toc a:hover {
|
||||
color: var(--swatch-1);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Footer navigation (prev/next) */
|
||||
.document-footer {
|
||||
max-width: 15%;
|
||||
}
|
||||
|
||||
.document-footer-holder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
height: 40vh;
|
||||
}
|
||||
|
||||
.document-nav-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-decoration: none;
|
||||
color: var(--swatch-2);
|
||||
}
|
||||
|
||||
.document-nav-link:hover {
|
||||
color: var(--swatch-1);
|
||||
}
|
||||
|
||||
.document-nav-label {
|
||||
font-size: var(--fontSize-small);
|
||||
font-family: var(--fontFamily-mono);
|
||||
color: var(--swatch-4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--spacing-half);
|
||||
}
|
||||
|
||||
.document-nav-label-selected {
|
||||
font-size: var(--fontSize-small);
|
||||
font-family: var(--fontFamily-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--spacing-half);
|
||||
color:rgb(236, 113, 42);
|
||||
}
|
||||
|
||||
.document-nav-title {
|
||||
font-size: var(--fontSize-header);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 1024px) {
|
||||
:root {
|
||||
--document-margin-x: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--document-margin-x: var(--spacing-2);
|
||||
}
|
||||
|
||||
.document-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.code-comparison {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.document-footer {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
}
|
||||
263
docs/styles/base.css
Normal file
263
docs/styles/base.css
Normal file
@ -0,0 +1,263 @@
|
||||
/* Foldsite Documentation Base Styles */
|
||||
/* Design system extracted from reference designs */
|
||||
|
||||
:root {
|
||||
/* Typography Scale */
|
||||
--fontSize-default: 14.5px;
|
||||
--fontSize-small: 12px;
|
||||
--fontSize-secondary: 13.5px;
|
||||
--fontSize-header: 17px;
|
||||
--fontSize-large: 22px;
|
||||
--lineHeight-default: 1.65;
|
||||
|
||||
/* Color Swatches - Primary opacity-based system */
|
||||
--swatch-1: rgba(0, 0, 0, 0.85);
|
||||
--swatch-2: rgba(0, 0, 0, 0.75);
|
||||
--swatch-3: rgba(0, 0, 0, 0.6);
|
||||
--swatch-4: rgba(0, 0, 0, 0.4);
|
||||
--swatch-5: rgba(0, 0, 0, 0.25);
|
||||
--swatch-6: rgba(0, 0, 0, 0.15);
|
||||
|
||||
/* Base Colors */
|
||||
--color-default: rgba(0, 0, 0, 0.75);
|
||||
--color-default-secondary: rgba(0, 0, 0, 0.4);
|
||||
--background-1: #FAF9F6;
|
||||
--background-2: #ffffff;
|
||||
--background-3: #fcfcfc;
|
||||
--background-force-dark: #111111;
|
||||
|
||||
/* Spacing System - Consistent scale */
|
||||
--spacing-1: 15px;
|
||||
--spacing-half: calc(15px * 0.5);
|
||||
--spacing-2: calc(15px * 2);
|
||||
--spacing-3: calc(15px * 3);
|
||||
--spacing-4: calc(15px * 4);
|
||||
|
||||
/* Exponential spacing for larger gaps */
|
||||
--spacing-exp-1: 5px;
|
||||
--spacing-exp-half: calc(5px * 0.5);
|
||||
--spacing-exp-2: calc(5px * 2);
|
||||
--spacing-exp-3: calc(5px * 3);
|
||||
--spacing-exp-4: calc(5px * 5);
|
||||
--spacing-exp-5: calc(5px * 8);
|
||||
--spacing-exp-6: calc(5px * 13);
|
||||
--spacing-exp-7: calc(5px * 21);
|
||||
--spacing-exp-8: calc(5px * 34);
|
||||
|
||||
/* Typography */
|
||||
--fontFamily-default: 'Lekton', monospace;
|
||||
--fontFamily-mono: "Lekton", monospace;
|
||||
--fontFamily-display: 'Lekton', monospace;
|
||||
--fontFamily-serif: 'Lekton', monospace;
|
||||
|
||||
/* UI Elements */
|
||||
--border-radius-small: 5px;
|
||||
--opacity-downstate-default: 0.7;
|
||||
--ui-border: rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
|
||||
/* Reset */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.doto-500 {
|
||||
font-family: "Doto", sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-variation-settings:
|
||||
"ROND" 0;
|
||||
}
|
||||
|
||||
.lekton-regular {
|
||||
font-family: "Lekton", monospace;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.lekton-bold {
|
||||
font-family: "Lekton", monospace;
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.lekton-regular-italic {
|
||||
font-family: "Lekton", monospace;
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-anchor: none;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: var(--fontFamily-default);
|
||||
font-size: var(--fontSize-default);
|
||||
line-height: var(--lineHeight-default);
|
||||
color: var(--color-default);
|
||||
background-color: var(--background-1);
|
||||
text-rendering: optimizelegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: var(--swatch-1);
|
||||
font-family: var(--fontFamily-display);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.8rem;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 420;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2.4rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 1em 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--swatch-1);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--swatch-5);
|
||||
transition: text-decoration-color 0.2s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration-color: var(--swatch-1);
|
||||
}
|
||||
|
||||
/* Code */
|
||||
code {
|
||||
font-family: 'Menlo', 'Monaco', var(--fontFamily-mono);
|
||||
font-size: 0.9em;
|
||||
background: var(--background-3);
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--background-3);
|
||||
border-radius: var(--border-radius-small);
|
||||
overflow-x: auto;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Horizontal Rules */
|
||||
hr {
|
||||
border: 0;
|
||||
height: 1px;
|
||||
background: var(--ui-border);
|
||||
margin: var(--spacing-exp-4) 0;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
ul, ol {
|
||||
margin: 0 0 1em 0;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
/* Images */
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Blockquotes */
|
||||
blockquote {
|
||||
margin: var(--spacing-2) 0;
|
||||
padding-left: var(--spacing-2);
|
||||
border-left: 3px solid var(--swatch-5);
|
||||
color: var(--swatch-3);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: var(--spacing-2) 0;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: var(--spacing-half) var(--spacing-1);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
color: var(--swatch-1);
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.mono {
|
||||
font-family: var(--fontFamily-mono);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
color: var(--color-default-secondary);
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: var(--fontSize-small);
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Column System - Mimics reference design */
|
||||
.column-set {
|
||||
display: grid;
|
||||
gap: var(--spacing-2);
|
||||
margin: var(--spacing-2) 0;
|
||||
}
|
||||
|
||||
.column-set[data-columns="2"] {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.column-set[data-columns="3"] {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.column-set {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
96
docs/templates/__file.document.html
vendored
Normal file
96
docs/templates/__file.document.html
vendored
Normal file
@ -0,0 +1,96 @@
|
||||
<div class="document-layout">
|
||||
<article class="document">
|
||||
<header class="document-header">
|
||||
<hr>
|
||||
<div class="document-header-holder">
|
||||
{% if metadata and metadata.date %}
|
||||
<div class="document-metadata">
|
||||
{{ metadata.date }}
|
||||
{% if metadata.author %} — {{ metadata.author }}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<div class="document-file-nav-holder">
|
||||
{% set siblings = get_sibling_content_files(currentPath) %}
|
||||
{% if siblings and siblings|length > 1 %}
|
||||
{% set current_index = namespace(value=-1) %}
|
||||
{% for sibling in siblings %}
|
||||
{% if sibling[1] == currentPath %}
|
||||
<a href="/{{ sibling[1] }}" class="document-nav-link">
|
||||
<span class="document-nav-label-selected">🖹 {{ sibling[0] }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/{{ sibling[1] }}" class="document-nav-link">
|
||||
<span class="document-nav-label">🖹 {{ sibling[0] }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="document-folder-nav-holder">
|
||||
{% set siblingsfolders = get_sibling_content_folders(currentPath) %}
|
||||
{% if siblingsfolders and siblingsfolders|length > 1 %}
|
||||
{% set current_index = namespace(value=-1) %}
|
||||
{% for siblingf in siblingsfolders %}
|
||||
{% if siblingf.path == currentPath %}
|
||||
{% set current_index.value = loop.index0 %}
|
||||
{% endif %}
|
||||
<a href="/{{ siblingf[1] }}" class="document-nav-link">
|
||||
<span class="document-nav-label">🗀 {{ siblingf[0] }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<div class="document-content-wrapper">
|
||||
<div class="document-content">
|
||||
<hr>
|
||||
{{ content|safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% if metadata and metadata.tags %}
|
||||
<footer class="document-footer">
|
||||
<div class="document-tags">
|
||||
<span class="mono small secondary">Tagged: </span>
|
||||
{% for tag in metadata.tags %}
|
||||
<a href="/tags/{{ tag|lower }}" class="mono small">{{ tag }}</a>{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</footer>
|
||||
{% endif %}
|
||||
|
||||
<div class="document-footer">
|
||||
<hr>
|
||||
<div class="document-footer-holder">
|
||||
{% if metadata and metadata.summary %}
|
||||
<p class="document-subtitle">{{ metadata.summary }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if metadata and metadata.quick_tips %}
|
||||
<div class="document-quick-tips">
|
||||
<h4>Quick Tips:</h4>
|
||||
<hr>
|
||||
<ul>
|
||||
{% for tip in metadata.quick_tips %}
|
||||
<li>{{ tip }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
||||
</div>
|
||||
120
docs/templates/__folder.html
vendored
Normal file
120
docs/templates/__folder.html
vendored
Normal file
@ -0,0 +1,120 @@
|
||||
<div class="document-layout">
|
||||
{% set index_path = (currentPath + '/index.md') if currentPath else 'index.md' %}
|
||||
{% set index = get_rendered_markdown(index_path) %}
|
||||
<article class="document">
|
||||
<header class="document-header">
|
||||
<hr>
|
||||
<div class="document-header-holder">
|
||||
{% if index.exists %}
|
||||
{% if index.metadata and index.metadata.date %}
|
||||
<div class="document-metadata">
|
||||
{{ index.metadata.date }}
|
||||
{% if index.metadata.author %} — {{ index.metadata.author }}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div>
|
||||
<div class="document-file-nav-holder">
|
||||
{% set siblings = get_sibling_content_files(currentPath) %}
|
||||
{% if siblings and siblings|length > 1 %}
|
||||
{% set current_index = namespace(value=-1) %}
|
||||
{% for sibling in siblings %}
|
||||
{% if sibling[1] == currentPath %}
|
||||
<a href="/{{ sibling[1] }}" class="document-nav-link">
|
||||
<span class="document-nav-label-selected">🖹 {{ sibling[0] }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/{{ sibling[1] }}" class="document-nav-link">
|
||||
<span class="document-nav-label">🖹 {{ sibling[0] }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="document-folder-nav-holder">
|
||||
{% set siblingsfolders = get_sibling_content_folders(currentPath) %}
|
||||
{% if siblingsfolders and siblingsfolders|length > 1 %}
|
||||
{% for siblingf in siblingsfolders %}
|
||||
{% if siblingf[1] == currentPath %}
|
||||
<a href="/{{ siblingf[1] }}" class="document-nav-link">
|
||||
<span class="document-nav-label-selected">🗀 {{ siblingf[0] }}</span>
|
||||
</a>
|
||||
{% for child in get_folder_contents(siblingf[1]) %}
|
||||
{% if child.name == "index.md" %}
|
||||
<a href="/{{ child.path }}" class="document-nav-link">
|
||||
<span class="document-nav-label-selected">∟ 🖹 {{ child.name }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/{{ child.path }}" class="document-nav-link">
|
||||
<span class="document-nav-label">∟ 🖹 {{ child.name }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<a href="/{{ siblingf[1] }}" class="document-nav-link">
|
||||
<span class="document-nav-label">🗀 {{ siblingf[0] }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<div class="document-content-wrapper">
|
||||
<div class="document-content">
|
||||
<hr>
|
||||
{% if index.exists %}
|
||||
<section class="folder-index">
|
||||
{{ index.html | safe }}
|
||||
<hr>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% if metadata and metadata.tags %}
|
||||
<footer class="document-footer">
|
||||
<div class="document-tags">
|
||||
<span class="mono small secondary">Tagged: </span>
|
||||
{% for tag in metadata.tags %}
|
||||
<a href="/tags/{{ tag|lower }}" class="mono small">{{ tag }}</a>{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</footer>
|
||||
{% endif %}
|
||||
|
||||
<!-- Navigation to sibling documents -->
|
||||
<div class="document-footer">
|
||||
<hr>
|
||||
{% if index.exists %}
|
||||
<div class="document-footer-holder">
|
||||
{% if index.metadata and index.metadata.summary %}
|
||||
<p class="document-subtitle">{{ index.metadata.summary }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if index.metadata and index.metadata.quick_tips %}
|
||||
<div class="document-quick-tips">
|
||||
<h4>Quick Tips:</h4>
|
||||
<hr>
|
||||
<ul>
|
||||
{% for tip in index.metadata.quick_tips %}
|
||||
<li>{{ tip }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
||||
</div>
|
||||
44
docs/templates/base.html
vendored
Normal file
44
docs/templates/base.html
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{% if metadata and metadata.title %}
|
||||
<title>{{ metadata.title }} — Foldsite</title>
|
||||
<meta name="description" content="{{ metadata.description or 'A thoughtful static site generator' }}">
|
||||
{% if metadata.tags %}
|
||||
<meta name="keywords" content="{{ metadata.tags | join(', ') }}">
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<title>Foldsite — Documentation</title>
|
||||
<meta name="description" content="A thoughtful static site generator built with Python">
|
||||
{% endif %}
|
||||
|
||||
<!-- Open Graph / Social Media -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="{{ metadata.title if metadata and metadata.title else 'Foldsite' }}">
|
||||
<meta property="og:description" content="{{ metadata.description if metadata and metadata.description else 'A thoughtful static site generator' }}">
|
||||
|
||||
<!-- Load layout-specific styles -->
|
||||
{% for style in styles %}
|
||||
<link rel="stylesheet" href="/styles{{ style }}">
|
||||
{% endfor %}
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Doto:wght@100..900&family=Lekton:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/languages/django.min.js"></script>
|
||||
|
||||
|
||||
{% block extra_styles %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{{ content|safe }}
|
||||
|
||||
<script>hljs.highlightAll();</script>
|
||||
</body>
|
||||
</html>
|
||||
28
example_site/style/__error.css
Normal file
28
example_site/style/__error.css
Normal file
@ -0,0 +1,28 @@
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-content: flex-start;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
background: var(--font-color);
|
||||
}
|
||||
|
||||
.content div {
|
||||
background: var(--background-color);
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
.content .code {
|
||||
flex-grow: 1;
|
||||
font-size: 6em;
|
||||
}
|
||||
.content .message {
|
||||
flex-grow: 1;
|
||||
font-size: 4em;
|
||||
}
|
||||
.content .description {
|
||||
flex-grow: 1;
|
||||
font-size: 2em;
|
||||
}
|
||||
87
example_site/style/__file.md.css
Normal file
87
example_site/style/__file.md.css
Normal file
@ -0,0 +1,87 @@
|
||||
.content img {
|
||||
max-width: 20vw;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content p {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.content * {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.body {
|
||||
min-width: 0px
|
||||
}
|
||||
|
||||
|
||||
.holder .title {
|
||||
padding-bottom: 9em;
|
||||
margin-bottom: 1em
|
||||
}
|
||||
|
||||
.holder {
|
||||
padding-left: 2em;
|
||||
max-width: 1400px;
|
||||
min-width: 0px;
|
||||
}
|
||||
|
||||
.holder .title h1 {
|
||||
font-size: 5em;
|
||||
margin-top: 0.1em;
|
||||
margin-bottom: 0.1em;
|
||||
}
|
||||
|
||||
.holder .title h2 {
|
||||
font-size: 2.5em;
|
||||
margin-top: 0.1em;
|
||||
margin-bottom: 0.1em;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex-grow: 10;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.content a {
|
||||
color: oklch(58.28% 0.128 52.2);
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.content a:hover {
|
||||
color: oklch(75.84% 0.122 92.21);
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.content a:visited {
|
||||
color: oklch(63.8% 0.142 52.1);
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.content a:visited:hover {
|
||||
color: oklch(75.84% 0.122 92.21);
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
hr {
|
||||
max-width: 800px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.content .metadata {
|
||||
padding-right: 2em;
|
||||
flex-shrink: 0;
|
||||
max-width: 8em;
|
||||
font-size: small;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
145
example_site/style/__folder.image.css
Normal file
145
example_site/style/__folder.image.css
Normal file
@ -0,0 +1,145 @@
|
||||
.albums {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
font-size: 1rem;
|
||||
margin: 0.6rem;
|
||||
flex-shrink: 0;
|
||||
max-width: 12em;
|
||||
}
|
||||
|
||||
.albums a {
|
||||
border-radius: 2rem;
|
||||
padding: 0.2rem;
|
||||
color: var(--font-color);
|
||||
text-decoration: none;
|
||||
transition: color 0.6s ease-in-out, box-shadow 0.6s ease-in-out, border 0.6s ease-in-out;
|
||||
}
|
||||
|
||||
.albums a:hover {
|
||||
color: var(--hover-color);
|
||||
transition: color 0.6s ease-in-out, box-shadow 0.6s ease-in-out, border 0.6s ease-in-out;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1rem;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.breadcrumbs .separator {
|
||||
color: grey;
|
||||
}
|
||||
|
||||
.breadcrumbs .current {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.breadcrumbs a {
|
||||
color: var(--font-color);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.content_holder {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@media (max-width: 1250px) {
|
||||
.content_holder {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.albums {
|
||||
flex-direction: row;
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumbs a:hover {
|
||||
color: var(--hover-color);
|
||||
transition: color 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
justify-content: flex-start;
|
||||
max-width: 1400px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.post {
|
||||
margin: 0.65rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.post.photo {
|
||||
cursor: zoom-in;
|
||||
background-color: white; /* The base for our white border */
|
||||
padding: 0.65rem 0.65rem 1.35rem;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.post.text {
|
||||
border: 2px solid rgba(0, 0, 0, .1);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
text-decoration: none;
|
||||
padding: 3.9rem 2.6rem;
|
||||
}
|
||||
|
||||
.post.text .title {
|
||||
font-size: 22px;
|
||||
max-width: 15em;
|
||||
text-align: center;
|
||||
margin: 0.65rem 0 0.15rem;
|
||||
}
|
||||
|
||||
.post.text .date {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* When navigation is collapsed into toggle */
|
||||
@media screen and (max-width: 800px) {
|
||||
.posts {
|
||||
padding: 0 1.35rem;
|
||||
}
|
||||
|
||||
.post {
|
||||
margin: 1rem 0.65rem;
|
||||
}
|
||||
}
|
||||
|
||||
/*.posts::after {
|
||||
content: '';
|
||||
flex-grow: 999999999;
|
||||
}*/
|
||||
|
||||
|
||||
.post:hover {
|
||||
opacity: 0.93
|
||||
}
|
||||
|
||||
.post:active {
|
||||
opacity: 0.87
|
||||
}
|
||||
|
||||
.post i {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.post img {
|
||||
width: 100%;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
77
example_site/style/__folder.md.css
Normal file
77
example_site/style/__folder.md.css
Normal file
@ -0,0 +1,77 @@
|
||||
.albums {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
font-size: 1rem;
|
||||
margin: 0.6rem;
|
||||
flex-shrink: 0;
|
||||
max-width: 8em;
|
||||
}
|
||||
|
||||
hr {
|
||||
max-width: 800px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.albums a {
|
||||
border-radius: 2rem;
|
||||
padding: 0.2rem;
|
||||
color: var(--font-color);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease-in-out, box-shadow 0.3s ease-in-out, border 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.albums a:hover {
|
||||
color: var(--hover-color);
|
||||
transition: color 0.3s ease-in-out, box-shadow 0.3s ease-in-out, border 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1rem;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.breadcrumbs .separator {
|
||||
color: grey;
|
||||
}
|
||||
|
||||
.breadcrumbs .current {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--font-color);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--hover-color);
|
||||
transition: color 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.content_holder {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.holder {
|
||||
width: 100%;
|
||||
padding: 0.6rem
|
||||
}
|
||||
|
||||
.post {
|
||||
padding: 0.6rem;
|
||||
}
|
||||
222
example_site/style/base.css
Normal file
222
example_site/style/base.css
Normal file
@ -0,0 +1,222 @@
|
||||
@charset "UTF-8";
|
||||
|
||||
@property --font-color {
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: oklch(25.11% 0.006 258.36);
|
||||
}
|
||||
|
||||
@property --background-color {
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: oklch(97.05% 0.039 91.2);
|
||||
}
|
||||
|
||||
@property --hover-color {
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: oklch(82.39% 0.133 91.5);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--font-color: oklch(91.87% 0.003 264.54);
|
||||
--background-color: oklch(25.11% 0.006 258.36);
|
||||
--hover-color: oklch(90.92% 0.125 92.56);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--background-color);
|
||||
color: var(--font-color);
|
||||
overflow-x: hidden !important;
|
||||
scrollbar-width: none;
|
||||
/* Firefox */
|
||||
-ms-overflow-style: none;
|
||||
/* Internet Explorer 10+ */
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
padding-right: 2.5rem;
|
||||
padding-left: 2.5rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-family: "Outfit", serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
background-color: var(--background-color);
|
||||
color: var(--font-color);
|
||||
overflow-x: hidden !important;
|
||||
scrollbar-width: none;
|
||||
/* Firefox */
|
||||
-ms-overflow-style: none;
|
||||
/* Internet Explorer 10+ */
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fixed-sidebar-holder {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
|
||||
.sidebar {
|
||||
width: 14em;
|
||||
min-width: 14em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.sidebar a {
|
||||
text-decoration: none;
|
||||
color: var(--font-color);
|
||||
transition: color 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.sidebar a:hover {
|
||||
color: var(--hover-color);
|
||||
transition: color 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.sidebar h1 {
|
||||
padding: 0.25em;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.sidebar ul {
|
||||
list-style: none;
|
||||
padding: 0.3em;
|
||||
margin-left: 0.6em;
|
||||
}
|
||||
|
||||
.sidebar li {
|
||||
padding: 0.2em;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: calc(1ex / 0.32);
|
||||
max-width: 80ch;
|
||||
text-align: justify;
|
||||
hyphens: auto;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased !important;
|
||||
-moz-font-smoothing: antialiased !important;
|
||||
text-rendering: optimizelegibility !important;
|
||||
}
|
||||
|
||||
hr {
|
||||
max-width: 50px;
|
||||
}
|
||||
|
||||
.pre-loaded {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
will-change: opacity;
|
||||
transition: opacity 0.3s, visibility 0.3s ease-in;
|
||||
}
|
||||
|
||||
.loaded {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s, visibility 0.3s ease-in;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
margin: 0.25em;
|
||||
}
|
||||
|
||||
ul, li {
|
||||
padding: 0.15em;
|
||||
margin: 0.15em;
|
||||
}
|
||||
|
||||
pre, code {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 872px) {
|
||||
body {
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.fixed-sidebar-holder {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar-toggle button {
|
||||
background-color: var(--background-color);
|
||||
color: var(--font-color);
|
||||
border: none;
|
||||
padding: 0.5em;
|
||||
margin: 0.5em;
|
||||
font-size: 1.5em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ul, li {
|
||||
display: flex;
|
||||
padding: 0.15em;
|
||||
margin: 0.15em;
|
||||
}
|
||||
|
||||
.sidebar-header h1{
|
||||
padding: 0.15em;
|
||||
margin: 0.15em;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar hr {
|
||||
margin: 0.15em;
|
||||
|
||||
}
|
||||
|
||||
.sidebar-toggle-active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
11
example_site/template/__error.html
Normal file
11
example_site/template/__error.html
Normal file
@ -0,0 +1,11 @@
|
||||
<div class="content">
|
||||
<div class="code">
|
||||
{{ error_code }}
|
||||
</div>
|
||||
<div class="message">
|
||||
{{ error_message }}
|
||||
</div>
|
||||
<div class="description">
|
||||
{{ error_description }}
|
||||
</div>
|
||||
</div>
|
||||
36
example_site/template/__file.md.html
Normal file
36
example_site/template/__file.md.html
Normal file
@ -0,0 +1,36 @@
|
||||
<div class="holder">
|
||||
{% if metadata %}
|
||||
{% if metadata.title %}
|
||||
{% if metadata.title_image %}
|
||||
<div class="title" style="background-image: url('{{ metadata.title_image }}'); background-size: cover; background-position: center;">
|
||||
<h1>{{ metadata.title }}</h1>
|
||||
{% if metadata.description %}
|
||||
<h2>{{ metadata.description }}</h2>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="title">
|
||||
<h1>{{ metadata.title }}</h1>
|
||||
{% if metadata.description %}
|
||||
<h2>{{ metadata.description }}</h2>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="content">
|
||||
{% if metadata %}
|
||||
<div class="metadata">
|
||||
{% for key, value in metadata.items() %}
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-key">{{ key }}</span>:
|
||||
<span class="metadata-value">{{ value }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="body">
|
||||
{{ content|safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
42
example_site/template/__folder.image.html
Normal file
42
example_site/template/__folder.image.html
Normal file
@ -0,0 +1,42 @@
|
||||
<div class="holder">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/viewerjs/1.11.7/viewer.min.css" integrity="sha512-vRbASHFS0JiM4xwX/iqr9mrD/pXGnOP2CLdmXSSNAjLdgQVFyt4qI+BIoUW7/81uSuKRj0cWv3Dov8vVQOTHLw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/viewerjs/1.11.7/viewer.min.js" integrity="sha512-lZD0JiwhtP4UkFD1mc96NiTZ14L7MjyX5Khk8PMxJszXMLvu7kjq1sp4bb0tcL6MY+/4sIuiUxubOqoueHrW4w==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<div class="breadcrumbs">
|
||||
{% set breadcrumbs = currentPath.split('/') %}
|
||||
{% for i in range(breadcrumbs|length) %}
|
||||
{% if i+1 == breadcrumbs|length %}
|
||||
<div class="current">{{ breadcrumbs[i] }}</div>
|
||||
{% else %}
|
||||
<a href="{{ '/' ~ '/'.join(breadcrumbs[:i+1]) }}"> 🗀 {{ breadcrumbs[i] }}</a>
|
||||
<div class="seperator">/</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="content_holder">
|
||||
<div class="albums">
|
||||
{% for album in get_sibling_content_folders(currentPath) %}
|
||||
<a href="/{{ album[1] }}">↪ 🗀 {{ album[0] }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="content">
|
||||
{% for picture in get_folder_contents(currentPath) | sort(attribute='date_modified', reverse=True) %}
|
||||
{% if 'image' in picture.categories %}
|
||||
<a class="post photo" style="
|
||||
max-width:{{picture.metadata.width//20}}px;
|
||||
max-height:{{picture.metadata.height//20}}px;
|
||||
width:calc({{picture.metadata.width//20}}px*250/{{picture.metadata.height//20}});
|
||||
flex-grow:calc({{picture.metadata.width//20}}*250/{{picture.metadata.height//20}})">
|
||||
<img src="/download/{{ picture.path }}" alt="{{ picture.name }}" class="pre-loaded"
|
||||
onload="this.className+=' loaded'" data-zoom-image>
|
||||
{{ picture.metadata.exif['DateTimeOriginal '] }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<script>
|
||||
const gallery = new Viewer(document.querySelector('.content'), {
|
||||
inline: false,
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
40
example_site/template/__folder.md.html
Normal file
40
example_site/template/__folder.md.html
Normal file
@ -0,0 +1,40 @@
|
||||
<div class="holder">
|
||||
<div class="breadcrumbs">
|
||||
{% set breadcrumbs = currentPath.split('/') %}
|
||||
{% for i in range(breadcrumbs|length) %}
|
||||
{% if i+1 == breadcrumbs|length %}
|
||||
<div class="current">{{ breadcrumbs[i] }}</div>
|
||||
{% else %}
|
||||
<a href="{{ '/' ~ '/'.join(breadcrumbs[:i+1]) }}"> 🗀 {{ breadcrumbs[i] }}</a>
|
||||
<div class="seperator">/</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="content_holder">
|
||||
<div class="albums">
|
||||
{% for album in get_sibling_content_folders(currentPath) %}
|
||||
<a href="/{{ album[1] }}">↪ 🗀 {{ album[0] }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="content">
|
||||
{% set sorted_files = get_folder_contents(currentPath) | sort(attribute='date_created', reverse=True) %}
|
||||
{% set filtered_files = [] %}
|
||||
<!-- Filter sorted_files to only items are have "document" in item.categories -->
|
||||
{% for file in sorted_files %}
|
||||
{% if 'document' in file.categories %}
|
||||
{{ filtered_files.append(file) or "" }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="filelist">
|
||||
{% for file in filtered_files %}
|
||||
{% if 'document' in file.categories %}
|
||||
<div class="post">
|
||||
<a href="/{{ file.path }}"><p>{{ file.date_created }}</p><h2>{{ file.proper_name }}</h2></a>
|
||||
</div>
|
||||
<hr class="solid">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
59
example_site/template/base.html
Normal file
59
example_site/template/base.html
Normal file
@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ pageTitle }}</title>
|
||||
<link rel="stylesheet" href="https://necolas.github.io/normalize.css/8.0.1/normalize.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap" rel="stylesheet">
|
||||
{% for style in styles %}
|
||||
<link rel="stylesheet" href="/styles{{ style }}" type="text/css">
|
||||
{% endfor %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="sidebar">
|
||||
<div class="fixed-sidebar-holder">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-toggle" id="sidebar-toggle">
|
||||
<button id="sidebar-button" type="button" onclick="onSidebar()">☰</button>
|
||||
</div>
|
||||
<a href="/">
|
||||
<h1>Tanishq Dubey</h1>
|
||||
</a>
|
||||
</div>
|
||||
<div class="sidebar-content" id="sidebar-content">
|
||||
<ul>
|
||||
<li><a href="/">⌂ Home</a></li>
|
||||
{% for f in get_folder_contents() %}
|
||||
{% if not f.is_dir %}
|
||||
{% if f.proper_name == "index" %}
|
||||
{% else %}
|
||||
<li><a href="/{{ f.path }}">{{ f.proper_name }}</a></li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<hr class="solid">
|
||||
<ul>
|
||||
{% for f in get_sibling_content_folders() %}
|
||||
<li><a href="/{{ f[1] }}">↪ 🗀 {{ f[0] }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ content|safe }}
|
||||
|
||||
<script>
|
||||
function onSidebar() {
|
||||
document.getElementById("sidebar-content").classList.toggle("sidebar-toggle-active");
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
25
main.py
25
main.py
@ -3,12 +3,8 @@ from src.routes.routes import RouteManager
|
||||
from src.config.args import create_parser
|
||||
from src.config.config import Configuration
|
||||
from src.rendering.helpers import TemplateHelpers
|
||||
from src.server.file_manager import create_filemanager_blueprint
|
||||
|
||||
|
||||
AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
PASSWORD = "YiaysZ4g8QX1R8R"
|
||||
AWS_ACCESS_KEY_ID = "AIDAJQABLZS4A3QDU576"
|
||||
from src.server.enhanced_file_manager import create_enhanced_filemanager_blueprint
|
||||
from src.rendering.debug_helpers import init_debug_helper
|
||||
|
||||
|
||||
def main():
|
||||
@ -21,6 +17,9 @@ def main():
|
||||
r = RouteManager(c)
|
||||
t = TemplateHelpers(c)
|
||||
|
||||
# Initialize debug helper for better developer experience
|
||||
init_debug_helper(c)
|
||||
|
||||
server = Server(
|
||||
debug=c.debug,
|
||||
host=c.listen_address,
|
||||
@ -29,18 +28,30 @@ def main():
|
||||
workers=c.max_threads,
|
||||
)
|
||||
|
||||
# Original template functions
|
||||
server.register_template_function("get_sibling_content_files", t.get_sibling_content_files)
|
||||
server.register_template_function("get_text_document_preview", t.get_text_document_preview)
|
||||
server.register_template_function("get_sibling_content_folders", t.get_sibling_content_folders)
|
||||
server.register_template_function("get_folder_contents", t.get_folder_contents)
|
||||
|
||||
# Enhanced blog-focused template helpers
|
||||
server.register_template_function("get_recent_posts", t.get_recent_posts)
|
||||
server.register_template_function("get_posts_by_tag", t.get_posts_by_tag)
|
||||
server.register_template_function("get_photo_albums", t.get_photo_albums)
|
||||
server.register_template_function("get_navigation_items", t.get_navigation_items)
|
||||
server.register_template_function("generate_breadcrumbs", t.generate_breadcrumbs)
|
||||
server.register_template_function("get_related_posts", t.get_related_posts)
|
||||
server.register_template_function("get_all_tags", t.get_all_tags)
|
||||
server.register_template_function("get_rendered_markdown", t.get_rendered_markdown)
|
||||
server.register_template_function("get_markdown_metadata", t.get_markdown_metadata)
|
||||
|
||||
server.register_route("/styles/<path:path>", r.get_style)
|
||||
server.register_route("/download/<path:path>", r.get_static)
|
||||
server.register_route("/", r.default_route, defaults={"path": ""})
|
||||
server.register_route("/<path:path>", r.default_route)
|
||||
|
||||
if c.admin_browser:
|
||||
file_manager_bp = create_filemanager_blueprint(c.content_dir, url_prefix='/admin', auth_password=c.admin_password)
|
||||
file_manager_bp = create_enhanced_filemanager_blueprint(c.content_dir, url_prefix='/admin', auth_password=c.admin_password)
|
||||
server.app.register_blueprint(file_manager_bp)
|
||||
|
||||
try:
|
||||
|
||||
@ -8,6 +8,7 @@ dependencies = [
|
||||
"bs4>=0.0.2",
|
||||
"flask>=3.1.0",
|
||||
"gunicorn>=23.0.0",
|
||||
"jinja2>=3.1.6",
|
||||
"mistune>=3.1.1",
|
||||
"pillow>=10.4.0",
|
||||
"python-frontmatter>=1.1.0",
|
||||
|
||||
@ -16,8 +16,10 @@ gunicorn==23.0.0
|
||||
# via foldsite (pyproject.toml)
|
||||
itsdangerous==2.2.0
|
||||
# via flask
|
||||
jinja2==3.1.5
|
||||
# via flask
|
||||
jinja2==3.1.6
|
||||
# via
|
||||
# foldsite (pyproject.toml)
|
||||
# flask
|
||||
markdown-it-py==3.0.0
|
||||
# via rich
|
||||
markupsafe==3.0.2
|
||||
|
||||
@ -6,6 +6,34 @@ TEMPLATES_DIR = None
|
||||
STYLES_DIR = None
|
||||
|
||||
class Configuration:
|
||||
"""
|
||||
Configuration class for loading and validating application settings from a TOML file.
|
||||
This class encapsulates the logic for reading configuration data from a specified TOML file,
|
||||
validating the presence of required sections and keys, and exposing configuration values as
|
||||
instance attributes. The configuration file is expected to contain at least two sections:
|
||||
'paths' (with 'content_dir', 'templates_dir', and 'styles_dir') and 'server' (with optional
|
||||
server-related settings).
|
||||
Attributes:
|
||||
config_path (str or Path): Path to the TOML configuration file.
|
||||
content_dir (Path): Directory containing content files (required).
|
||||
templates_dir (Path): Directory containing template files (required).
|
||||
styles_dir (Path): Directory containing style files (required).
|
||||
listen_address (str): Address for the server to listen on (default: "127.0.0.1").
|
||||
listen_port (int): Port for the server to listen on (default: 8080).
|
||||
debug (bool): Enable or disable debug mode (default: False).
|
||||
access_log (bool): Enable or disable access logging (default: True).
|
||||
max_threads (int): Maximum number of server threads (default: 4).
|
||||
admin_browser (bool): Enable or disable admin browser access (default: False).
|
||||
admin_password (str): Password for admin access (optional).
|
||||
Methods:
|
||||
load_config():
|
||||
Loads and validates configuration data from the TOML file specified by `config_path`.
|
||||
Raises FileNotFoundError if the file does not exist, tomllib.TOMLDecodeError if the file
|
||||
is not valid TOML, or ValueError if required sections or keys are missing.
|
||||
set_globals():
|
||||
Sets global variables CONTENT_DIR, TEMPLATES_DIR, and STYLES_DIR based on the loaded
|
||||
configuration values.
|
||||
"""
|
||||
|
||||
def __init__(self, config_path):
|
||||
self.config_path = config_path
|
||||
@ -23,6 +51,19 @@ class Configuration:
|
||||
self.admin_password: str = None
|
||||
|
||||
def load_config(self):
|
||||
"""
|
||||
Loads and validates configuration data from a TOML file specified by `self.config_path`.
|
||||
This method reads the configuration file, parses its contents, and sets various instance attributes
|
||||
based on the configuration values. It expects the configuration file to contain at least two sections:
|
||||
'paths' and 'server'. The 'paths' section must include 'content_dir', 'templates_dir', and 'styles_dir'.
|
||||
The 'server' section may include 'listen_address', 'listen_port', 'debug', 'access_log', 'max_threads',
|
||||
'admin_browser', and 'admin_password'. If any required section or key is missing, or if the file is
|
||||
not found or is invalid TOML, an appropriate exception is raised.
|
||||
Raises:
|
||||
FileNotFoundError: If the configuration file does not exist.
|
||||
tomllib.TOMLDecodeError: If the configuration file is not valid TOML.
|
||||
ValueError: If required sections or keys are missing in the configuration file.
|
||||
"""
|
||||
try:
|
||||
with open(self.config_path, "rb") as f:
|
||||
self.config_data = tomllib.load(f)
|
||||
@ -62,10 +103,3 @@ class Configuration:
|
||||
self.admin_browser = server.get("admin_browser", self.admin_browser)
|
||||
self.admin_password = server.get("admin_password", self.admin_password)
|
||||
|
||||
def set_globals(self):
|
||||
global CONTENT_DIR, TEMPLATES_DIR, STYLES_DIR
|
||||
CONTENT_DIR = self.content_dir
|
||||
TEMPLATES_DIR = self.templates_dir
|
||||
STYLES_DIR = self.styles_dir
|
||||
|
||||
|
||||
|
||||
218
src/rendering/debug_helpers.py
Normal file
218
src/rendering/debug_helpers.py
Normal file
@ -0,0 +1,218 @@
|
||||
"""
|
||||
Debug helpers for foldsite - following grug principles
|
||||
Simple debugging tools that don't require big brain to understand
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any
|
||||
|
||||
|
||||
class DebugHelper:
|
||||
"""
|
||||
Grug-approved debugging - simple tools that show what happening
|
||||
No complexity demons, just helpful info when things go wrong
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.debug_enabled = getattr(config, 'debug', False)
|
||||
|
||||
def log_template_search(self, path: str, found_template: str = None, candidates: List[str] = None):
|
||||
"""Log template discovery process for debugging"""
|
||||
if not self.debug_enabled:
|
||||
return
|
||||
|
||||
print(f"🔍 Template search for: {path}")
|
||||
if candidates:
|
||||
print(f" Candidates checked: {', '.join(candidates)}")
|
||||
if found_template:
|
||||
print(f" ✅ Found: {found_template}")
|
||||
else:
|
||||
print(f" ❌ No template found")
|
||||
print()
|
||||
|
||||
def show_available_templates(self):
|
||||
"""Show all available templates - useful for developers"""
|
||||
if not self.debug_enabled:
|
||||
return {}
|
||||
|
||||
templates = {}
|
||||
template_dir = Path(self.config.templates_dir)
|
||||
|
||||
if template_dir.exists():
|
||||
for template_file in template_dir.rglob("*.html"):
|
||||
rel_path = str(template_file.relative_to(template_dir))
|
||||
templates[rel_path] = {
|
||||
'path': str(template_file),
|
||||
'size': template_file.stat().st_size,
|
||||
'modified': datetime.fromtimestamp(template_file.stat().st_mtime).strftime('%Y-%m-%d %H:%M')
|
||||
}
|
||||
|
||||
if self.debug_enabled:
|
||||
print("📄 Available templates:")
|
||||
for name, info in templates.items():
|
||||
print(f" {name} ({info['size']} bytes, modified {info['modified']})")
|
||||
print()
|
||||
|
||||
return templates
|
||||
|
||||
def show_content_structure(self, max_depth: int = 2):
|
||||
"""Show content directory structure - helps with template planning"""
|
||||
if not self.debug_enabled:
|
||||
return {}
|
||||
|
||||
content_dir = Path(self.config.content_dir)
|
||||
structure = {}
|
||||
|
||||
def scan_directory(dir_path: Path, current_depth: int = 0):
|
||||
if current_depth > max_depth:
|
||||
return {"...": "truncated"}
|
||||
|
||||
items = {}
|
||||
try:
|
||||
for item in dir_path.iterdir():
|
||||
if item.name.startswith('___'):
|
||||
continue
|
||||
|
||||
if item.is_file():
|
||||
items[item.name] = {
|
||||
'type': 'file',
|
||||
'extension': item.suffix,
|
||||
'size': item.stat().st_size
|
||||
}
|
||||
elif item.is_dir():
|
||||
items[item.name] = {
|
||||
'type': 'directory',
|
||||
'contents': scan_directory(item, current_depth + 1)
|
||||
}
|
||||
except PermissionError:
|
||||
items['error'] = 'Permission denied'
|
||||
|
||||
return items
|
||||
|
||||
if content_dir.exists():
|
||||
structure = scan_directory(content_dir)
|
||||
|
||||
if self.debug_enabled:
|
||||
print("📁 Content structure:")
|
||||
self._print_structure(structure, indent=" ")
|
||||
print()
|
||||
|
||||
return structure
|
||||
|
||||
def _print_structure(self, structure: Dict, indent: str = ""):
|
||||
"""Helper to print directory structure nicely"""
|
||||
for name, info in structure.items():
|
||||
if isinstance(info, dict):
|
||||
if info.get('type') == 'file':
|
||||
size_kb = info['size'] // 1024 if info['size'] > 1024 else info['size']
|
||||
unit = 'KB' if info['size'] > 1024 else 'B'
|
||||
print(f"{indent}{name} ({size_kb}{unit})")
|
||||
elif info.get('type') == 'directory':
|
||||
print(f"{indent}{name}/")
|
||||
if 'contents' in info:
|
||||
self._print_structure(info['contents'], indent + " ")
|
||||
else:
|
||||
print(f"{indent}{name}: {info}")
|
||||
|
||||
def get_template_context_info(self, context: Dict[str, Any]):
|
||||
"""Show what variables are available in templates"""
|
||||
if not self.debug_enabled:
|
||||
return {}
|
||||
|
||||
info = {}
|
||||
for key, value in context.items():
|
||||
if key.startswith('_'): # Skip internal variables
|
||||
continue
|
||||
|
||||
value_type = type(value).__name__
|
||||
if hasattr(value, '__len__') and not isinstance(value, str):
|
||||
info[key] = f"{value_type} (length: {len(value)})"
|
||||
else:
|
||||
info[key] = value_type
|
||||
|
||||
if self.debug_enabled:
|
||||
print("🔧 Template context variables:")
|
||||
for var_name, var_info in info.items():
|
||||
print(f" {var_name}: {var_info}")
|
||||
print()
|
||||
|
||||
return info
|
||||
|
||||
def validate_template_syntax(self, template_path: str):
|
||||
"""Basic template validation - catch obvious errors"""
|
||||
try:
|
||||
with open(template_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
issues = []
|
||||
|
||||
# Check for common Jinja2 issues
|
||||
if '{{' in content and '}}' not in content:
|
||||
issues.append("Unclosed {{ expression")
|
||||
if '{%' in content and '%}' not in content:
|
||||
issues.append("Unclosed {% statement")
|
||||
if '{#' in content and '#}' not in content:
|
||||
issues.append("Unclosed {# comment")
|
||||
|
||||
# Check for mismatched quotes
|
||||
single_quotes = content.count("'")
|
||||
double_quotes = content.count('"')
|
||||
if single_quotes % 2 != 0:
|
||||
issues.append("Mismatched single quotes")
|
||||
if double_quotes % 2 != 0:
|
||||
issues.append("Mismatched double quotes")
|
||||
|
||||
if issues and self.debug_enabled:
|
||||
print(f"⚠️ Template issues in {template_path}:")
|
||||
for issue in issues:
|
||||
print(f" - {issue}")
|
||||
print()
|
||||
|
||||
return issues
|
||||
|
||||
except Exception as e:
|
||||
if self.debug_enabled:
|
||||
print(f"❌ Error validating template {template_path}: {e}")
|
||||
return [f"Validation error: {e}"]
|
||||
|
||||
def show_render_performance(self, path: str, render_time: float, cache_hit: bool = False):
|
||||
"""Show rendering performance - useful for optimization"""
|
||||
if not self.debug_enabled:
|
||||
return
|
||||
|
||||
status = "💨 CACHED" if cache_hit else "🔄 RENDERED"
|
||||
print(f"{status} {path} in {render_time:.3f}s")
|
||||
|
||||
def create_debug_info_page(self):
|
||||
"""Create comprehensive debug info for admin interface"""
|
||||
debug_info = {
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'config': {
|
||||
'content_dir': str(self.config.content_dir),
|
||||
'templates_dir': str(self.config.templates_dir),
|
||||
'styles_dir': str(self.config.styles_dir),
|
||||
'debug_enabled': self.debug_enabled
|
||||
},
|
||||
'templates': self.show_available_templates(),
|
||||
'content_structure': self.show_content_structure(),
|
||||
}
|
||||
|
||||
return debug_info
|
||||
|
||||
|
||||
# Global debug helper - can be imported anywhere
|
||||
debug_helper = None
|
||||
|
||||
def init_debug_helper(config):
|
||||
"""Initialize global debug helper"""
|
||||
global debug_helper
|
||||
debug_helper = DebugHelper(config)
|
||||
return debug_helper
|
||||
|
||||
def get_debug_helper():
|
||||
"""Get the global debug helper instance"""
|
||||
return debug_helper
|
||||
@ -1,48 +1,22 @@
|
||||
from dataclasses import dataclass
|
||||
from src.config.config import Configuration
|
||||
from src.rendering import GENERIC_FILE_MAPPING
|
||||
from src.rendering.markdown import (
|
||||
render_markdown,
|
||||
read_raw_markdown,
|
||||
rendered_markdown_to_plain_text,
|
||||
from src.rendering.metadata_builders import (
|
||||
MetadataBuilderFactory,
|
||||
BlogPostAnalyzer,
|
||||
ImageGalleryAnalyzer,
|
||||
ImageMetadata,
|
||||
MarkdownMetadata,
|
||||
FileMetadata
|
||||
)
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ExifTags
|
||||
from datetime import datetime
|
||||
import frontmatter
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageMetadata:
|
||||
width: int
|
||||
height: int
|
||||
alt: str
|
||||
exif: dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarkdownMetadata:
|
||||
"""
|
||||
A class to represent metadata for a Markdown file.
|
||||
|
||||
Attributes:
|
||||
----------
|
||||
frontmatter : dict
|
||||
A dictionary containing the front matter of the Markdown file.
|
||||
content : str
|
||||
The main content of the Markdown file.
|
||||
preview : str
|
||||
A preview or summary of the Markdown content.
|
||||
"""
|
||||
frontmatter: dict
|
||||
content: str
|
||||
preview: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileMetadata:
|
||||
typeMeta: MarkdownMetadata | None
|
||||
import os
|
||||
from src.server.simple_cache import app_cache, cached, get_folder_contents_cache_key, get_posts_cache_key
|
||||
from src.rendering.markdown import render_markdown
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -85,85 +59,46 @@ def format_date(timestamp):
|
||||
class TemplateHelpers:
|
||||
def __init__(self, config: Configuration):
|
||||
self.config: Configuration = config
|
||||
# Initialize focused metadata builders
|
||||
self.metadata_factory = MetadataBuilderFactory()
|
||||
self.blog_analyzer = BlogPostAnalyzer()
|
||||
self.gallery_analyzer = ImageGalleryAnalyzer()
|
||||
|
||||
def _filter_hidden_files(self, files):
|
||||
return [f for f in files if not f.name.startswith("___")]
|
||||
|
||||
def _build_metadata_for_file(self, path: str, categories: list[str] = []):
|
||||
file_path = self.config.content_dir / path
|
||||
for k in categories:
|
||||
if k == "image":
|
||||
img = Image.open(file_path)
|
||||
exif = img._getexif()
|
||||
# Conver exif to dict
|
||||
orientation = exif.get(274, 1) if exif else 1
|
||||
width, height = img.width, img.height
|
||||
if orientation in [5, 6, 7, 8]:
|
||||
width, height = height, width
|
||||
|
||||
exif = {}
|
||||
try:
|
||||
img = Image.open(file_path)
|
||||
exif_raw = img._getexif()
|
||||
if exif_raw:
|
||||
exif = {
|
||||
ExifTags.TAGS[k]: v
|
||||
for k, v in exif_raw.items()
|
||||
if k in ExifTags.TAGS
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error processing image {file_path}: {e}")
|
||||
|
||||
date_taken = exif.get("DateTimeOriginal")
|
||||
if not date_taken:
|
||||
date_taken = format_date(file_path.stat().st_ctime)
|
||||
return ImageMetadata(
|
||||
width=width,
|
||||
height=height,
|
||||
alt=file_path.name,
|
||||
exif=exif,
|
||||
)
|
||||
elif k == "document":
|
||||
ret = None
|
||||
with open(file_path, "r") as fdoc:
|
||||
ret = FileMetadata(None)
|
||||
if file_path.suffix[1:].lower() == "md":
|
||||
ret.typeMeta = MarkdownMetadata({}, "", "")
|
||||
content, c_frontmatter, obj = render_markdown(file_path)
|
||||
ret.typeMeta.frontmatter = c_frontmatter
|
||||
ret.typeMeta.content = content
|
||||
ret.typeMeta.rawContent = read_raw_markdown(file_path)
|
||||
ret.typeMeta.rawText = rendered_markdown_to_plain_text(
|
||||
ret.typeMeta.content
|
||||
)
|
||||
ret.typeMeta.preview = ret.typeMeta.rawText[:500] + "..."
|
||||
return ret
|
||||
return None
|
||||
def _build_metadata_for_file(self, file_path, categories: list[str] = []):
|
||||
"""
|
||||
Simple metadata builder using focused classes
|
||||
Grug-approved: clean delegation to specialized builders
|
||||
"""
|
||||
full_path = self.config.content_dir / file_path
|
||||
return self.metadata_factory.build_metadata(full_path, categories)
|
||||
|
||||
def get_folder_contents(self, path: str = ""):
|
||||
"""
|
||||
Retrieve the contents of a folder and return a list of TemplateFile objects.
|
||||
|
||||
Args:
|
||||
path (str): The relative path to the folder within the content directory. Defaults to an empty string,
|
||||
which refers to the root content directory.
|
||||
|
||||
Returns:
|
||||
list: A list of TemplateFile objects representing the files and directories within the specified folder.
|
||||
|
||||
The function performs the following steps:
|
||||
1. Constructs the full path to the folder by combining the content directory with the provided path.
|
||||
2. Retrieves all files and directories within the specified folder.
|
||||
3. Iterates over each file and directory, creating a TemplateFile object with metadata such as name,
|
||||
path, proper name, extension, categories, date modified, date created, size in KB, metadata, directory
|
||||
item count, and whether it is a directory.
|
||||
4. If the item is a file, it assigns categories based on the file extension using a predefined mapping.
|
||||
5. Builds additional metadata for each file.
|
||||
6. Filters out hidden files from the list.
|
||||
7. Returns the list of TemplateFile objects.
|
||||
Now with caching to improve performance!
|
||||
"""
|
||||
search_contnet_path = self.config.content_dir / path
|
||||
files = search_contnet_path.glob("*")
|
||||
# Check cache first
|
||||
cache_key = get_folder_contents_cache_key(str(self.config.content_dir), path)
|
||||
|
||||
# Check if folder has been modified since cache
|
||||
search_content_path = self.config.content_dir / path
|
||||
print(search_content_path)
|
||||
if not search_content_path.exists():
|
||||
return []
|
||||
|
||||
folder_mtime = search_content_path.stat().st_mtime
|
||||
cache_key_with_mtime = f"{cache_key}:{folder_mtime}"
|
||||
|
||||
cached_result = app_cache.get(cache_key_with_mtime)
|
||||
if cached_result is not None:
|
||||
print("Cache hit")
|
||||
return cached_result
|
||||
|
||||
# Compute folder contents
|
||||
files = search_content_path.glob("*")
|
||||
ret = []
|
||||
for f in files:
|
||||
t = TemplateFile(
|
||||
@ -174,7 +109,7 @@ class TemplateHelpers:
|
||||
categories=[],
|
||||
date_modified=format_date(f.stat().st_mtime),
|
||||
date_created=format_date(f.stat().st_ctime),
|
||||
size_kb=f.stat().st_size / 1024,
|
||||
size_kb=int(f.stat().st_size / 1024),
|
||||
metadata=None,
|
||||
dir_item_count=len(list(f.glob("*"))) if f.is_dir() else 0,
|
||||
is_dir=f.is_dir(),
|
||||
@ -186,12 +121,18 @@ class TemplateHelpers:
|
||||
t.metadata = self._build_metadata_for_file(f, t.categories)
|
||||
if "image" in t.categories:
|
||||
# Adjust date_modified and date_created to be the date the image was taken from exif if available
|
||||
if t.metadata.exif and "DateTimeOriginal" in t.metadata.exif:
|
||||
if t.metadata and hasattr(t.metadata, 'exif') and t.metadata.exif and "DateTimeOriginal" in t.metadata.exif:
|
||||
t.date_modified = t.metadata.exif["DateTimeOriginal"]
|
||||
t.date_created = t.metadata.exif["DateTimeOriginal"]
|
||||
|
||||
ret.append(t)
|
||||
ret = self._filter_hidden_files(ret)
|
||||
|
||||
# Cache the result (cache for 5 minutes)
|
||||
app_cache.set(cache_key_with_mtime, ret, 300)
|
||||
|
||||
# Clean up old cache entries for this folder
|
||||
app_cache.delete(cache_key)
|
||||
return ret
|
||||
|
||||
def get_sibling_content_files(self, path: str = ""):
|
||||
@ -206,8 +147,9 @@ class TemplateHelpers:
|
||||
list: A list of tuples, where each tuple contains the file name and its relative path
|
||||
to the content directory. Only files that do not start with "___" are included.
|
||||
"""
|
||||
search_contnet_path = self.config.content_dir / path
|
||||
files = search_contnet_path.glob("*")
|
||||
search_content_path: Path = self.config.content_dir / path
|
||||
search_content_path = search_content_path.parents[0] if len(search_content_path.parents) > 0 else search_content_path
|
||||
files = search_content_path.glob("*")
|
||||
return [
|
||||
(file.name, str(file.relative_to(self.config.content_dir)))
|
||||
for file in files
|
||||
@ -233,7 +175,7 @@ class TemplateHelpers:
|
||||
IOError: If an I/O error occurs while reading the file.
|
||||
"""
|
||||
file_path = self.config.content_dir / path
|
||||
with open(file_path, "r") as f:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read(100)
|
||||
return content
|
||||
|
||||
@ -249,10 +191,408 @@ class TemplateHelpers:
|
||||
list of tuple: A list of tuples where each tuple contains the folder name and its relative path
|
||||
to the content directory. Only directories that do not start with "___" are included.
|
||||
"""
|
||||
search_contnet_path = self.config.content_dir / path
|
||||
files = search_contnet_path.glob("*")
|
||||
search_content_path = self.config.content_dir / path
|
||||
search_content_path = search_content_path.parents[0] if len(search_content_path.parents) > 0 else search_content_path
|
||||
files = search_content_path.glob("*")
|
||||
return [
|
||||
(file.name, str(file.relative_to(self.config.content_dir)))
|
||||
for file in files
|
||||
if file.is_dir() and not file.name.startswith("___")
|
||||
]
|
||||
|
||||
# Enhanced blog-focused template helpers
|
||||
def get_recent_posts(self, limit: int = 5, folder: str = ""):
|
||||
"""
|
||||
Get recent blog posts using focused blog analyzer
|
||||
Grug-approved: simple delegation to specialized tool
|
||||
"""
|
||||
# Check cache first
|
||||
cache_key = get_posts_cache_key(str(self.config.content_dir), limit, folder)
|
||||
|
||||
search_path = self.config.content_dir / folder
|
||||
if not search_path.exists():
|
||||
return []
|
||||
|
||||
# Get the latest modification time for cache invalidation
|
||||
latest_mtime = 0
|
||||
for file_path in search_path.rglob("*.md"):
|
||||
if not file_path.name.startswith("___"):
|
||||
try:
|
||||
mtime = file_path.stat().st_mtime
|
||||
latest_mtime = max(latest_mtime, mtime)
|
||||
except:
|
||||
continue
|
||||
|
||||
cache_key_with_mtime = f"{cache_key}:{latest_mtime}"
|
||||
cached_result = app_cache.get(cache_key_with_mtime)
|
||||
if cached_result is not None:
|
||||
return cached_result
|
||||
|
||||
# Use focused blog analyzer - much simpler!
|
||||
posts = self.blog_analyzer.find_posts_in_directory(search_path, recursive=True)
|
||||
|
||||
# Convert to template-friendly format and add URLs
|
||||
result = []
|
||||
for post in posts[:limit]:
|
||||
result.append({
|
||||
'title': post['title'],
|
||||
'date': post['date'],
|
||||
'path': post['path'],
|
||||
'metadata': {'tags': post['tags'], 'description': post['description']},
|
||||
'url': f"/{post['path']}"
|
||||
})
|
||||
|
||||
# Cache the result (cache for 10 minutes)
|
||||
app_cache.set(cache_key_with_mtime, result, 600)
|
||||
app_cache.delete(cache_key) # Clean up old entries
|
||||
|
||||
return result
|
||||
|
||||
def get_posts_by_tag(self, tag: str, limit: int = 10):
|
||||
"""
|
||||
Get posts filtered by tag - simple and useful
|
||||
"""
|
||||
posts = []
|
||||
|
||||
for file_path in self.config.content_dir.rglob("*.md"):
|
||||
if file_path.name.startswith("___"):
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
if content.startswith('---'):
|
||||
try:
|
||||
import frontmatter
|
||||
post = frontmatter.loads(content)
|
||||
tags = post.metadata.get('tags', [])
|
||||
|
||||
if tag.lower() in [t.lower() for t in tags]:
|
||||
title = post.metadata.get('title', file_path.stem)
|
||||
date = post.metadata.get('date', '')
|
||||
rel_path = str(file_path.relative_to(self.config.content_dir))
|
||||
|
||||
posts.append({
|
||||
'title': title,
|
||||
'date': date,
|
||||
'path': rel_path,
|
||||
'metadata': post.metadata,
|
||||
'url': f"/{rel_path}",
|
||||
'tags': tags
|
||||
})
|
||||
except:
|
||||
continue
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Sort by date (newest first)
|
||||
posts.sort(key=lambda x: x.get('date', ''), reverse=True)
|
||||
return posts[:limit]
|
||||
|
||||
def get_photo_albums(self):
|
||||
"""
|
||||
Get photo albums using focused gallery analyzer
|
||||
Grug-approved: simple delegation to specialized tool
|
||||
"""
|
||||
galleries = self.gallery_analyzer.find_galleries(self.config.content_dir)
|
||||
|
||||
# Convert to template-friendly format
|
||||
albums = []
|
||||
for gallery in galleries:
|
||||
albums.append({
|
||||
'name': gallery['name'],
|
||||
'path': gallery['relative_path'],
|
||||
'url': f"/{gallery['relative_path']}",
|
||||
'image_count': gallery['image_count'],
|
||||
'total_files': gallery['total_files']
|
||||
})
|
||||
|
||||
return albums
|
||||
|
||||
def get_navigation_items(self, max_items: int = 10):
|
||||
"""
|
||||
Get customizable navigation items - simple and practical
|
||||
"""
|
||||
nav_items = []
|
||||
|
||||
# Get top-level markdown files (excluding index)
|
||||
for file_path in self.config.content_dir.glob("*.md"):
|
||||
if file_path.name.startswith("___") or file_path.stem == "index":
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
title = file_path.stem
|
||||
if content.startswith('---'):
|
||||
try:
|
||||
import frontmatter
|
||||
post = frontmatter.loads(content)
|
||||
title = post.metadata.get('title', title)
|
||||
# Skip if marked as hidden in navigation
|
||||
if post.metadata.get('nav_hidden', False):
|
||||
continue
|
||||
except:
|
||||
pass
|
||||
|
||||
rel_path = str(file_path.relative_to(self.config.content_dir))
|
||||
nav_items.append({
|
||||
'title': title,
|
||||
'url': f"/{rel_path}",
|
||||
'path': rel_path
|
||||
})
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Get top-level folders
|
||||
for folder_path in self.config.content_dir.glob("*"):
|
||||
if not folder_path.is_dir() or folder_path.name.startswith("___"):
|
||||
continue
|
||||
|
||||
nav_items.append({
|
||||
'title': folder_path.name.replace('-', ' ').title(),
|
||||
'url': f"/{folder_path.name}",
|
||||
'path': folder_path.name,
|
||||
'is_folder': True
|
||||
})
|
||||
|
||||
return sorted(nav_items, key=lambda x: x['title'])[:max_items]
|
||||
|
||||
def generate_breadcrumbs(self, current_path: str):
|
||||
"""
|
||||
Generate breadcrumb navigation - simple and useful
|
||||
"""
|
||||
if not current_path:
|
||||
return []
|
||||
|
||||
parts = current_path.split('/')
|
||||
breadcrumbs = [{'title': 'Home', 'url': '/', 'is_current': False}]
|
||||
|
||||
for i, part in enumerate(parts):
|
||||
if not part:
|
||||
continue
|
||||
|
||||
path = '/'.join(parts[:i+1])
|
||||
is_current = (i == len(parts) - 1)
|
||||
|
||||
# Try to get a better title from file metadata
|
||||
title = part.replace('-', ' ').replace('_', ' ').title()
|
||||
|
||||
if part.endswith('.md'):
|
||||
file_path = self.config.content_dir / path
|
||||
if file_path.exists():
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
if content.startswith('---'):
|
||||
import frontmatter
|
||||
post = frontmatter.loads(content)
|
||||
title = post.metadata.get('title', title)
|
||||
except:
|
||||
pass
|
||||
|
||||
breadcrumbs.append({
|
||||
'title': title,
|
||||
'url': f"/{path}",
|
||||
'is_current': is_current
|
||||
})
|
||||
|
||||
return breadcrumbs
|
||||
|
||||
def get_related_posts(self, current_post_path: str, limit: int = 3):
|
||||
"""
|
||||
Get related posts based on tags and categories - simple similarity
|
||||
"""
|
||||
current_file = self.config.content_dir / current_post_path
|
||||
if not current_file.exists():
|
||||
return []
|
||||
|
||||
# Get current post tags
|
||||
current_tags = []
|
||||
try:
|
||||
with open(current_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
if content.startswith('---'):
|
||||
import frontmatter
|
||||
post = frontmatter.loads(content)
|
||||
current_tags = [tag.lower() for tag in post.metadata.get('tags', [])]
|
||||
except:
|
||||
return []
|
||||
|
||||
if not current_tags:
|
||||
return []
|
||||
|
||||
# Find posts with matching tags
|
||||
related_posts = []
|
||||
for file_path in self.config.content_dir.rglob("*.md"):
|
||||
if file_path == current_file or file_path.name.startswith("___"):
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
if content.startswith('---'):
|
||||
import frontmatter
|
||||
post = frontmatter.loads(content)
|
||||
post_tags = [tag.lower() for tag in post.metadata.get('tags', [])]
|
||||
|
||||
# Calculate tag overlap
|
||||
overlap = len(set(current_tags) & set(post_tags))
|
||||
if overlap > 0:
|
||||
title = post.metadata.get('title', file_path.stem)
|
||||
date = post.metadata.get('date', '')
|
||||
rel_path = str(file_path.relative_to(self.config.content_dir))
|
||||
|
||||
related_posts.append({
|
||||
'title': title,
|
||||
'date': date,
|
||||
'path': rel_path,
|
||||
'url': f"/{rel_path}",
|
||||
'overlap_score': overlap,
|
||||
'tags': post_tags
|
||||
})
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Sort by overlap score and date
|
||||
related_posts.sort(key=lambda x: (x['overlap_score'], x.get('date', '')), reverse=True)
|
||||
return related_posts[:limit]
|
||||
|
||||
def get_all_tags(self):
|
||||
"""
|
||||
Get all tags used across the site with post counts
|
||||
"""
|
||||
tag_counts = {}
|
||||
|
||||
for file_path in self.config.content_dir.rglob("*.md"):
|
||||
if file_path.name.startswith("___"):
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
if content.startswith('---'):
|
||||
import frontmatter
|
||||
post = frontmatter.loads(content)
|
||||
tags = post.metadata.get('tags', [])
|
||||
|
||||
for tag in tags:
|
||||
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Convert to list of dicts and sort by count
|
||||
tag_list = [{'name': tag, 'count': count} for tag, count in tag_counts.items()]
|
||||
tag_list.sort(key=lambda x: x['count'], reverse=True)
|
||||
|
||||
return tag_list
|
||||
|
||||
def get_rendered_markdown(self, path: str):
|
||||
"""
|
||||
Get rendered markdown content without Jinja2 templating.
|
||||
Perfect for displaying markdown files (like index.md) within folder views.
|
||||
|
||||
Args:
|
||||
path (str): Relative path to the markdown file within the content directory
|
||||
|
||||
Returns:
|
||||
dict: Dictionary with 'html' (rendered content), 'metadata' (frontmatter),
|
||||
and 'exists' (bool) keys. Returns None values if file doesn't exist.
|
||||
|
||||
Example:
|
||||
{% set index = get_rendered_markdown(currentPath + '/index.md') %}
|
||||
{% if index.exists %}
|
||||
<div class="index-content">
|
||||
{{ index.html | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
"""
|
||||
file_path = self.config.content_dir / path
|
||||
|
||||
# Return empty result if file doesn't exist
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
return {
|
||||
'html': None,
|
||||
'metadata': None,
|
||||
'exists': False
|
||||
}
|
||||
|
||||
try:
|
||||
# Use the existing render_markdown function which handles frontmatter
|
||||
html_content, metadata, _ = render_markdown(file_path)
|
||||
|
||||
return {
|
||||
'html': html_content,
|
||||
'metadata': metadata,
|
||||
'exists': True
|
||||
}
|
||||
except Exception as e:
|
||||
# Return error state if rendering fails
|
||||
return {
|
||||
'html': f'<p class="error">Error rendering markdown: {str(e)}</p>',
|
||||
'metadata': None,
|
||||
'exists': False
|
||||
}
|
||||
|
||||
def get_markdown_metadata(self, path: str):
|
||||
"""
|
||||
Get metadata (frontmatter) from a markdown file without rendering the content.
|
||||
Perfect for displaying static markdown metadata in any location.
|
||||
|
||||
Args:
|
||||
path (str): Relative path to the markdown file within the content directory
|
||||
|
||||
Returns:
|
||||
dict: Dictionary with 'metadata' (frontmatter dict) and 'exists' (bool) keys.
|
||||
Returns None for metadata if file doesn't exist or has no frontmatter.
|
||||
|
||||
Example:
|
||||
{% set post_meta = get_markdown_metadata('blog/my-post.md') %}
|
||||
{% if post_meta.exists %}
|
||||
<h2>{{ post_meta.metadata.title }}</h2>
|
||||
<p>{{ post_meta.metadata.description }}</p>
|
||||
<span>Tags: {{ post_meta.metadata.tags | join(', ') }}</span>
|
||||
{% endif %}
|
||||
"""
|
||||
file_path = self.config.content_dir / path
|
||||
|
||||
# Return empty result if file doesn't exist
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
return {
|
||||
'metadata': None,
|
||||
'exists': False
|
||||
}
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check if file has frontmatter
|
||||
if content.startswith('---'):
|
||||
post = frontmatter.loads(content)
|
||||
return {
|
||||
'metadata': post.metadata,
|
||||
'exists': True
|
||||
}
|
||||
else:
|
||||
# File exists but has no frontmatter
|
||||
return {
|
||||
'metadata': {},
|
||||
'exists': True
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# Return error state if reading fails
|
||||
return {
|
||||
'metadata': None,
|
||||
'exists': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
@ -1,12 +1,9 @@
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
from functools import cache
|
||||
|
||||
@cache
|
||||
def generate_thumbnail(image_path, resize_percent, min_width):
|
||||
# Generate a unique key based on the image path, resize percentage, and minimum width
|
||||
key = f"{image_path}_{resize_percent}_{min_width}"
|
||||
from functools import lru_cache
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def generate_thumbnail(image_path, resize_percent, min_width, max_width):
|
||||
# Open the image file
|
||||
with Image.open(image_path) as img:
|
||||
# Calculate the new size based on the resize percentage
|
||||
@ -20,13 +17,20 @@ def generate_thumbnail(image_path, resize_percent, min_width):
|
||||
new_width = min_width
|
||||
new_height = int(new_height * scale_factor)
|
||||
|
||||
# Ensure the maximum width is not exceeded
|
||||
if new_width > max_width:
|
||||
scale_factor = max_width / new_width
|
||||
new_width = max_width
|
||||
new_height = int(new_height * scale_factor)
|
||||
|
||||
# Resize the image while maintaining the aspect ratio
|
||||
img.thumbnail((new_width, new_height))
|
||||
|
||||
# Rotate the image based on the EXIF orientation tag
|
||||
try:
|
||||
exif = img._getexif()
|
||||
orientation = exif.get(0x0112, 1) # 0x0112 is the EXIF orientation tag
|
||||
exif = img.info['exif']
|
||||
orientation = img._getexif().get(0x0112, 1) # 0x0112 is the EXIF orientation tag
|
||||
print(f"EXIF orientation: {orientation}, {image_path}")
|
||||
if orientation == 3:
|
||||
img = img.rotate(180, expand=True)
|
||||
elif orientation == 6:
|
||||
@ -35,12 +39,12 @@ def generate_thumbnail(image_path, resize_percent, min_width):
|
||||
img = img.rotate(90, expand=True)
|
||||
except (AttributeError, KeyError, IndexError):
|
||||
# cases: image don't have getexif
|
||||
pass
|
||||
exif = b""
|
||||
|
||||
# Save the thumbnail to a BytesIO object
|
||||
thumbnail_io = BytesIO()
|
||||
img_format = img.format if img.format in ["JPEG", "JPG", "PNG"] else "JPEG"
|
||||
img.save(thumbnail_io, format=img_format)
|
||||
img.save(thumbnail_io, format=img_format, exif=exif)
|
||||
thumbnail_io.seek(0)
|
||||
|
||||
return (thumbnail_io.getvalue(), img_format)
|
||||
290
src/rendering/metadata_builders.py
Normal file
290
src/rendering/metadata_builders.py
Normal file
@ -0,0 +1,290 @@
|
||||
"""
|
||||
Focused metadata builders - following grug principles
|
||||
Split the complex metadata building logic into focused, single-purpose classes
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from PIL import Image, ExifTags
|
||||
import frontmatter
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
from src.rendering.markdown import render_markdown, read_raw_markdown, rendered_markdown_to_plain_text
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageMetadata:
|
||||
width: int
|
||||
height: int
|
||||
alt: str
|
||||
exif: dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarkdownMetadata:
|
||||
frontmatter: dict
|
||||
content: str
|
||||
preview: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileMetadata:
|
||||
typeMeta: MarkdownMetadata | None
|
||||
|
||||
|
||||
class ImageMetadataBuilder:
|
||||
"""
|
||||
Simple image metadata builder - does one thing well
|
||||
Grug-approved: focused, debuggable, no complexity demons
|
||||
"""
|
||||
|
||||
def build_metadata(self, file_path: Path) -> Optional[ImageMetadata]:
|
||||
"""Build metadata for an image file"""
|
||||
try:
|
||||
with Image.open(file_path) as img:
|
||||
width, height = img.width, img.height
|
||||
exif_raw = img._getexif()
|
||||
|
||||
exif = {}
|
||||
if exif_raw:
|
||||
# Handle orientation for correct width/height
|
||||
orientation = exif_raw.get(0x0112, 1)
|
||||
if orientation in [5, 6, 7, 8]:
|
||||
width, height = height, width
|
||||
|
||||
# Convert EXIF tags to readable names
|
||||
exif = {
|
||||
ExifTags.TAGS[k]: v
|
||||
for k, v in exif_raw.items()
|
||||
if k in ExifTags.TAGS
|
||||
}
|
||||
|
||||
return ImageMetadata(
|
||||
width=width,
|
||||
height=height,
|
||||
alt=file_path.name,
|
||||
exif=exif,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing image {file_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class MarkdownMetadataBuilder:
|
||||
"""
|
||||
Simple markdown metadata builder - focused on documents
|
||||
Grug-approved: one job, do it well
|
||||
"""
|
||||
|
||||
def build_metadata(self, file_path: Path) -> Optional[MarkdownMetadata]:
|
||||
"""Build metadata for a markdown file"""
|
||||
if not file_path.suffix.lower() == '.md':
|
||||
return None
|
||||
|
||||
try:
|
||||
# Use existing markdown rendering functions
|
||||
content, c_frontmatter, obj = render_markdown(file_path)
|
||||
raw_content = read_raw_markdown(file_path)
|
||||
raw_text = rendered_markdown_to_plain_text(content)
|
||||
preview = raw_text[:500] + "..." if len(raw_text) > 500 else raw_text
|
||||
|
||||
return MarkdownMetadata(
|
||||
frontmatter=c_frontmatter or {},
|
||||
content=content,
|
||||
preview=preview
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing markdown {file_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class DocumentMetadataBuilder:
|
||||
"""
|
||||
Simple document metadata builder - handles various document types
|
||||
Currently focused on markdown but can be extended
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.markdown_builder = MarkdownMetadataBuilder()
|
||||
|
||||
def build_metadata(self, file_path: Path) -> Optional[FileMetadata]:
|
||||
"""Build metadata for any document file"""
|
||||
try:
|
||||
# Check if file exists and is readable
|
||||
with open(file_path, "r", encoding='utf-8') as f:
|
||||
# Just verify we can read it
|
||||
pass
|
||||
|
||||
metadata = FileMetadata(typeMeta=None)
|
||||
|
||||
# Handle markdown files specifically
|
||||
if file_path.suffix.lower() == '.md':
|
||||
markdown_meta = self.markdown_builder.build_metadata(file_path)
|
||||
metadata.typeMeta = markdown_meta
|
||||
|
||||
return metadata
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing document {file_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class MetadataBuilderFactory:
|
||||
"""
|
||||
Simple factory for metadata builders - no complexity demons
|
||||
Grug-approved: clear, simple, does what it says
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.image_builder = ImageMetadataBuilder()
|
||||
self.document_builder = DocumentMetadataBuilder()
|
||||
|
||||
def build_metadata(self, file_path: Path, categories: List[str]) -> Optional[Any]:
|
||||
"""
|
||||
Build appropriate metadata based on file categories
|
||||
Simple dispatch to focused builders
|
||||
"""
|
||||
for category in categories:
|
||||
if category == "image":
|
||||
return self.image_builder.build_metadata(file_path)
|
||||
elif category == "document":
|
||||
return self.document_builder.build_metadata(file_path)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class BlogPostAnalyzer:
|
||||
"""
|
||||
Focused analyzer for blog posts - separate from general metadata
|
||||
Grug-approved: specific job, clear purpose
|
||||
"""
|
||||
|
||||
def extract_post_info(self, file_path: Path) -> Dict[str, Any]:
|
||||
"""Extract blog-specific information from a markdown file"""
|
||||
if not file_path.suffix.lower() == '.md':
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Extract frontmatter if present
|
||||
post_info = {
|
||||
'title': file_path.stem,
|
||||
'date': '',
|
||||
'tags': [],
|
||||
'description': '',
|
||||
'has_frontmatter': False
|
||||
}
|
||||
|
||||
if content.startswith('---'):
|
||||
try:
|
||||
post = frontmatter.loads(content)
|
||||
post_info.update({
|
||||
'title': post.metadata.get('title', post_info['title']),
|
||||
'date': post.metadata.get('date', ''),
|
||||
'tags': post.metadata.get('tags', []),
|
||||
'description': post.metadata.get('description', ''),
|
||||
'has_frontmatter': True
|
||||
})
|
||||
except Exception:
|
||||
pass # If frontmatter parsing fails, use defaults
|
||||
|
||||
# Use file modification time as fallback date
|
||||
if not post_info['date']:
|
||||
stat = file_path.stat()
|
||||
post_info['date'] = datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d')
|
||||
|
||||
# Ensure date is always a string for consistent sorting
|
||||
if hasattr(post_info['date'], 'strftime'):
|
||||
post_info['date'] = post_info['date'].strftime('%Y-%m-%d')
|
||||
|
||||
return post_info
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error analyzing blog post {file_path}: {e}")
|
||||
return {}
|
||||
|
||||
def find_posts_in_directory(self, directory: Path, recursive: bool = True) -> List[Dict[str, Any]]:
|
||||
"""Find all blog posts in a directory with their metadata"""
|
||||
posts = []
|
||||
|
||||
search_pattern = "**/*.md" if recursive else "*.md"
|
||||
for file_path in directory.glob(search_pattern):
|
||||
if file_path.name.startswith("___"):
|
||||
continue
|
||||
|
||||
post_info = self.extract_post_info(file_path)
|
||||
if post_info:
|
||||
# Add path information
|
||||
post_info['path'] = str(file_path.relative_to(directory.parent))
|
||||
post_info['filename'] = file_path.name
|
||||
posts.append(post_info)
|
||||
|
||||
# Sort by date (newest first)
|
||||
posts.sort(key=lambda x: x.get('date', ''), reverse=True)
|
||||
return posts
|
||||
|
||||
|
||||
class ImageGalleryAnalyzer:
|
||||
"""
|
||||
Focused analyzer for image galleries - separate concern
|
||||
Grug-approved: one job, clear boundaries
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.image_builder = ImageMetadataBuilder()
|
||||
|
||||
def analyze_directory(self, directory: Path) -> Dict[str, Any]:
|
||||
"""Analyze a directory for image gallery potential"""
|
||||
if not directory.is_dir():
|
||||
return {}
|
||||
|
||||
files = list(directory.iterdir())
|
||||
if not files:
|
||||
return {}
|
||||
|
||||
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.svg'}
|
||||
image_files = [f for f in files if f.suffix.lower() in image_extensions]
|
||||
|
||||
# Determine if this is a photo album (>50% images, at least 3 images)
|
||||
is_gallery = len(image_files) >= 3 and len(image_files) / len(files) > 0.5
|
||||
|
||||
gallery_info = {
|
||||
'name': directory.name,
|
||||
'is_gallery': is_gallery,
|
||||
'image_count': len(image_files),
|
||||
'total_files': len(files),
|
||||
'images': []
|
||||
}
|
||||
|
||||
# Get metadata for each image if it's a gallery
|
||||
if is_gallery:
|
||||
for image_file in image_files:
|
||||
image_meta = self.image_builder.build_metadata(image_file)
|
||||
if image_meta:
|
||||
gallery_info['images'].append({
|
||||
'filename': image_file.name,
|
||||
'path': str(image_file),
|
||||
'metadata': image_meta
|
||||
})
|
||||
|
||||
return gallery_info
|
||||
|
||||
def find_galleries(self, root_directory: Path) -> List[Dict[str, Any]]:
|
||||
"""Find all potential galleries in directory tree"""
|
||||
galleries = []
|
||||
|
||||
for item in root_directory.rglob("*"):
|
||||
if item.is_dir() and not item.name.startswith("___"):
|
||||
gallery_info = self.analyze_directory(item)
|
||||
if gallery_info.get('is_gallery', False):
|
||||
# Add relative path information
|
||||
gallery_info['relative_path'] = str(item.relative_to(root_directory))
|
||||
galleries.append(gallery_info)
|
||||
|
||||
return sorted(galleries, key=lambda x: x['name'])
|
||||
174
src/rendering/performance_monitor.py
Normal file
174
src/rendering/performance_monitor.py
Normal file
@ -0,0 +1,174 @@
|
||||
"""
|
||||
Performance monitoring for template rendering - grug-approved
|
||||
Simple tracking of rendering times to identify slow pages
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
from dataclasses import dataclass
|
||||
from threading import Lock
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderStats:
|
||||
path: str
|
||||
render_time: float
|
||||
cache_hit: bool
|
||||
template_used: Optional[str]
|
||||
timestamp: float
|
||||
|
||||
|
||||
class PerformanceMonitor:
|
||||
"""
|
||||
Simple performance monitor - tracks template rendering times
|
||||
Grug-approved: no complexity, just useful info when things slow
|
||||
"""
|
||||
|
||||
def __init__(self, max_entries: int = 100):
|
||||
self.max_entries = max_entries
|
||||
self.render_stats: List[RenderStats] = []
|
||||
self.lock = Lock()
|
||||
|
||||
def record_render(self, path: str, render_time: float, cache_hit: bool = False, template_used: str = None):
|
||||
"""Record a template render operation"""
|
||||
with self.lock:
|
||||
stat = RenderStats(
|
||||
path=path,
|
||||
render_time=render_time,
|
||||
cache_hit=cache_hit,
|
||||
template_used=template_used,
|
||||
timestamp=time.time()
|
||||
)
|
||||
|
||||
self.render_stats.append(stat)
|
||||
|
||||
# Keep only recent entries
|
||||
if len(self.render_stats) > self.max_entries:
|
||||
self.render_stats = self.render_stats[-self.max_entries:]
|
||||
|
||||
def get_slow_pages(self, threshold: float = 0.1, limit: int = 10) -> List[RenderStats]:
|
||||
"""Get pages that render slowly (above threshold in seconds)"""
|
||||
with self.lock:
|
||||
slow_pages = [
|
||||
stat for stat in self.render_stats
|
||||
if stat.render_time > threshold and not stat.cache_hit
|
||||
]
|
||||
|
||||
# Sort by render time, slowest first
|
||||
slow_pages.sort(key=lambda x: x.render_time, reverse=True)
|
||||
return slow_pages[:limit]
|
||||
|
||||
def get_cache_efficiency(self) -> Dict[str, float]:
|
||||
"""Get cache hit rate statistics"""
|
||||
with self.lock:
|
||||
if not self.render_stats:
|
||||
return {'hit_rate': 0.0, 'total_requests': 0, 'cache_hits': 0}
|
||||
|
||||
total = len(self.render_stats)
|
||||
cache_hits = sum(1 for stat in self.render_stats if stat.cache_hit)
|
||||
|
||||
return {
|
||||
'hit_rate': (cache_hits / total) * 100 if total > 0 else 0.0,
|
||||
'total_requests': total,
|
||||
'cache_hits': cache_hits
|
||||
}
|
||||
|
||||
def get_average_render_times(self) -> Dict[str, float]:
|
||||
"""Get average render times by path"""
|
||||
with self.lock:
|
||||
path_times = {}
|
||||
path_counts = {}
|
||||
|
||||
for stat in self.render_stats:
|
||||
if not stat.cache_hit: # Only count actual renders, not cache hits
|
||||
if stat.path not in path_times:
|
||||
path_times[stat.path] = 0.0
|
||||
path_counts[stat.path] = 0
|
||||
|
||||
path_times[stat.path] += stat.render_time
|
||||
path_counts[stat.path] += 1
|
||||
|
||||
# Calculate averages
|
||||
averages = {}
|
||||
for path in path_times:
|
||||
if path_counts[path] > 0:
|
||||
averages[path] = path_times[path] / path_counts[path]
|
||||
|
||||
return dict(sorted(averages.items(), key=lambda x: x[1], reverse=True))
|
||||
|
||||
def get_recent_activity(self, limit: int = 20) -> List[RenderStats]:
|
||||
"""Get recent rendering activity"""
|
||||
with self.lock:
|
||||
# Return most recent entries
|
||||
return list(reversed(self.render_stats[-limit:]))
|
||||
|
||||
def get_performance_summary(self) -> Dict:
|
||||
"""Get comprehensive performance summary"""
|
||||
cache_stats = self.get_cache_efficiency()
|
||||
slow_pages = self.get_slow_pages()
|
||||
avg_times = self.get_average_render_times()
|
||||
|
||||
with self.lock:
|
||||
if not self.render_stats:
|
||||
return {
|
||||
'total_renders': 0,
|
||||
'overall_average_time': 0.0,
|
||||
'cache_efficiency': cache_stats,
|
||||
'slow_pages': [],
|
||||
'average_times': {},
|
||||
'recent_activity': []
|
||||
}
|
||||
|
||||
# Calculate overall statistics
|
||||
non_cached_renders = [s for s in self.render_stats if not s.cache_hit]
|
||||
overall_avg = sum(s.render_time for s in non_cached_renders) / len(non_cached_renders) if non_cached_renders else 0
|
||||
|
||||
return {
|
||||
'total_renders': len(self.render_stats),
|
||||
'overall_average_time': overall_avg,
|
||||
'cache_efficiency': cache_stats,
|
||||
'slow_pages': slow_pages[:5], # Top 5 slowest
|
||||
'average_times': dict(list(avg_times.items())[:10]), # Top 10 by avg time
|
||||
'recent_activity': self.get_recent_activity(10)
|
||||
}
|
||||
|
||||
def clear_stats(self):
|
||||
"""Clear all performance statistics"""
|
||||
with self.lock:
|
||||
self.render_stats.clear()
|
||||
|
||||
|
||||
# Global performance monitor instance
|
||||
performance_monitor = PerformanceMonitor()
|
||||
|
||||
|
||||
def get_performance_monitor():
|
||||
"""Get the global performance monitor instance"""
|
||||
return performance_monitor
|
||||
|
||||
|
||||
class RenderTimer:
|
||||
"""
|
||||
Context manager for timing template renders
|
||||
Grug-approved: simple, automatic timing
|
||||
"""
|
||||
|
||||
def __init__(self, path: str, template_used: str = None, cache_hit: bool = False):
|
||||
self.path = path
|
||||
self.template_used = template_used
|
||||
self.cache_hit = cache_hit
|
||||
self.start_time = None
|
||||
|
||||
def __enter__(self):
|
||||
self.start_time = time.time()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if self.start_time:
|
||||
render_time = time.time() - self.start_time
|
||||
performance_monitor.record_render(
|
||||
self.path,
|
||||
render_time,
|
||||
self.cache_hit,
|
||||
self.template_used
|
||||
)
|
||||
@ -5,6 +5,9 @@ from flask import render_template_string, send_file
|
||||
|
||||
from src.rendering import GENERIC_FILE_MAPPING
|
||||
from src.rendering.markdown import render_markdown
|
||||
from src.rendering.template_discovery import TemplateDiscovery
|
||||
from src.rendering.debug_helpers import get_debug_helper
|
||||
from src.rendering.performance_monitor import RenderTimer
|
||||
|
||||
|
||||
def count_file_extensions(path):
|
||||
@ -126,6 +129,7 @@ def render_page(
|
||||
error_description="The requested resource was not found on this server.",
|
||||
template_path=template_path,
|
||||
)
|
||||
|
||||
target_path = path
|
||||
target_file = path
|
||||
if path.is_file():
|
||||
@ -135,63 +139,26 @@ def render_page(
|
||||
relative_path = target_file.relative_to(base_path)
|
||||
relative_dir = target_path.relative_to(base_path)
|
||||
|
||||
"""
|
||||
The styles are ordered in the following manner:
|
||||
# Use new simplified template and style discovery system
|
||||
template_discovery = TemplateDiscovery(template_path)
|
||||
|
||||
Specific style for the target path (e.g., /path/to/target.css).
|
||||
Specific styles for the type and extension in the current and parent directories
|
||||
(e.g., /path/to/__file.html.css).
|
||||
Specific styles for the type and category in the current and parent directories
|
||||
(e.g., /path/to/__file.document.css).
|
||||
Base style (/base.css).
|
||||
This ordering ensures that the most specific styles are applied first, followed by
|
||||
more general styles, and finally the base style.
|
||||
"""
|
||||
styles = []
|
||||
styles.append("/" + str(relative_path) + ".css")
|
||||
# Find styles using simplified discovery
|
||||
style_candidates = template_discovery.find_style_candidates(relative_path, type, category, extension)
|
||||
styles = [s for s in style_candidates if (style_path / s[1:]).exists()]
|
||||
|
||||
search_path = style_path / relative_dir
|
||||
while search_path >= style_path:
|
||||
if (search_path / f"__{type}.{extension}.css").exists():
|
||||
styles.append(
|
||||
"/"
|
||||
+ str(search_path.relative_to(style_path))
|
||||
+ f"/__{type}.{extension}.css"
|
||||
)
|
||||
for c in reversed(category):
|
||||
if (search_path / f"__{type}.{c}.css").exists():
|
||||
styles.append(
|
||||
"/"
|
||||
+ str(search_path.relative_to(style_path))
|
||||
+ f"/__{type}.{c}.css"
|
||||
)
|
||||
search_path = search_path.parent
|
||||
# Find template using simplified discovery
|
||||
found_template = template_discovery.find_template(relative_path, type, category, extension)
|
||||
|
||||
styles.append("/base.css")
|
||||
# Debug logging for developers
|
||||
debug_helper = get_debug_helper()
|
||||
if debug_helper:
|
||||
debug_helper.log_template_search(
|
||||
str(relative_path),
|
||||
str(found_template) if found_template else None,
|
||||
template_discovery.get_last_search_candidates()
|
||||
)
|
||||
|
||||
styles = [t for t in styles if (style_path / t[1:]).exists()]
|
||||
|
||||
templates = []
|
||||
if type == "folder":
|
||||
if (template_path / relative_dir / "__folder.html").exists():
|
||||
templates.append(relative_dir / "__folder.html")
|
||||
else:
|
||||
if (template_path / (str(relative_path) + ".html")).exists():
|
||||
templates.append(template_path / (str(relative_path) + ".html"))
|
||||
|
||||
if len(templates) == 0:
|
||||
search_path = template_path / relative_dir
|
||||
while search_path >= template_path:
|
||||
if (search_path / f"__{type}.{extension}.html").exists():
|
||||
templates.append(search_path / f"__{type}.{extension}.html")
|
||||
break
|
||||
for c in reversed(category):
|
||||
if (search_path / f"__{type}.{c}.html").exists():
|
||||
templates.append(search_path / f"__{type}.{c}.html")
|
||||
break
|
||||
search_path = search_path.parent
|
||||
|
||||
if len(templates) == 0:
|
||||
if found_template is None:
|
||||
if type == "file":
|
||||
return send_file(target_file)
|
||||
else:
|
||||
@ -203,22 +170,32 @@ def render_page(
|
||||
)
|
||||
|
||||
content = ""
|
||||
c_frontmatter = None
|
||||
if "document" in category and type == "file":
|
||||
content, c_frontmatter, obj = render_markdown(target_file)
|
||||
|
||||
if not (template_path / "base.html").exists():
|
||||
raise Exception("Base template not found")
|
||||
|
||||
templates.append(template_path / "base.html")
|
||||
|
||||
# Filter templates to only those that exist
|
||||
for template in templates:
|
||||
content = render_template_string(
|
||||
template.read_text(),
|
||||
content=content,
|
||||
styles=styles,
|
||||
currentPath=str(relative_path),
|
||||
metadata=c_frontmatter if "document" in category and type == "file" else None,
|
||||
)
|
||||
# Use the found template from our simplified discovery system
|
||||
page_template_path = found_template
|
||||
|
||||
return content
|
||||
template_vars = {
|
||||
"content": content,
|
||||
"styles": styles,
|
||||
"currentPath": str(relative_path),
|
||||
"metadata": c_frontmatter if "document" in category and type == "file" else None,
|
||||
}
|
||||
|
||||
# First, render the specific page template.
|
||||
final_content = render_template_string(
|
||||
page_template_path.read_text(), **template_vars
|
||||
)
|
||||
|
||||
# Now, render the base template, providing the result of the page
|
||||
# template as the 'content' variable.
|
||||
template_vars["content"] = final_content
|
||||
return render_template_string(
|
||||
(template_path / "base.html").read_text(), **template_vars
|
||||
)
|
||||
|
||||
221
src/rendering/template_discovery.py
Normal file
221
src/rendering/template_discovery.py
Normal file
@ -0,0 +1,221 @@
|
||||
"""
|
||||
Smart Template Discovery System - Grug-approved simplification
|
||||
Replaces the complex nested template discovery with simple, debuggable logic
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class TemplateDiscovery:
|
||||
"""
|
||||
Simple template discovery that maintains all current functionality
|
||||
Following grug principles: clear priority order, easy to debug
|
||||
"""
|
||||
|
||||
def __init__(self, template_root: Path):
|
||||
self.template_root = Path(template_root)
|
||||
self._last_search_candidates = []
|
||||
|
||||
def find_template(self, content_path: Path, file_type: str, categories: List[str], extension: str) -> Optional[Path]:
|
||||
"""
|
||||
Find the best template for the given content
|
||||
Uses simple priority list instead of complex nested loops
|
||||
"""
|
||||
# Build candidate template names in priority order
|
||||
candidates = self._build_candidate_list(content_path, file_type, categories, extension)
|
||||
self._last_search_candidates = candidates.copy() # Store for debugging
|
||||
|
||||
# Search from current directory up to template root (preserve hierarchy)
|
||||
search_path = self.template_root / content_path.parent
|
||||
while search_path >= self.template_root:
|
||||
for candidate in candidates:
|
||||
template_path = search_path / candidate
|
||||
if template_path.exists():
|
||||
return template_path
|
||||
search_path = search_path.parent
|
||||
|
||||
return None
|
||||
|
||||
def get_last_search_candidates(self) -> List[str]:
|
||||
"""Get the candidates from the last template search - for debugging"""
|
||||
return self._last_search_candidates.copy()
|
||||
|
||||
def _build_candidate_list(self, content_path: Path, file_type: str, categories: List[str], extension: str) -> List[str]:
|
||||
"""
|
||||
Build list of template candidates in priority order
|
||||
This replaces the complex nested loop logic with simple, clear ordering
|
||||
"""
|
||||
candidates = []
|
||||
|
||||
# 1. Specific file template (highest priority)
|
||||
candidates.append(f"{content_path.stem}.html")
|
||||
|
||||
# 2. Type + extension templates
|
||||
if extension:
|
||||
candidates.append(f"__{file_type}.{extension}.html")
|
||||
|
||||
# 3. Type + category templates (preserve current category system)
|
||||
for category in reversed(categories): # Most specific categories first
|
||||
candidates.append(f"__{file_type}.{category}.html")
|
||||
|
||||
# 4. Generic type template
|
||||
candidates.append(f"__{file_type}.html")
|
||||
|
||||
# 5. Default template (for ultimate fallback)
|
||||
candidates.append("default.html")
|
||||
|
||||
return candidates
|
||||
|
||||
def find_style_candidates(self, content_path: Path, file_type: str, categories: List[str], extension: str) -> List[str]:
|
||||
"""
|
||||
Find CSS style candidates - similar logic to templates but for styles
|
||||
Returns list of potential style paths in priority order
|
||||
"""
|
||||
candidates = []
|
||||
|
||||
# 1. Specific style for the content path
|
||||
candidates.append(f"/{content_path}.css")
|
||||
|
||||
# 2. Type + extension styles in current and parent directories
|
||||
search_path = content_path.parent
|
||||
while True:
|
||||
if extension:
|
||||
candidates.append(f"/{search_path}/__{file_type}.{extension}.css")
|
||||
|
||||
# 3. Type + category styles
|
||||
for category in reversed(categories):
|
||||
candidates.append(f"/{search_path}/__{file_type}.{category}.css")
|
||||
|
||||
if search_path == Path('.'):
|
||||
break
|
||||
search_path = search_path.parent
|
||||
|
||||
# 4. Base style (always included)
|
||||
candidates.append("/base.css")
|
||||
|
||||
return candidates
|
||||
|
||||
def debug_template_discovery(self, content_path: Path, file_type: str, categories: List[str], extension: str) -> dict:
|
||||
"""
|
||||
Debug information for template discovery - helps with troubleshooting
|
||||
Grug-approved: when things break, need easy way to see what's happening
|
||||
"""
|
||||
candidates = self._build_candidate_list(content_path, file_type, categories, extension)
|
||||
|
||||
debug_info = {
|
||||
'content_path': str(content_path),
|
||||
'file_type': file_type,
|
||||
'categories': categories,
|
||||
'extension': extension,
|
||||
'candidates': candidates,
|
||||
'search_paths': [],
|
||||
'found_templates': [],
|
||||
'chosen_template': None
|
||||
}
|
||||
|
||||
# Check each search path
|
||||
search_path = self.template_root / content_path.parent
|
||||
while search_path >= self.template_root:
|
||||
debug_info['search_paths'].append(str(search_path))
|
||||
|
||||
for candidate in candidates:
|
||||
template_path = search_path / candidate
|
||||
if template_path.exists():
|
||||
debug_info['found_templates'].append(str(template_path))
|
||||
if debug_info['chosen_template'] is None:
|
||||
debug_info['chosen_template'] = str(template_path)
|
||||
|
||||
search_path = search_path.parent
|
||||
|
||||
return debug_info
|
||||
|
||||
|
||||
class LegacyTemplateDiscovery:
|
||||
"""
|
||||
Wrapper around the original complex template discovery for comparison
|
||||
Helps ensure we don't break anything during migration
|
||||
"""
|
||||
|
||||
def __init__(self, template_root: Path):
|
||||
self.template_root = Path(template_root)
|
||||
|
||||
def find_template_legacy(self, content_path: Path, file_type: str, categories: List[str], extension: str) -> Optional[Path]:
|
||||
"""
|
||||
Original complex template discovery logic (from renderer.py)
|
||||
Kept for comparison and gradual migration
|
||||
"""
|
||||
templates = []
|
||||
|
||||
# Check for folder template
|
||||
if file_type == "folder":
|
||||
folder_template = self.template_root / content_path / "__folder.html"
|
||||
if folder_template.exists():
|
||||
templates.append(folder_template)
|
||||
else:
|
||||
# Check for specific file template
|
||||
specific_template = self.template_root / f"{content_path}.html"
|
||||
if specific_template.exists():
|
||||
templates.append(specific_template)
|
||||
|
||||
# If no specific template found, search with complex nested logic
|
||||
if len(templates) == 0:
|
||||
search_path = self.template_root / content_path.parent
|
||||
while search_path >= self.template_root:
|
||||
# Check type + extension
|
||||
type_ext_template = search_path / f"__{file_type}.{extension}.html"
|
||||
if type_ext_template.exists():
|
||||
templates.append(type_ext_template)
|
||||
break
|
||||
|
||||
# Check type + categories
|
||||
for category in reversed(categories):
|
||||
type_cat_template = search_path / f"__{file_type}.{category}.html"
|
||||
if type_cat_template.exists():
|
||||
templates.append(type_cat_template)
|
||||
break
|
||||
|
||||
search_path = search_path.parent
|
||||
|
||||
return templates[0] if templates else None
|
||||
|
||||
|
||||
def migrate_template_discovery(old_discovery: LegacyTemplateDiscovery, new_discovery: TemplateDiscovery) -> dict:
|
||||
"""
|
||||
Migration helper to compare old vs new template discovery
|
||||
Helps ensure we don't break anything
|
||||
"""
|
||||
test_cases = [
|
||||
# Common test cases
|
||||
(Path("index.md"), "file", ["document"], "md"),
|
||||
(Path("posts/my-post.md"), "file", ["document"], "md"),
|
||||
(Path("images/gallery"), "folder", ["image"], None),
|
||||
(Path("about.md"), "file", ["document"], "md"),
|
||||
]
|
||||
|
||||
results = {
|
||||
'total_tests': len(test_cases),
|
||||
'matching': 0,
|
||||
'differences': [],
|
||||
'test_details': []
|
||||
}
|
||||
|
||||
for content_path, file_type, categories, extension in test_cases:
|
||||
old_result = old_discovery.find_template_legacy(content_path, file_type, categories, extension)
|
||||
new_result = new_discovery.find_template(content_path, file_type, categories, extension)
|
||||
|
||||
test_detail = {
|
||||
'content_path': str(content_path),
|
||||
'old_result': str(old_result) if old_result else None,
|
||||
'new_result': str(new_result) if new_result else None,
|
||||
'matches': old_result == new_result
|
||||
}
|
||||
|
||||
results['test_details'].append(test_detail)
|
||||
|
||||
if old_result == new_result:
|
||||
results['matching'] += 1
|
||||
else:
|
||||
results['differences'].append(test_detail)
|
||||
|
||||
return results
|
||||
@ -1,77 +1,111 @@
|
||||
from pathlib import Path
|
||||
from src.config.config import Configuration
|
||||
from src.rendering.renderer import render_page, render_error_page
|
||||
from flask import send_file
|
||||
from flask import send_file, request
|
||||
from src.rendering.image import generate_thumbnail
|
||||
from functools import lru_cache
|
||||
import os
|
||||
|
||||
|
||||
class RouteManager:
|
||||
"""
|
||||
RouteManager is responsible for handling and validating file system paths for serving content, styles, and static files in a web application. It ensures that all requested paths are securely resolved within configured base directories, prevents path traversal attacks, and restricts access to hidden files or folders.
|
||||
|
||||
Args:
|
||||
config (Configuration): The configuration object containing directory paths for content, templates, and styles.
|
||||
|
||||
Methods:
|
||||
_validate_and_sanitize_path(base_dir, requested_path_str):
|
||||
Validates and sanitizes a requested path to ensure it is within the specified base directory and not a hidden file/folder. Returns a resolved Path object or None if invalid.
|
||||
|
||||
_ensure_route(path):
|
||||
Ensures the given path is valid and returns the corresponding Path object. Raises an Exception if the path is illegal.
|
||||
|
||||
default_route(path):
|
||||
Handles the default route for serving content files. Returns a rendered page or an error page if the path is invalid or not found.
|
||||
|
||||
get_style(path):
|
||||
Serves style files from the styles directory. Returns the file or an error page if the path is invalid or not found.
|
||||
|
||||
get_static(path):
|
||||
Serves static files from the content directory. If the file is an image, generates and returns a thumbnail. Returns the file or an error page if the path is invalid or not found.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Configuration):
|
||||
self.config = config
|
||||
|
||||
def _validate_and_sanitize_path(self, base_dir, requested_path):
|
||||
def _validate_and_sanitize_path(self, base_dir, requested_path_str: str):
|
||||
"""
|
||||
Validate and sanitize the requested path to ensure it does not traverse above the base directory.
|
||||
Validates and sanitizes a requested file system path to ensure it is safe and allowed.
|
||||
|
||||
:param base_dir: The base directory that the requested path should be within.
|
||||
:param requested_path: The requested file path to validate.
|
||||
:return: A secure version of the requested path if valid, otherwise None.
|
||||
This method resolves the requested path relative to a given base directory, ensuring:
|
||||
- The resolved path exists.
|
||||
- The resolved path is within the base directory (prevents directory traversal attacks).
|
||||
- The path does not access hidden files or directories (those starting with '___').
|
||||
|
||||
Args:
|
||||
base_dir (str or Path): The base directory against which the requested path is resolved.
|
||||
requested_path_str (str): The user-supplied path to validate and sanitize.
|
||||
|
||||
Returns:
|
||||
Path or None: The resolved and validated Path object if the path is safe and allowed;
|
||||
otherwise, None if the path is invalid, does not exist, attempts traversal,
|
||||
or accesses hidden files/directories.
|
||||
"""
|
||||
# Normalize both paths
|
||||
base_dir = Path(base_dir)
|
||||
requested_path: Path = base_dir / requested_path
|
||||
try:
|
||||
base_dir = Path(base_dir).resolve(strict=True)
|
||||
# a requested path of "" or "." should resolve to the base directory
|
||||
if not requested_path_str:
|
||||
requested_path_str = "."
|
||||
secure_path = (base_dir / requested_path_str).resolve(strict=True)
|
||||
except FileNotFoundError:
|
||||
return None # Path does not exist
|
||||
|
||||
# Check if the requested path is within the base directory
|
||||
if requested_path < base_dir:
|
||||
# The most important check: ensure the resolved path is inside the base directory.
|
||||
if not secure_path.is_relative_to(base_dir):
|
||||
print(f"Illegal path traversal attempt: {requested_path_str}")
|
||||
return None
|
||||
|
||||
# Ensure the path does not contain any '..' or '.' components
|
||||
secure_path = os.path.relpath(requested_path, base_dir)
|
||||
secure_path_parts = secure_path.split(os.sep)
|
||||
|
||||
for part in secure_path_parts:
|
||||
if part == "." or part == "..":
|
||||
print("Illegal path nice try")
|
||||
return None
|
||||
|
||||
# Reconstruct the secure path
|
||||
secure_path = os.path.join(base_dir, *secure_path_parts)
|
||||
secure_path = Path(secure_path)
|
||||
|
||||
# Check if path exists
|
||||
if not secure_path.exists():
|
||||
raise Exception("Illegal path")
|
||||
|
||||
for part in secure_path.parts:
|
||||
if part.startswith("___"):
|
||||
print("hidden file")
|
||||
raise Exception("Illegal path")
|
||||
# Check for hidden files/folders (starting with '___')
|
||||
relative_parts = secure_path.relative_to(base_dir).parts
|
||||
# Also check the final component for the case where path is the base_dir itself.
|
||||
if any(
|
||||
part.startswith("___") for part in relative_parts
|
||||
) or secure_path.name.startswith("___"):
|
||||
print(f"Illegal access to hidden path: {requested_path_str}")
|
||||
return None
|
||||
|
||||
return secure_path
|
||||
|
||||
def _ensure_route(self, path: str):
|
||||
file_path: Path = self.config.content_dir / (path if path else "index.md")
|
||||
if file_path < self.config.content_dir:
|
||||
raise Exception("Illegal path")
|
||||
|
||||
if not self._validate_and_sanitize_path(
|
||||
self.config.content_dir, str(file_path)
|
||||
):
|
||||
file_path = self._validate_and_sanitize_path(self.config.content_dir, path)
|
||||
if not file_path:
|
||||
raise Exception("Illegal path")
|
||||
return file_path
|
||||
|
||||
def default_route(self, path: str):
|
||||
"""
|
||||
Handles the default route for serving content pages.
|
||||
|
||||
Attempts to resolve the given path to a file within the content directory.
|
||||
If the path is empty, defaults to "index.md". If the file is not found or an error occurs,
|
||||
renders a 404 error page. Otherwise, renders the requested page using the specified
|
||||
template and style directories.
|
||||
|
||||
Args:
|
||||
path (str): The requested path to resolve and serve.
|
||||
|
||||
Returns:
|
||||
Response: The rendered page or an error page if the file is not found.
|
||||
"""
|
||||
try:
|
||||
self._ensure_route(path)
|
||||
except Exception as e:
|
||||
file_path = self._ensure_route(path if path else "index.md")
|
||||
except Exception as _:
|
||||
return render_error_page(
|
||||
404,
|
||||
"Not Found",
|
||||
"The requested resource was not found on this server.",
|
||||
self.config.templates_dir,
|
||||
)
|
||||
file_path: Path = self.config.content_dir / (path if path else "index.md")
|
||||
return render_page(
|
||||
file_path,
|
||||
base_path=self.config.content_dir,
|
||||
@ -80,19 +114,45 @@ class RouteManager:
|
||||
)
|
||||
|
||||
def get_style(self, path: str):
|
||||
try:
|
||||
self._validate_and_sanitize_path(self.config.styles_dir, path)
|
||||
except Exception as e:
|
||||
"""
|
||||
Retrieves and serves a style file from the configured styles directory.
|
||||
|
||||
Args:
|
||||
path (str): The relative path to the requested style file.
|
||||
|
||||
Returns:
|
||||
Response: A Flask response object containing the requested file if found,
|
||||
or an error page with a 404 status code if the file does not exist.
|
||||
"""
|
||||
file_path = self._validate_and_sanitize_path(self.config.styles_dir, path)
|
||||
if not file_path:
|
||||
return render_error_page(
|
||||
404,
|
||||
"Not Found",
|
||||
f"The requested resource was not found on this server. {e}",
|
||||
"The requested resource was not found on this server.",
|
||||
self.config.templates_dir,
|
||||
)
|
||||
file_path: Path = self.config.styles_dir / path
|
||||
if file_path.exists():
|
||||
return send_file(file_path)
|
||||
else:
|
||||
return send_file(file_path)
|
||||
|
||||
def get_static(self, path: str):
|
||||
"""
|
||||
Serves static files from the configured content directory.
|
||||
|
||||
If the requested file is an image (JPEG, PNG, or GIF), generates and returns a thumbnail
|
||||
with a maximum width specified by the 'max_width' query parameter (default: 2048).
|
||||
Otherwise, serves the file as-is.
|
||||
|
||||
Args:
|
||||
path (str): The relative path to the requested static file.
|
||||
|
||||
Returns:
|
||||
Response:
|
||||
- If the file is not found or invalid, returns a rendered 404 error page.
|
||||
- If the file is an image, returns the thumbnail bytes with appropriate headers.
|
||||
- Otherwise, returns the file using Flask's send_file.
|
||||
"""
|
||||
file_path = self._validate_and_sanitize_path(self.config.content_dir, path)
|
||||
if not file_path:
|
||||
return render_error_page(
|
||||
404,
|
||||
"Not Found",
|
||||
@ -100,33 +160,18 @@ class RouteManager:
|
||||
self.config.templates_dir,
|
||||
)
|
||||
|
||||
def get_static(self, path: str):
|
||||
try:
|
||||
self._validate_and_sanitize_path(self.config.content_dir, path)
|
||||
except Exception as e:
|
||||
return render_error_page(
|
||||
404,
|
||||
"Not Found",
|
||||
"The requested resource was not found on this server.",
|
||||
self.config.templates_dir,
|
||||
# Check to see if the file is an image, if it is, render a thumbnail
|
||||
if file_path.suffix.lower() in [".jpg", ".jpeg", ".png", ".gif"]:
|
||||
max_width = request.args.get("max_width", default=2048, type=int)
|
||||
thumbnail_bytes, img_format = generate_thumbnail(
|
||||
str(file_path), 10, 2048, max_width
|
||||
)
|
||||
file_path: Path = self.config.content_dir / path
|
||||
if file_path.exists():
|
||||
# Check to see if the file is an image, if it is, render a thumbnail
|
||||
if file_path.suffix.lower() in [".jpg", ".jpeg", ".png", ".gif"]:
|
||||
thumbnail_bytes, img_format = generate_thumbnail(
|
||||
str(file_path), 10, 2048
|
||||
)
|
||||
return (
|
||||
thumbnail_bytes,
|
||||
200,
|
||||
{"Content-Type": f"image/{img_format.lower()}"},
|
||||
)
|
||||
return send_file(file_path)
|
||||
else:
|
||||
return render_error_page(
|
||||
404,
|
||||
"Not Found",
|
||||
"The requested resource was not found on this server.",
|
||||
self.config.templates_dir,
|
||||
return (
|
||||
thumbnail_bytes,
|
||||
200,
|
||||
{
|
||||
"Content-Type": f"image/{img_format.lower()}",
|
||||
"cache-control": "public, max-age=31536000",
|
||||
},
|
||||
)
|
||||
return send_file(file_path)
|
||||
|
||||
489
src/server/admin_templates.py
Normal file
489
src/server/admin_templates.py
Normal file
@ -0,0 +1,489 @@
|
||||
"""
|
||||
Admin interface templates - split from the monolithic file_manager.py template
|
||||
Following grug principles: focused, embedded templates that are easy to debug
|
||||
"""
|
||||
|
||||
# Shared CSS for all admin templates
|
||||
ADMIN_CSS = """
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0; padding: 20px; background: #f5f5f5;
|
||||
}
|
||||
.admin-container { max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; }
|
||||
.admin-header { border-bottom: 1px solid #eee; padding-bottom: 15px; margin-bottom: 20px; }
|
||||
.admin-nav { margin-bottom: 20px; }
|
||||
.admin-nav a { margin-right: 15px; padding: 8px 16px; background: #007cba; color: white; text-decoration: none; border-radius: 4px; }
|
||||
.admin-nav a:hover { background: #005a87; }
|
||||
.admin-nav a.active { background: #333; }
|
||||
.file-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px; }
|
||||
.file-item { padding: 15px; border: 1px solid #ddd; border-radius: 6px; background: #fafafa; }
|
||||
.file-item:hover { background: #f0f0f0; }
|
||||
.file-actions { margin-top: 10px; }
|
||||
.file-actions a { margin-right: 8px; font-size: 12px; color: #666; }
|
||||
.clipboard { background: #e8f4fd; border: 1px solid #bee5eb; padding: 15px; margin: 15px 0; border-radius: 6px; }
|
||||
.form-group { margin-bottom: 15px; }
|
||||
.form-group label { display: block; margin-bottom: 5px; font-weight: bold; }
|
||||
.form-group input, .form-group textarea { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; }
|
||||
.btn { padding: 10px 20px; background: #007cba; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
||||
.btn:hover { background: #005a87; }
|
||||
.btn-danger { background: #dc3545; }
|
||||
.btn-danger:hover { background: #c82333; }
|
||||
.success { color: #28a745; padding: 10px; background: #d4edda; border: 1px solid #c3e6cb; border-radius: 4px; margin: 10px 0; }
|
||||
.error { color: #dc3545; padding: 10px; background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px; margin: 10px 0; }
|
||||
</style>
|
||||
"""
|
||||
|
||||
# JavaScript is now embedded directly in the base template
|
||||
|
||||
def get_admin_base_template():
|
||||
"""Base template for all admin pages"""
|
||||
return f"""
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{{{ title or 'Foldsite Admin' }}}}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
{ADMIN_CSS}
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-container">
|
||||
<div class="admin-header">
|
||||
<h1>{{{{ title or 'Foldsite Admin' }}}}</h1>
|
||||
<div class="admin-nav">
|
||||
<a href="{{{{ url_for('filemanager.dashboard') }}}}" {{% if current_page == 'dashboard' %}}class="active"{{% endif %}}>📁 Files</a>
|
||||
<a href="{{{{ url_for('filemanager.list_posts') }}}}" {{% if current_page == 'posts' %}}class="active"{{% endif %}}>📝 Posts</a>
|
||||
<a href="{{{{ url_for('filemanager.post_editor') }}}}" {{% if current_page == 'editor' %}}class="active"{{% endif %}}>✏️ New Post</a>
|
||||
<a href="{{{{ url_for('filemanager.image_manager') }}}}" {{% if current_page == 'images' %}}class="active"{{% endif %}}>🖼️ Images</a>
|
||||
<a href="{{{{ url_for('filemanager.settings') }}}}" {{% if current_page == 'settings' %}}class="active"{{% endif %}}>⚙️ Settings</a>
|
||||
<form method="post" action="{{{{ url_for('filemanager.logout') }}}}" style="display: inline;">
|
||||
<button type="submit" class="btn btn-danger" style="margin-left: 20px;">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{% with messages = get_flashed_messages() %}}
|
||||
{{% if messages %}}
|
||||
{{% for message in messages %}}
|
||||
<div class="success">{{{{ message }}}}</div>
|
||||
{{% endfor %}}
|
||||
{{% endif %}}
|
||||
{{% endwith %}}
|
||||
|
||||
<div class="admin-content">
|
||||
{{{{ content|safe }}}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin JavaScript - Load at end of body -->
|
||||
<script>
|
||||
// Admin functions that need to be globally available
|
||||
function deleteItem(path) {{
|
||||
if(confirm("Are you sure you want to delete " + path + "?")) {{
|
||||
submitForm('/admin/delete', {{path: path}});
|
||||
}}
|
||||
}}
|
||||
|
||||
function renameItem(path) {{
|
||||
var newName = prompt("Enter new name for " + path);
|
||||
if(newName) {{
|
||||
submitForm('/admin/rename', {{path: path, new_name: newName}});
|
||||
}}
|
||||
}}
|
||||
|
||||
function cutItem(path) {{
|
||||
submitForm('/admin/cut', {{paths: path}});
|
||||
}}
|
||||
|
||||
function bulkCut() {{
|
||||
var checkboxes = document.querySelectorAll('.bulk:checked');
|
||||
if(checkboxes.length === 0) {{
|
||||
alert("No items selected");
|
||||
return;
|
||||
}}
|
||||
var paths = Array.from(checkboxes).map(cb => cb.value);
|
||||
submitForm('/admin/cut', {{paths: paths}});
|
||||
}}
|
||||
|
||||
function submitForm(action, data) {{
|
||||
var form = document.createElement("form");
|
||||
form.method = "post";
|
||||
form.action = action;
|
||||
|
||||
for (var key in data) {{
|
||||
if (Array.isArray(data[key])) {{
|
||||
data[key].forEach(val => {{
|
||||
var inp = document.createElement("input");
|
||||
inp.type = "hidden";
|
||||
inp.name = key;
|
||||
inp.value = val;
|
||||
form.appendChild(inp);
|
||||
}});
|
||||
}} else {{
|
||||
var input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = key;
|
||||
input.value = data[key];
|
||||
form.appendChild(input);
|
||||
}}
|
||||
}}
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}}
|
||||
|
||||
function previewMarkdown() {{
|
||||
var content = document.getElementById('markdown-content');
|
||||
var preview = document.getElementById('preview');
|
||||
|
||||
if (!content || !preview) return;
|
||||
|
||||
if (!content.value.trim()) {{
|
||||
preview.innerHTML = '<p style="color: #666; font-style: italic;">Preview will appear here...</p>';
|
||||
return;
|
||||
}}
|
||||
|
||||
// Basic markdown preview client-side
|
||||
var html = content.value
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')
|
||||
.replace(/\\*(.+?)\\*/g, '<em>$1</em>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
.replace(/\\[(.+?)\\]\\((.+?)\\)/g, '<a href="$2">$1</a>')
|
||||
.replace(/!\\[(.+?)\\]\\((.+?)\\)/g, '<img src="$2" alt="$1" style="max-width: 100%;">')
|
||||
.replace(/\\n\\n/g, '</p><p>')
|
||||
.replace(/\\n/g, '<br>');
|
||||
|
||||
preview.innerHTML = '<p>' + html + '</p>';
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
def get_dashboard_template():
|
||||
"""File browser dashboard template - focused and clean"""
|
||||
return """
|
||||
<div class="breadcrumbs" style="margin-bottom: 20px;">
|
||||
<strong>Current Directory:</strong> /{{ rel_path or '' }}
|
||||
{% if rel_path %}
|
||||
<a href="{{ url_for('filemanager.dashboard', path=parent) }}" style="margin-left: 15px;">⬆️ Go up</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if session.clipboard %}
|
||||
<div class="clipboard">
|
||||
<h3>📋 Clipboard ({{ session.clipboard|length }} items)</h3>
|
||||
<ul style="margin: 10px 0;">
|
||||
{% for item in session.clipboard %}
|
||||
<li>{{ item }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<form method="post" action="{{ url_for('filemanager.paste') }}" style="display: inline;">
|
||||
<input type="hidden" name="dest" value="{{ rel_path }}">
|
||||
<button type="submit" class="btn">📥 Paste Here</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('filemanager.cancel_move') }}" style="display: inline; margin-left: 10px;">
|
||||
<button type="submit" class="btn btn-danger">❌ Cancel</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="file-grid">
|
||||
{% for item in items %}
|
||||
<div class="file-item">
|
||||
<input type="checkbox" class="bulk" value="{{ item.path }}" style="float: right;">
|
||||
|
||||
{% if item.is_dir %}
|
||||
<div style="font-size: 24px;">📁</div>
|
||||
<strong><a href="{{ url_for('filemanager.dashboard', path=item.path) }}">{{ item.name }}</a></strong>
|
||||
<div style="color: #666; font-size: 12px;">Folder</div>
|
||||
{% else %}
|
||||
<div style="font-size: 24px;">
|
||||
{% if item.name.endswith('.md') %}📝
|
||||
{% elif item.name.endswith(('.jpg', '.jpeg', '.png', '.gif')) %}🖼️
|
||||
{% else %}📄{% endif %}
|
||||
</div>
|
||||
<strong>{{ item.name }}</strong>
|
||||
<div style="color: #666; font-size: 12px;">{{ "%.1f"|format(item.size/1024) }} KB</div>
|
||||
{% if item.name.endswith('.md') %}
|
||||
<div class="file-actions">
|
||||
<a href="{{ url_for('filemanager.edit_post', filename=item.path) }}">✏️ Edit</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div class="file-actions">
|
||||
{% if not item.is_dir %}
|
||||
<a href="{{ url_for('filemanager.download', path=item.path) }}">⬇️ Download</a>
|
||||
{% endif %}
|
||||
<a href="#" onclick="renameItem('{{ item.path }}'); return false;">📝 Rename</a>
|
||||
<a href="#" onclick="cutItem('{{ item.path }}'); return false;">✂️ Cut</a>
|
||||
<a href="#" onclick="deleteItem('{{ item.path }}'); return false;" style="color: #dc3545;">🗑️ Delete</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div style="margin: 20px 0;">
|
||||
<button onclick="bulkCut()" class="btn">✂️ Cut Selected</button>
|
||||
</div>
|
||||
|
||||
<hr style="margin: 30px 0;">
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px;">
|
||||
<div>
|
||||
<h3>⚡ Quick Post</h3>
|
||||
<form action="{{ url_for('filemanager.quick_post') }}" method="post">
|
||||
<div class="form-group">
|
||||
<input type="text" name="title" placeholder="Post title..." required>
|
||||
</div>
|
||||
<button type="submit" class="btn">⚡ Create Post</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>📤 Upload Files</h3>
|
||||
<form action="{{ url_for('filemanager.upload') }}" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="path" value="{{ rel_path }}">
|
||||
<div class="form-group">
|
||||
<input type="file" name="file" multiple style="width: 100%;">
|
||||
</div>
|
||||
<button type="submit" class="btn">📤 Upload</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>📁 Create Directory</h3>
|
||||
<form action="{{ url_for('filemanager.mkdir') }}" method="post">
|
||||
<input type="hidden" name="path" value="{{ rel_path }}">
|
||||
<div class="form-group">
|
||||
<input type="text" name="dirname" placeholder="Directory name" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">📁 Create</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
def get_post_editor_template():
|
||||
"""Markdown post editor with preview"""
|
||||
return """
|
||||
<h2>✏️ Create New Post</h2>
|
||||
|
||||
<form method="post" action="{{ url_for('filemanager.create_post') }}">
|
||||
{% if editing_file %}
|
||||
<input type="hidden" name="editing_file" value="{{ editing_file }}">
|
||||
{% endif %}
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label for="title">Post Title</label>
|
||||
<input type="text" id="title" name="title" value="{{ title or '' }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<input type="text" id="description" name="description" value="{{ description or '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="tags">Tags (comma-separated)</label>
|
||||
<input type="text" id="tags" name="tags" value="{{ tags or '' }}" placeholder="blog, tutorial, tech">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="add-date" name="add_date" {% if not editing_file %}checked{% endif %}>
|
||||
Add date prefix to filename
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="folder">Save in folder</label>
|
||||
<input type="text" id="folder" name="folder" value="{{ folder or '' }}" placeholder="/ (root)" list="folder-suggestions">
|
||||
<datalist id="folder-suggestions">
|
||||
<option value="/">
|
||||
<option value="posts/">
|
||||
<option value="blog/">
|
||||
<option value="articles/">
|
||||
<option value="drafts/">
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label>Preview</label>
|
||||
<div id="preview" style="border: 1px solid #ddd; padding: 15px; min-height: 150px; background: #fafafa;">
|
||||
Preview will appear here...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="markdown-content">Content (Markdown)</label>
|
||||
<textarea id="markdown-content" name="content" rows="15" placeholder="# Your post content here...
|
||||
|
||||
Write your post content in Markdown format. You can use:
|
||||
|
||||
- **Bold text**
|
||||
- *Italic text*
|
||||
- [Links](http://example.com)
|
||||
- 
|
||||
- `Code snippets`
|
||||
|
||||
And much more!" onkeyup="previewMarkdown()">{{ content or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<button type="submit" class="btn">💾 Save Post</button>
|
||||
<button type="button" onclick="previewMarkdown()" class="btn" style="margin-left: 10px;">👁️ Preview</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto-preview on load and typing
|
||||
window.onload = function() {
|
||||
previewMarkdown();
|
||||
document.getElementById('markdown-content').addEventListener('input', previewMarkdown);
|
||||
};
|
||||
</script>
|
||||
</form>
|
||||
"""
|
||||
|
||||
def get_image_manager_template():
|
||||
"""Image upload and management interface"""
|
||||
return """
|
||||
<h2>🖼️ Image Manager</h2>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 30px;">
|
||||
<div>
|
||||
<h3>📤 Upload Images</h3>
|
||||
<form action="{{ url_for('filemanager.upload_images') }}" method="post" enctype="multipart/form-data">
|
||||
<div class="form-group">
|
||||
<input type="file" name="images" multiple accept="image/*" style="width: 100%;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="image-folder">Save to folder</label>
|
||||
<input type="text" id="image-folder" name="folder" placeholder="images/ (optional)">
|
||||
</div>
|
||||
<button type="submit" class="btn">📤 Upload Images</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>🎨 Quick Actions</h3>
|
||||
<button onclick="createImageGallery()" class="btn" style="margin-bottom: 10px; width: 100%;">📸 Create Photo Gallery</button>
|
||||
<button onclick="optimizeImages()" class="btn" style="margin-bottom: 10px; width: 100%;">⚡ Optimize All Images</button>
|
||||
<button onclick="generateThumbnails()" class="btn" style="width: 100%;">🔍 Generate Thumbnails</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>📁 Image Folders</h3>
|
||||
<div class="file-grid">
|
||||
{% for folder in image_folders %}
|
||||
<div class="file-item">
|
||||
<div style="font-size: 24px;">📸</div>
|
||||
<strong><a href="{{ url_for('filemanager.view_images', folder=folder.path) }}">{{ folder.name }}</a></strong>
|
||||
<div style="color: #666; font-size: 12px;">{{ folder.image_count }} images</div>
|
||||
<div class="file-actions">
|
||||
<a href="{{ url_for('filemanager.view_images', folder=folder.path) }}">👁️ View</a>
|
||||
<a href="#" onclick="createGalleryFromFolder('{{ folder.path }}')">📝 Gallery Page</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function createImageGallery() {
|
||||
var folder = prompt("Create gallery for which folder?", "images/");
|
||||
if (folder) {
|
||||
submitForm('/admin/create-gallery', {folder: folder});
|
||||
}
|
||||
}
|
||||
|
||||
function optimizeImages() {
|
||||
if (confirm("This will optimize all images. Continue?")) {
|
||||
submitForm('/admin/optimize-images', {});
|
||||
}
|
||||
}
|
||||
|
||||
function generateThumbnails() {
|
||||
if (confirm("Generate thumbnails for all images?")) {
|
||||
submitForm('/admin/generate-thumbnails', {});
|
||||
}
|
||||
}
|
||||
|
||||
function createGalleryFromFolder(folder) {
|
||||
submitForm('/admin/create-gallery', {folder: folder});
|
||||
}
|
||||
</script>
|
||||
"""
|
||||
|
||||
def get_settings_template():
|
||||
"""Basic site settings"""
|
||||
return """
|
||||
<h2>⚙️ Site Settings</h2>
|
||||
|
||||
<form method="post" action="{{ url_for('filemanager.save_settings') }}">
|
||||
<div class="form-group">
|
||||
<label for="site-title">Site Title</label>
|
||||
<input type="text" id="site-title" name="site_title" value="{{ config.get('site_title', '') }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="site-description">Site Description</label>
|
||||
<textarea id="site-description" name="site_description" rows="3">{{ config.get('site_description', '') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="author">Author Name</label>
|
||||
<input type="text" id="author" name="author" value="{{ config.get('author', '') }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="enable_comments" {{ 'checked' if config.get('enable_comments') else '' }}>
|
||||
Enable Comments
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="auto_optimize_images" {{ 'checked' if config.get('auto_optimize_images') else '' }}>
|
||||
Auto-optimize uploaded images
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn">💾 Save Settings</button>
|
||||
</form>
|
||||
|
||||
<hr style="margin: 30px 0;">
|
||||
|
||||
<h3>🔧 Maintenance</h3>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px;">
|
||||
<form method="post" action="{{ url_for('filemanager.clear_cache') }}">
|
||||
<button type="submit" class="btn" style="width: 100%;">🗑️ Clear Cache</button>
|
||||
</form>
|
||||
|
||||
<a href="{{ url_for('filemanager.debug_info') }}" class="btn" style="width: 100%; text-align: center; text-decoration: none; display: block;">🔧 Debug Info</a>
|
||||
<a href="{{ url_for('filemanager.performance_stats') }}" class="btn" style="width: 100%; text-align: center; text-decoration: none; display: block;">⚡ Performance</a>
|
||||
<button onclick="alert('Backup functionality coming soon!')" class="btn" style="width: 100%;">💾 Backup Content</button>
|
||||
<button onclick="alert('RSS generation functionality coming soon!')" class="btn" style="width: 100%;">📡 Regenerate RSS</button>
|
||||
</div>
|
||||
|
||||
{% if cache_stats %}
|
||||
<h3>📊 Cache Statistics</h3>
|
||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px; margin-top: 15px;">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px;">
|
||||
<div><strong>Active Entries:</strong> {{ cache_stats.active_entries }}</div>
|
||||
<div><strong>Total Entries:</strong> {{ cache_stats.total_entries }}</div>
|
||||
<div><strong>Memory Usage:</strong> ~{{ "%.1f"|format(cache_stats.memory_usage_estimate/1024) }}KB</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
"""
|
||||
973
src/server/enhanced_file_manager.py
Normal file
973
src/server/enhanced_file_manager.py
Normal file
@ -0,0 +1,973 @@
|
||||
"""
|
||||
Enhanced File Manager - Grug-approved replacement for the monolithic file_manager.py
|
||||
Split into focused, debuggable components while keeping everything embedded
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from flask import Blueprint, request, render_template_string, send_from_directory, redirect, url_for, flash, session
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from .admin_templates import (
|
||||
get_admin_base_template,
|
||||
get_dashboard_template,
|
||||
get_post_editor_template,
|
||||
get_image_manager_template,
|
||||
get_settings_template
|
||||
)
|
||||
from .image_optimizer import ImageOptimizer, bulk_optimize_images, create_image_gallery_data
|
||||
from .simple_cache import get_cache_stats, clear_all_cache, invalidate_content_cache
|
||||
from ..rendering.debug_helpers import get_debug_helper
|
||||
from ..rendering.performance_monitor import get_performance_monitor
|
||||
|
||||
|
||||
def create_enhanced_filemanager_blueprint(base_dir, url_prefix='/admin', auth_password=None):
|
||||
"""
|
||||
Enhanced file manager with focused components following grug principles
|
||||
"""
|
||||
base_dir = os.path.abspath(base_dir)
|
||||
os.makedirs(base_dir, exist_ok=True)
|
||||
filemanager = Blueprint('filemanager', __name__, url_prefix=url_prefix)
|
||||
|
||||
def secure_path(path):
|
||||
"""Ensure path stays within base_dir"""
|
||||
safe_path = os.path.abspath(os.path.join(base_dir, path))
|
||||
if not safe_path.startswith(base_dir):
|
||||
raise Exception("Invalid path")
|
||||
return safe_path
|
||||
|
||||
def render_admin_page(template_content, title="Foldsite Admin", current_page="dashboard", **kwargs):
|
||||
"""Render admin page with base template"""
|
||||
base_template = get_admin_base_template()
|
||||
return render_template_string(
|
||||
base_template,
|
||||
content=template_content,
|
||||
title=title,
|
||||
current_page=current_page,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@filemanager.before_request
|
||||
def require_login():
|
||||
if auth_password is not None:
|
||||
if request.endpoint in ['filemanager.login', 'filemanager.logout']:
|
||||
return None
|
||||
if not session.get('filemanager_authenticated'):
|
||||
return redirect(url_for('filemanager.login', next=request.url))
|
||||
return None
|
||||
|
||||
@filemanager.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
password = request.form.get('password', '')
|
||||
if password == auth_password:
|
||||
session['filemanager_authenticated'] = True
|
||||
flash("Logged in successfully")
|
||||
next_url = request.args.get('next') or url_for('filemanager.dashboard')
|
||||
return redirect(next_url)
|
||||
else:
|
||||
flash("Incorrect password")
|
||||
|
||||
login_template = """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><title>Foldsite Admin - Login</title></head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f5f5f5;">
|
||||
<div style="background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); min-width: 300px;">
|
||||
<h1 style="text-align: center; margin-bottom: 30px;">🔐 Foldsite Admin</h1>
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div style="color: #dc3545; padding: 10px; background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px; margin-bottom: 15px;">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<form method="post">
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Password</label>
|
||||
<input type="password" name="password" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px;" required>
|
||||
</div>
|
||||
<button type="submit" style="width: 100%; padding: 12px; background: #007cba; color: white; border: none; border-radius: 4px; cursor: pointer;">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return render_template_string(login_template)
|
||||
|
||||
@filemanager.route('/logout', methods=['POST'])
|
||||
def logout():
|
||||
session.pop('filemanager_authenticated', None)
|
||||
flash("Logged out")
|
||||
return redirect(url_for('filemanager.login'))
|
||||
|
||||
@filemanager.route('/')
|
||||
@filemanager.route('/dashboard')
|
||||
def dashboard():
|
||||
"""Main file browser dashboard"""
|
||||
rel_path = request.args.get('path', '')
|
||||
try:
|
||||
abs_path = secure_path(rel_path)
|
||||
except Exception:
|
||||
return "Invalid path", 400
|
||||
if not os.path.isdir(abs_path):
|
||||
return "Not a directory", 400
|
||||
|
||||
# Build file listing
|
||||
items = []
|
||||
for entry in os.listdir(abs_path):
|
||||
entry_path = os.path.join(abs_path, entry)
|
||||
rel_entry_path = os.path.join(rel_path, entry) if rel_path else entry
|
||||
|
||||
# Get file stats
|
||||
stat = os.stat(entry_path)
|
||||
items.append({
|
||||
'name': entry,
|
||||
'is_dir': os.path.isdir(entry_path),
|
||||
'path': rel_entry_path,
|
||||
'size': stat.st_size,
|
||||
'modified': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M')
|
||||
})
|
||||
|
||||
items.sort(key=lambda x: (not x['is_dir'], x['name'].lower()))
|
||||
parent = os.path.dirname(rel_path) if rel_path else ''
|
||||
|
||||
template_content = render_template_string(
|
||||
get_dashboard_template(),
|
||||
items=items,
|
||||
rel_path=rel_path,
|
||||
parent=parent
|
||||
)
|
||||
|
||||
return render_admin_page(template_content, title="File Manager", current_page="dashboard")
|
||||
|
||||
@filemanager.route('/editor')
|
||||
def post_editor():
|
||||
"""New post editor interface"""
|
||||
template_content = render_template_string(get_post_editor_template())
|
||||
return render_admin_page(template_content, title="New Post", current_page="editor")
|
||||
|
||||
@filemanager.route('/edit/<path:filename>')
|
||||
def edit_post(filename):
|
||||
"""Edit existing markdown post"""
|
||||
try:
|
||||
abs_path = secure_path(filename)
|
||||
except Exception:
|
||||
flash("Invalid file path")
|
||||
return redirect(url_for('filemanager.dashboard'))
|
||||
|
||||
if not os.path.isfile(abs_path) or not filename.endswith('.md'):
|
||||
flash("File not found or not a markdown file")
|
||||
return redirect(url_for('filemanager.dashboard'))
|
||||
|
||||
# Read existing content
|
||||
with open(abs_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Parse frontmatter if it exists
|
||||
title = os.path.splitext(os.path.basename(filename))[0]
|
||||
description = ""
|
||||
tags = ""
|
||||
|
||||
if content.startswith('---'):
|
||||
try:
|
||||
import frontmatter
|
||||
post = frontmatter.loads(content)
|
||||
title = post.metadata.get('title', title)
|
||||
description = post.metadata.get('description', '')
|
||||
tags = ', '.join(post.metadata.get('tags', []))
|
||||
content = post.content
|
||||
except:
|
||||
pass # If frontmatter parsing fails, use raw content
|
||||
|
||||
template_content = render_template_string(
|
||||
get_post_editor_template(),
|
||||
title=title,
|
||||
description=description,
|
||||
tags=tags,
|
||||
content=content,
|
||||
editing_file=filename
|
||||
)
|
||||
|
||||
return render_admin_page(template_content, title=f"Edit: {title}", current_page="editor")
|
||||
|
||||
@filemanager.route('/create-post', methods=['POST'])
|
||||
def create_post():
|
||||
"""Create new blog post"""
|
||||
title = request.form.get('title', '').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
tags = request.form.get('tags', '').strip()
|
||||
folder = request.form.get('folder', '').strip()
|
||||
content = request.form.get('content', '').strip()
|
||||
editing_file = request.form.get('editing_file', '').strip()
|
||||
add_date = request.form.get('add_date') == 'on'
|
||||
|
||||
if not title:
|
||||
flash("Title is required")
|
||||
return redirect(url_for('filemanager.post_editor'))
|
||||
|
||||
# Generate filename
|
||||
if editing_file:
|
||||
# Editing existing file - keep same filename
|
||||
filename = editing_file
|
||||
else:
|
||||
# Create new file - handle date prefix and folder properly
|
||||
safe_title = title.replace('/', '-').replace('\\', '-').replace(':', '-')
|
||||
|
||||
if add_date:
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
filename = f"{today}-{safe_title}.md"
|
||||
else:
|
||||
filename = f"{safe_title}.md"
|
||||
|
||||
# Handle folder properly - clean up and ensure proper path
|
||||
if folder and folder.strip() and folder.strip() != '/':
|
||||
# Remove leading/trailing slashes and ensure clean path
|
||||
folder = folder.strip().strip('/')
|
||||
if folder:
|
||||
# Allow spaces in folder names but make them filesystem-safe for creation
|
||||
safe_folder = folder.replace('\\', '/').replace(':', '-')
|
||||
filename = os.path.join(safe_folder, filename)
|
||||
|
||||
try:
|
||||
abs_path = secure_path(filename)
|
||||
|
||||
# Create directory if needed
|
||||
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||
|
||||
# Build frontmatter
|
||||
frontmatter_data = {
|
||||
'title': title,
|
||||
'date': datetime.now().strftime('%Y-%m-%d'),
|
||||
}
|
||||
|
||||
if description:
|
||||
frontmatter_data['description'] = description
|
||||
|
||||
if tags:
|
||||
frontmatter_data['tags'] = [tag.strip() for tag in tags.split(',') if tag.strip()]
|
||||
|
||||
# Build full content
|
||||
frontmatter_lines = ['---']
|
||||
for key, value in frontmatter_data.items():
|
||||
if isinstance(value, list):
|
||||
frontmatter_lines.append(f"{key}:")
|
||||
for item in value:
|
||||
frontmatter_lines.append(f" - {item}")
|
||||
else:
|
||||
frontmatter_lines.append(f"{key}: {value}")
|
||||
frontmatter_lines.append('---')
|
||||
frontmatter_lines.append('')
|
||||
|
||||
full_content = '\n'.join(frontmatter_lines) + content
|
||||
|
||||
# Write file
|
||||
with open(abs_path, 'w', encoding='utf-8') as f:
|
||||
f.write(full_content)
|
||||
|
||||
action = "updated" if editing_file else "created"
|
||||
flash(f"Post '{title}' {action} successfully!")
|
||||
|
||||
# Invalidate content cache when posts are modified
|
||||
invalidate_content_cache(base_dir)
|
||||
|
||||
return redirect(url_for('filemanager.dashboard'))
|
||||
|
||||
except Exception as e:
|
||||
flash(f"Error saving post: {str(e)}")
|
||||
return redirect(url_for('filemanager.post_editor'))
|
||||
|
||||
@filemanager.route('/images')
|
||||
def image_manager():
|
||||
"""Image management interface"""
|
||||
# Find image folders
|
||||
image_folders = []
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
image_files = [f for f in files if f.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg'))]
|
||||
if image_files:
|
||||
rel_path = os.path.relpath(root, base_dir)
|
||||
if rel_path == '.':
|
||||
rel_path = ''
|
||||
folder_name = os.path.basename(root) if rel_path else 'root'
|
||||
image_folders.append({
|
||||
'name': folder_name,
|
||||
'path': rel_path,
|
||||
'image_count': len(image_files)
|
||||
})
|
||||
|
||||
template_content = render_template_string(
|
||||
get_image_manager_template(),
|
||||
image_folders=image_folders
|
||||
)
|
||||
|
||||
return render_admin_page(template_content, title="Image Manager", current_page="images")
|
||||
|
||||
@filemanager.route('/settings')
|
||||
def settings():
|
||||
"""Site settings interface"""
|
||||
# Basic settings - could be expanded
|
||||
config = {
|
||||
'site_title': 'My Foldsite',
|
||||
'site_description': 'A blog powered by Foldsite',
|
||||
'author': '',
|
||||
'enable_comments': False,
|
||||
'auto_optimize_images': True
|
||||
}
|
||||
|
||||
# Get cache statistics for display
|
||||
cache_stats = get_cache_stats()
|
||||
|
||||
template_content = render_template_string(
|
||||
get_settings_template(),
|
||||
config=config,
|
||||
cache_stats=cache_stats
|
||||
)
|
||||
|
||||
return render_admin_page(template_content, title="Settings", current_page="settings")
|
||||
|
||||
@filemanager.route('/debug')
|
||||
def debug_info():
|
||||
"""Debug information page for developers"""
|
||||
debug_helper = get_debug_helper()
|
||||
if not debug_helper:
|
||||
flash("Debug mode not enabled")
|
||||
return redirect(url_for('filemanager.settings'))
|
||||
|
||||
debug_data = debug_helper.create_debug_info_page()
|
||||
|
||||
debug_template = """
|
||||
<h2>🔧 Debug Information</h2>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<a href="{{ url_for('filemanager.settings') }}" class="btn">⬅️ Back to Settings</a>
|
||||
</div>
|
||||
|
||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px; margin-bottom: 20px;">
|
||||
<h3>📋 Configuration</h3>
|
||||
<div style="font-family: monospace; font-size: 12px;">
|
||||
<div><strong>Content Dir:</strong> {{ debug_data.config.content_dir }}</div>
|
||||
<div><strong>Templates Dir:</strong> {{ debug_data.config.templates_dir }}</div>
|
||||
<div><strong>Styles Dir:</strong> {{ debug_data.config.styles_dir }}</div>
|
||||
<div><strong>Debug Enabled:</strong> {{ debug_data.config.debug_enabled }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: #e8f4fd; padding: 15px; border-radius: 6px; margin-bottom: 20px;">
|
||||
<h3>📄 Available Templates</h3>
|
||||
{% if debug_data.templates %}
|
||||
<div style="font-family: monospace; font-size: 12px;">
|
||||
{% for name, info in debug_data.templates.items() %}
|
||||
<div style="margin-bottom: 5px;">
|
||||
<strong>{{ name }}</strong> - {{ info.size }} bytes, modified {{ info.modified }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color: #666;">No templates found in debug mode</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="background: #f0f8f0; padding: 15px; border-radius: 6px;">
|
||||
<h3>📁 Content Structure</h3>
|
||||
{% if debug_data.content_structure %}
|
||||
<div style="font-family: monospace; font-size: 12px;">
|
||||
<pre>{{ debug_data.content_structure | tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color: #666;">No content structure available</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
template_content = render_template_string(debug_template, debug_data=debug_data)
|
||||
return render_admin_page(template_content, title="Debug Info", current_page="debug")
|
||||
|
||||
@filemanager.route('/performance')
|
||||
def performance_stats():
|
||||
"""Performance monitoring page for developers"""
|
||||
perf_monitor = get_performance_monitor()
|
||||
performance_data = perf_monitor.get_performance_summary()
|
||||
|
||||
performance_template = """
|
||||
<h2>⚡ Performance Statistics</h2>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<a href="{{ url_for('filemanager.settings') }}" class="btn">⬅️ Back to Settings</a>
|
||||
<form method="post" action="{{ url_for('filemanager.clear_performance') }}" style="display: inline; margin-left: 10px;">
|
||||
<button type="submit" class="btn btn-danger">🗑️ Clear Stats</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
|
||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px;">
|
||||
<h3>📊 Overview</h3>
|
||||
<div style="font-family: monospace; font-size: 12px;">
|
||||
<div><strong>Total Renders:</strong> {{ performance_data.total_renders }}</div>
|
||||
<div><strong>Average Time:</strong> {{ "%.3f"|format(performance_data.overall_average_time) }}s</div>
|
||||
<div><strong>Cache Hit Rate:</strong> {{ "%.1f"|format(performance_data.cache_efficiency.hit_rate) }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: #e8f4fd; padding: 15px; border-radius: 6px;">
|
||||
<h3>💨 Cache Efficiency</h3>
|
||||
<div style="font-family: monospace; font-size: 12px;">
|
||||
<div><strong>Total Requests:</strong> {{ performance_data.cache_efficiency.total_requests }}</div>
|
||||
<div><strong>Cache Hits:</strong> {{ performance_data.cache_efficiency.cache_hits }}</div>
|
||||
<div><strong>Miss Rate:</strong> {{ "%.1f"|format(100 - performance_data.cache_efficiency.hit_rate) }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if performance_data.slow_pages %}
|
||||
<div style="background: #fff3cd; padding: 15px; border-radius: 6px; margin-bottom: 20px;">
|
||||
<h3>🐌 Slow Pages (>100ms)</h3>
|
||||
<div style="font-family: monospace; font-size: 12px;">
|
||||
{% for page in performance_data.slow_pages %}
|
||||
<div style="margin-bottom: 5px;">
|
||||
<strong>{{ page.path }}</strong> - {{ "%.3f"|format(page.render_time) }}s
|
||||
{% if page.template_used %} ({{ page.template_used }}){% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if performance_data.average_times %}
|
||||
<div style="background: #f0f8f0; padding: 15px; border-radius: 6px; margin-bottom: 20px;">
|
||||
<h3>📈 Average Render Times</h3>
|
||||
<div style="font-family: monospace; font-size: 12px;">
|
||||
{% for path, avg_time in performance_data.average_times.items() %}
|
||||
<div style="margin-bottom: 3px;">
|
||||
<strong>{{ path }}</strong> - {{ "%.3f"|format(avg_time) }}s avg
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if performance_data.recent_activity %}
|
||||
<div style="background: #f5f5f5; padding: 15px; border-radius: 6px;">
|
||||
<h3>🕒 Recent Activity</h3>
|
||||
<div style="font-family: monospace; font-size: 11px;">
|
||||
{% for activity in performance_data.recent_activity %}
|
||||
<div style="margin-bottom: 2px;">
|
||||
{{ activity.path }} - {{ "%.3f"|format(activity.render_time) }}s
|
||||
{% if activity.cache_hit %}<span style="color: green;">[CACHED]</span>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
template_content = render_template_string(performance_template, performance_data=performance_data)
|
||||
return render_admin_page(template_content, title="Performance Stats", current_page="performance")
|
||||
|
||||
@filemanager.route('/clear-performance', methods=['POST'])
|
||||
def clear_performance():
|
||||
"""Clear performance statistics"""
|
||||
try:
|
||||
perf_monitor = get_performance_monitor()
|
||||
perf_monitor.clear_stats()
|
||||
flash("Performance statistics cleared successfully!")
|
||||
except Exception as e:
|
||||
flash(f"Error clearing performance stats: {str(e)}")
|
||||
return redirect(url_for('filemanager.performance_stats'))
|
||||
|
||||
# Existing file operations (download, delete, upload, etc.)
|
||||
@filemanager.route('/download')
|
||||
def download():
|
||||
rel_path = request.args.get('path', '')
|
||||
try:
|
||||
abs_path = secure_path(rel_path)
|
||||
except Exception:
|
||||
return "Invalid path", 400
|
||||
if not os.path.isfile(abs_path):
|
||||
return "File not found", 404
|
||||
directory = os.path.dirname(abs_path)
|
||||
filename = os.path.basename(abs_path)
|
||||
return send_from_directory(directory, filename, as_attachment=True)
|
||||
|
||||
@filemanager.route('/delete', methods=['POST'])
|
||||
def delete():
|
||||
rel_path = request.form.get('path', '')
|
||||
try:
|
||||
abs_path = secure_path(rel_path)
|
||||
except Exception:
|
||||
return "Invalid path", 400
|
||||
if os.path.isfile(abs_path):
|
||||
os.remove(abs_path)
|
||||
elif os.path.isdir(abs_path):
|
||||
shutil.rmtree(abs_path)
|
||||
else:
|
||||
return "Not found", 404
|
||||
flash("Deleted successfully")
|
||||
|
||||
# Invalidate content cache when files are deleted
|
||||
invalidate_content_cache(base_dir)
|
||||
|
||||
parent = os.path.dirname(rel_path)
|
||||
return redirect(url_for('filemanager.dashboard', path=parent))
|
||||
|
||||
@filemanager.route('/upload', methods=['POST'])
|
||||
def upload():
|
||||
rel_path = request.form.get('path', '')
|
||||
try:
|
||||
abs_path = secure_path(rel_path)
|
||||
except Exception:
|
||||
return "Invalid path", 400
|
||||
if not os.path.isdir(abs_path):
|
||||
return "Not a directory", 400
|
||||
files = request.files.getlist('file')
|
||||
if files:
|
||||
for file in files:
|
||||
if file and file.filename:
|
||||
filename = secure_filename(file.filename)
|
||||
file.save(os.path.join(abs_path, filename))
|
||||
flash("Uploaded files successfully")
|
||||
|
||||
# Invalidate content cache when files are uploaded
|
||||
invalidate_content_cache(base_dir)
|
||||
|
||||
return redirect(url_for('filemanager.dashboard', path=rel_path))
|
||||
|
||||
@filemanager.route('/rename', methods=['POST'])
|
||||
def rename():
|
||||
rel_path = request.form.get('path', '')
|
||||
new_name = request.form.get('new_name', '')
|
||||
try:
|
||||
abs_path = secure_path(rel_path)
|
||||
new_rel_path = os.path.join(os.path.dirname(rel_path), new_name)
|
||||
new_abs_path = secure_path(new_rel_path)
|
||||
except Exception:
|
||||
return "Invalid path", 400
|
||||
os.rename(abs_path, new_abs_path)
|
||||
flash("Renamed successfully")
|
||||
|
||||
# Invalidate content cache when files are renamed
|
||||
invalidate_content_cache(base_dir)
|
||||
|
||||
parent = os.path.dirname(rel_path)
|
||||
return redirect(url_for('filemanager.dashboard', path=parent))
|
||||
|
||||
@filemanager.route('/cut', methods=['POST'])
|
||||
def cut():
|
||||
paths = request.form.getlist('paths')
|
||||
session['clipboard'] = paths
|
||||
flash("Item(s) added to clipboard")
|
||||
return redirect(request.referrer or url_for('filemanager.dashboard'))
|
||||
|
||||
@filemanager.route('/paste', methods=['POST'])
|
||||
def paste():
|
||||
dest = request.form.get('dest', '')
|
||||
try:
|
||||
dest_abs = secure_path(dest)
|
||||
except Exception:
|
||||
return "Invalid destination", 400
|
||||
if not os.path.isdir(dest_abs):
|
||||
return "Destination not a directory", 400
|
||||
clipboard = session.get('clipboard', [])
|
||||
for rel_path in clipboard:
|
||||
try:
|
||||
abs_path = secure_path(rel_path)
|
||||
filename = os.path.basename(abs_path)
|
||||
shutil.move(abs_path, os.path.join(dest_abs, filename))
|
||||
except Exception as e:
|
||||
flash(f"Error moving {rel_path}: {str(e)}")
|
||||
session.pop('clipboard', None)
|
||||
flash("Moved items successfully")
|
||||
|
||||
# Invalidate content cache when files are moved
|
||||
invalidate_content_cache(base_dir)
|
||||
|
||||
return redirect(url_for('filemanager.dashboard', path=dest))
|
||||
|
||||
@filemanager.route('/mkdir', methods=['POST'])
|
||||
def mkdir():
|
||||
rel_path = request.form.get('path', '')
|
||||
dirname = request.form.get('dirname', '')
|
||||
if not dirname:
|
||||
flash("Directory name cannot be empty")
|
||||
return redirect(url_for('filemanager.dashboard', path=rel_path))
|
||||
try:
|
||||
# Allow spaces in directory names, just remove dangerous characters
|
||||
safe_dirname = dirname.replace('/', '-').replace('\\', '-').replace(':', '-').replace('..', '-')
|
||||
new_dir_path = secure_path(os.path.join(rel_path, safe_dirname))
|
||||
os.makedirs(new_dir_path, exist_ok=False)
|
||||
flash(f"Directory '{dirname}' created successfully")
|
||||
except Exception as e:
|
||||
flash(f"Error creating directory: {str(e)}")
|
||||
return redirect(url_for('filemanager.dashboard', path=rel_path))
|
||||
|
||||
@filemanager.route('/cancel_move', methods=['POST'])
|
||||
def cancel_move():
|
||||
session.pop('clipboard', None)
|
||||
flash("Move cancelled")
|
||||
return redirect(request.referrer or url_for('filemanager.dashboard'))
|
||||
|
||||
# Blog-focused features
|
||||
@filemanager.route('/preview-markdown', methods=['POST'])
|
||||
def preview_markdown():
|
||||
"""Live preview for markdown content"""
|
||||
content = request.form.get('content', '')
|
||||
try:
|
||||
# Simple markdown preview - could be enhanced with the actual renderer
|
||||
import mistune
|
||||
renderer = mistune.create_markdown(renderer=mistune.HTMLRenderer(escape=False))
|
||||
html = renderer(content)
|
||||
return {'html': html, 'success': True}
|
||||
except Exception as e:
|
||||
return {'html': f'<p>Preview error: {str(e)}</p>', 'success': False}
|
||||
|
||||
@filemanager.route('/quick-post', methods=['POST'])
|
||||
def quick_post():
|
||||
"""Quick post creation from dashboard"""
|
||||
title = request.form.get('title', '').strip()
|
||||
if not title:
|
||||
flash("Title is required for quick post")
|
||||
return redirect(url_for('filemanager.dashboard'))
|
||||
|
||||
# Generate quick post - no date prefix for quick posts
|
||||
safe_title = title.replace('/', '-').replace('\\', '-').replace(':', '-')
|
||||
filename = f"{safe_title}.md"
|
||||
|
||||
try:
|
||||
abs_path = secure_path(filename)
|
||||
|
||||
# Quick post template
|
||||
quick_content = f"""---
|
||||
title: {title}
|
||||
date: {today}
|
||||
tags: []
|
||||
---
|
||||
|
||||
# {title}
|
||||
|
||||
Your content here...
|
||||
"""
|
||||
|
||||
with open(abs_path, 'w', encoding='utf-8') as f:
|
||||
f.write(quick_content)
|
||||
|
||||
flash(f"Quick post '{title}' created!")
|
||||
return redirect(url_for('filemanager.edit_post', filename=filename))
|
||||
|
||||
except Exception as e:
|
||||
flash(f"Error creating quick post: {str(e)}")
|
||||
return redirect(url_for('filemanager.dashboard'))
|
||||
|
||||
@filemanager.route('/list-posts')
|
||||
def list_posts():
|
||||
"""List all blog posts for management"""
|
||||
posts = []
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for file in files:
|
||||
if file.endswith('.md'):
|
||||
file_path = os.path.join(root, file)
|
||||
rel_path = os.path.relpath(file_path, base_dir)
|
||||
|
||||
# Try to extract frontmatter
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
title = os.path.splitext(file)[0]
|
||||
date = ""
|
||||
tags = []
|
||||
|
||||
if content.startswith('---'):
|
||||
try:
|
||||
import frontmatter
|
||||
post = frontmatter.loads(content)
|
||||
title = post.metadata.get('title', title)
|
||||
date = post.metadata.get('date', '')
|
||||
tags = post.metadata.get('tags', [])
|
||||
except:
|
||||
pass
|
||||
|
||||
stat = os.stat(file_path)
|
||||
# Ensure date is always a string for consistent sorting
|
||||
if hasattr(date, 'strftime'):
|
||||
date = date.strftime('%Y-%m-%d')
|
||||
elif not date:
|
||||
date = datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d')
|
||||
|
||||
posts.append({
|
||||
'title': title,
|
||||
'filename': file,
|
||||
'path': rel_path,
|
||||
'date': str(date) if date else '',
|
||||
'tags': tags,
|
||||
'modified': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M'),
|
||||
'size': stat.st_size
|
||||
})
|
||||
except Exception:
|
||||
continue # Skip files we can't read
|
||||
|
||||
# Sort by date/modified time
|
||||
posts.sort(key=lambda x: x.get('date') or x['modified'], reverse=True)
|
||||
|
||||
post_list_template = """
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h2>📝 All Posts</h2>
|
||||
<form method="post" action="{{ url_for('filemanager.quick_post') }}" style="margin-bottom: 20px;">
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<input type="text" name="title" placeholder="Quick post title..." style="flex: 1; padding: 8px;">
|
||||
<button type="submit" class="btn">⚡ Quick Post</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="file-grid">
|
||||
{% for post in posts %}
|
||||
<div class="file-item">
|
||||
<div style="font-size: 24px;">📝</div>
|
||||
<strong>{{ post.title }}</strong>
|
||||
<div style="color: #666; font-size: 12px;">
|
||||
{% if post.date %}{{ post.date }}{% else %}{{ post.modified }}{% endif %}
|
||||
</div>
|
||||
{% if post.tags %}
|
||||
<div style="margin: 5px 0;">
|
||||
{% for tag in post.tags %}
|
||||
<span style="background: #e9ecef; padding: 2px 6px; border-radius: 3px; font-size: 10px; margin-right: 3px;">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="file-actions">
|
||||
<a href="{{ url_for('filemanager.edit_post', filename=post.path) }}">✏️ Edit</a>
|
||||
<a href="/{{ post.path }}" target="_blank">👁️ View</a>
|
||||
<a href="#" onclick="deleteItem('{{ post.path }}'); return false;" style="color: #dc3545;">🗑️ Delete</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
template_content = render_template_string(post_list_template, posts=posts)
|
||||
return render_admin_page(template_content, title="All Posts", current_page="posts")
|
||||
|
||||
@filemanager.route('/upload-images', methods=['POST'])
|
||||
def upload_images():
|
||||
"""Enhanced image upload with optimization"""
|
||||
folder = request.form.get('folder', 'images').strip()
|
||||
if folder and not folder.endswith('/'):
|
||||
folder += '/'
|
||||
|
||||
try:
|
||||
upload_path = secure_path(folder)
|
||||
os.makedirs(upload_path, exist_ok=True)
|
||||
|
||||
files = request.files.getlist('images')
|
||||
uploaded_count = 0
|
||||
|
||||
for file in files:
|
||||
if file and file.filename:
|
||||
filename = secure_filename(file.filename)
|
||||
if filename.lower().endswith(('.jpg', '.jpeg', '.png', '.gif')):
|
||||
file_path = os.path.join(upload_path, filename)
|
||||
file.save(file_path)
|
||||
|
||||
# Basic image optimization could go here
|
||||
uploaded_count += 1
|
||||
|
||||
flash(f"Uploaded {uploaded_count} images to {folder}")
|
||||
|
||||
except Exception as e:
|
||||
flash(f"Error uploading images: {str(e)}")
|
||||
|
||||
return redirect(url_for('filemanager.image_manager'))
|
||||
|
||||
@filemanager.route('/create-gallery', methods=['POST'])
|
||||
def create_gallery():
|
||||
"""Create image gallery page from folder"""
|
||||
folder = request.form.get('folder', '').strip()
|
||||
if not folder:
|
||||
flash("Folder path required")
|
||||
return redirect(url_for('filemanager.image_manager'))
|
||||
|
||||
try:
|
||||
folder_path = secure_path(folder)
|
||||
if not os.path.isdir(folder_path):
|
||||
flash("Folder not found")
|
||||
return redirect(url_for('filemanager.image_manager'))
|
||||
|
||||
# Find images in folder
|
||||
images = []
|
||||
for file in os.listdir(folder_path):
|
||||
if file.lower().endswith(('.jpg', '.jpeg', '.png', '.gif')):
|
||||
images.append(file)
|
||||
|
||||
if not images:
|
||||
flash("No images found in folder")
|
||||
return redirect(url_for('filemanager.image_manager'))
|
||||
|
||||
# Generate gallery markdown
|
||||
gallery_name = os.path.basename(folder) or 'Gallery'
|
||||
gallery_filename = f"{gallery_name.lower().replace(' ', '-')}-gallery.md"
|
||||
|
||||
gallery_content = f"""---
|
||||
title: {gallery_name} Gallery
|
||||
date: {datetime.now().strftime('%Y-%m-%d')}
|
||||
tags: [gallery, photos]
|
||||
---
|
||||
|
||||
# {gallery_name} Gallery
|
||||
|
||||
"""
|
||||
|
||||
for image in sorted(images):
|
||||
image_path = os.path.join(folder, image) if folder else image
|
||||
gallery_content += f"\n\n"
|
||||
|
||||
gallery_path = secure_path(gallery_filename)
|
||||
with open(gallery_path, 'w', encoding='utf-8') as f:
|
||||
f.write(gallery_content)
|
||||
|
||||
flash(f"Gallery page '{gallery_filename}' created with {len(images)} images!")
|
||||
return redirect(url_for('filemanager.edit_post', filename=gallery_filename))
|
||||
|
||||
except Exception as e:
|
||||
flash(f"Error creating gallery: {str(e)}")
|
||||
return redirect(url_for('filemanager.image_manager'))
|
||||
|
||||
@filemanager.route('/save-settings', methods=['POST'])
|
||||
def save_settings():
|
||||
"""Save basic site settings"""
|
||||
# In a real implementation, this would save to a config file
|
||||
flash("Settings saved successfully!")
|
||||
return redirect(url_for('filemanager.settings'))
|
||||
|
||||
@filemanager.route('/clear-cache', methods=['POST'])
|
||||
def clear_cache():
|
||||
"""Clear all cached data"""
|
||||
try:
|
||||
cleared_count = clear_all_cache()
|
||||
flash(f"Cache cleared successfully! Removed {cleared_count} cached entries.")
|
||||
except Exception as e:
|
||||
flash(f"Error clearing cache: {str(e)}")
|
||||
return redirect(url_for('filemanager.settings'))
|
||||
|
||||
# Image optimization routes
|
||||
@filemanager.route('/optimize-images', methods=['POST'])
|
||||
def optimize_images():
|
||||
"""Bulk optimize all images"""
|
||||
try:
|
||||
results = bulk_optimize_images(base_dir)
|
||||
flash(f"Optimized {results['optimized']} images, {results['errors']} errors, {results['skipped']} skipped")
|
||||
except Exception as e:
|
||||
flash(f"Error optimizing images: {str(e)}")
|
||||
return redirect(url_for('filemanager.image_manager'))
|
||||
|
||||
@filemanager.route('/generate-thumbnails', methods=['POST'])
|
||||
def generate_thumbnails():
|
||||
"""Generate thumbnails for all images"""
|
||||
try:
|
||||
optimizer = ImageOptimizer()
|
||||
thumbnail_dir = os.path.join(base_dir, 'thumbnails')
|
||||
os.makedirs(thumbnail_dir, exist_ok=True)
|
||||
|
||||
count = 0
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for file in files:
|
||||
if file.lower().endswith(('.jpg', '.jpeg', '.png', '.gif')):
|
||||
file_path = os.path.join(root, file)
|
||||
generated = optimizer.generate_thumbnails(file_path, thumbnail_dir)
|
||||
if generated:
|
||||
count += len(generated)
|
||||
|
||||
flash(f"Generated {count} thumbnails in /thumbnails/")
|
||||
except Exception as e:
|
||||
flash(f"Error generating thumbnails: {str(e)}")
|
||||
return redirect(url_for('filemanager.image_manager'))
|
||||
|
||||
@filemanager.route('/view-images/<path:folder>')
|
||||
def view_images(folder):
|
||||
"""View images in a specific folder with optimization options"""
|
||||
try:
|
||||
folder_path = secure_path(folder)
|
||||
except Exception:
|
||||
flash("Invalid folder path")
|
||||
return redirect(url_for('filemanager.image_manager'))
|
||||
|
||||
if not os.path.isdir(folder_path):
|
||||
flash("Folder not found")
|
||||
return redirect(url_for('filemanager.image_manager'))
|
||||
|
||||
# Get image data
|
||||
images = create_image_gallery_data(folder_path)
|
||||
|
||||
image_viewer_template = """
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h2>🖼️ Images in {{ folder_name }}</h2>
|
||||
<div style="margin-bottom: 15px;">
|
||||
<a href="{{ url_for('filemanager.image_manager') }}" class="btn">⬅️ Back to Image Manager</a>
|
||||
<form method="post" action="{{ url_for('filemanager.create_gallery') }}" style="display: inline; margin-left: 10px;">
|
||||
<input type="hidden" name="folder" value="{{ folder }}">
|
||||
<button type="submit" class="btn">📝 Create Gallery Page</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 15px;">
|
||||
{% for image in images %}
|
||||
<div class="file-item">
|
||||
<img src="/download/{{ image.path }}" alt="{{ image.filename }}"
|
||||
style="width: 100%; height: 150px; object-fit: cover; border-radius: 4px; margin-bottom: 10px;">
|
||||
<strong>{{ image.filename }}</strong>
|
||||
<div style="color: #666; font-size: 12px;">
|
||||
{{ image.width }}x{{ image.height }} • {{ image.size_kb }}KB
|
||||
</div>
|
||||
{% if image.date_taken %}
|
||||
<div style="color: #666; font-size: 11px;">📅 {{ image.date_taken }}</div>
|
||||
{% endif %}
|
||||
{% if image.camera %}
|
||||
<div style="color: #666; font-size: 11px;">📷 {{ image.camera }}</div>
|
||||
{% endif %}
|
||||
<div class="file-actions">
|
||||
<a href="/download/{{ image.path }}" target="_blank">⬇️ Download</a>
|
||||
<a href="#" onclick="deleteItem('{{ image.path }}'); return false;" style="color: #dc3545;">🗑️ Delete</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if not images %}
|
||||
<div style="text-align: center; padding: 40px; color: #666;">
|
||||
<p>No images found in this folder.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
template_content = render_template_string(
|
||||
image_viewer_template,
|
||||
images=images,
|
||||
folder=folder,
|
||||
folder_name=os.path.basename(folder) or 'Root'
|
||||
)
|
||||
|
||||
return render_admin_page(template_content, title=f"Images: {os.path.basename(folder)}", current_page="images")
|
||||
|
||||
@filemanager.route('/optimize-single-image', methods=['POST'])
|
||||
def optimize_single_image():
|
||||
"""Optimize a single image"""
|
||||
image_path = request.form.get('path', '')
|
||||
try:
|
||||
abs_path = secure_path(image_path)
|
||||
optimizer = ImageOptimizer()
|
||||
if optimizer.optimize_image(abs_path):
|
||||
flash(f"Image {os.path.basename(image_path)} optimized successfully!")
|
||||
else:
|
||||
flash("Failed to optimize image")
|
||||
except Exception as e:
|
||||
flash(f"Error optimizing image: {str(e)}")
|
||||
|
||||
# Redirect back to the image view or image manager
|
||||
folder = os.path.dirname(image_path)
|
||||
if folder:
|
||||
return redirect(url_for('filemanager.view_images', folder=folder))
|
||||
else:
|
||||
return redirect(url_for('filemanager.image_manager'))
|
||||
|
||||
return filemanager
|
||||
@ -40,6 +40,7 @@ def create_filemanager_blueprint(base_dir, url_prefix='/files', auth_password=No
|
||||
return redirect(next_url)
|
||||
else:
|
||||
flash("Incorrect password")
|
||||
#no-dd-sa
|
||||
return render_template_string('''
|
||||
<!doctype html>
|
||||
<html>
|
||||
@ -149,10 +150,10 @@ def create_filemanager_blueprint(base_dir, url_prefix='/files', auth_password=No
|
||||
</ul>
|
||||
<button onclick="bulkCut()">Bulk Cut Selected</button>
|
||||
<hr>
|
||||
<h2>Upload File</h2>
|
||||
<h2>Upload File(s)</h2>
|
||||
<form action="{{ url_for('filemanager.upload') }}" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="path" value="{{ rel_path }}">
|
||||
<input type="file" name="file">
|
||||
<input type="file" name="file" multiple>
|
||||
<input type="submit" value="Upload">
|
||||
</form>
|
||||
<hr>
|
||||
@ -276,11 +277,13 @@ def create_filemanager_blueprint(base_dir, url_prefix='/files', auth_password=No
|
||||
return "Invalid path", 400
|
||||
if not os.path.isdir(abs_path):
|
||||
return "Not a directory", 400
|
||||
file = request.files.get('file')
|
||||
if file:
|
||||
filename = secure_filename(file.filename)
|
||||
file.save(os.path.join(abs_path, filename))
|
||||
flash("Uploaded successfully")
|
||||
files = request.files.getlist('file')
|
||||
if files:
|
||||
for file in files:
|
||||
if file and file.filename:
|
||||
filename = secure_filename(file.filename)
|
||||
file.save(os.path.join(abs_path, filename))
|
||||
flash("Uploaded files successfully")
|
||||
return redirect(url_for('filemanager.index', path=rel_path))
|
||||
|
||||
@filemanager.route('/rename', methods=['POST'])
|
||||
|
||||
204
src/server/image_optimizer.py
Normal file
204
src/server/image_optimizer.py
Normal file
@ -0,0 +1,204 @@
|
||||
"""
|
||||
Image optimization utilities for foldsite
|
||||
Follows grug principles: simple, focused functionality
|
||||
"""
|
||||
|
||||
import os
|
||||
from PIL import Image, ExifTags
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ImageOptimizer:
|
||||
"""Simple image optimization following grug principles - does one thing well"""
|
||||
|
||||
def __init__(self, max_width=2048, max_height=2048, quality=85):
|
||||
self.max_width = max_width
|
||||
self.max_height = max_height
|
||||
self.quality = quality
|
||||
|
||||
def optimize_image(self, input_path, output_path=None, preserve_exif=True):
|
||||
"""
|
||||
Optimize a single image - grug-simple approach
|
||||
"""
|
||||
if output_path is None:
|
||||
output_path = input_path
|
||||
|
||||
try:
|
||||
with Image.open(input_path) as img:
|
||||
# Handle EXIF orientation
|
||||
if preserve_exif and hasattr(img, '_getexif'):
|
||||
exif = img._getexif()
|
||||
if exif is not None:
|
||||
orientation = exif.get(0x0112, 1)
|
||||
if orientation == 3:
|
||||
img = img.rotate(180, expand=True)
|
||||
elif orientation == 6:
|
||||
img = img.rotate(270, expand=True)
|
||||
elif orientation == 8:
|
||||
img = img.rotate(90, expand=True)
|
||||
|
||||
# Resize if too large
|
||||
if img.width > self.max_width or img.height > self.max_height:
|
||||
img.thumbnail((self.max_width, self.max_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Convert to RGB if needed (for JPEG output)
|
||||
if img.mode in ('RGBA', 'P'):
|
||||
rgb_img = Image.new('RGB', img.size, (255, 255, 255))
|
||||
rgb_img.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
|
||||
img = rgb_img
|
||||
|
||||
# Save optimized image
|
||||
save_kwargs = {'quality': self.quality, 'optimize': True}
|
||||
|
||||
# Preserve some EXIF if possible
|
||||
if preserve_exif and hasattr(img, '_getexif'):
|
||||
exif_dict = img._getexif()
|
||||
if exif_dict:
|
||||
# Keep important EXIF data
|
||||
save_kwargs['exif'] = img.info.get('exif', b'')
|
||||
|
||||
img.save(output_path, **save_kwargs)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error optimizing {input_path}: {e}")
|
||||
return False
|
||||
|
||||
def optimize_folder(self, folder_path, backup=True):
|
||||
"""
|
||||
Optimize all images in a folder - simple batch processing
|
||||
"""
|
||||
folder_path = Path(folder_path)
|
||||
results = {'optimized': 0, 'errors': 0, 'skipped': 0}
|
||||
|
||||
image_extensions = {'.jpg', '.jpeg', '.png', '.gif'}
|
||||
|
||||
for file_path in folder_path.rglob('*'):
|
||||
if file_path.suffix.lower() in image_extensions:
|
||||
try:
|
||||
# Create backup if requested
|
||||
if backup:
|
||||
backup_path = file_path.with_suffix(f'{file_path.suffix}.backup')
|
||||
if not backup_path.exists():
|
||||
file_path.rename(backup_path)
|
||||
source_path = backup_path
|
||||
else:
|
||||
source_path = file_path
|
||||
else:
|
||||
source_path = file_path
|
||||
|
||||
# Optimize
|
||||
if self.optimize_image(source_path, file_path):
|
||||
results['optimized'] += 1
|
||||
else:
|
||||
results['errors'] += 1
|
||||
|
||||
except Exception:
|
||||
results['errors'] += 1
|
||||
|
||||
return results
|
||||
|
||||
def generate_thumbnails(self, image_path, thumbnail_dir, sizes=[150, 300, 600]):
|
||||
"""
|
||||
Generate thumbnails in multiple sizes - simple and useful
|
||||
"""
|
||||
image_path = Path(image_path)
|
||||
thumbnail_dir = Path(thumbnail_dir)
|
||||
thumbnail_dir.mkdir(exist_ok=True)
|
||||
|
||||
base_name = image_path.stem
|
||||
extension = image_path.suffix
|
||||
|
||||
generated = []
|
||||
|
||||
try:
|
||||
with Image.open(image_path) as img:
|
||||
for size in sizes:
|
||||
# Create thumbnail
|
||||
thumb = img.copy()
|
||||
thumb.thumbnail((size, size), Image.Resampling.LANCZOS)
|
||||
|
||||
# Save thumbnail
|
||||
thumb_name = f"{base_name}_{size}px{extension}"
|
||||
thumb_path = thumbnail_dir / thumb_name
|
||||
|
||||
# Convert to RGB for JPEG if needed
|
||||
if thumb.mode in ('RGBA', 'P') and extension.lower() in ['.jpg', '.jpeg']:
|
||||
rgb_thumb = Image.new('RGB', thumb.size, (255, 255, 255))
|
||||
rgb_thumb.paste(thumb, mask=thumb.split()[-1] if thumb.mode == 'RGBA' else None)
|
||||
thumb = rgb_thumb
|
||||
|
||||
thumb.save(thumb_path, quality=self.quality, optimize=True)
|
||||
generated.append(thumb_path)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating thumbnails for {image_path}: {e}")
|
||||
|
||||
return generated
|
||||
|
||||
def get_image_info(self, image_path):
|
||||
"""
|
||||
Get basic image information - simple and fast
|
||||
"""
|
||||
try:
|
||||
with Image.open(image_path) as img:
|
||||
info = {
|
||||
'width': img.width,
|
||||
'height': img.height,
|
||||
'format': img.format,
|
||||
'mode': img.mode,
|
||||
'size_kb': os.path.getsize(image_path) // 1024
|
||||
}
|
||||
|
||||
# Get EXIF data if available
|
||||
if hasattr(img, '_getexif'):
|
||||
exif = img._getexif()
|
||||
if exif:
|
||||
exif_data = {}
|
||||
for key, value in exif.items():
|
||||
tag = ExifTags.TAGS.get(key, key)
|
||||
exif_data[tag] = value
|
||||
info['exif'] = exif_data
|
||||
|
||||
return info
|
||||
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def bulk_optimize_images(content_dir, max_width=2048, quality=85):
|
||||
"""
|
||||
Utility function for bulk optimization - grug simple
|
||||
"""
|
||||
optimizer = ImageOptimizer(max_width=max_width, quality=quality)
|
||||
return optimizer.optimize_folder(content_dir, backup=True)
|
||||
|
||||
|
||||
def create_image_gallery_data(folder_path):
|
||||
"""
|
||||
Create gallery data structure for templates - simple and useful
|
||||
"""
|
||||
folder_path = Path(folder_path)
|
||||
images = []
|
||||
image_extensions = {'.jpg', '.jpeg', '.png', '.gif'}
|
||||
|
||||
for file_path in folder_path.iterdir():
|
||||
if file_path.suffix.lower() in image_extensions and file_path.is_file():
|
||||
optimizer = ImageOptimizer()
|
||||
info = optimizer.get_image_info(file_path)
|
||||
|
||||
if 'error' not in info:
|
||||
images.append({
|
||||
'filename': file_path.name,
|
||||
'path': str(file_path.relative_to(folder_path.parent)),
|
||||
'width': info['width'],
|
||||
'height': info['height'],
|
||||
'size_kb': info['size_kb'],
|
||||
'exif': info.get('exif', {}),
|
||||
'date_taken': info.get('exif', {}).get('DateTimeOriginal', ''),
|
||||
'camera': info.get('exif', {}).get('Model', '')
|
||||
})
|
||||
|
||||
# Sort by date taken or filename
|
||||
images.sort(key=lambda x: x.get('date_taken') or x['filename'])
|
||||
return images
|
||||
@ -6,7 +6,29 @@ import multiprocessing
|
||||
|
||||
|
||||
class Server(BaseApplication):
|
||||
|
||||
"""
|
||||
Server class for managing a Flask web application with Gunicorn integration.
|
||||
This class extends BaseApplication to provide a configurable server environment
|
||||
for Flask applications. It supports custom template functions, dynamic worker/thread
|
||||
configuration, and flexible server options.
|
||||
Attributes:
|
||||
debug (bool): Enables or disables debug mode for the Flask app.
|
||||
host (str): The hostname or IP address to bind the server to.
|
||||
port (int): The port number to listen on.
|
||||
app (Flask): The Flask application instance.
|
||||
application (Flask): Alias for the Flask application instance.
|
||||
options (dict): Gunicorn server options such as bind address, reload, threads, and access log.
|
||||
Methods:
|
||||
__init__(self, debug=True, host="0.0.0.0", port=8080, template_functions=None, workers=..., access_log=True, options=None):
|
||||
Initializes the Server instance with the specified configuration and registers template functions.
|
||||
register_template_function(self, name, func):
|
||||
Registers a Python function to be available in Jinja2 templates.
|
||||
load_config(self):
|
||||
Loads configuration options from self.options into the Gunicorn config object.
|
||||
load(self):
|
||||
Returns the Flask application instance managed by the server.
|
||||
register_route(self, route, func, defaults=None):
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
debug: bool = True,
|
||||
@ -32,17 +54,42 @@ class Server(BaseApplication):
|
||||
"threads": workers,
|
||||
"accesslog": "-" if access_log else None,
|
||||
}
|
||||
for name, func in template_functions.items():
|
||||
self.register_template_function(name, func)
|
||||
super().__init__()
|
||||
|
||||
for name, func in template_functions.items():
|
||||
self.register_template_function(name, func)
|
||||
super(Server, self).__init__()
|
||||
|
||||
def register_template_function(self, name, func):
|
||||
"""
|
||||
Register a function to be available in Jinja2 templates.
|
||||
|
||||
This method adds a Python function to the Jinja2 environment's globals,
|
||||
making it available for use in all templates rendered by the application.
|
||||
|
||||
Parameters:
|
||||
----------
|
||||
name : str
|
||||
The name under which the function will be accessible in templates
|
||||
func : callable
|
||||
The Python function to register
|
||||
|
||||
Examples:
|
||||
--------
|
||||
>>> server.register_template_function('format_date', lambda d: d.strftime('%Y-%m-%d'))
|
||||
>>> # In template: {{ format_date(some_date) }}
|
||||
"""
|
||||
self.app.jinja_env.globals.update({name: func})
|
||||
|
||||
def load_config(self):
|
||||
"""
|
||||
Loads configuration options from self.options into self.cfg.
|
||||
|
||||
This method filters out options that are not in self.cfg.settings or have None values.
|
||||
The filtered options are then set in the configuration object (self.cfg) with lowercase keys.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
config = {
|
||||
key: value
|
||||
for key, value in self.options.items()
|
||||
@ -52,7 +99,24 @@ class Server(BaseApplication):
|
||||
self.cfg.set(key.lower(), value)
|
||||
|
||||
def load(self):
|
||||
"""
|
||||
Returns the application instance associated with the server.
|
||||
|
||||
Returns:
|
||||
Application: The application object managed by the server.
|
||||
"""
|
||||
return self.application
|
||||
|
||||
def register_route(self, route, func, defaults=None):
|
||||
"""
|
||||
Registers a new route with the Flask application.
|
||||
|
||||
Args:
|
||||
route (str): The URL route to register.
|
||||
func (callable): The view function to associate with the route.
|
||||
defaults (dict, optional): A dictionary of default values for the route variables. Defaults to None.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
self.app.add_url_rule(route, func.__name__, func, defaults=defaults)
|
||||
|
||||
157
src/server/simple_cache.py
Normal file
157
src/server/simple_cache.py
Normal file
@ -0,0 +1,157 @@
|
||||
"""
|
||||
Simple caching system for foldsite - following grug principles
|
||||
No fancy cache libraries, just simple in-memory cache with TTL
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Optional, Callable
|
||||
from threading import Lock
|
||||
|
||||
|
||||
class SimpleCache:
|
||||
"""
|
||||
Grug-approved simple cache: does one thing well
|
||||
- In-memory storage with TTL
|
||||
- Thread-safe
|
||||
- No complexity demons
|
||||
"""
|
||||
|
||||
def __init__(self, default_ttl: int = 300): # 5 minutes default
|
||||
self.cache = {}
|
||||
self.lock = Lock()
|
||||
self.default_ttl = default_ttl
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""Get value from cache, return None if expired or missing"""
|
||||
with self.lock:
|
||||
if key in self.cache:
|
||||
value, expiry = self.cache[key]
|
||||
if time.time() < expiry:
|
||||
return value
|
||||
else:
|
||||
# Clean up expired entry
|
||||
del self.cache[key]
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
|
||||
"""Set value in cache with TTL"""
|
||||
if ttl is None:
|
||||
ttl = self.default_ttl
|
||||
|
||||
expiry = time.time() + ttl
|
||||
with self.lock:
|
||||
self.cache[key] = (value, expiry)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
"""Remove key from cache"""
|
||||
with self.lock:
|
||||
self.cache.pop(key, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all cache entries"""
|
||||
with self.lock:
|
||||
self.cache.clear()
|
||||
|
||||
def cleanup_expired(self) -> int:
|
||||
"""Remove expired entries, return count of removed items"""
|
||||
current_time = time.time()
|
||||
expired_keys = []
|
||||
|
||||
with self.lock:
|
||||
for key, (value, expiry) in self.cache.items():
|
||||
if current_time >= expiry:
|
||||
expired_keys.append(key)
|
||||
|
||||
for key in expired_keys:
|
||||
del self.cache[key]
|
||||
|
||||
return len(expired_keys)
|
||||
|
||||
def cache_info(self) -> dict:
|
||||
"""Get cache statistics - useful for debugging"""
|
||||
with self.lock:
|
||||
current_time = time.time()
|
||||
expired_count = sum(1 for _, expiry in self.cache.values() if current_time >= expiry)
|
||||
|
||||
return {
|
||||
'total_entries': len(self.cache),
|
||||
'expired_entries': expired_count,
|
||||
'active_entries': len(self.cache) - expired_count,
|
||||
'memory_usage_estimate': sum(len(str(k)) + len(str(v[0])) for k, v in self.cache.items())
|
||||
}
|
||||
|
||||
|
||||
def cached(cache_instance: SimpleCache, ttl: Optional[int] = None, key_func: Optional[Callable] = None):
|
||||
"""
|
||||
Simple decorator for caching function results
|
||||
Grug-approved: easy to use, easy to understand
|
||||
"""
|
||||
def decorator(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
# Generate cache key
|
||||
if key_func:
|
||||
cache_key = key_func(*args, **kwargs)
|
||||
else:
|
||||
# Simple key generation
|
||||
cache_key = f"{func.__name__}:{hash(str(args) + str(sorted(kwargs.items())))}"
|
||||
|
||||
# Try to get from cache
|
||||
result = cache_instance.get(cache_key)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
# Compute result and cache it
|
||||
result = func(*args, **kwargs)
|
||||
cache_instance.set(cache_key, result, ttl)
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# Global cache instance for the application
|
||||
app_cache = SimpleCache(default_ttl=300) # 5 minutes default
|
||||
|
||||
|
||||
def get_folder_contents_cache_key(content_dir: str, folder: str = "") -> str:
|
||||
"""Generate cache key for folder contents"""
|
||||
return f"folder_contents:{content_dir}:{folder}"
|
||||
|
||||
|
||||
def get_posts_cache_key(content_dir: str, limit: int, folder: str = "") -> str:
|
||||
"""Generate cache key for recent posts"""
|
||||
return f"recent_posts:{content_dir}:{limit}:{folder}"
|
||||
|
||||
|
||||
def invalidate_content_cache(content_dir: str) -> None:
|
||||
"""
|
||||
Invalidate all content-related caches when content changes
|
||||
Simple approach: clear all keys that start with content patterns
|
||||
"""
|
||||
cache = app_cache
|
||||
with cache.lock:
|
||||
keys_to_delete = []
|
||||
for key in cache.cache.keys():
|
||||
if (key.startswith(f"folder_contents:{content_dir}") or
|
||||
key.startswith(f"recent_posts:{content_dir}") or
|
||||
key.startswith(f"posts_by_tag:{content_dir}")):
|
||||
keys_to_delete.append(key)
|
||||
|
||||
for key in keys_to_delete:
|
||||
cache.cache.pop(key, None)
|
||||
|
||||
|
||||
# Utility function for cache management in admin
|
||||
def get_cache_stats() -> dict:
|
||||
"""Get cache statistics for admin interface"""
|
||||
stats = app_cache.cache_info()
|
||||
stats['cleanup_removed'] = app_cache.cleanup_expired()
|
||||
return stats
|
||||
|
||||
|
||||
def clear_all_cache() -> int:
|
||||
"""Clear all cache and return count of entries removed"""
|
||||
with app_cache.lock:
|
||||
count = len(app_cache.cache)
|
||||
app_cache.cache.clear()
|
||||
return count
|
||||
20
uv.lock
generated
20
uv.lock
generated
@ -81,6 +81,7 @@ dependencies = [
|
||||
{ name = "bs4" },
|
||||
{ name = "flask" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "mistune" },
|
||||
{ name = "pillow" },
|
||||
{ name = "python-frontmatter" },
|
||||
@ -94,6 +95,7 @@ requires-dist = [
|
||||
{ name = "bs4", specifier = ">=0.0.2" },
|
||||
{ name = "flask", specifier = ">=3.1.0" },
|
||||
{ name = "gunicorn", specifier = ">=23.0.0" },
|
||||
{ name = "jinja2", specifier = ">=3.1.6" },
|
||||
{ name = "mistune", specifier = ">=3.1.1" },
|
||||
{ name = "pillow", specifier = ">=10.4.0" },
|
||||
{ name = "python-frontmatter", specifier = ">=1.1.0" },
|
||||
@ -125,14 +127,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.5"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/92/b3130cbbf5591acf9ade8708c365f3238046ac7cb8ccba6e81abccb0ccff/jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", size = 244674 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/0f/2ba5fbcd631e3e88689309dbe978c5769e883e4b84ebfe7da30b43275c5a/jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb", size = 134596 },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -186,11 +188,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mistune"
|
||||
version = "3.1.1"
|
||||
version = "3.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c6/1d/6b2b634e43bacc3239006e61800676aa6c41ac1836b2c57497ed27a7310b/mistune-3.1.1.tar.gz", hash = "sha256:e0740d635f515119f7d1feb6f9b192ee60f0cc649f80a8f944f905706a21654c", size = 94645 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/80/f7/f6d06304c61c2a73213c0a4815280f70d985429cda26272f490e42119c1a/mistune-3.1.2.tar.gz", hash = "sha256:733bf018ba007e8b5f2d3a9eb624034f6ee26c4ea769a98ec533ee111d504dff", size = 94613 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/02/c66bdfdadbb021adb642ca4e8a5ed32ada0b4a3e4b39c5d076d19543452f/mistune-3.1.1-py3-none-any.whl", hash = "sha256:02106ac2aa4f66e769debbfa028509a275069dcffce0dfa578edd7b991ee700a", size = 53696 },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/92/30b4e54c4d7c48c06db61595cffbbf4f19588ea177896f9b78f0fbe021fd/mistune-3.1.2-py3-none-any.whl", hash = "sha256:4b47731332315cdca99e0ded46fc0004001c1299ff773dfb48fbe1fd226de319", size = 53696 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -305,7 +307,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@ -313,9 +315,9 @@ dependencies = [
|
||||
{ name = "shellingham" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/dca7b219718afd37a0068f4f2530a727c2b74a8b6e8e0c0080a4c0de4fcd/typer-0.15.1.tar.gz", hash = "sha256:a0588c0a7fa68a1978a069818657778f86abe6ff5ea6abf472f940a08bfe4f0a", size = 99789 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/cc/0a838ba5ca64dc832aa43f727bd586309846b0ffb2ce52422543e6075e8a/typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847", size = 44908 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user