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
|
runs-on: ubuntu-latest
|
||||||
name: Datadog Static Analyzer
|
name: Datadog Static Analyzer
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
- name: Check code for comitted secrets
|
- name: Check code for comitted secrets
|
||||||
id: datadog-static-analysis
|
id: datadog-static-analysis
|
||||||
uses: DataDog/datadog-static-analyzer-github-action@v1
|
uses: DataDog/datadog-static-analyzer-github-action@v1
|
||||||
with:
|
with:
|
||||||
dd_api_key: ${{ secrets.DD_API_KEY }}
|
dd_api_key: ${{ secrets.DD_API_KEY }}
|
||||||
dd_app_key: ${{ secrets.DD_APP_KEY }}
|
dd_app_key: ${{ secrets.DD_APP_KEY }}
|
||||||
dd_site: datadoghq.com
|
dd_site: datadoghq.com
|
||||||
secrets_enabled: true
|
secrets_enabled: true
|
||||||
static_analysis_enabled: false
|
static_analysis_enabled: false
|
||||||
cpu_count: 2
|
cpu_count: 8
|
||||||
|
|||||||
@ -16,4 +16,26 @@ jobs:
|
|||||||
dd_api_key: ${{ secrets.DD_API_KEY }}
|
dd_api_key: ${{ secrets.DD_API_KEY }}
|
||||||
dd_app_key: ${{ secrets.DD_APP_KEY }}
|
dd_app_key: ${{ secrets.DD_APP_KEY }}
|
||||||
dd_site: datadoghq.com
|
dd_site: datadoghq.com
|
||||||
cpu_count: 2
|
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:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@ -41,4 +44,29 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
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]
|
[paths]
|
||||||
content_dir = "/home/dubey/projects/foldsite/example/content"
|
content_dir = "/home/dubey/projects/foldsite/docs/content"
|
||||||
templates_dir = "/home/dubey/projects/foldsite/example/templates"
|
templates_dir = "/home/dubey/projects/foldsite/docs/templates"
|
||||||
styles_dir = "/home/dubey/projects/foldsite/example/styles"
|
styles_dir = "/home/dubey/projects/foldsite/docs/styles"
|
||||||
|
|
||||||
[server]
|
[server]
|
||||||
listen_address = "0.0.0.0"
|
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.args import create_parser
|
||||||
from src.config.config import Configuration
|
from src.config.config import Configuration
|
||||||
from src.rendering.helpers import TemplateHelpers
|
from src.rendering.helpers import TemplateHelpers
|
||||||
from src.server.file_manager import create_filemanager_blueprint
|
from src.server.enhanced_file_manager import create_enhanced_filemanager_blueprint
|
||||||
|
from src.rendering.debug_helpers import init_debug_helper
|
||||||
|
|
||||||
AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
|
||||||
PASSWORD = "YiaysZ4g8QX1R8R"
|
|
||||||
AWS_ACCESS_KEY_ID = "AIDAJQABLZS4A3QDU576"
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@ -21,6 +17,9 @@ def main():
|
|||||||
r = RouteManager(c)
|
r = RouteManager(c)
|
||||||
t = TemplateHelpers(c)
|
t = TemplateHelpers(c)
|
||||||
|
|
||||||
|
# Initialize debug helper for better developer experience
|
||||||
|
init_debug_helper(c)
|
||||||
|
|
||||||
server = Server(
|
server = Server(
|
||||||
debug=c.debug,
|
debug=c.debug,
|
||||||
host=c.listen_address,
|
host=c.listen_address,
|
||||||
@ -29,18 +28,30 @@ def main():
|
|||||||
workers=c.max_threads,
|
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_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_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_sibling_content_folders", t.get_sibling_content_folders)
|
||||||
server.register_template_function("get_folder_contents", t.get_folder_contents)
|
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("/styles/<path:path>", r.get_style)
|
||||||
server.register_route("/download/<path:path>", r.get_static)
|
server.register_route("/download/<path:path>", r.get_static)
|
||||||
server.register_route("/", r.default_route, defaults={"path": ""})
|
server.register_route("/", r.default_route, defaults={"path": ""})
|
||||||
server.register_route("/<path:path>", r.default_route)
|
server.register_route("/<path:path>", r.default_route)
|
||||||
|
|
||||||
if c.admin_browser:
|
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)
|
server.app.register_blueprint(file_manager_bp)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -8,6 +8,7 @@ dependencies = [
|
|||||||
"bs4>=0.0.2",
|
"bs4>=0.0.2",
|
||||||
"flask>=3.1.0",
|
"flask>=3.1.0",
|
||||||
"gunicorn>=23.0.0",
|
"gunicorn>=23.0.0",
|
||||||
|
"jinja2>=3.1.6",
|
||||||
"mistune>=3.1.1",
|
"mistune>=3.1.1",
|
||||||
"pillow>=10.4.0",
|
"pillow>=10.4.0",
|
||||||
"python-frontmatter>=1.1.0",
|
"python-frontmatter>=1.1.0",
|
||||||
|
|||||||
@ -16,8 +16,10 @@ gunicorn==23.0.0
|
|||||||
# via foldsite (pyproject.toml)
|
# via foldsite (pyproject.toml)
|
||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
# via flask
|
# via flask
|
||||||
jinja2==3.1.5
|
jinja2==3.1.6
|
||||||
# via flask
|
# via
|
||||||
|
# foldsite (pyproject.toml)
|
||||||
|
# flask
|
||||||
markdown-it-py==3.0.0
|
markdown-it-py==3.0.0
|
||||||
# via rich
|
# via rich
|
||||||
markupsafe==3.0.2
|
markupsafe==3.0.2
|
||||||
|
|||||||
@ -6,6 +6,34 @@ TEMPLATES_DIR = None
|
|||||||
STYLES_DIR = None
|
STYLES_DIR = None
|
||||||
|
|
||||||
class Configuration:
|
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):
|
def __init__(self, config_path):
|
||||||
self.config_path = config_path
|
self.config_path = config_path
|
||||||
@ -23,6 +51,19 @@ class Configuration:
|
|||||||
self.admin_password: str = None
|
self.admin_password: str = None
|
||||||
|
|
||||||
def load_config(self):
|
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:
|
try:
|
||||||
with open(self.config_path, "rb") as f:
|
with open(self.config_path, "rb") as f:
|
||||||
self.config_data = tomllib.load(f)
|
self.config_data = tomllib.load(f)
|
||||||
@ -61,11 +102,4 @@ class Configuration:
|
|||||||
self.max_threads = server.get("max_threads", self.max_threads)
|
self.max_threads = server.get("max_threads", self.max_threads)
|
||||||
self.admin_browser = server.get("admin_browser", self.admin_browser)
|
self.admin_browser = server.get("admin_browser", self.admin_browser)
|
||||||
self.admin_password = server.get("admin_password", self.admin_password)
|
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 dataclasses import dataclass
|
||||||
from src.config.config import Configuration
|
from src.config.config import Configuration
|
||||||
from src.rendering import GENERIC_FILE_MAPPING
|
from src.rendering import GENERIC_FILE_MAPPING
|
||||||
from src.rendering.markdown import (
|
from src.rendering.metadata_builders import (
|
||||||
render_markdown,
|
MetadataBuilderFactory,
|
||||||
read_raw_markdown,
|
BlogPostAnalyzer,
|
||||||
rendered_markdown_to_plain_text,
|
ImageGalleryAnalyzer,
|
||||||
|
ImageMetadata,
|
||||||
|
MarkdownMetadata,
|
||||||
|
FileMetadata
|
||||||
)
|
)
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from PIL import Image, ExifTags
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import frontmatter
|
import frontmatter
|
||||||
|
import os
|
||||||
|
from src.server.simple_cache import app_cache, cached, get_folder_contents_cache_key, get_posts_cache_key
|
||||||
@dataclass
|
from src.rendering.markdown import render_markdown
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -85,85 +59,46 @@ def format_date(timestamp):
|
|||||||
class TemplateHelpers:
|
class TemplateHelpers:
|
||||||
def __init__(self, config: Configuration):
|
def __init__(self, config: Configuration):
|
||||||
self.config: Configuration = config
|
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):
|
def _filter_hidden_files(self, files):
|
||||||
return [f for f in files if not f.name.startswith("___")]
|
return [f for f in files if not f.name.startswith("___")]
|
||||||
|
|
||||||
def _build_metadata_for_file(self, path: str, categories: list[str] = []):
|
def _build_metadata_for_file(self, file_path, categories: list[str] = []):
|
||||||
file_path = self.config.content_dir / path
|
"""
|
||||||
for k in categories:
|
Simple metadata builder using focused classes
|
||||||
if k == "image":
|
Grug-approved: clean delegation to specialized builders
|
||||||
img = Image.open(file_path)
|
"""
|
||||||
exif = img._getexif()
|
full_path = self.config.content_dir / file_path
|
||||||
# Conver exif to dict
|
return self.metadata_factory.build_metadata(full_path, categories)
|
||||||
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 get_folder_contents(self, path: str = ""):
|
def get_folder_contents(self, path: str = ""):
|
||||||
"""
|
"""
|
||||||
Retrieve the contents of a folder and return a list of TemplateFile objects.
|
Retrieve the contents of a folder and return a list of TemplateFile objects.
|
||||||
|
Now with caching to improve performance!
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
search_contnet_path = self.config.content_dir / path
|
# Check cache first
|
||||||
files = search_contnet_path.glob("*")
|
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 = []
|
ret = []
|
||||||
for f in files:
|
for f in files:
|
||||||
t = TemplateFile(
|
t = TemplateFile(
|
||||||
@ -174,7 +109,7 @@ class TemplateHelpers:
|
|||||||
categories=[],
|
categories=[],
|
||||||
date_modified=format_date(f.stat().st_mtime),
|
date_modified=format_date(f.stat().st_mtime),
|
||||||
date_created=format_date(f.stat().st_ctime),
|
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,
|
metadata=None,
|
||||||
dir_item_count=len(list(f.glob("*"))) if f.is_dir() else 0,
|
dir_item_count=len(list(f.glob("*"))) if f.is_dir() else 0,
|
||||||
is_dir=f.is_dir(),
|
is_dir=f.is_dir(),
|
||||||
@ -186,12 +121,18 @@ class TemplateHelpers:
|
|||||||
t.metadata = self._build_metadata_for_file(f, t.categories)
|
t.metadata = self._build_metadata_for_file(f, t.categories)
|
||||||
if "image" in 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
|
# 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_modified = t.metadata.exif["DateTimeOriginal"]
|
||||||
t.date_created = t.metadata.exif["DateTimeOriginal"]
|
t.date_created = t.metadata.exif["DateTimeOriginal"]
|
||||||
|
|
||||||
ret.append(t)
|
ret.append(t)
|
||||||
ret = self._filter_hidden_files(ret)
|
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
|
return ret
|
||||||
|
|
||||||
def get_sibling_content_files(self, path: str = ""):
|
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
|
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.
|
to the content directory. Only files that do not start with "___" are included.
|
||||||
"""
|
"""
|
||||||
search_contnet_path = self.config.content_dir / path
|
search_content_path: Path = self.config.content_dir / path
|
||||||
files = search_contnet_path.glob("*")
|
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 [
|
return [
|
||||||
(file.name, str(file.relative_to(self.config.content_dir)))
|
(file.name, str(file.relative_to(self.config.content_dir)))
|
||||||
for file in files
|
for file in files
|
||||||
@ -233,7 +175,7 @@ class TemplateHelpers:
|
|||||||
IOError: If an I/O error occurs while reading the file.
|
IOError: If an I/O error occurs while reading the file.
|
||||||
"""
|
"""
|
||||||
file_path = self.config.content_dir / path
|
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)
|
content = f.read(100)
|
||||||
return content
|
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
|
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.
|
to the content directory. Only directories that do not start with "___" are included.
|
||||||
"""
|
"""
|
||||||
search_contnet_path = self.config.content_dir / path
|
search_content_path = self.config.content_dir / path
|
||||||
files = search_contnet_path.glob("*")
|
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 [
|
return [
|
||||||
(file.name, str(file.relative_to(self.config.content_dir)))
|
(file.name, str(file.relative_to(self.config.content_dir)))
|
||||||
for file in files
|
for file in files
|
||||||
if file.is_dir() and not file.name.startswith("___")
|
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 PIL import Image
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from functools import cache
|
from functools import lru_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}"
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=512)
|
||||||
|
def generate_thumbnail(image_path, resize_percent, min_width, max_width):
|
||||||
# Open the image file
|
# Open the image file
|
||||||
with Image.open(image_path) as img:
|
with Image.open(image_path) as img:
|
||||||
# Calculate the new size based on the resize percentage
|
# 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_width = min_width
|
||||||
new_height = int(new_height * scale_factor)
|
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
|
# Resize the image while maintaining the aspect ratio
|
||||||
img.thumbnail((new_width, new_height))
|
img.thumbnail((new_width, new_height))
|
||||||
|
|
||||||
# Rotate the image based on the EXIF orientation tag
|
# Rotate the image based on the EXIF orientation tag
|
||||||
try:
|
try:
|
||||||
exif = img._getexif()
|
exif = img.info['exif']
|
||||||
orientation = exif.get(0x0112, 1) # 0x0112 is the EXIF orientation tag
|
orientation = img._getexif().get(0x0112, 1) # 0x0112 is the EXIF orientation tag
|
||||||
|
print(f"EXIF orientation: {orientation}, {image_path}")
|
||||||
if orientation == 3:
|
if orientation == 3:
|
||||||
img = img.rotate(180, expand=True)
|
img = img.rotate(180, expand=True)
|
||||||
elif orientation == 6:
|
elif orientation == 6:
|
||||||
@ -35,12 +39,12 @@ def generate_thumbnail(image_path, resize_percent, min_width):
|
|||||||
img = img.rotate(90, expand=True)
|
img = img.rotate(90, expand=True)
|
||||||
except (AttributeError, KeyError, IndexError):
|
except (AttributeError, KeyError, IndexError):
|
||||||
# cases: image don't have getexif
|
# cases: image don't have getexif
|
||||||
pass
|
exif = b""
|
||||||
|
|
||||||
# Save the thumbnail to a BytesIO object
|
# Save the thumbnail to a BytesIO object
|
||||||
thumbnail_io = BytesIO()
|
thumbnail_io = BytesIO()
|
||||||
img_format = img.format if img.format in ["JPEG", "JPG", "PNG"] else "JPEG"
|
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)
|
thumbnail_io.seek(0)
|
||||||
|
|
||||||
return (thumbnail_io.getvalue(), img_format)
|
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 import GENERIC_FILE_MAPPING
|
||||||
from src.rendering.markdown import render_markdown
|
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):
|
def count_file_extensions(path):
|
||||||
@ -126,6 +129,7 @@ def render_page(
|
|||||||
error_description="The requested resource was not found on this server.",
|
error_description="The requested resource was not found on this server.",
|
||||||
template_path=template_path,
|
template_path=template_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
target_path = path
|
target_path = path
|
||||||
target_file = path
|
target_file = path
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
@ -135,63 +139,26 @@ def render_page(
|
|||||||
relative_path = target_file.relative_to(base_path)
|
relative_path = target_file.relative_to(base_path)
|
||||||
relative_dir = target_path.relative_to(base_path)
|
relative_dir = target_path.relative_to(base_path)
|
||||||
|
|
||||||
"""
|
# Use new simplified template and style discovery system
|
||||||
The styles are ordered in the following manner:
|
template_discovery = TemplateDiscovery(template_path)
|
||||||
|
|
||||||
Specific style for the target path (e.g., /path/to/target.css).
|
# Find styles using simplified discovery
|
||||||
Specific styles for the type and extension in the current and parent directories
|
style_candidates = template_discovery.find_style_candidates(relative_path, type, category, extension)
|
||||||
(e.g., /path/to/__file.html.css).
|
styles = [s for s in style_candidates if (style_path / s[1:]).exists()]
|
||||||
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")
|
|
||||||
|
|
||||||
search_path = style_path / relative_dir
|
# Find template using simplified discovery
|
||||||
while search_path >= style_path:
|
found_template = template_discovery.find_template(relative_path, type, category, extension)
|
||||||
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
|
|
||||||
|
|
||||||
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()]
|
if found_template is None:
|
||||||
|
|
||||||
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 type == "file":
|
if type == "file":
|
||||||
return send_file(target_file)
|
return send_file(target_file)
|
||||||
else:
|
else:
|
||||||
@ -203,22 +170,32 @@ def render_page(
|
|||||||
)
|
)
|
||||||
|
|
||||||
content = ""
|
content = ""
|
||||||
|
c_frontmatter = None
|
||||||
if "document" in category and type == "file":
|
if "document" in category and type == "file":
|
||||||
content, c_frontmatter, obj = render_markdown(target_file)
|
content, c_frontmatter, obj = render_markdown(target_file)
|
||||||
|
|
||||||
if not (template_path / "base.html").exists():
|
if not (template_path / "base.html").exists():
|
||||||
raise Exception("Base template not found")
|
raise Exception("Base template not found")
|
||||||
|
|
||||||
templates.append(template_path / "base.html")
|
|
||||||
|
|
||||||
# Filter templates to only those that exist
|
# Use the found template from our simplified discovery system
|
||||||
for template in templates:
|
page_template_path = found_template
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
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 pathlib import Path
|
||||||
from src.config.config import Configuration
|
from src.config.config import Configuration
|
||||||
from src.rendering.renderer import render_page, render_error_page
|
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 src.rendering.image import generate_thumbnail
|
||||||
from functools import lru_cache
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
class RouteManager:
|
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):
|
def __init__(self, config: Configuration):
|
||||||
self.config = config
|
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.
|
This method resolves the requested path relative to a given base directory, ensuring:
|
||||||
:param requested_path: The requested file path to validate.
|
- The resolved path exists.
|
||||||
:return: A secure version of the requested path if valid, otherwise None.
|
- 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
|
try:
|
||||||
base_dir = Path(base_dir)
|
base_dir = Path(base_dir).resolve(strict=True)
|
||||||
requested_path: Path = base_dir / requested_path
|
# 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
|
# The most important check: ensure the resolved path is inside the base directory.
|
||||||
if requested_path < base_dir:
|
if not secure_path.is_relative_to(base_dir):
|
||||||
|
print(f"Illegal path traversal attempt: {requested_path_str}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Ensure the path does not contain any '..' or '.' components
|
# Check for hidden files/folders (starting with '___')
|
||||||
secure_path = os.path.relpath(requested_path, base_dir)
|
relative_parts = secure_path.relative_to(base_dir).parts
|
||||||
secure_path_parts = secure_path.split(os.sep)
|
# Also check the final component for the case where path is the base_dir itself.
|
||||||
|
if any(
|
||||||
for part in secure_path_parts:
|
part.startswith("___") for part in relative_parts
|
||||||
if part == "." or part == "..":
|
) or secure_path.name.startswith("___"):
|
||||||
print("Illegal path nice try")
|
print(f"Illegal access to hidden path: {requested_path_str}")
|
||||||
return None
|
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")
|
|
||||||
|
|
||||||
return secure_path
|
return secure_path
|
||||||
|
|
||||||
def _ensure_route(self, path: str):
|
def _ensure_route(self, path: str):
|
||||||
file_path: Path = self.config.content_dir / (path if path else "index.md")
|
file_path = self._validate_and_sanitize_path(self.config.content_dir, path)
|
||||||
if file_path < self.config.content_dir:
|
if not file_path:
|
||||||
raise Exception("Illegal path")
|
|
||||||
|
|
||||||
if not self._validate_and_sanitize_path(
|
|
||||||
self.config.content_dir, str(file_path)
|
|
||||||
):
|
|
||||||
raise Exception("Illegal path")
|
raise Exception("Illegal path")
|
||||||
|
return file_path
|
||||||
|
|
||||||
def default_route(self, path: str):
|
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:
|
try:
|
||||||
self._ensure_route(path)
|
file_path = self._ensure_route(path if path else "index.md")
|
||||||
except Exception as e:
|
except Exception as _:
|
||||||
return render_error_page(
|
return render_error_page(
|
||||||
404,
|
404,
|
||||||
"Not Found",
|
"Not Found",
|
||||||
"The requested resource was not found on this server.",
|
"The requested resource was not found on this server.",
|
||||||
self.config.templates_dir,
|
self.config.templates_dir,
|
||||||
)
|
)
|
||||||
file_path: Path = self.config.content_dir / (path if path else "index.md")
|
|
||||||
return render_page(
|
return render_page(
|
||||||
file_path,
|
file_path,
|
||||||
base_path=self.config.content_dir,
|
base_path=self.config.content_dir,
|
||||||
@ -80,19 +114,45 @@ class RouteManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_style(self, path: str):
|
def get_style(self, path: str):
|
||||||
try:
|
"""
|
||||||
self._validate_and_sanitize_path(self.config.styles_dir, path)
|
Retrieves and serves a style file from the configured styles directory.
|
||||||
except Exception as e:
|
|
||||||
|
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(
|
return render_error_page(
|
||||||
404,
|
404,
|
||||||
"Not Found",
|
"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,
|
self.config.templates_dir,
|
||||||
)
|
)
|
||||||
file_path: Path = self.config.styles_dir / path
|
return send_file(file_path)
|
||||||
if file_path.exists():
|
|
||||||
return send_file(file_path)
|
def get_static(self, path: str):
|
||||||
else:
|
"""
|
||||||
|
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(
|
return render_error_page(
|
||||||
404,
|
404,
|
||||||
"Not Found",
|
"Not Found",
|
||||||
@ -100,33 +160,18 @@ class RouteManager:
|
|||||||
self.config.templates_dir,
|
self.config.templates_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_static(self, path: str):
|
# Check to see if the file is an image, if it is, render a thumbnail
|
||||||
try:
|
if file_path.suffix.lower() in [".jpg", ".jpeg", ".png", ".gif"]:
|
||||||
self._validate_and_sanitize_path(self.config.content_dir, path)
|
max_width = request.args.get("max_width", default=2048, type=int)
|
||||||
except Exception as e:
|
thumbnail_bytes, img_format = generate_thumbnail(
|
||||||
return render_error_page(
|
str(file_path), 10, 2048, max_width
|
||||||
404,
|
|
||||||
"Not Found",
|
|
||||||
"The requested resource was not found on this server.",
|
|
||||||
self.config.templates_dir,
|
|
||||||
)
|
)
|
||||||
file_path: Path = self.config.content_dir / path
|
return (
|
||||||
if file_path.exists():
|
thumbnail_bytes,
|
||||||
# Check to see if the file is an image, if it is, render a thumbnail
|
200,
|
||||||
if file_path.suffix.lower() in [".jpg", ".jpeg", ".png", ".gif"]:
|
{
|
||||||
thumbnail_bytes, img_format = generate_thumbnail(
|
"Content-Type": f"image/{img_format.lower()}",
|
||||||
str(file_path), 10, 2048
|
"cache-control": "public, max-age=31536000",
|
||||||
)
|
},
|
||||||
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 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)
|
return redirect(next_url)
|
||||||
else:
|
else:
|
||||||
flash("Incorrect password")
|
flash("Incorrect password")
|
||||||
|
#no-dd-sa
|
||||||
return render_template_string('''
|
return render_template_string('''
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
@ -149,10 +150,10 @@ def create_filemanager_blueprint(base_dir, url_prefix='/files', auth_password=No
|
|||||||
</ul>
|
</ul>
|
||||||
<button onclick="bulkCut()">Bulk Cut Selected</button>
|
<button onclick="bulkCut()">Bulk Cut Selected</button>
|
||||||
<hr>
|
<hr>
|
||||||
<h2>Upload File</h2>
|
<h2>Upload File(s)</h2>
|
||||||
<form action="{{ url_for('filemanager.upload') }}" method="post" enctype="multipart/form-data">
|
<form action="{{ url_for('filemanager.upload') }}" method="post" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="path" value="{{ rel_path }}">
|
<input type="hidden" name="path" value="{{ rel_path }}">
|
||||||
<input type="file" name="file">
|
<input type="file" name="file" multiple>
|
||||||
<input type="submit" value="Upload">
|
<input type="submit" value="Upload">
|
||||||
</form>
|
</form>
|
||||||
<hr>
|
<hr>
|
||||||
@ -276,11 +277,13 @@ def create_filemanager_blueprint(base_dir, url_prefix='/files', auth_password=No
|
|||||||
return "Invalid path", 400
|
return "Invalid path", 400
|
||||||
if not os.path.isdir(abs_path):
|
if not os.path.isdir(abs_path):
|
||||||
return "Not a directory", 400
|
return "Not a directory", 400
|
||||||
file = request.files.get('file')
|
files = request.files.getlist('file')
|
||||||
if file:
|
if files:
|
||||||
filename = secure_filename(file.filename)
|
for file in files:
|
||||||
file.save(os.path.join(abs_path, filename))
|
if file and file.filename:
|
||||||
flash("Uploaded successfully")
|
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))
|
return redirect(url_for('filemanager.index', path=rel_path))
|
||||||
|
|
||||||
@filemanager.route('/rename', methods=['POST'])
|
@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):
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
debug: bool = True,
|
debug: bool = True,
|
||||||
@ -32,17 +54,42 @@ class Server(BaseApplication):
|
|||||||
"threads": workers,
|
"threads": workers,
|
||||||
"accesslog": "-" if access_log else None,
|
"accesslog": "-" if access_log else None,
|
||||||
}
|
}
|
||||||
for name, func in template_functions.items():
|
|
||||||
self.register_template_function(name, func)
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
for name, func in template_functions.items():
|
for name, func in template_functions.items():
|
||||||
self.register_template_function(name, func)
|
self.register_template_function(name, func)
|
||||||
super(Server, self).__init__()
|
|
||||||
|
|
||||||
def register_template_function(self, name, func):
|
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})
|
self.app.jinja_env.globals.update({name: func})
|
||||||
|
|
||||||
def load_config(self):
|
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 = {
|
config = {
|
||||||
key: value
|
key: value
|
||||||
for key, value in self.options.items()
|
for key, value in self.options.items()
|
||||||
@ -52,7 +99,24 @@ class Server(BaseApplication):
|
|||||||
self.cfg.set(key.lower(), value)
|
self.cfg.set(key.lower(), value)
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
|
"""
|
||||||
|
Returns the application instance associated with the server.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Application: The application object managed by the server.
|
||||||
|
"""
|
||||||
return self.application
|
return self.application
|
||||||
|
|
||||||
def register_route(self, route, func, defaults=None):
|
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)
|
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 = "bs4" },
|
||||||
{ name = "flask" },
|
{ name = "flask" },
|
||||||
{ name = "gunicorn" },
|
{ name = "gunicorn" },
|
||||||
|
{ name = "jinja2" },
|
||||||
{ name = "mistune" },
|
{ name = "mistune" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
{ name = "python-frontmatter" },
|
{ name = "python-frontmatter" },
|
||||||
@ -94,6 +95,7 @@ requires-dist = [
|
|||||||
{ name = "bs4", specifier = ">=0.0.2" },
|
{ name = "bs4", specifier = ">=0.0.2" },
|
||||||
{ name = "flask", specifier = ">=3.1.0" },
|
{ name = "flask", specifier = ">=3.1.0" },
|
||||||
{ name = "gunicorn", specifier = ">=23.0.0" },
|
{ name = "gunicorn", specifier = ">=23.0.0" },
|
||||||
|
{ name = "jinja2", specifier = ">=3.1.6" },
|
||||||
{ name = "mistune", specifier = ">=3.1.1" },
|
{ name = "mistune", specifier = ">=3.1.1" },
|
||||||
{ name = "pillow", specifier = ">=10.4.0" },
|
{ name = "pillow", specifier = ">=10.4.0" },
|
||||||
{ name = "python-frontmatter", specifier = ">=1.1.0" },
|
{ name = "python-frontmatter", specifier = ">=1.1.0" },
|
||||||
@ -125,14 +127,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jinja2"
|
name = "jinja2"
|
||||||
version = "3.1.5"
|
version = "3.1.6"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "markupsafe" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
@ -186,11 +188,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mistune"
|
name = "mistune"
|
||||||
version = "3.1.1"
|
version = "3.1.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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]]
|
[[package]]
|
||||||
@ -305,7 +307,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typer"
|
name = "typer"
|
||||||
version = "0.15.1"
|
version = "0.15.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
@ -313,9 +315,9 @@ dependencies = [
|
|||||||
{ name = "shellingham" },
|
{ name = "shellingham" },
|
||||||
{ name = "typing-extensions" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user