Skip to content

Corg-Labs/md2html

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 

Repository files navigation

Markdown-to-HTML Converter in C

A single-pass Markdown to HTML5 converter that reads from a file or stdin and emits a complete, styled HTML document — handling headings, bold, italic, code, lists, blockquotes, links, and horizontal rules.

Written in pure C with no external dependencies. Part of the Corg-Labs collection.


Features

  • ATX headings # H1 through ###### H6
  • Inline formatting: **bold**, *italic*, `code`, [text](url) links
  • Fenced code blocks with optional language class (```python)
  • Unordered lists (-, *, +) and ordered lists (1.)
  • Blockquotes (>)
  • Horizontal rules (---, ***, ___)
  • Paragraph detection from blank-line separation
  • HTML5 output with embedded dark-mode CSS (Catppuccin palette)
  • HTML entity escaping for &, <, >, "
  • Reads from a file argument or stdin (pipe-friendly)

Tutorial

1. The Context State Machine

The converter tracks a Context enum that records which block-level element is currently open. On every new line the context may transition — for example, a blank line closes a paragraph; a > prefix opens a blockquote.

typedef enum {
    CTX_NONE,
    CTX_PARA,
    CTX_UL,
    CTX_OL,
    CTX_BLOCKQUOTE,
    CTX_CODEBLOCK
} Context;

static Context ctx          = CTX_NONE;
static int     in_code_block = 0;

close_context() emits the appropriate closing HTML tag for whatever is currently open:

static void close_context(void) {
    switch (ctx) {
        case CTX_PARA:       printf("</p>\n");          break;
        case CTX_UL:         printf("</ul>\n");         break;
        case CTX_OL:         printf("</ol>\n");         break;
        case CTX_BLOCKQUOTE: printf("</blockquote>\n"); break;
        case CTX_CODEBLOCK:  printf("</code></pre>\n"); break;
        default: break;
    }
    ctx = CTX_NONE;
}

2. Processing Lines — the Dispatch Chain

process_line is called for every input line. It tests each Markdown pattern in priority order: fenced code first, then blank lines, horizontal rules, headings, blockquotes, lists, and finally plain paragraphs.

static void process_line(char *line) {
    rtrim(line);

    /* Fenced code block ``` */
    if (strncmp(line, "```", 3) == 0) { /* toggle in_code_block */ return; }

    /* Raw code content: HTML-escape and emit as-is */
    if (in_code_block) { html_escape(line, ...); printf("%s\n", esc); return; }

    /* Blank line */
    if (line[0] == '\0') { if (ctx == CTX_PARA) close_context(); return; }

    /* Horizontal rule --- */
    /* Heading # */
    /* Blockquote > */
    /* Unordered list - */
    /* Ordered list 1. */
    /* Paragraph fallthrough */
}

3. Heading Detection

Headings are identified by counting leading # characters. The level must be 1–6 and be followed by a space.

if (line[0] == '#') {
    int level = 0;
    while (line[level] == '#') level++;
    if (level <= 6 && line[level] == ' ') {
        close_context();
        const char *text = line + level + 1;
        printf("<h%d>", level);
        render_inline(text);   /* handles bold/italic/code within heading */
        printf("</h%d>\n", level);
        return;
    }
}

4. List Handling with Context Switching

When a line starts with - and the current context is not already CTX_UL, the open context is closed and a <ul> tag is emitted. Subsequent list items stay in the same context without reopening the tag.

if ((line[0] == '-' || line[0] == '*' || line[0] == '+') && line[1] == ' ') {
    const char *text = line + 2;
    if (ctx != CTX_UL) {
        close_context();
        printf("<ul>\n");
        ctx = CTX_UL;
    }
    printf("<li>");
    render_inline(text);
    printf("</li>\n");
    return;
}

Ordered lists use the same pattern but detect N. with isdigit followed by . and a space.

5. Inline Formatting Pass

