Gem2Web - A simple gemtext to HTML toolchain

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.

Installation:

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.

gem2web.py

A simple python program that formats gemtext .gmi capsule pages into HTML webpages.

Usage Syntax

 python3 gem2web.py < input.gmi > output.html

Notes

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.

gem2web.py Source Code

#!/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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')

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()

publish-gem2web.sh

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.

Example Commands

Usage Syntax:

./publish-gem2web.sh /your/gemtext/path /your/html/path

real example

sudo ./publish-gem2web.sh /home/caddy/gemini/gemtext /var/www/html

.bashrc alias shortcut

alias gempub='sudo /home/user/gemini/scripts/gem2web/publish-gem2web.sh /home/user/gemini/gemtext /var/www/html'

Notes:

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.

publish-gem2web.sh Source Code

#!/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"