Add GitHub-style syntax highlighting to the code-block preview

The WebKit HTML preview previously rendered fenced code blocks
monochrome. Wire the existing tokenizer into the HTML renderer so code
blocks are highlighted like GitHub, and extend language coverage.

- markdown.c: accumulate each code block's text, tokenize it on block
  leave, and emit <span class="pl-*"> per token with HTML-escaped gaps
  and token text; unknown languages fall back to plain escaped text.
- Add GitHub Primer syntax colors (light/dark) for keywords, constants,
  strings, and comments.
- code_highlight.c: tokenize comments (//, /* */, #), which were
  previously skipped and left uncolored.
- Add languages: javascript, kotlin, and bash (aliases js, kt, sh,
  shell). Bash uses a dedicated scanner whose '#' comments are
  word-position aware so $#, x=$#, and a.com#frag are not comments.
- Create the code_comment tag so the source editor colors comments too.
- README: document the highlighted languages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
NotAlexNoyleandClaude Opus 4.8 committed 2026-07-01 15:20:50 -04:00
1 parent 66cf21ec71
commit 181cbf79b2
7 files changed
+372 -7

No files matched your search

+1 -1
View File
@@ -34,7 +34,7 @@ A lightweight GTK 3 markdown viewer and editor for desktop Linux. It is ideal as
| `<strong>HTML</strong>` | GitHub-style inline and block HTML rendered in preview mode |
| `---` | Horizontal rule |
Code blocks currently support a beta version of keyword highlighting for fenced languages `c`, `java`, and `python`.
Fenced code blocks are syntax-highlighted GitHub-style in the preview for the languages `c`, `java`, `javascript`, `kotlin`, `python`, and `bash`, covering keywords, strings, numbers, and comments.
## Usage
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+218 -6
View File
@@ -76,6 +76,84 @@ static const MarkydKeywordGroup py_groups[] = {
{MARKYD_TAG_CODE_KW_C, py_kw_group_c, G_N_ELEMENTS(py_kw_group_c)},
};
static const gchar *const js_kw_group_a[] = {
"await", "break", "case", "catch", "continue", "default",
"do", "else", "finally", "for", "if", "in",
"instanceof", "of", "return", "switch", "throw", "try",
"while", "with", "yield",
};
static const gchar *const js_kw_group_b[] = {
"async", "class", "const", "debugger", "delete", "export",
"extends","function","import", "let", "new", "static",
"super", "this", "typeof", "var", "void",
};
static const gchar *const js_kw_group_c[] = {
"false", "Infinity", "NaN", "null", "true", "undefined",
};
static const MarkydKeywordGroup js_groups[] = {
{MARKYD_TAG_CODE_KW_A, js_kw_group_a, G_N_ELEMENTS(js_kw_group_a)},
{MARKYD_TAG_CODE_KW_B, js_kw_group_b, G_N_ELEMENTS(js_kw_group_b)},
{MARKYD_TAG_CODE_KW_C, js_kw_group_c, G_N_ELEMENTS(js_kw_group_c)},
};
static const gchar *const kotlin_kw_group_a[] = {
"break", "catch", "continue", "do", "else", "finally",
"for", "if", "in", "is", "return","throw",
"try", "when", "while",
};
static const gchar *const kotlin_kw_group_b[] = {
"abstract", "actual", "annotation", "as", "by",
"class", "companion", "const", "constructor","crossinline",
"data", "enum", "expect", "external", "final",
"fun", "import", "infix", "init", "inline",
"inner", "interface", "internal", "lateinit", "noinline",
"object", "open", "operator", "out", "override",
"package", "private", "protected", "public", "reified",
"sealed", "super", "suspend", "tailrec", "this",
"typealias", "val", "var", "vararg", "where",
};
static const gchar *const kotlin_kw_group_c[] = {
"false", "null", "true", "Any", "Boolean", "Byte", "Char",
"Double","Float","Int", "Long", "Nothing", "Short", "String",
"Unit",
};
static const MarkydKeywordGroup kotlin_groups[] = {
{MARKYD_TAG_CODE_KW_A, kotlin_kw_group_a, G_N_ELEMENTS(kotlin_kw_group_a)},
{MARKYD_TAG_CODE_KW_B, kotlin_kw_group_b, G_N_ELEMENTS(kotlin_kw_group_b)},
{MARKYD_TAG_CODE_KW_C, kotlin_kw_group_c, G_N_ELEMENTS(kotlin_kw_group_c)},
};
static const gchar *const bash_kw_group_a[] = {
"case", "do", "done", "elif", "else", "esac", "fi",
"for", "function","if", "in", "select","then", "time",
"until","while",
};
static const gchar *const bash_kw_group_b[] = {
"alias", "bg", "break", "cd", "command", "continue",
"declare", "echo", "eval", "exec", "exit", "export",
"getopts", "hash", "jobs", "kill", "let", "local",
"printf", "pwd", "read", "readonly","return", "set",
"shift", "source", "test", "trap", "type", "typeset",
"ulimit", "umask", "unalias", "unset", "wait",
};
static const gchar *const bash_kw_group_c[] = {
"false", "true",
};
static const MarkydKeywordGroup bash_groups[] = {
{MARKYD_TAG_CODE_KW_A, bash_kw_group_a, G_N_ELEMENTS(bash_kw_group_a)},
{MARKYD_TAG_CODE_KW_B, bash_kw_group_b, G_N_ELEMENTS(bash_kw_group_b)},
{MARKYD_TAG_CODE_KW_C, bash_kw_group_c, G_N_ELEMENTS(bash_kw_group_c)},
};
static gboolean is_ascii_identifier_char(gchar c) {
return (c == '_') || g_ascii_isalnum((guchar)c);
}
@@ -554,23 +632,43 @@ static void scan_line_c_like(const MarkydLanguageHighlight *language,
}
if (in_block_comment) {
if (p[0] == '*' && p[1] == '/') {
p += 2;
char_index += 2;
in_block_comment = FALSE;
continue;
gint start_char_index = char_index;
while (*p) {
if (p[0] == '*' && p[1] == '/') {
p += 2;
char_index += 2;
in_block_comment = FALSE;
break;
}
advance_utf8_char(&p, &char_index);
}
advance_utf8_char(&p, &char_index);
on_token(start_char_index, char_index, MARKYD_TAG_CODE_COMMENT, user_data);
continue;
}
if (p[0] == '/' && p[1] == '/') {
gint start_char_index = char_index;
while (*p) {
advance_utf8_char(&p, &char_index);
}
on_token(start_char_index, char_index, MARKYD_TAG_CODE_COMMENT, user_data);
break;
}
if (p[0] == '/' && p[1] == '*') {
gint start_char_index = char_index;
p += 2;
char_index += 2;
in_block_comment = TRUE;
while (*p) {
if (p[0] == '*' && p[1] == '/') {
p += 2;
char_index += 2;
in_block_comment = FALSE;
break;
}
advance_utf8_char(&p, &char_index);
}
on_token(start_char_index, char_index, MARKYD_TAG_CODE_COMMENT, user_data);
continue;
}
if (allow_java_text_blocks && starts_with_triple_quote(p)) {
@@ -704,6 +802,11 @@ static void scan_line_python(const MarkydLanguageHighlight *language,
}
if (p[0] == '#') {
gint start_char_index = char_index;
while (*p) {
advance_utf8_char(&p, &char_index);
}
on_token(start_char_index, char_index, MARKYD_TAG_CODE_COMMENT, user_data);
break;
}
@@ -827,11 +930,120 @@ static void scan_line_python(const MarkydLanguageHighlight *language,
}
}
/* Bash: '#' comments (position-sensitive), single/double-quoted strings, and
* keyword/builtin identifiers. No multi-line state is tracked. */
static void scan_line_bash(const MarkydLanguageHighlight *language,
const gchar *line, MarkydCodeScanState *state,
MarkydCodeTokenCallback on_token,
gpointer user_data) {
const gchar *p;
gint char_index = 0;
gchar prev = '\0';
if (!language || !line || !state || !on_token) {
return;
}
p = line;
while (*p) {
/* A '#' starts a comment only at the start of a word (line start or after
* whitespace), so tokens like "$#" and "a#b" are left alone. */
if (p[0] == '#' && (p == line || prev == ' ' || prev == '\t')) {
gint start_char_index = char_index;
while (*p) {
advance_utf8_char(&p, &char_index);
}
on_token(start_char_index, char_index, MARKYD_TAG_CODE_COMMENT, user_data);
break;
}
if (p[0] == '\'') {
gint start_char_index = char_index;
p++;
char_index++;
while (*p && p[0] != '\'') {
advance_utf8_char(&p, &char_index);
}
if (*p == '\'') {
p++;
char_index++;
}
on_token(start_char_index, char_index, MARKYD_TAG_CODE_LITERAL, user_data);
prev = '\'';
continue;
}
if (p[0] == '"') {
gint start_char_index = char_index;
p++;
char_index++;
while (*p) {
if (p[0] == '\\' && p[1]) {
p++;
char_index++;
advance_utf8_char(&p, &char_index);
continue;
}
if (p[0] == '"') {
p++;
char_index++;
break;
}
advance_utf8_char(&p, &char_index);
}
on_token(start_char_index, char_index, MARKYD_TAG_CODE_LITERAL, user_data);
prev = '"';
continue;
}
{
gunichar c = g_utf8_get_char(p);
const gchar *next = g_utf8_next_char(p);
if (is_identifier_start(c)) {
const gchar *token_start = p;
gint start_char_index = char_index;
p = next;
char_index++;
while (*p) {
gunichar tc = g_utf8_get_char(p);
const gchar *tnext = g_utf8_next_char(p);
if (!is_identifier_char(tc)) {
break;
}
p = tnext;
char_index++;
}
gsize token_len = (gsize)(p - token_start);
const gchar *tag_name =
lookup_keyword_tag(language, token_start, token_len);
if (tag_name) {
on_token(start_char_index, char_index, tag_name, user_data);
}
prev = 'a';
continue;
}
}
prev = *p;
advance_utf8_char(&p, &char_index);
}
}
static const MarkydLanguageHighlight languages[] = {
{"c", c_groups, G_N_ELEMENTS(c_groups), scan_line_c},
{"java", java_groups, G_N_ELEMENTS(java_groups), scan_line_java},
{"python", py_groups, G_N_ELEMENTS(py_groups), scan_line_python},
{"py", py_groups, G_N_ELEMENTS(py_groups), scan_line_python},
{"javascript", js_groups, G_N_ELEMENTS(js_groups), scan_line_c},
{"js", js_groups, G_N_ELEMENTS(js_groups), scan_line_c},
{"kotlin", kotlin_groups, G_N_ELEMENTS(kotlin_groups), scan_line_java},
{"kt", kotlin_groups, G_N_ELEMENTS(kotlin_groups), scan_line_java},
{"bash", bash_groups, G_N_ELEMENTS(bash_groups), scan_line_bash},
{"sh", bash_groups, G_N_ELEMENTS(bash_groups), scan_line_bash},
{"shell", bash_groups, G_N_ELEMENTS(bash_groups), scan_line_bash},
};
const MarkydLanguageHighlight *
+1
View File
@@ -7,6 +7,7 @@
#define MARKYD_TAG_CODE_KW_B "code_kw_b"
#define MARKYD_TAG_CODE_KW_C "code_kw_c"
#define MARKYD_TAG_CODE_LITERAL "code_literal"
#define MARKYD_TAG_CODE_COMMENT "code_comment"
typedef struct _MarkydKeywordGroup {
const gchar *tag_name;
+152
View File
@@ -2517,6 +2517,9 @@ void markdown_init_tags(GtkTextBuffer *buffer) {
PANGO_WEIGHT_BOLD, NULL);
gtk_text_buffer_create_tag(buffer, MARKYD_TAG_CODE_LITERAL, "family",
"Monospace", "foreground", config->h3_color, NULL);
gtk_text_buffer_create_tag(buffer, MARKYD_TAG_CODE_COMMENT, "family",
"Monospace", "foreground", "#7F848E", "style",
PANGO_STYLE_ITALIC, NULL);
gtk_text_buffer_create_tag(buffer, TAG_QUOTE, "left-margin", 24, "style",
PANGO_STYLE_ITALIC, "foreground", "#5C6370",
@@ -2805,8 +2808,15 @@ typedef struct {
gchar *image_src;
gchar *image_title;
GString *image_alt;
gboolean in_code_block;
GString *code_text;
const MarkydLanguageHighlight *code_language;
} GithubHtmlCtx;
static void github_html_append_highlighted_code(
GString *target, const gchar *code,
const MarkydLanguageHighlight *language);
static GString *github_html_target(GithubHtmlCtx *ctx) {
return (ctx && ctx->heading_html) ? ctx->heading_html : (ctx ? ctx->out : NULL);
}
@@ -3084,6 +3094,13 @@ static int github_html_enter_block(MD_BLOCKTYPE type, void *detail,
} else {
github_html_append(ctx, "<pre><code>");
}
ctx->in_code_block = TRUE;
ctx->code_language = markyd_code_lookup_language(language);
if (ctx->code_text) {
g_string_set_size(ctx->code_text, 0);
} else {
ctx->code_text = g_string_new(NULL);
}
g_free(class_attr);
g_free(class_name);
g_free(language);
@@ -3185,6 +3202,16 @@ static int github_html_leave_block(MD_BLOCKTYPE type, void *detail,
break;
case MD_BLOCK_CODE:
if (ctx->in_code_block) {
github_html_append_highlighted_code(
github_html_target(ctx),
ctx->code_text ? ctx->code_text->str : "", ctx->code_language);
ctx->in_code_block = FALSE;
ctx->code_language = NULL;
if (ctx->code_text) {
g_string_set_size(ctx->code_text, 0);
}
}
github_html_append(ctx, "</code></pre>\n");
break;
@@ -3365,6 +3392,106 @@ static int github_html_leave_span(MD_SPANTYPE type, void *detail,
return 0;
}
/* Map an internal highlight tag name to a GitHub Primer CSS class. */
static const gchar *github_html_code_token_class(const gchar *tag) {
if (!tag) {
return NULL;
}
if (strcmp(tag, MARKYD_TAG_CODE_KW_A) == 0 ||
strcmp(tag, MARKYD_TAG_CODE_KW_B) == 0) {
return "pl-k";
}
if (strcmp(tag, MARKYD_TAG_CODE_KW_C) == 0) {
return "pl-c1";
}
if (strcmp(tag, MARKYD_TAG_CODE_LITERAL) == 0) {
return "pl-s";
}
if (strcmp(tag, MARKYD_TAG_CODE_COMMENT) == 0) {
return "pl-c";
}
return NULL;
}
typedef struct {
GString *target;
const gchar *line;
gint cursor_char;
} GithubHtmlCodeLineCtx;
/* Token callback: escape the gap before the token, then wrap the token in a
* span. Ranges are non-overlapping and delivered left-to-right. */
static void github_html_code_token(gint start_char_offset, gint end_char_offset,
const gchar *tag_name, gpointer user_data) {
GithubHtmlCodeLineCtx *lc = (GithubHtmlCodeLineCtx *)user_data;
const gchar *cur;
const gchar *sp;
const gchar *ep;
const gchar *cls;
if (!lc || !lc->target || start_char_offset < lc->cursor_char ||
end_char_offset <= start_char_offset) {
return;
}
cur = g_utf8_offset_to_pointer(lc->line, lc->cursor_char);
sp = g_utf8_offset_to_pointer(lc->line, start_char_offset);
ep = g_utf8_offset_to_pointer(lc->line, end_char_offset);
if (sp > cur) {
github_html_append_escaped_len(lc->target, cur, (gsize)(sp - cur));
}
cls = github_html_code_token_class(tag_name);
if (cls) {
g_string_append_printf(lc->target, "<span class=\"%s\">", cls);
}
github_html_append_escaped_len(lc->target, sp, (gsize)(ep - sp));
if (cls) {
g_string_append(lc->target, "</span>");
}
lc->cursor_char = end_char_offset;
}
/* Emit code-block contents into target, syntax-highlighted when the language
* is recognized. Falls back to plain escaped text otherwise. */
static void github_html_append_highlighted_code(
GString *target, const gchar *code,
const MarkydLanguageHighlight *language) {
MarkydCodeScanState state;
const gchar *line_start;
if (!target || !code) {
return;
}
if (!language) {
github_html_append_escaped_len(target, code, strlen(code));
return;
}
markyd_code_scan_state_reset(&state);
line_start = code;
for (;;) {
const gchar *nl = strchr(line_start, '\n');
gsize line_len = nl ? (gsize)(nl - line_start) : strlen(line_start);
gchar *line = g_strndup(line_start, line_len);
GithubHtmlCodeLineCtx lc = {target, line, 0};
const gchar *tail;
markyd_code_scan_line(language, line, &state, github_html_code_token, &lc);
tail = g_utf8_offset_to_pointer(line, lc.cursor_char);
github_html_append_escaped_len(target, tail, strlen(tail));
g_free(line);
if (!nl) {
break;
}
g_string_append_c(target, '\n');
line_start = nl + 1;
}
}
static void github_html_append_text(GithubHtmlCtx *ctx, MD_TEXTTYPE type,
const MD_CHAR *text, MD_SIZE size) {
GString *target;
@@ -3374,6 +3501,17 @@ static void github_html_append_text(GithubHtmlCtx *ctx, MD_TEXTTYPE type,
return;
}
if (ctx->in_code_block && ctx->code_text) {
if (type == MD_TEXT_BR || type == MD_TEXT_SOFTBR) {
g_string_append_c(ctx->code_text, '\n');
} else {
gchar *code_plain = md_text_to_utf8(type, text, size);
g_string_append(ctx->code_text, code_plain);
g_free(code_plain);
}
return;
}
if (type == MD_TEXT_BR) {
if (ctx->in_image) {
g_string_append_c(ctx->image_alt, '\n');
@@ -3450,6 +3588,10 @@ static gchar *github_markdown_css(gboolean dark) {
const gchar *table_alt = dark ? "#161b22" : "#f6f8fa";
const gchar *kbd_bg = dark ? "#161b22" : "#f6f8fa";
const gchar *kbd_shadow = dark ? "#6e7681" : "#afb8c1";
const gchar *hl_kw = dark ? "#ff7b72" : "#cf222e";
const gchar *hl_const = dark ? "#79c0ff" : "#0550ae";
const gchar *hl_str = dark ? "#a5d6ff" : "#0a3069";
const gchar *hl_com = dark ? "#8b949e" : "#6e7781";
g_string_append_printf(css,
"html,body{margin:0;background:%s;color:%s;}\n",
@@ -3533,6 +3675,12 @@ static gchar *github_markdown_css(gboolean dark) {
"padding:0;margin:0;overflow:visible;line-height:inherit;word-wrap:normal;"
"background-color:transparent;border:0;}\n",
code_bg, pre_bg);
g_string_append_printf(css,
".markdown-body pre .pl-k{color:%s;}"
".markdown-body pre .pl-c1{color:%s;}"
".markdown-body pre .pl-s{color:%s;}"
".markdown-body pre .pl-c{color:%s;}\n",
hl_kw, hl_const, hl_str, hl_com);
g_string_append_printf(css,
".markdown-body kbd{display:inline-block;padding:3px 5px;font:11px ui-monospace,"
"SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace;"
@@ -3568,6 +3716,7 @@ static gchar *github_html_render_body(const gchar *source) {
ctx.block_stack = g_array_new(FALSE, FALSE, sizeof(GithubHtmlBlockState));
ctx.list_stack = g_array_new(FALSE, FALSE, sizeof(GithubHtmlListState));
ctx.image_alt = g_string_new(NULL);
ctx.code_text = g_string_new(NULL);
normalized_source = normalize_markdown_source(source ? source : "");
@@ -3603,6 +3752,9 @@ static gchar *github_html_render_body(const gchar *source) {
if (ctx.image_alt) {
g_string_free(ctx.image_alt, TRUE);
}
if (ctx.code_text) {
g_string_free(ctx.code_text, TRUE);
}
g_array_free(ctx.list_stack, TRUE);
g_array_free(ctx.block_stack, TRUE);
g_hash_table_destroy(ctx.anchor_counts);