{{ post.title }}
+ {% if post.date %} + + {% endif %} + {% if post.description %} +{{ post.description }}
+ {% endif %} +diff --git a/CHANGELOG.md b/CHANGELOG.md index b08a28b9..098baeff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,26 @@ ## 0.10.0 +### Feed Pagination + +- Feat: Add feed pagination with three types: `htmx`, `manual`, `js` +- Feat: HTMX infinite scroll with partial loading +- Feat: JavaScript infinite scroll using Intersection Observer +- Feat: Manual page navigation with prev/next controls +- Feat: Configurable `items_per_page`, `pagination_type`, and `enabled` per feed + +### Security + +- Feat: SHA-256 integrity verification for HTMX downloads (25+ versions supported) +- Feat: Path traversal protection for feed slugs +- Feat: XSS prevention in templates using `|tojson` filter +- Feat: Fail securely if HTMX download fails (no CDN fallback) +- Feat: Comprehensive security test suite for feeds + +### Other + +- Feat: Improve feed name sanitization with Python identifier conversion +- Feat: Add pagination implementation guide documentation - Fix: `auto_description` now more accurately returns plain text, does not cut off words, and add an ellipsis. - Fix: article_html now typed such that it may be a dict without warning - publish_source now only supports using post models that include a dumps command, i.e. no longer frontmatter post objects diff --git a/docs/pagination-implementation-guide.md b/docs/pagination-implementation-guide.md new file mode 100644 index 00000000..82ba6fe8 --- /dev/null +++ b/docs/pagination-implementation-guide.md @@ -0,0 +1,357 @@ +--- +title: Pagination Implementation Guide +description: Guide for implementing pagination in Markata templates with manual, HTMX, and JavaScript options + +--- + +# Pagination Implementation Guide + +This guide provides clear instructions for implementing pagination in your Markata templates. The pagination system is integrated into the feeds plugin and supports three types: manual, HTMX, and JavaScript infinite scroll. + +## Core Components + +### Feeds Plugin with Pagination +**Location:** `markata/plugins/feeds.py` + +The feeds plugin includes built-in pagination support with these features: +- Automatic pagination for any feed +- Three pagination types: manual, HTMX, JavaScript +- Configurable items per page +- SEO-friendly URL generation +- Template context variables + +### Pagination Plugin (Core Logic) +**Location:** `markata/plugins/pagination.py` + +Core pagination functionality that provides: +- Pagination configuration models +- URL generation helpers +- Template rendering context +- Support for all pagination types + +## Quick Setup + +### 1. Basic Configuration + +Add to your `markata.yaml`: + +```yaml +# Enable pagination globally (optional, per-feed config also available) +pagination: + default: + enabled: true + items_per_page: 20 + pagination_type: 'manual' # 'manual', 'htmx', 'js' + +# Or configure per feed +feeds: + blog: + template: "feed.html" + partial_template: "feed_partial.html" + enabled: true + items_per_page: 10 + pagination_type: 'htmx' +``` + +### 2. Template Variables + +All pagination templates receive these variables: + +```jinja2 +{{ markata }} # Markata instance +{{ feed }} # Current feed object +{{ posts }} # Posts for current page +{{ page }} # Current page number (1-based) +{{ total_pages }} # Total number of pages +{{ has_prev }} # Boolean: has previous page? +{{ has_next }} # Boolean: has next page? +{{ prev_page }} # Previous page number or null +{{ next_page }} # Next page number or null +{{ pagination_enabled }} # Boolean: is pagination enabled? +{{ pagination_config }} # PaginationConfig object +{{ feed_name }} # Feed slug/name +{{ pagination_js_url }} # URL to pagination config JS (when using JS pagination) +``` + +## Pagination Types + +### 1. Manual Pagination + +**Best for:** SEO, accessibility, traditional blogs +**Features:** +- Traditional click navigation +- Page numbers +- Previous/Next buttons +- Clean permanent URLs +- Works without JavaScript + +**Configuration:** +```yaml +feeds: + blog: + pagination_type: 'manual' + items_per_page: 10 + show_page_numbers: true + max_page_links: 7 +``` + +**Template Implementation:** + +The feeds plugin automatically handles manual pagination when `pagination_type: 'manual'`. Your template just needs to include pagination controls: + +```jinja2 +{% if pagination_enabled %} +{% set config = pagination_config %} + +
{{ post.description }}
+ {% endif %} +{{ pagination_config | pprint }}
+Page: {{ page }}, Total: {{ total_pages }}
+Has Prev: {{ has_prev }}, Has Next: {{ has_next }}
+{% endif %}
+```
+
+This comprehensive guide should help you implement any pagination type in your Markata templates. Choose the pagination type that best fits your use case and customize templates to match your site's design.
\ No newline at end of file
diff --git a/markata.toml b/markata.toml
index 04a9f1eb..55a7024c 100644
--- a/markata.toml
+++ b/markata.toml
@@ -28,6 +28,9 @@ markdown_backend = 'markdown-it-py'
default_cache_expire = 1209600
# subroute = "docs"
+# HTMX version for pagination
+htmx_version = "2.0.8"
+
# set the subroute if you are deploying to a subroute of a site
# make sure you enable the subroute plugin
# subroute = "docs"
@@ -292,5 +295,39 @@ plugin = "markata.plugins.md_it_wikilinks:wikilinks_plugin"
config = { markata = "markata" }
[markata.glob]
-glob_patterns = ["docs/**/*.md","CHANGELOG.md"]
+glob_patterns = [
+"docs/**/*.md",
+"pages/**/*.md",
+"CHANGELOG.md",
+]
use_gitignore = true
+
+[[markata.feeds]]
+slug = 'tag/htmx'
+filter = "date<=today and published==True"
+sort = "date"
+reverse = true
+description = 'Test HTMX pagination feed'
+enabled = true
+items_per_page = 2
+pagination_type = 'htmx'
+
+[[markata.feeds]]
+slug = 'tag/manual'
+filter = "date<=today and published==True"
+sort = "date"
+reverse = true
+description = 'Test manual pagination feed'
+enabled = true
+items_per_page = 3
+pagination_type = 'manual'
+
+[[markata.feeds]]
+slug = 'tag/js'
+filter = "date<=today and published==True"
+sort = "date"
+reverse = true
+description = 'Test JS pagination feed'
+enabled = true
+items_per_page = 2
+pagination_type = 'js'
diff --git a/markata/plugins/feeds.py b/markata/plugins/feeds.py
index 46f9b2af..f52a2f84 100644
--- a/markata/plugins/feeds.py
+++ b/markata/plugins/feeds.py
@@ -188,7 +188,10 @@
"""
+from __future__ import annotations
+
import datetime
+import re
import shutil
import textwrap
import warnings
@@ -197,6 +200,7 @@
from typing import Any
from typing import List
from typing import Optional
+from urllib.request import urlopen
import jinja2
import pydantic
@@ -204,15 +208,15 @@
from jinja2 import Template
from jinja2 import Undefined
from pydantic import ConfigDict
+from pydantic import Field
from pydantic import field_validator
+from rich.console import Console
from rich.jupyter import JupyterMixin
from rich.pretty import Pretty
from rich.table import Table
-from markata import Markata
from markata import __version__
from markata import background
-from markata.errors import DeprecationWarning
from markata.hookspec import hook_impl
from markata.hookspec import register_attr
from markata.plugins.jinja_env import get_template
@@ -220,15 +224,68 @@
if TYPE_CHECKING:
from frontmatter import Post
- from rich.console import Console
+
+ from markata import Markata
+
+
+def to_pythonic_identifier(name: str) -> str:
+ """
+ Convert a string to a valid Python identifier.
+
+ This function handles various problematic characters that might appear
+ in feed names or slugs, making them suitable for use as Python attribute
+ names and dictionary keys.
+
+ Rules applied:
+ - Replace spaces, slashes, dots, and other non-alphanumeric characters with underscores
+ - Convert to lowercase
+ - Remove leading/trailing underscores
+ - Ensure the result starts with a letter or underscore
+ - Collapse multiple consecutive underscores to a single one
+
+ Examples:
+ 'project-gallery' -> 'project_gallery'
+ 'tag/htmx' -> 'tag_htmx'
+ 'My Feed Name' -> 'my_feed_name'
+ '123start' -> '_123start'
+ """
+ if not name:
+ return "_unnamed"
+
+ # Replace non-alphanumeric characters (except underscores) with underscores
+ pythonic = re.sub(r"[^a-zA-Z0-9_]", "_", str(name))
+
+ # Convert to lowercase
+ pythonic = pythonic.lower()
+
+ # Collapse multiple consecutive underscores
+ pythonic = re.sub(r"_+", "_", pythonic)
+
+ # Remove leading and trailing underscores
+ pythonic = pythonic.strip("_")
+
+ # Ensure it starts with a letter or underscore (not a digit)
+ if pythonic and pythonic[0].isdigit():
+ pythonic = "_" + pythonic
+
+ # Handle empty result or result that became empty after processing
+ if not pythonic:
+ pythonic = "_unnamed"
+
+ return pythonic
class SilentUndefined(Undefined):
+ """A Jinja2 Undefined subclass that silently returns empty string on errors."""
+
def _fail_with_undefined_error(self, *args, **kwargs):
return ""
-class MarkataFilterError(RuntimeError): ...
+class MarkataFilterError(RuntimeError):
+ """Raised when a feed filter expression fails."""
+
+ ...
class FeedConfig(pydantic.BaseModel, JupyterMixin):
@@ -255,6 +312,12 @@ class FeedConfig(pydantic.BaseModel, JupyterMixin):
sitemap_template: str = "sitemap.xml"
xsl_template: str = "rss.xsl"
+ # Pagination configuration
+ enabled: bool = False
+ items_per_page: int = 10
+ pagination_type: str = "htmx" # htmx, manual, js
+ per_page: int = 10 # backwards compatibility
+
model_config = ConfigDict(
validate_assignment=True, # Config model
arbitrary_types_allowed=True,
@@ -269,11 +332,11 @@ class FeedConfig(pydantic.BaseModel, JupyterMixin):
@classmethod
def default_name(cls, v, info) -> str:
if v:
- return v
+ return to_pythonic_identifier(str(v))
slug = info.data.get("slug")
if not slug:
raise ValueError("Either name or slug must be provided")
- return str(slug).replace("-", "_")
+ return to_pythonic_identifier(str(slug))
@field_validator("slug", mode="before")
@classmethod
@@ -290,7 +353,7 @@ def __rich_console__(self) -> "Console":
return self.markata.console
@property
- def __rich__(self) -> Pretty:
+ def __rich__(self):
return lambda: Pretty(self)
@@ -300,8 +363,9 @@ class Feed(pydantic.BaseModel, JupyterMixin):
## Usage
``` python
- from markata import Markata
- m = Markata()
+ if not TYPE_CHECKING:
+ from markata import Markata
+ m = Markata()
# access posts for a feed
m.feeds.docs.posts
@@ -312,7 +376,7 @@ class Feed(pydantic.BaseModel, JupyterMixin):
"""
config: FeedConfig
- markata: Markata = pydantic.Field(exclude=True)
+ markata: Any = Field(exclude=True)
model_config = ConfigDict(
validate_assignment=False,
@@ -331,6 +395,22 @@ def name(self) -> str:
@property
def posts(self):
+ # Get posts from instance state or compute normally
+ return self._get_posts()
+
+ def _get_posts(self, override_posts=None):
+ """
+ Get posts with optional override for pagination.
+
+ Args:
+ override_posts: If provided, returns these posts instead of computing
+
+ Returns:
+ PrettyList of posts
+ """
+ if override_posts is not None:
+ return PrettyList(override_posts)
+
posts = self.map("post")
if self.config.head is not None and self.config.tail is not None:
head_posts = posts[: self.config.head]
@@ -393,6 +473,8 @@ def dump_bytecode(self, bucket):
class FeedsConfig(pydantic.BaseModel):
feeds: List[FeedConfig] = [FeedConfig(slug="archive")]
+ htmx_version: str = "2.0.8"
+ skip_htmx_integrity_check: bool = False
@property
def jinja_env(self):
@@ -430,10 +512,313 @@ def __rich__(self) -> Pretty:
@hook_impl(tryfirst=True)
+@register_attr("config_models")
def config_model(markata: Markata) -> None:
markata.config_models.append(FeedsConfig)
+@hook_impl(tryfirst=True)
+def htmx_config_model(markata: Markata) -> None:
+ """Register HTMX configuration model with validation."""
+
+ class HtmxConfig(pydantic.BaseModel):
+ version: str = "2.0.8"
+
+ model_config = ConfigDict(
+ validate_assignment=True,
+ extra="forbid",
+ )
+
+ markata.config_models.append(HtmxConfig)
+
+
+@hook_impl
+def configure(markata: Markata) -> None:
+ """
+ Configure feeds during configuration phase.
+ """
+ _download_htmx_if_needed(markata)
+ _copy_pagination_static_files(markata, Path(markata.config.output_dir))
+
+
+def _download_htmx_if_needed(markata: Markata) -> None:
+ """
+ Download HTMX library to static directory if needed with integrity verification.
+ """
+ import hashlib
+ from urllib.error import HTTPError
+ from urllib.error import URLError
+ from urllib.request import Request
+
+ htmx_version = markata.config.htmx_version
+ htmx_filename = "htmx.min.js"
+ htmx_static_path = Path(markata.config.output_dir) / "static" / "js" / htmx_filename
+ htmx_url = f"https://unpkg.com/htmx.org@{htmx_version}/dist/htmx.min.js"
+
+ # Known SHA-256 hashes for HTMX versions
+ HTMX_INTEGRITY_HASHES = {
+ "1.9.10": "b3bdcf5c741897a53648b1207fff0469a0d61901429ba1f6e88f98ebd84e669e",
+ "2.0.8": "22283ef68cb7545914f0a88a1bdedc7256a703d1d580c1d255217d0a50d31313",
+ "2.0.7": "60231ae6ba9db3825eb15a261122d5f55921c4d53b66bf637dc18b4ee27c79f9",
+ "2.0.6": "b6768eed4f3af85b73a75054701bd60e17cac718aef2b7f6b254e5e0e2045616",
+ "2.0.5": "f601807715bde32e458b73821e16c5641a3d90dfb670f6ebd986f128b8222fcf",
+ "2.0.4": "e209dda5c8235479f3166defc7750e1dbcd5a5c1808b7792fc2e6733768fb447",
+ "2.0.3": "491955cd1810747d7d7b9ccb936400afb760e06d25d53e4572b64b6563b2784e",
+ "2.0.2": "e1746d9759ec0d43c5c284452333a310bb5fd7285ebac4b2dc9bf44d72b5a887",
+ "2.0.1": "6d4aaa4b0d3e8b4c91f8d97b92a361a19b1bd4544dea3f668fdc3e62a63995df",
+ "2.0.0": "0fc57ba0e655504d282bb6ec1c3d89240cde9f2ce1c393d5b38a95c5bc6da875",
+ "1.9.12": "449317ade7881e949510db614991e195c3a099c4c791c24dacec55f9f4a2a452",
+ "1.9.11": "d15107cc7f040a9e83b1b66176fd927ad40b5e0255813a03f8ccfeed46ee42b0",
+ "1.9.9": "96a334a9570a382cf9c61a1f86d55870ba1c65e166cc5bcae98ddd8cdabeb886",
+ "1.9.8": "c4fce4dc5cc9c8c3c9bf1aa788d54bb2cb25cd27114eb06551494ff61c30d6fb",
+ "1.9.7": "30c95cb75e7f7c9471c2bf43fa3db0a30a39077764295b15c405869fed7e5764",
+ "1.9.6": "cbb723c305cf6d6315c890909815523588509e2e092a59f8cfc4a885829689d5",
+ "1.9.5": "76a9887f1ce3bf8f88bea3b327f1e74b9d9b42e1dd9cb8237a87a74261d5d042",
+ "1.9.4": "5c88af44013df62fde8a5e4fdf524d8a16834a28b1d15e34ae0994ac27cd4c7e",
+ "1.9.3": "8f567d21cbe0553643db48866b2377a3bbb9247f8d924428002c2b847f28b23c",
+ "1.9.2": "fd346e9c8639d4624893fc455f2407a09b418301736dd18ebbb07764637fb478",
+ "1.9.1": "d7bff1d0f45e3418fa820d8a6f0de1ca5e87562f218a0f06add08652c7691a9c",
+ "1.9.0": "97df3adfbf23b873d9a3a80f7143d801a32604ba29de9a33f21a92a171076aa8",
+ "1.8.5": "705fb60063bf5270b7077409b848b57ea24d2277b806aa04efea513287bf63a6",
+ "1.8.4": "df72edb141a16578945a0356c8a6a37239015251962071639b99b0184691ed1d",
+ "1.8.3": "df811b5d27b3dddfec9a858b437b0c7302a56959450f0f9c133ef356c25fcf1c",
+ "1.8.2": "91e7fb193c4a6a5d3bb56ed0a7007933664e7803da389a696de61147a6f66058",
+ "1.8.1": "1a1c942f7bb50dcc2198b2f3c6cc64199332e32a5ba08e7bd2215aa0a1966a55",
+ "1.8.0": "914e05e274362f2e166fc5a8cf6272e2042d9b9e50647678c64c579dcb5fa441",
+ }
+
+ expected_hash = HTMX_INTEGRITY_HASHES.get(htmx_version)
+ if not expected_hash:
+ if markata.config.skip_htmx_integrity_check:
+ markata.console.warn(
+ f"No integrity hash available for HTMX version {htmx_version}, skipping verification"
+ )
+ expected_hash = None
+ else:
+ raise ValueError(
+ f"No integrity hash available for HTMX version {htmx_version}. "
+ f"You can add 'skip_htmx_integrity_check: true' to your config to skip verification, "
+ f"or add the hash to HTMX_INTEGRITY_HASHES in markata/plugins/feeds.py"
+ )
+
+ # Download if file doesn't exist
+ if not htmx_static_path.exists():
+ try:
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", category=ResourceWarning)
+
+ # Ensure static/js directory exists
+ htmx_static_path.parent.mkdir(parents=True, exist_ok=True)
+
+ # Download HTMX with timeout and integrity verification
+ request = Request(htmx_url, headers={"User-Agent": "Markata/1.0"})
+ with urlopen(request, timeout=10) as response:
+ content = response.read()
+
+ # Verify content integrity if hash is available
+ if expected_hash:
+ actual_hash = hashlib.sha256(content).hexdigest()
+ if actual_hash != expected_hash:
+ raise ValueError(
+ f"HTMX integrity check failed. Expected: {expected_hash}, Got: {actual_hash}"
+ )
+
+ htmx_static_path.write_bytes(content)
+
+ verification_status = (
+ "verified" if expected_hash else "without verification"
+ )
+ markata.console.print(
+ f"Downloaded HTMX {htmx_version} to {htmx_static_path} ({verification_status})"
+ )
+
+ except (URLError, HTTPError, ValueError) as e:
+ markata.console.error(f"Failed to download HTMX: {e}")
+ # Critical security: no fallback to CDN
+ raise RuntimeError(
+ f"HTMX download failed: {e}. Cannot proceed without verified HTMX."
+ )
+ except Exception as e:
+ markata.console.error(f"Unexpected error downloading HTMX: {e}")
+ raise RuntimeError(f"HTMX download failed: {e}")
+
+ return True
+
+
+def _generate_pagination_js(
+ markata: Markata, pagination_config: dict, output_dir: Path
+) -> str:
+ """
+ Generate JavaScript file for pagination and return its path.
+
+ Args:
+ markata: Markata instance
+ pagination_config: Pagination configuration data
+ output_dir: Output directory for JS file
+
+ Returns:
+ Path to generated JS file relative to output_dir
+ """
+ import json
+
+ js_content = f"""// Generated JavaScript for pagination
+window.paginationData = {json.dumps(pagination_config)};
+"""
+
+ js_dir = output_dir / "static" / "js"
+ js_dir.mkdir(parents=True, exist_ok=True)
+
+ js_file = js_dir / "pagination-config.js"
+ js_file.write_text(js_content)
+
+ return "/static/js/pagination-config.js"
+
+
+def _copy_pagination_static_files(markata: Markata, output_dir: Path) -> None:
+ """
+ Copy pagination static files (JS and CSS) from markata package to output directory.
+
+ Args:
+ markata: Markata instance
+ output_dir: Output directory for static files
+ """
+ import importlib.resources
+
+ # Get the markata static directory
+ static_package = importlib.resources.files("markata") / "static"
+
+ # Copy pagination.js
+ js_src = static_package / "js" / "pagination.js"
+ js_dst_dir = output_dir / "static" / "js"
+ js_dst_dir.mkdir(parents=True, exist_ok=True)
+ js_dst = js_dst_dir / "pagination.js"
+
+ if js_src.is_file():
+ js_dst.write_text(js_src.read_text())
+ markata.console.print(f"Copied pagination.js to {js_dst}")
+
+ # Copy pagination.css
+ css_src = static_package / "css" / "pagination.css"
+ css_dst_dir = output_dir / "static" / "css"
+ css_dst_dir.mkdir(parents=True, exist_ok=True)
+ css_dst = css_dst_dir / "pagination.css"
+
+ if css_src.is_file():
+ css_dst.write_text(css_src.read_text())
+ markata.console.print(f"Copied pagination.css to {css_dst}")
+
+
+def _sanitize_feed_slug(slug: str) -> str:
+ """
+ Sanitize feed slug to prevent path traversal attacks.
+
+ Args:
+ slug: User-provided feed slug
+
+ Returns:
+ Sanitized slug safe for filesystem use
+
+ Raises:
+ ValueError: If slug contains dangerous characters
+ """
+ import re
+
+ if not slug:
+ raise ValueError("Feed slug cannot be empty")
+
+ # Remove path traversal sequences (allow forward slashes for nested paths)
+ if ".." in slug or "\\" in slug:
+ raise ValueError(f"Invalid characters in feed slug: {slug}")
+
+ # Allow alphanumeric characters, hyphens, underscores, and forward slashes for nested paths
+ if not re.match(r"^[a-zA-Z0-9_/-]+$", slug):
+ raise ValueError(f"Feed slug contains invalid characters: {slug}")
+
+ # Prevent leading or trailing slashes and double slashes
+ if slug.startswith("/") or slug.endswith("/") or "//" in slug:
+ raise ValueError(f"Feed slug has invalid slash usage: {slug}")
+
+ # Sanitize by removing any path traversal attempts
+ safe_slug = slug.replace("..", "")
+
+ # Additional safety check
+ if safe_slug != slug:
+ raise ValueError(f"Feed slug attempts path traversal: {slug}")
+
+ return safe_slug
+
+
+def _ensure_head_links(markata: Markata) -> None:
+ """
+ Ensure pagination CSS and JS links are in markata.config.head.link
+ without duplicating existing links.
+ """
+ pagination_css_href = "/static/css/pagination.css"
+ pagination_js_config_href = "/static/js/pagination-config.js"
+ pagination_js_href = "/static/js/pagination.js"
+ htmx_version = markata.config.htmx_version
+ htmx_static_href = "/static/js/htmx.min.js"
+
+ # Try to download HTMX first
+ if not _download_htmx_if_needed(markata):
+ # Fallback to CDN if download fails
+ htmx_cdn_href = f"https://unpkg.com/htmx.org@{htmx_version}"
+ else:
+ htmx_cdn_href = htmx_static_href
+
+ # Helper function to get href from link (supports both dicts and objects)
+ def get_href(link):
+ if hasattr(link, "href"):
+ return link.href
+ return link.get("href", "")
+
+ # Helper function to get src from script (supports both dicts and objects)
+ def get_src(script):
+ if hasattr(script, "src"):
+ return script.src
+ return script.get("src", "")
+
+ # Check if pagination CSS is already in head.links
+ css_exists = any(
+ get_href(link) == pagination_css_href for link in markata.config.head.link
+ )
+
+ # Add CSS link if not already present
+ if not css_exists:
+ markata.config.head.link.append(
+ {"rel": "stylesheet", "href": pagination_css_href}
+ )
+
+ # Check if pagination JS config is already in head.script
+ js_config_exists = any(
+ get_src(script) == pagination_js_config_href
+ for script in markata.config.head.script
+ )
+
+ # Check if pagination JS is already in head.script
+ js_exists = any(
+ get_src(script) == pagination_js_href for script in markata.config.head.script
+ )
+
+ # Add JS config link if not already present
+ if not js_config_exists:
+ markata.config.head.script.append({"src": pagination_js_config_href})
+
+ # Add JS link if not already present
+ if not js_exists:
+ markata.config.head.script.append({"src": pagination_js_href})
+
+ # Check if HTMX is already in head.script
+ htmx_exists = any(
+ get_src(script) in [htmx_cdn_href, htmx_static_href]
+ for script in markata.config.head.script
+ )
+
+ # Add HTMX link if not already present
+ if not htmx_exists:
+ markata.config.head.script.append({"src": htmx_cdn_href})
+
+
@hook_impl
@register_attr("feeds")
def pre_render(markata: Markata) -> None:
@@ -448,13 +833,21 @@ def save(markata: Markata) -> None:
"""
Creates a new feed page for each page in the config.
"""
+ _ensure_head_links(markata)
with markata.cache as cache:
for feed in markata.feeds.values():
- create_page(
- markata,
- feed,
- cache,
- )
+ if feed.config.enabled:
+ create_paginated_feed(
+ markata,
+ feed,
+ cache,
+ )
+ else:
+ create_page(
+ markata,
+ feed,
+ cache,
+ )
home = Path(str(markata.config.output_dir)) / "index.html"
archive = Path(str(markata.config.output_dir)) / "archive" / "index.html"
@@ -478,6 +871,7 @@ def save(markata: Markata) -> None:
if should_write:
xsl_file.write_text(xsl)
+
def create_page(
markata: Markata,
feed: Feed,
@@ -489,7 +883,10 @@ def create_page(
template = get_template(markata.jinja_env, feed.config.template)
partial_template = get_template(markata.jinja_env, feed.config.partial_template)
- canonical_url = f"{markata.config.url}/{feed.config.slug}/"
+
+ # Security: Sanitize feed slug to prevent path traversal attacks
+ safe_slug = _sanitize_feed_slug(feed.config.slug)
+ canonical_url = f"{markata.config.url}/{safe_slug}/"
# Get templates mtime to bust cache when any template changes
templates_mtime = get_templates_mtime(markata.jinja_env)
@@ -503,7 +900,9 @@ def create_page(
if cache_key_posts not in markata._feed_hash_cache:
# Use post slugs and published dates instead of full to_dict()
# This provides a stable, lightweight cache key
- posts_data = feed.map("(post.slug, str(getattr(post, 'date', '')), getattr(post, 'title', ''))")
+ posts_data = feed.map(
+ "(post.slug, str(getattr(post, 'date', '')), getattr(post, 'title', ''))"
+ )
markata._feed_hash_cache[cache_key_posts] = str(sorted(posts_data))
posts_hash_data = markata._feed_hash_cache[cache_key_posts]
@@ -534,17 +933,13 @@ def create_page(
feed_sitemap_from_cache = markata.precache.get(feed_sitemap_key)
feed_atom_from_cache = markata.precache.get(feed_atom_key)
- output_file = Path(markata.config.output_dir) / feed.config.slug / "index.html"
+ output_file = Path(markata.config.output_dir) / safe_slug / "index.html"
partial_output_file = (
- Path(markata.config.output_dir) / feed.config.slug / "partial" / "index.html"
- )
- rss_output_file = Path(markata.config.output_dir) / feed.config.slug / "rss.xml"
- sitemap_output_file = (
- Path(markata.config.output_dir) / feed.config.slug / "sitemap.xml"
- )
- atom_output_file = (
- Path(markata.config.output_dir) / feed.config.slug / "atom.xml"
+ Path(markata.config.output_dir) / safe_slug / "partial" / "index.html"
)
+ rss_output_file = Path(markata.config.output_dir) / safe_slug / "rss.xml"
+ sitemap_output_file = Path(markata.config.output_dir) / safe_slug / "sitemap.xml"
+ atom_output_file = Path(markata.config.output_dir) / safe_slug / "atom.xml"
# Create all directories in one batch
partial_output_file.parent.mkdir(exist_ok=True, parents=True)
@@ -597,7 +992,9 @@ def create_page(
if feed.config.sitemap:
if feed_sitemap_from_cache is None:
from_cache = False
- sitemap_template = get_template(markata.jinja_env, feed.config.sitemap_template)
+ sitemap_template = get_template(
+ markata.jinja_env, feed.config.sitemap_template
+ )
feed_sitemap = sitemap_template.render(markata=markata, feed=feed)
cache.set(feed_sitemap_key, feed_sitemap)
else:
@@ -662,6 +1059,187 @@ def create_page(
atom_output_file.write_text(feed_atom)
+def create_paginated_feed(
+ markata: Markata,
+ feed: Feed,
+ cache,
+) -> None:
+ """
+ Create paginated feed pages.
+ """
+ posts = feed.posts
+ per_page = getattr(feed.config, "items_per_page", feed.config.per_page)
+
+ # Validate per_page to prevent division by zero
+ if per_page <= 0:
+ raise ValueError(
+ f"items_per_page must be a positive integer, got {per_page} for feed '{feed.config.slug}'"
+ )
+
+ total_posts = len(posts)
+
+ # Handle empty feeds gracefully
+ if total_posts == 0:
+ total_pages = 1 # Still create one empty page
+ else:
+ total_pages = (total_posts + per_page - 1) // per_page
+
+ # Security: Sanitize feed slug to prevent path traversal attacks
+ safe_slug = _sanitize_feed_slug(feed.config.slug)
+
+ template = get_template(markata, feed.config.template)
+ canonical_url = f"{markata.config.url}/{safe_slug}/"
+
+ for page_num in range(1, total_pages + 1):
+ start_idx = (page_num - 1) * per_page
+ end_idx = start_idx + per_page
+ page_posts = posts[start_idx:end_idx]
+
+ # Create pagination context
+ pagination_context = {
+ "current_page": page_num,
+ "total_pages": total_pages,
+ "total_posts": total_posts,
+ "per_page": per_page,
+ "has_prev": page_num > 1,
+ "has_next": page_num < total_pages,
+ "prev_page": page_num - 1 if page_num > 1 else None,
+ "next_page": page_num + 1 if page_num < total_pages else None,
+ "pagination_type": feed.config.pagination_type,
+ }
+
+ # Generate JS config file if JS pagination is used
+ pagination_js_url = None
+ if feed.config.pagination_type == "js":
+ pagination_config = {
+ "enabled": True,
+ "type": feed.config.pagination_type,
+ "page": page_num,
+ "totalPages": total_pages,
+ "totalPosts": total_posts,
+ "itemsShown": len(page_posts),
+ "feedName": safe_slug,
+ "hasNext": page_num < total_pages,
+ "config": {
+ "pagination_type": feed.config.pagination_type,
+ "posts_per_page": getattr(feed.config, "posts_per_page", None),
+ "template": getattr(feed.config, "template", None),
+ },
+ }
+ pagination_js_url = _generate_pagination_js(
+ markata, pagination_config, Path(markata.config.output_dir)
+ )
+
+ # Create a feed object for this page (no state mutation)
+ page_feed = Feed(config=feed.config, markata=feed.markata)
+
+ key = markata.make_hash(
+ "feeds",
+ "paginated",
+ template,
+ __version__,
+ markata.config.url,
+ markata.config.description,
+ feed.config.title,
+ [p.content for p in page_posts],
+ canonical_url,
+ page_num,
+ pagination_context,
+ )
+
+ html_key = markata.make_hash(key, "html")
+ html_partial_key = markata.make_hash(key, "partial_html")
+
+ # Determine output file paths
+ if page_num == 1:
+ # First page goes to the main feed index
+ output_file = Path(markata.config.output_dir) / safe_slug / "index.html"
+ else:
+ # Subsequent pages go to numbered subdirectories
+ output_file = (
+ Path(markata.config.output_dir)
+ / safe_slug
+ / str(page_num)
+ / "index.html"
+ )
+
+ partial_output_file = output_file.parent / "partial" / "index.html"
+ output_file.parent.mkdir(exist_ok=True, parents=True)
+ partial_output_file.parent.mkdir(exist_ok=True, parents=True)
+
+ # Check cache
+ feed_html_from_cache = markata.precache.get(html_key)
+ feed_html_partial_from_cache = markata.precache.get(html_partial_key)
+
+ from_cache = True
+ if feed_html_from_cache is None:
+ from_cache = False
+ feed_html = template.render(
+ markata=markata,
+ __version__=__version__,
+ post=feed.config.model_dump(),
+ url=markata.config.url,
+ config=markata.config,
+ feed=page_feed,
+ pagination_enabled=True,
+ pagination_config=pagination_context,
+ pagination_context=pagination_context,
+ title=feed.config.title,
+ page=page_num,
+ total_pages=total_pages,
+ total_posts=total_posts,
+ has_next=pagination_context["has_next"],
+ has_prev=pagination_context["has_prev"],
+ next_page=pagination_context["next_page"],
+ prev_page=pagination_context["prev_page"],
+ feed_name=safe_slug,
+ posts=page_posts,
+ page_posts=page_posts,
+ pagination_js_url=pagination_js_url,
+ )
+ cache.set(html_key, feed_html)
+ else:
+ feed_html = feed_html_from_cache
+
+ if feed_html_partial_from_cache is None:
+ from_cache = False
+ # For HTMX partials, use items-only template to avoid duplicating page structure
+ items_partial_template = get_template(markata, "feed_items_partial.html")
+ feed_html_partial = items_partial_template.render(
+ markata=markata,
+ __version__=__version__,
+ post=feed.config.model_dump(),
+ url=markata.config.url,
+ config=markata.config,
+ feed=page_feed,
+ card_template=feed.config.card_template,
+ posts=page_posts,
+ page_posts=page_posts,
+ has_next=pagination_context["has_next"],
+ next_page=pagination_context["next_page"],
+ feed_name=safe_slug,
+ page=page_num,
+ total_pages=total_pages,
+ total_posts=total_posts,
+ pagination_context=pagination_context,
+ )
+ cache.set(html_partial_key, feed_html_partial)
+ else:
+ feed_html_partial = feed_html_partial_from_cache
+
+ if from_cache and output_file.exists() and partial_output_file.exists():
+ continue
+
+ current_html = output_file.read_text() if output_file.exists() else ""
+ if current_html != feed_html:
+ output_file.write_text(feed_html)
+
+ current_partial_html = (
+ partial_output_file.read_text() if partial_output_file.exists() else ""
+ )
+ if current_partial_html != feed_html_partial:
+ partial_output_file.write_text(feed_html_partial)
+
@background.task
def create_card(
@@ -803,7 +1381,7 @@ def refresh(self):
for feed_config in self.markata.config.feeds:
# Ensure feed has a name, falling back to slug if needed
if feed_config.name is None and feed_config.slug is not None:
- feed_config.name = feed_config.slug.replace("-", "_")
+ feed_config.name = to_pythonic_identifier(str(feed_config.slug))
elif feed_config.name is None and feed_config.slug is None:
feed_config.slug = "archive"
feed_config.name = "archive"
@@ -824,10 +1402,10 @@ def items(self):
return [(key, self[key]) for key in self.config]
def __getitem__(self, key: str) -> Any:
- return getattr(self, key.replace("-", "_").lower())
+ return getattr(self, to_pythonic_identifier(str(key)))
def get(self, key: str, default: Any = None) -> Any:
- return getattr(self, key.replace("-", "_").lower(), default)
+ return getattr(self, to_pythonic_identifier(str(key)), default)
def _dict_panel(self, config) -> str:
"""pretty print configs with rich"""
diff --git a/markata/static/css/pagination.css b/markata/static/css/pagination.css
new file mode 100644
index 00000000..f688fe17
--- /dev/null
+++ b/markata/static/css/pagination.css
@@ -0,0 +1,180 @@
+/* Pagination Styles */
+
+.loading-indicator {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+ padding: 2rem;
+ color: var(--text-color-muted, #6b7280);
+}
+
+.spinner {
+ width: 1.5rem;
+ height: 1.5rem;
+ border: 2px solid var(--border-color, #e5e7eb);
+ border-top: 2px solid var(--primary-bg, #3b82f6);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+.pagination-info {
+ color: var(--text-color-muted, #6b7280);
+ font-size: 0.875rem;
+}
+
+/* Manual Pagination */
+.pagination {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1rem;
+ margin: 2rem 0;
+ padding: 1rem;
+ background: var(--bg-color, #fff);
+ border-radius: 8px;
+ border: 1px solid var(--border-color, #e5e7eb);
+}
+
+.pagination-links {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.page-link {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 2.5rem;
+ height: 2.5rem;
+ padding: 0 0.75rem;
+ border: 1px solid var(--border-color, #e5e7eb);
+ border-radius: 6px;
+ background: var(--bg-color, #fff);
+ color: var(--text-color, #374151);
+ text-decoration: none;
+ font-size: 0.875rem;
+ font-weight: 500;
+ transition: all 0.2s ease;
+}
+
+.page-link:hover {
+ background: var(--hover-bg, #f3f4f6);
+ border-color: var(--hover-border, #d1d5db);
+ color: var(--text-color, #374151);
+}
+
+.page-link.current {
+ background: var(--primary-bg, #3b82f6);
+ border-color: var(--primary-border, #3b82f6);
+ color: var(--primary-text, #fff);
+ font-weight: 600;
+}
+
+.page-link[aria-label="First page"],
+.page-link[aria-label="Last page"] {
+ font-size: 0.75rem;
+ min-width: auto;
+ padding: 0 0.5rem;
+}
+
+/* Error and End Messages */
+.end-message {
+ text-align: center;
+ color: var(--text-color-muted, #6b7280);
+ font-style: italic;
+ margin-top: 1rem;
+}
+
+.error-message {
+ text-align: center;
+ padding: 2rem;
+ background: var(--error-bg, #fef2f2);
+ border: 1px solid var(--error-border, #fecaca);
+ border-radius: 6px;
+ color: var(--error-text, #dc2626);
+ margin: 2rem 0;
+}
+
+.error-message a {
+ color: var(--error-text, #dc2626);
+ text-decoration: underline;
+}
+
+.error-message a:hover {
+ text-decoration: none;
+}
+
+/* Responsive design */
+@media (max-width: 640px) {
+ .pagination-links {
+ gap: 0.25rem;
+ }
+
+ .page-link {
+ min-width: 2rem;
+ height: 2rem;
+ padding: 0 0.5rem;
+ font-size: 0.75rem;
+ }
+
+ .page-link[aria-label="First page"],
+ .page-link[aria-label="Last page"] {
+ display: none;
+ }
+}
+
+/* Dark mode support */
+@media (prefers-color-scheme: dark) {
+ .loading-indicator,
+ .pagination-info,
+ .end-message {
+ color: var(--text-color-muted-dark, #9ca3af);
+ }
+
+ .spinner {
+ border-color: var(--border-color-dark, #374151);
+ border-top-color: var(--primary-bg-dark, #2563eb);
+ }
+
+ .pagination {
+ background: var(--bg-color-dark, #1f2937);
+ border-color: var(--border-color-dark, #374151);
+ }
+
+ .page-link {
+ background: var(--bg-color-dark, #1f2937);
+ border-color: var(--border-color-dark, #374151);
+ color: var(--text-color-dark, #f9fafb);
+ }
+
+ .page-link:hover {
+ background: var(--hover-bg-dark, #374151);
+ border-color: var(--hover-border-dark, #4b5563);
+ }
+
+ .error-message {
+ background: var(--error-bg-dark, #7f1d1d);
+ border-color: var(--error-border-dark, #991b1b);
+ color: var(--error-text-dark, #fecaca);
+ }
+
+ .error-message a {
+ color: var(--error-text-dark, #fecaca);
+ }
+}
+
+/* Accessible focus styles */
+.loading-indicator:focus,
+.error-message:focus,
+.page-link:focus {
+ outline: 2px solid var(--primary-bg, #3b82f6);
+ outline-offset: 2px;
+}
\ No newline at end of file
diff --git a/markata/static/js/pagination.js b/markata/static/js/pagination.js
new file mode 100644
index 00000000..6f222b9c
--- /dev/null
+++ b/markata/static/js/pagination.js
@@ -0,0 +1,213 @@
+// JavaScript-based infinite scroll pagination
+class InfiniteScroll {
+ constructor(paginationData) {
+ this.currentPage = paginationData.page;
+ this.totalPages = paginationData.totalPages;
+ this.totalPosts = paginationData.totalPosts;
+ this.itemsShown = paginationData.itemsShown;
+ this.feedName = paginationData.feedName;
+ this.loading = false;
+ this.retryCount = 0;
+ this.maxRetries = 3;
+
+ this.setupObserver();
+
+ // Check if we need to load more content initially
+ // (when initial content doesn't fill the viewport)
+ this.checkInitialFill();
+ }
+
+ setupObserver() {
+ // Create a persistent element at the bottom to observe
+ this.createPersistentTrigger();
+
+ this.observer = new IntersectionObserver((entries) => {
+ if (entries[0].isIntersecting && !this.loading) {
+ this.loadMore();
+ }
+ }, {
+ rootMargin: '100px'
+ });
+
+ this.observeTrigger();
+ }
+
+ createPersistentTrigger() {
+ // Create a persistent trigger that won't be replaced
+ this.persistentTrigger = document.createElement('div');
+ this.persistentTrigger.id = 'js-scroll-trigger';
+ this.persistentTrigger.style.height = '1px';
+ this.persistentTrigger.style.width = '100%';
+
+ // Insert it before the template trigger
+ const templateTrigger = document.getElementById('scroll-trigger');
+ if (templateTrigger) {
+ templateTrigger.parentNode.insertBefore(this.persistentTrigger, templateTrigger);
+ } else {
+ // Fallback: add to end of feed container
+ const feed = document.getElementById('feed');
+ if (feed) {
+ feed.appendChild(this.persistentTrigger);
+ }
+ }
+ }
+
+ observeTrigger() {
+ if (this.persistentTrigger && this.observer) {
+ this.observer.observe(this.persistentTrigger);
+ }
+ }
+
+ checkInitialFill() {
+ // Wait a frame for layout to complete
+ requestAnimationFrame(() => {
+ this.fillViewportIfNeeded();
+ });
+ }
+
+ fillViewportIfNeeded() {
+ // If we're already loading, no more pages, or exceeded retries, stop
+ if (this.loading || this.currentPage >= this.totalPages || this.retryCount >= this.maxRetries) return;
+
+ // Check if the trigger is visible in the viewport
+ // (meaning content doesn't fill the page)
+ if (this.isTriggerVisible()) {
+ this.loadMore().then((success) => {
+ if (success) {
+ // Reset retry count on success
+ this.retryCount = 0;
+ // After loading, check again if we need more
+ // Use requestAnimationFrame to wait for DOM update
+ requestAnimationFrame(() => {
+ this.fillViewportIfNeeded();
+ });
+ }
+ });
+ }
+ }
+
+ isTriggerVisible() {
+ if (!this.persistentTrigger) return false;
+
+ const rect = this.persistentTrigger.getBoundingClientRect();
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
+
+ // Check if the trigger is within the viewport (with some margin)
+ return rect.top < viewportHeight + 100;
+ }
+
+ async loadMore() {
+ if (this.currentPage >= this.totalPages) return false;
+
+ this.loading = true;
+ this.showLoading();
+
+ try {
+ const nextPage = this.currentPage + 1;
+ const response = await fetch(`/${this.feedName}/${nextPage}/`);
+
+ // Check if response is ok (status 200-299)
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ const html = await response.text();
+
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(html, 'text/html');
+ const newItems = doc.querySelectorAll('#feed li');
+ const container = document.getElementById('feed');
+
+ if (!container) {
+ throw new Error('Feed container not found');
+ }
+
+ newItems.forEach(item => container.appendChild(item));
+
+ this.currentPage = nextPage;
+ this.itemsShown += newItems.length;
+
+ // Update pagination info
+ this.updatePaginationInfo();
+
+ // Remove our persistent trigger if this was the last page
+ if (this.currentPage >= this.totalPages) {
+ if (this.persistentTrigger) {
+ this.persistentTrigger.remove();
+ }
+ }
+
+ return true;
+
+ } catch (error) {
+ console.error('Failed to load more content:', error);
+ this.retryCount++;
+
+ // Show error message if we've exceeded retries
+ if (this.retryCount >= this.maxRetries) {
+ this.showError('Failed to load more content. Please refresh the page.');
+ }
+
+ return false;
+ } finally {
+ this.loading = false;
+ this.hideLoading();
+ }
+ }
+
+ showLoading() {
+ const indicator = document.querySelector('.loading-indicator');
+ if (indicator) indicator.style.display = 'flex';
+ }
+
+ hideLoading() {
+ const indicator = document.querySelector('.loading-indicator');
+ if (indicator) indicator.style.display = 'none';
+ }
+
+ showError(message) {
+ const container = document.getElementById('feed');
+ if (container) {
+ const errorDiv = document.createElement('div');
+ errorDiv.className = 'error-message';
+ errorDiv.textContent = message;
+ container.appendChild(errorDiv);
+ }
+ }
+
+ updatePaginationInfo() {
+ const currentPageEl = document.getElementById('current-page');
+ const itemsShownEl = document.getElementById('items-shown');
+
+ if (currentPageEl) currentPageEl.textContent = this.currentPage;
+ if (itemsShownEl) itemsShownEl.textContent = this.itemsShown;
+ }
+
+ // Clean up observer on page unload
+ destroy() {
+ if (this.observer) {
+ this.observer.disconnect();
+ }
+ }
+}
+
+// Feature detection and initialization
+if ('IntersectionObserver' in window && window.paginationData) {
+ let infiniteScroll;
+
+ // Initialize when DOM is ready
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () => {
+ infiniteScroll = new InfiniteScroll(window.paginationData);
+ });
+ } else {
+ infiniteScroll = new InfiniteScroll(window.paginationData);
+ }
+
+ // Clean up on page unload to prevent memory leaks
+ window.addEventListener('beforeunload', () => {
+ if (infiniteScroll) {
+ infiniteScroll.destroy();
+ }
+ });
+}
diff --git a/markata/templates/didyoumean_partial.html b/markata/templates/didyoumean_partial.html
index 8ab727fe..59702a99 100644
--- a/markata/templates/didyoumean_partial.html
+++ b/markata/templates/didyoumean_partial.html
@@ -13,8 +13,6 @@
+
+{% if has_next %}
+