Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 41 additions & 9 deletions src/bloggereasy/cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import json
import re
from pathlib import Path

import typer
Expand All @@ -17,7 +19,7 @@
from bloggereasy.parse.fetch import fetch_html_url
from bloggereasy.parse.html_page import parse_html_file
from bloggereasy.theme.builder import build_blogger_xml, sanitize_filename
from bloggereasy.theme.presets import PRESETS
from bloggereasy.theme.presets import PRESETS, PRESET_TAGS
from bloggereasy.theme.validate import validate_theme_file

app = typer.Typer(
Expand Down Expand Up @@ -139,16 +141,30 @@ def demo_cmd(


@templates_app.command("list")
def templates_list() -> None:
table = Table(title="Templates")
def templates_list(
tag: str | None = typer.Option(None, "--tag", "-t", help="Filter templates by tag/category (e.g. light, dark, blog, portfolio, creative)."),
) -> None:
"""List built-in templates, optionally filtered by tag."""
table = Table(title="Templates" + (f" (tag: {tag})" if tag else ""))
table.add_column("Name")
table.add_column("Tags")
table.add_column("Notes")
shown = 0
for name, meta in PRESETS.items():
table.add_row(name, str(meta))
tags = PRESET_TAGS.get(name, [])
if tag and tag not in tags:
continue
table.add_row(name, ", ".join(tags), str(meta.get("notes", "")))
shown += 1
if not shown:
console.print(f"[yellow]No templates match tag '{tag}'[/yellow]")
available = sorted({t for tags in PRESET_TAGS.values() for t in tags})
console.print(f"Available tags: {', '.join(available)}")
else:
console.print(table)
if TEMPLATES_DIR.exists():
for path in sorted(TEMPLATES_DIR.glob("*.xml")):
table.add_row(path.stem, f"file:{path.name}")
console.print(table)
table.add_row(path.stem, "custom", f"file:{path.name}")


@parse_app.command("html")
Expand Down Expand Up @@ -324,20 +340,21 @@ def product_cmd(
def validate_cmd(
file: Path | None = typer.Option(None, "--file", "-f", exists=True, dir_okay=False),
directory: Path | None = typer.Option(None, "--dir", "-d", exists=True, file_okay=False),
strict: bool = typer.Option(False, "--strict", help="Apply stricter validation (size floor, CDATA depth check)."),
) -> None:
"""Validate one theme XML or batch-validate a directory of themes."""
if directory is not None:
from bloggereasy.theme.batch import validate_theme_dir

report = validate_theme_dir(directory)
report = validate_theme_dir(directory, strict=strict)
console.print_json(data=report)
if report["fail"]:
raise typer.Exit(1)
return
if file is None:
console.print("[red]Provide --file or --dir[/red]")
raise typer.Exit(1)
result = validate_theme_file(file)
result = validate_theme_file(file, strict=strict)
console.print_json(data=result)
if not result["ok"]:
raise typer.Exit(1)
Expand Down Expand Up @@ -366,6 +383,21 @@ def serve_cmd(
uvicorn.run("bloggereasy.api.app:app", host=host, port=port, log_level="info")


@app.command("tokens")
def tokens_cmd(
template: str = typer.Option("simple", "--template", "-t", help="Template preset to extract tokens from."),
out: Path | None = typer.Option(None, "--out", "-o", help="Write JSON file instead of printing."),
) -> None:
"""Export CSS variable theme tokens as JSON from a preset."""
from bloggereasy.theme.presets import tokens_for_preset

data = tokens_for_preset(template)
if out is not None:
out.write_text(json.dumps(data, indent=2), encoding="utf-8")
console.print(f"[green]Tokens written[/green] → {out}")
else:
console.print_json(data=data)


if __name__ == "__main__":
app()

5 changes: 3 additions & 2 deletions src/bloggereasy/theme/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
from bloggereasy.theme.validate import validate_theme_file


def validate_theme_dir(directory: Path) -> dict:
def validate_theme_dir(directory: Path, *, strict: bool = False) -> dict:
files = sorted(directory.glob("*.xml")) if directory.exists() else []
rows = []
ok_n = 0
for path in files:
result = validate_theme_file(path)
result = validate_theme_file(path, strict=strict)
ok = bool(result.get("ok"))
if ok:
ok_n += 1
Expand All @@ -22,5 +22,6 @@ def validate_theme_dir(directory: Path) -> dict:
"n": len(files),
"ok": ok_n,
"fail": len(files) - ok_n,
"strict": strict,
"rows": rows,
}
98 changes: 94 additions & 4 deletions src/bloggereasy/theme/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,60 +3,83 @@
from bloggereasy.theme.models import PageStructure, structure_dict

PRESETS: dict[str, dict] = {
"simple": {"layout_hint": "auto", "dark": False},
"magazine": {"layout_hint": "three-column", "dark": False, "dense": True},
"dark": {"layout_hint": "two-column", "dark": True},
"from-image": {"layout_hint": "two-column", "dark": False},
"simple": {"layout_hint": "auto", "dark": False, "notes": "Clean universal starting point"},
"magazine": {"layout_hint": "three-column", "dark": False, "dense": True, "notes": "Three-column news/magazine layout"},
"dark": {"layout_hint": "two-column", "dark": True, "notes": "Dark mode developer/devops look"},
"from-image": {"layout_hint": "two-column", "dark": False, "notes": "Derive palette from image"},
"portfolio": {
"layout_hint": "two-column",
"dark": False,
"dense": False,
"accent": "#c4a574",
"notes": "Warm portfolio/showcase accent",
},
"news": {
"layout_hint": "two-column",
"dark": False,
"dense": True,
"accent": "#b91c1c",
"notes": "Dense news portal with red accent",
},
"personal": {
"layout_hint": "single-column",
"dark": False,
"dense": False,
"accent": "#7c3aed",
"notes": "Personal blog with purple accent",
},
"docs": {
"layout_hint": "two-column",
"dark": False,
"dense": True,
"accent": "#0d9488",
"notes": "Documentation-style with teal accent",
},
"portfolio_photo": {
"layout_hint": "two-column",
"dark": False,
"dense": False,
"accent": "#c4a574",
"notes": "Photography portfolio warm accent",
},
"food_recipe": {
"layout_hint": "two-column",
"dark": False,
"dense": False,
"accent": "#d97742",
"notes": "Food blog with orange accent",
},
"magazine_news": {
"layout_hint": "two-column",
"dark": False,
"dense": True,
"accent": "#b91c1c",
"notes": "News magazine with bold red accent",
},
"corporate_blue": {
"layout_hint": "two-column",
"dark": False,
"dense": False,
"accent": "#0055aa",
"notes": "Corporate blue professional style",
},
}

PRESET_TAGS: dict[str, list[str]] = {
"simple": ["light", "blog", "minimal"],
"magazine": ["light", "blog", "dense", "magazine"],
"dark": ["dark", "blog", "dev"],
"from-image": ["light", "blog", "creative"],
"portfolio": ["light", "portfolio", "creative"],
"news": ["light", "blog", "dense", "news"],
"personal": ["light", "blog", "personal"],
"docs": ["light", "docs", "dense"],
"portfolio_photo": ["light", "portfolio", "creative"],
"food_recipe": ["light", "blog", "creative", "food"],
"magazine_news": ["light", "blog", "dense", "news", "magazine"],
"corporate_blue": ["light", "blog", "corporate"],
}


def apply_preset(structure: PageStructure | dict, template: str) -> dict:
preset = PRESETS.get(template, PRESETS["simple"])
Expand Down Expand Up @@ -103,3 +126,70 @@ def apply_dark_variant(structure: dict) -> dict:
features["dark"] = True
out["features"] = features
return out


def tokens_for_preset(template: str) -> dict:
"""Extract design tokens (CSS custom properties) from a template preset."""
if template not in PRESETS:
available = sorted(PRESETS.keys())
raise ValueError(f"Unknown template '{template}'. Available: {', '.join(available)}")

preset = PRESETS[template]
tokens: dict[str, str] = {}

# Base colors
tokens["--color-primary"] = preset.get("accent") or "#1a73e8"
tokens["--color-secondary"] = "#34a853"

if preset.get("dark"):
tokens["--color-background"] = "#0f172a"
tokens["--color-text"] = "#e2e8f0"
tokens["--color-surface"] = "#111827"
tokens["--color-muted"] = "#1e293b"
tokens["--color-border"] = "#334155"
tokens["--color-footer"] = "#020617"
tokens["--color-footer-text"] = "#cbd5e1"
else:
tokens["--color-background"] = "#ffffff"
tokens["--color-text"] = "#222222"
tokens["--color-surface"] = "#ffffff"
tokens["--color-muted"] = "#f8fafc"
tokens["--color-border"] = "#e5e7eb"
tokens["--color-footer"] = "#0f172a"
tokens["--color-footer-text"] = "#e2e8f0"

# Typography
tokens["--font-body"] = "system-ui, sans-serif"
tokens["--font-heading"] = "system-ui, sans-serif"

# Layout
layout = preset.get("layout_hint", "single-column")
tokens["--layout"] = layout
tokens["--layout-sidebar"] = str(layout in {"two-column", "three-column"}).lower()

# Spacing
if preset.get("dense"):
tokens["--spacing-post-pad"] = "0.6rem 0.85rem"
tokens["--spacing-content-pad"] = "0.5rem"
tokens["--spacing-gap"] = "1rem"
else:
tokens["--spacing-post-pad"] = "1rem 1.25rem"
tokens["--spacing-content-pad"] = "1rem"
tokens["--spacing-gap"] = "1.5rem"

tokens["--spacing-radius"] = "8px"
tokens["--spacing-button-radius"] = "6px"

return {
"template": template,
"tags": PRESET_TAGS.get(template, []),
"dark": preset.get("dark", False),
"dense": preset.get("dense", False),
"features": {
"sidebar": layout in {"two-column", "three-column"},
"magazine_left_rail": layout == "three-column",
"dark": preset.get("dark", False),
"dense": preset.get("dense", False),
},
"tokens": tokens,
}
31 changes: 27 additions & 4 deletions src/bloggereasy/theme/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@


def _has_namespace(xml: str, namespace: str) -> bool:
pattern = rf"xmlns(?::[a-zA-Z0-9_-]+)?\s*=\s*(['\"]){re.escape(namespace)}\1"
escaped_ns = re.escape(namespace)
pattern = "xmlns(?::[a-zA-Z0-9_-]+)?\\s*=\\s*(['\\\"])\\b" + escaped_ns + "\\b\\1"
return re.search(pattern, xml, flags=re.IGNORECASE) is not None


def validate_blogger_xml(xml: str) -> dict:
def validate_blogger_xml(xml: str, *, strict: bool = False) -> dict:
errors: list[str] = []
warnings: list[str] = []

Expand Down Expand Up @@ -44,16 +45,38 @@ def validate_blogger_xml(xml: str) -> dict:
warnings.append("no Header widget title found")
if len(xml) < 800:
warnings.append("theme XML is unusually small")

# Strict mode: additional checks
if strict:
xml_bytes = len(xml.encode("utf-8"))
if xml_bytes < 2000:
errors.append("strict: theme XML below 2000 byte floor (likely incomplete)")
if "<b:includable" not in xml:
errors.append("strict: missing <b:includable> blocks (widget templates incomplete)")
if "CDATA" not in xml:
errors.append("strict: missing CDATA skin block (CSS not properly wrapped)")
if "<head>" not in xml.lower():
errors.append("strict: missing <head> element")
if "viewport" not in xml.lower():
warnings.append("strict: no viewport meta tag (responsive breakpoints may fail)")
if "charset" not in xml.lower():
errors.append("strict: missing charset declaration")
if not re.search(r"<meta\b[^>]*\bog:title\b", xml, flags=re.IGNORECASE):
warnings.append("strict: missing og:title meta tag (social sharing preview degraded)")
if len(re.findall(r"<b:section\b", xml)) < 3:
warnings.append("strict: fewer than 3 <b:section> blocks (layout may be sparse)")

return {
"ok": len(errors) == 0,
"errors": errors,
"warnings": warnings,
"bytes": len(xml.encode("utf-8")),
"strict": strict,
}


def validate_theme_file(path: Path) -> dict:
def validate_theme_file(path: Path, *, strict: bool = False) -> dict:
xml = path.read_text(encoding="utf-8", errors="replace")
result = validate_blogger_xml(xml)
result = validate_blogger_xml(xml, strict=strict)
result["path"] = str(path)
return result
34 changes: 34 additions & 0 deletions tests/test_strict_validate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Tests for #79: validate --strict schema mode."""
from __future__ import annotations

from bloggereasy.theme.validate import validate_blogger_xml


def test_strict_mode_rejects_small_xml() -> None:
"""Strict mode flags XML below 2000 bytes."""
tiny = '<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml" xmlns:b="http://www.google.com/2005/gml/b"><head></head><body></body></html>'
result = validate_blogger_xml(tiny, strict=True)
assert any("2000 byte floor" in e for e in result["errors"]), f"Should flag small XML: {result}"


def test_non_strict_accepts_small_xml() -> None:
"""Non-strict mode only warns about small XML size."""
tiny = '<html xmlns="http://www.w3.org/1999/xhtml" xmlns:b="http://www.google.com/2005/gml/b"><head></head><body></body></html>'
result = validate_blogger_xml(tiny, strict=False)
assert any("unusually small" in w for w in result["warnings"]), f"Should warn about small XML: {result['warnings']}"


def test_strict_mode_checks_cdata() -> None:
"""Strict mode flags missing CDATA."""
xml = '<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml" xmlns:b="http://www.google.com/2005/gml/b"><head></head><body><b:section id="main"><b:widget id="Blog1" type="Blog" version="1"><b:includable id="main"></b:includable></b:widget></b:section></body></html>'
result = validate_blogger_xml(xml, strict=True)
assert any("CDATA" in e for e in result["errors"]), f"Should flag missing CDATA: {result}"


def test_strict_checks_preserved_in_non_strict() -> None:
"""Non-strict mode does not apply strict checks."""
tiny = '<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml" xmlns:b="http://www.google.com/2005/gml/b"><head></head><body></body></html>'
result = validate_blogger_xml(tiny, strict=False)
# strict checks should NOT appear
for err in result["errors"]:
assert "strict:" not in err, f"Strict check leaked into non-strict: {err}"
Loading
Loading