Add gopher integration core infrastructure (Phase 1)

Create scripts for converting Hugo blog posts to gopher-friendly
plain text format. Includes ASCII art assets for headers/footers
and markdown-to-gopher conversion with proper line wrapping.
This commit is contained in:
mnw
2026-01-14 16:24:09 -06:00
parent e293d2b528
commit e087a6eb83
4 changed files with 558 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Gopher conversion scripts for marcus-web
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""
ASCII art assets for gopher content generation.
Contains category headers, post templates, footers, and utility functions
for generating ASCII tables and dividers.
"""
# Line width for gopher content
LINE_WIDTH = 70
# Dividers
DOUBLE_LINE = "=" * LINE_WIDTH
SINGLE_LINE = "-" * LINE_WIDTH
BOX_LINE = "\u2500" * LINE_WIDTH # Unicode box drawing horizontal
# Post header (MNW owl)
POST_HEADER = """\
___ ___ ___
/\\ \\ /\\ \\ /\\ \\
|::\\ \\ \\:\\ \\ _\\:\\ \\
|:|:\\ \\ \\:\\ \\ /\\ \\:\\ \\
__|:|\\:\\ \\ _____\\:\\ \\ _\\:\\ \\:\\ \\
/::::|_\\:\\__\\ /::::::::\\__\\ /\\ \\:\\ \\:\\__\\
\\:\\~~\\ \\/__/ \\:\\~~\\~~\\/__/ \\:\\ \\:\\/:/ /
\\:\\ \\ \\:\\ \\ \\:\\ \\::/ /
\\:\\ \\ \\:\\ \\ \\:\\/:/ /
\\:\\__\\ \\:\\__\\ \\::/ /
\\/__/ \\/__/ \\/__/
mnw(at)sdf.org | SDF VOIP Ext. 1908
"""
# Category headers
HEADER_FUN_CENTER = """\
___ ___ _
| __| _ _ _ / __|___ _ _| |_ ___ _ _
| _| || | ' \\ | (__/ -_) ' \\ _/ -_) '_|
|_| \\_,_|_||_| \\___\\___|_||_\\__\\___|_|
Tech posts from the Double Lunch Dispatch
"""
HEADER_FRANKS_COUCH = """\
___ _ _ ___ _
| __| _ __ _ _ _| |_( )___ / __|___ _ _ __| |_
| _| '_/ _` | ' \\ / /|_-< | (__/ _ \\ || (_-< ' \\
|_||_| \\__,_|_||_\\_\\ /__/ \\___\\___/\\_,_/__/_||_|
Movie reviews from the couch
"""
HEADER_BEERCALLS = """\
___ _ _
| _ ) ___ ___ _ _ __ __ _| | |___
| _ \\/ -_) -_) '_/ _/ _` | | (_-<
|___/\\___\\___|_| \\__\\__,_|_|_/__/
Thursday night adventures in Austin
"""
HEADER_BLOG_INDEX = """\
___ _ ___ _ _
| _ )| |___ __ | __|_ _ | |_ _ _ (_)___ ___
| _ \\| / _ \\/ _|| _|| ' \\| _| '_|| / -_|_-<
|___/|_\\___/\\__||___|_||_|\\__|_| |_\\___/__/
Posts from mnw.sdf.org
"""
# Footer (SDF box)
POST_FOOTER = """\
__^__ __^__
( ___ )------------------------------------------------------( ___ )
| / | | \\ |
| / | This gopher space proudly hosted by SDF | \\ |
| \\ | mnw on pixelfed.social and tilde.zone | / |
| \\ | | / |
(_____)------------------------------------------------------(_____)
"""
# Map series names to gopher directory names
SERIES_TO_DIR = {
"Fun Center": "fun-center",
"Frank's Couch": "franks-couch",
"Beercalls": "beercalls",
}
# Map directory names to headers
DIR_TO_HEADER = {
"fun-center": HEADER_FUN_CENTER,
"franks-couch": HEADER_FRANKS_COUCH,
"beercalls": HEADER_BEERCALLS,
}
# Map directory names to descriptions
DIR_TO_DESCRIPTION = {
"fun-center": "Tech posts from the Double Lunch Dispatch",
"franks-couch": "Movie reviews from the couch",
"beercalls": "Thursday night adventures in Austin",
}
def get_section_header(title: str) -> str:
"""Generate a section header with double lines."""
return f"\n{DOUBLE_LINE}\n {title.upper()}\n{DOUBLE_LINE}\n"
def get_subheading(title: str) -> str:
"""Generate a subheading with dashes."""
return f"\n--- {title} ---\n"
def get_post_meta_header(date: str, series: str) -> str:
"""Generate the metadata header for a post."""
return f"""\
{SINGLE_LINE}
{date} | {series}
{SINGLE_LINE}
"""
def get_title_block(title: str, summary: str = "") -> str:
"""Generate a centered title block."""
# Center the title
centered_title = title.upper().center(LINE_WIDTH)
lines = [centered_title]
if summary:
centered_summary = summary.center(LINE_WIDTH)
lines.append(centered_summary)
return "\n".join(lines)
def generate_movie_table(
title: str,
year: int,
director: str = "",
runtime: int = 0,
genres: list = None,
web_url: str = "",
) -> str:
"""Generate an ASCII table for movie metadata."""
genres = genres or []
width = 65
border_h = "\u2500" # horizontal line
corner_tl = "\u250c" # top left
corner_tr = "\u2510" # top right
corner_bl = "\u2514" # bottom left
corner_br = "\u2518" # bottom right
border_v = "\u2502" # vertical
tee_l = "\u251c" # left tee
tee_r = "\u2524" # right tee
def row(content: str) -> str:
return f"{border_v} {content.ljust(width - 4)} {border_v}"
lines = [
f"{corner_tl}{border_h * width}{corner_tr}",
row(f"{title.upper()} ({year})"),
f"{tee_l}{border_h * width}{tee_r}",
]
if director:
lines.append(row(f"Director: {director}"))
if runtime:
lines.append(row(f"Runtime: {runtime} minutes"))
if genres:
lines.append(row(f"Genres: {', '.join(genres)}"))
lines.append(row(""))
if web_url:
lines.append(row(f"View on web: {web_url}"))
lines.append(f"{corner_bl}{border_h * width}{corner_br}")
return "\n".join(lines)
def wrap_text(text: str, width: int = LINE_WIDTH) -> str:
"""Wrap text to specified width, preserving paragraphs."""
import textwrap
paragraphs = text.split("\n\n")
wrapped = []
for para in paragraphs:
# Preserve single newlines within paragraphs as spaces
para = " ".join(para.split())
if para:
wrapped.append(textwrap.fill(para, width=width))
else:
wrapped.append("")
return "\n\n".join(wrapped)
def format_links_section(links: list) -> str:
"""Format a list of links for the footer section."""
if not links:
return ""
lines = [get_section_header("LINKS")]
for i, url in enumerate(links, 1):
lines.append(f"[{i}] {url}")
return "\n".join(lines)
+350
View File
@@ -0,0 +1,350 @@
#!/usr/bin/env python3
"""
Convert Hugo markdown posts to gopher-formatted text files.
Usage:
python scripts/gopher/convert_to_gopher.py content/posts/blog-posting.md
python scripts/gopher/convert_to_gopher.py --all
python scripts/gopher/convert_to_gopher.py --all --output gopher_build/blog/
"""
import argparse
import re
import textwrap
import yaml
from pathlib import Path
from ascii_art import (
LINE_WIDTH,
POST_HEADER,
POST_FOOTER,
SERIES_TO_DIR,
get_post_meta_header,
get_title_block,
get_section_header,
get_subheading,
generate_movie_table,
format_links_section,
)
# Paths
SCRIPT_DIR = Path(__file__).parent
PROJECT_ROOT = SCRIPT_DIR.parent.parent
CONTENT_DIR = PROJECT_ROOT / "content" / "posts"
DEFAULT_OUTPUT = PROJECT_ROOT / "gopher_build" / "blog"
def parse_frontmatter(content: str) -> tuple[dict, str]:
"""Parse YAML frontmatter and return (metadata, body)."""
if not content.startswith("---"):
return {}, content
# Find the closing ---
end_match = re.search(r"\n---\n", content[3:])
if not end_match:
return {}, content
yaml_end = end_match.start() + 3
yaml_content = content[3:yaml_end]
body = content[yaml_end + 4 :] # Skip the closing ---\n
try:
metadata = yaml.safe_load(yaml_content)
except yaml.YAMLError:
metadata = {}
return metadata or {}, body
def extract_links(text: str) -> tuple[str, list]:
"""Extract markdown links and replace with numbered references."""
links = []
link_pattern = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
def replace_link(match):
text = match.group(1)
url = match.group(2)
links.append(url)
return f"{text} [{len(links)}]"
converted = link_pattern.sub(replace_link, text)
return converted, links
def convert_headings(text: str) -> str:
"""Convert markdown headings to gopher-style text."""
# H1: Double line with centered text
def h1_replace(match):
title = match.group(1).strip()
return get_section_header(title)
# H2: Dashed subheading
def h2_replace(match):
title = match.group(1).strip()
return get_subheading(title)
# H3-H6: Just bold-style text
def h3_replace(match):
title = match.group(1).strip()
return f"\n*{title}*\n"
text = re.sub(r"^# (.+)$", h1_replace, text, flags=re.MULTILINE)
text = re.sub(r"^## (.+)$", h2_replace, text, flags=re.MULTILINE)
text = re.sub(r"^###+ (.+)$", h3_replace, text, flags=re.MULTILINE)
return text
def convert_formatting(text: str) -> str:
"""Convert markdown formatting to gopher-style text."""
# Bold: **text** -> *text*
text = re.sub(r"\*\*([^*]+)\*\*", r"*\1*", text)
# Italic: *text* or _text_ -> _text_
# Be careful not to match our converted bold
text = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"_\1_", text)
text = re.sub(r"_([^_]+)_", r"_\1_", text)
# Horizontal rules
text = re.sub(r"^---+$", "-" * LINE_WIDTH, text, flags=re.MULTILINE)
return text
def convert_code_blocks(text: str) -> str:
"""Convert code blocks to indented text."""
# Fenced code blocks
def indent_code(match):
code = match.group(2)
# Indent each line by 4 spaces
indented = "\n".join(" " + line for line in code.split("\n"))
return f"\n{indented}\n"
text = re.sub(r"```(\w*)\n(.*?)```", indent_code, text, flags=re.DOTALL)
# Inline code: `code` -> code (just remove backticks)
text = re.sub(r"`([^`]+)`", r"\1", text)
return text
def handle_imdbposter(text: str, metadata: dict, slug: str) -> str:
"""Replace imdbposter shortcode with ASCII movie table."""
# Check if there's an imdbposter shortcode
pattern = r"\{\{<\s*imdbposter\s*>\}\}(.*?)\{\{<\s*/imdbposter\s*>\}\}"
match = re.search(pattern, text, flags=re.DOTALL)
if not match:
return text
# Extract movie info from frontmatter
title = metadata.get("title", "Unknown")
year = metadata.get("year", "")
director = metadata.get("director", "")
runtime = metadata.get("runtime", 0)
genres = metadata.get("genres", [])
web_url = f"https://mnw.sdf.org/posts/{slug}/"
# If year not in frontmatter, try to parse from date
if not year and metadata.get("date"):
date_str = str(metadata.get("date"))
year_match = re.match(r"(\d{4})", date_str)
if year_match:
year = int(year_match.group(1))
# Generate the ASCII table
table = generate_movie_table(
title=title,
year=year,
director=director,
runtime=runtime,
genres=genres,
web_url=web_url,
)
# Also extract and preserve the viewing info table if present
inner_content = match.group(1).strip()
if inner_content:
# Convert markdown table to plain text
table_lines = []
for line in inner_content.split("\n"):
line = line.strip()
if line and not line.startswith("|--"):
# Remove leading/trailing pipes and clean up
line = re.sub(r"^\||\|$", "", line)
cells = [c.strip() for c in line.split("|")]
if len(cells) >= 2:
table_lines.append(f" {cells[0]}: {cells[1]}")
if table_lines:
table += "\n\n" + "\n".join(table_lines)
return text[: match.start()] + table + text[match.end() :]
def wrap_paragraphs(text: str) -> str:
"""Wrap text to LINE_WIDTH, preserving code blocks and structure."""
lines = text.split("\n")
result = []
in_code_block = False
paragraph = []
def flush_paragraph():
if paragraph:
para_text = " ".join(paragraph)
wrapped = textwrap.fill(para_text, width=LINE_WIDTH)
result.append(wrapped)
paragraph.clear()
for line in lines:
# Detect code blocks (indented by 4 spaces)
if line.startswith(" "):
flush_paragraph()
result.append(line)
continue
# Empty line = paragraph break
if not line.strip():
flush_paragraph()
result.append("")
continue
# Lines that look like headers or dividers - don't wrap
if (
line.startswith("=")
or line.startswith("-" * 10)
or line.startswith("*")
or line.startswith(" " * 10)
):
flush_paragraph()
result.append(line)
continue
# Accumulate paragraph text
paragraph.append(line.strip())
flush_paragraph()
return "\n".join(result)
def convert_post(filepath: Path, output_dir: Path = None) -> Path | None:
"""Convert a single markdown post to gopher format."""
content = filepath.read_text()
metadata, body = parse_frontmatter(content)
# Check if phlog is enabled
if not metadata.get("phlog", False):
return None
# Check if draft
if metadata.get("draft", False):
return None
# Determine output directory from series
series = metadata.get("series", "Fun Center")
gopher_dir = SERIES_TO_DIR.get(series, "fun-center")
# Get slug from filename
slug = filepath.stem
# Output path
output_dir = output_dir or DEFAULT_OUTPUT
category_dir = output_dir / gopher_dir
category_dir.mkdir(parents=True, exist_ok=True)
output_path = category_dir / f"{slug}.txt"
# Handle imdbposter shortcode first
body = handle_imdbposter(body, metadata, slug)
# Extract links before other conversions
body, links = extract_links(body)
# Convert markdown to gopher text
body = convert_headings(body)
body = convert_formatting(body)
body = convert_code_blocks(body)
# Wrap paragraphs
body = wrap_paragraphs(body)
# Build the final document
date_str = ""
if metadata.get("date"):
date_obj = metadata["date"]
if hasattr(date_obj, "strftime"):
date_str = date_obj.strftime("%Y-%m-%d")
else:
date_str = str(date_obj)[:10]
title = metadata.get("title", slug)
summary = metadata.get("summary", "")
parts = [
POST_HEADER,
get_post_meta_header(date_str, series),
get_title_block(title, summary),
"",
"-" * LINE_WIDTH,
"",
body.strip(),
"",
]
if links:
parts.append(format_links_section(links))
parts.append("")
parts.append(POST_FOOTER)
parts.append("")
parts.append(f"Web version: https://mnw.sdf.org/posts/{slug}/")
output_content = "\n".join(parts)
output_path.write_text(output_content)
return output_path
def convert_all(output_dir: Path = None) -> list[Path]:
"""Convert all posts with phlog: true."""
output_dir = output_dir or DEFAULT_OUTPUT
converted = []
for post_path in CONTENT_DIR.glob("*.md"):
result = convert_post(post_path, output_dir)
if result:
converted.append(result)
print(f"Converted: {post_path.name} -> {result.relative_to(output_dir)}")
return converted
def main():
parser = argparse.ArgumentParser(description="Convert Hugo posts to gopher format")
parser.add_argument("file", nargs="?", help="Single file to convert")
parser.add_argument("--all", action="store_true", help="Convert all phlog posts")
parser.add_argument("--output", "-o", type=Path, help="Output directory")
args = parser.parse_args()
output = args.output or DEFAULT_OUTPUT
if args.all:
converted = convert_all(output)
print(f"\nConverted {len(converted)} posts to {output}")
elif args.file:
filepath = Path(args.file)
if not filepath.exists():
print(f"File not found: {filepath}")
return 1
result = convert_post(filepath, output)
if result:
print(f"Converted: {result}")
else:
print("Post skipped (no phlog: true or is draft)")
else:
parser.print_help()
return 1
return 0
if __name__ == "__main__":
exit(main())