gem2web premade source archive (.zip)
A simple tool-chain for converting gemtext to HTML. Written with python and bash/shell. Made by Thomas Conway.
released under GPL3-or-later, all rights reserved.
chmod +x gem2web.py chmod +x publish-gem2web.sh
3. Test gem2web.py first to ensure it works. Create a sample .gmi file to convert and point the script at it using the command documented in usage.
4. After your sure it works, test publish-gem2web.sh with the usage command documented below.
A simple python program that formats gemtext .gmi capsule pages into HTML webpages.
python3 gem2web.py < input.gmi > output.html
Adjust headers like <title> to reflect your domain and feel free to add/adjust style.
if you choose to rename this python script make sure that convert-gemini.sh points to the scripts new name which is set around ln 19 in the shell script.
#!/usr/bin/env python3
# Copyright (c) 2025 Thomas Conway. All rights reserved.
# SPDX-License-Identifier: GPL-3.0-or-later
# Description: A simple python program to format gemtext .gmi text files into HTML webpages.
# Usage: python3 gemini_to_html.py < input.gmi > output.html
import sys
def escape_html(text):
"""Escape special HTML characters in text"""
return text.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"')
def convert_gmi_links(url):
"""Convert .gmi extension to .html in relative URLs"""
if '://' not in url: # Relative URL
# Split URL into path and query/fragment components
path_part = url
query_fragment = ''
# Find first occurrence of ? or #
for sep in ('?', '#'):
idx = url.find(sep)
if idx != -1:
path_part = url[:idx]
query_fragment = url[idx:]
break
# Replace .gmi extension if found at end of path
if path_part.endswith('.gmi'):
return path_part[:-4] + '.html' + query_fragment
return url
def gemini_to_html():
"""Convert Gemtext from stdin to HTML"""
in_pre = False
current_block = None # 'p', 'ul', 'blockquote'
output_lines = []
print('<!DOCTYPE html>')
print('<html>')
print('<head>')
print('<meta charset="UTF-8">')
print('<meta name="viewport" content="width=device-width, initial-scale=1.0">')
print('<title>yourdomain.com</title>')
print('<style>')
print(' blockquote {')
print(' border-left: 4px solid #ccc;')
print(' padding-left: 10px;')
print(' margin-left: 20px;')
print(' color: green;')
print(' font-style: italic;')
print(' }')
print('</style>')
print('</head>')
print('<body>')
for line in sys.stdin:
line = line.rstrip()
# Handle preformatted blocks
if in_pre:
if line == '```':
print('</pre>')
in_pre = False
else:
print(escape_html(line))
continue
# Handle special blocks
if line.startswith('```'):
print('<pre>')
in_pre = True
elif line.startswith('=>'):
parts = line[2:].strip().split(maxsplit=1)
url = parts[0]
text = url if len(parts) < 2 else parts[1].strip()
# Convert .gmi links to .html
url = convert_gmi_links(url)
print(f'<p><a href="{escape_html(url)}">{escape_html(text)}</a></p>')
elif line.startswith('#'):
level = min(3, line.count('#'))
heading = line.lstrip('#').strip()
print(f'<h{level}>{escape_html(heading)}</h{level}>')
elif line.startswith('>'):
# Start new blockquote if needed
if current_block != 'blockquote':
if current_block:
print(f'</{current_block}>')
print('<blockquote>')
current_block = 'blockquote'
quote = line[1:].strip()
print(f'<p>{escape_html(quote)}</p>')
elif line.startswith('* '):
# Start new list if needed
if current_block != 'ul':
if current_block:
print(f'</{current_block}>')
print('<ul>')
current_block = 'ul'
item = line[2:].strip()
print(f'<li>{escape_html(item)}</li>')
elif line == '':
# Blank line ends current block
if current_block:
print(f'</{current_block}>')
current_block = None
else:
# Start new paragraph if needed
if current_block != 'p':
if current_block:
print(f'</{current_block}>')
print('<p>', end='')
current_block = 'p'
else:
# Continue existing paragraph
print('<br>', end='')
print(escape_html(line), end='')
# Close any open blocks at end
if current_block:
print(f'</{current_block}>')
if in_pre:
print('</pre>')
print('</body>')
print('</html>')
if __name__ == '__main__':
gemini_to_html()
bash/shell script to batch apply the python conversion program to every gemtext file in the input path and copy the output to a specified web server html file path.
./publish-gem2web.sh /your/gemtext/path /your/html/path
sudo ./publish-gem2web.sh /home/caddy/gemini/gemtext /var/www/html
alias gempub='sudo /home/user/gemini/scripts/gem2web/publish-gem2web.sh /home/user/gemini/gemtext /var/www/html'
sudo will probably be required for write permissions.
Setting file permissions beginning at line number 34 may be needed depending on your setup. I commented it out in the source code. Theres also some funkyness that may go on if you mess with chmod and chown that could be worked on.
#!/bin/bash
# Copyright (c) 2025 Thomas Conway. All rights reserved.
# SPDX-License-Identifier: GPL-3.0-or-later
# Description: Batch convert shell script to convert a whole directory of gemtext .gmi files to HTML.
# Usage: ./publish-gem2web.sh /your/gemtext/path /your/html/path
# real example: sudo ./publish-gem2web.sh /home/caddy/gemini/gemtext /var/www/html
set -e
# Validate arguments
if [ "$#" -ne 2 ]; then
echo "Usage: $0 <source-dir> <output-dir>"
echo "Example: $0 ./gemini ./html"
exit 1
fi
SOURCE_DIR="$1"
OUTPUT_DIR="$2"
SCRIPT_PATH="$(dirname "$0")/gem2web.py"
# Verify directories exist
[ -d "$SOURCE_DIR" ] || { echo "Error: Source directory not found"; exit 1; }
[ -d "$OUTPUT_DIR" ] || { echo "Error: Output directory not found"; exit 1; }
[ -f "$SCRIPT_PATH" ] || { echo "Error: gem2web.py not found in script directory"; exit 1; }
# Process files
echo "Starting conversion from $SOURCE_DIR to $OUTPUT_DIR"
for gmi_file in "$SOURCE_DIR"/*.gmi; do
[ -f "$gmi_file" ] || continue # Skip if no files found
base_name=$(basename "$gmi_file" .gmi)
html_file="$OUTPUT_DIR/${base_name}.html"
# Set proper permissions
chown username "$html_file" 2>/dev/null || true
# chmod 644 "$html_file"
# Convert Gemini to HTML
"$SCRIPT_PATH" < "$gmi_file" > "$html_file"
echo "Converted: $gmi_file to $html_file"
done
echo "Success! Gemtext files from $SOURCE_DIR have been converted to HTML and put into $OUTPUT_DIR"