render_inline scans the line character by character and emits HTML spans for each inline pattern. It handles them in a priority order: backtick code first (to prevent * inside code from being misread), then **bold**, then *italic*, then [text](url) links.

static void render_inline(const char *s) {
    size_t i = 0, len = strlen(s);
    while (i < len) {

        /* Inline code: `...` */
        if (s[i] == '`') {
            size_t j = i + 1;
            while (j < len && s[j] != '`') j++;
            if (j < len) {
                printf("<code>");
                html_escape(s + i + 1, j - i - 1, esc, sizeof esc);
                printf("%s</code>", esc);
                i = j + 1; continue;
            }
        }

        /* Bold: **...** */
        if (i + 1 < len && s[i] == '*' && s[i+1] == '*') {
            size_t j = i + 2;
            while (j + 1 < len && !(s[j] == '*' && s[j+1] == '*')) j++;
            if (j + 1 < len) {
                printf("<strong>");
                html_escape(s + i + 2, j - i - 2, esc, sizeof esc);
                printf("%s</strong>", esc);
                i = j + 2; continue;
            }
        }

        /* Italic: *...* — checked after bold to avoid false trigger */
        /* Link: [text](url) — uses strchr to find brackets */

        /* Plain character — always HTML-escaped */
        char tmp[2] = { s[i], '\0' };
        html_escape(tmp, 1, esc, sizeof esc);
        printf("%s", esc);
        i++;
    }
}

6. HTML Escaping

Before any user content reaches the output it passes through html_escape, which replaces the four special HTML characters with their named entities.

static void html_escape(const char *s, size_t len, char *out, size_t outsz) {
    size_t j = 0;
    for (size_t i = 0; i < len && j + 8 < outsz; i++) {
        switch (s[i]) {
            case '&':  strcpy(out+j, "&amp;");  j += 5; break;
            case '<':  strcpy(out+j, "&lt;");   j += 4; break;
            case '>':  strcpy(out+j, "&gt;");   j += 4; break;
            case '"':  strcpy(out+j, "&quot;"); j += 6; break;
            default:   out[j++] = s[i]; break;
        }
    }
    out[j] = '\0';
}

7. HTML Header and Footer

print_header emits a full <!DOCTYPE html> preamble with embedded CSS. The stylesheet uses CSS custom properties for a dark Catppuccin-Mocha colour theme. print_footer calls close_context() to flush any unclosed block before writing </body></html>.

static void print_header(void) {
    printf("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n"
           "<meta charset=\"UTF-8\">\n"
           "<title>Document</title>\n"
           "<style>\n"
           "  :root { --bg:#1e1e2e; --fg:#cdd6f4; --acc:#89b4fa; }\n"
           "  body { background:var(--bg); color:var(--fg); "
                   "font-family:system-ui,sans-serif; }\n"
           /* ... more rules ... */
           "</style>\n</head>\n<body>\n");
}

8. Main — File or Stdin

main opens a file if one is provided as argv[1], otherwise uses stdin. It calls print_header, then process_line in a fgets loop, then print_footer.

int main(int argc, char *argv[]) {
    FILE *fp = (argc >= 2) ? fopen(argv[1], "r") : stdin;
    print_header();
    char line[MAX_LINE];
    while (fgets(line, sizeof line, fp))
        process_line(line);
    print_footer();
    if (fp != stdin) fclose(fp);
    return 0;
}

Build

gcc md2html.c -o md2html

Run

./md2html README.md > out.html
cat notes.md | ./md2html > notes.html

Concepts Practiced

  • Line-by-line state machine with context enum for block-level elements
  • Inline parser with multi-character lookahead (**, [text](url))
  • HTML entity escaping for safe user content output
  • Fenced code block toggle with language class extraction
  • Single-pass streaming design — no AST, no intermediate representation
  • Pipe-friendly I/O: reads from file argument or stdin transparently

Dependencies

Standard C libraries only: stdio.h, stdlib.h, string.h, ctype.h

About

► A single-pass Markdown to HTML5 converter

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages