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.
- ATX headings
# H1through###### 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)
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;
}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 */
}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;
}
}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.
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++;
}
}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, "&"); j += 5; break;
case '<': strcpy(out+j, "<"); j += 4; break;
case '>': strcpy(out+j, ">"); j += 4; break;
case '"': strcpy(out+j, """); j += 6; break;
default: out[j++] = s[i]; break;
}
}
out[j] = '\0';
}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");
}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;
}gcc md2html.c -o md2html
./md2html README.md > out.html
cat notes.md | ./md2html > notes.html
- 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
Standard C libraries only: stdio.h, stdlib.h, string.h, ctype.h