From 56a701b2b8019db0365044d7eeb1046563730180 Mon Sep 17 00:00:00 2001
From: flybot-nam-nguyenhoai
<137136212+flybot-nam-nguyenhoai@users.noreply.github.com>
Date: Fri, 4 Aug 2023 15:32:27 +0800
Subject: [PATCH] Add syntax highlighting for Markdown code blocks (#255)
* Add code syntax highlighting using highlight.js
* Highlight blocks without languages as plain text
* Add themes for syntax highlighting
* Fix CSS; support highlighting for more languages
* Syntax highlight each post only once
---
.../src/flybot/client/web/core/db/event.cljs | 7 +
.../web/src/flybot/client/web/core/db/fx.cljs | 12 +-
.../client/web/core/db/fx/highlight.cljs | 12 +
.../flybot/client/web/core/dom/hiccup.cljs | 42 ++-
.../flybot/client/web/core/dom/page/post.cljs | 20 +-
deps.edn | 1 +
resources/public/css/highlight.css | 270 ++++++++++++++++++
resources/public/css/style.css | 3 +-
resources/public/index.html | 1 +
9 files changed, 353 insertions(+), 15 deletions(-)
create mode 100644 client/web/src/flybot/client/web/core/db/fx/highlight.cljs
create mode 100644 resources/public/css/highlight.css
diff --git a/client/web/src/flybot/client/web/core/db/event.cljs b/client/web/src/flybot/client/web/core/db/event.cljs
index ca290fda..d5a70f52 100644
--- a/client/web/src/flybot/client/web/core/db/event.cljs
+++ b/client/web/src/flybot/client/web/core/db/event.cljs
@@ -123,6 +123,13 @@
:fx [[:fx.app/set-theme-local-store next-theme]
[:fx.app/toggle-css-class [cur-theme next-theme]]]})))
+;; Code syntax highlighting
+
+(rf/reg-event-fx
+ :evt.app/highlight-code
+ (fn [_ [_ html-id]]
+ {:fx [[:fx.app/highlight-code html-id]]}))
+
;; ---------- Navbar ----------
(rf/reg-event-db
diff --git a/client/web/src/flybot/client/web/core/db/fx.cljs b/client/web/src/flybot/client/web/core/db/fx.cljs
index 4ad6d3f7..cd6d8759 100644
--- a/client/web/src/flybot/client/web/core/db/fx.cljs
+++ b/client/web/src/flybot/client/web/core/db/fx.cljs
@@ -1,9 +1,11 @@
(ns flybot.client.web.core.db.fx
- (:require [clojure.edn :as edn]
+ (:require [cljsjs.highlight]
+ [clojure.edn :as edn]
[clojure.string :as str]
[flybot.client.common.db.fx]
[flybot.client.common.utils :refer [cljs->js]]
[flybot.client.web.core.db.class-utils :as cu]
+ [flybot.client.web.core.db.fx.highlight]
[flybot.client.web.core.db.localstorage :as l-storage]
[re-frame.core :as rf]
[reagent.core :as reagent]
@@ -64,6 +66,14 @@
(fn [next-theme]
(l-storage/set-item :theme next-theme)))
+;; code syntax highlighting
+
+(rf/reg-fx
+ :fx.app/highlight-code
+ (fn [id]
+ (.configure js/hljs #js {:cssSelector (str "#" id " pre code")})
+ (.highlightAll js/hljs)))
+
;; ----- Notification ------
;; Pop-ups (toasts)
diff --git a/client/web/src/flybot/client/web/core/db/fx/highlight.cljs b/client/web/src/flybot/client/web/core/db/fx/highlight.cljs
new file mode 100644
index 00000000..e7594408
--- /dev/null
+++ b/client/web/src/flybot/client/web/core/db/fx/highlight.cljs
@@ -0,0 +1,12 @@
+(ns flybot.client.web.core.db.fx.highlight
+ (:require [cljsjs.highlight.langs.asciidoc]
+ [cljsjs.highlight.langs.bash]
+ [cljsjs.highlight.langs.clojure]
+ [cljsjs.highlight.langs.clojure-repl]
+ [cljsjs.highlight.langs.cpp]
+ [cljsjs.highlight.langs.dockerfile]
+ [cljsjs.highlight.langs.java]
+ [cljsjs.highlight.langs.javascript]
+ [cljsjs.highlight.langs.lisp]
+ [cljsjs.highlight.langs.markdown]
+ [cljsjs.highlight.langs.python]))
diff --git a/client/web/src/flybot/client/web/core/dom/hiccup.cljs b/client/web/src/flybot/client/web/core/dom/hiccup.cljs
index f0923bb9..7a2fce8e 100644
--- a/client/web/src/flybot/client/web/core/dom/hiccup.cljs
+++ b/client/web/src/flybot/client/web/core/dom/hiccup.cljs
@@ -1,26 +1,58 @@
(ns flybot.client.web.core.dom.hiccup
- (:require [clojure.walk :refer [postwalk]]
+ (:require [clojure.string :as str]
+ [clojure.walk :refer [postwalk]]
[markdown-to-hiccup.core :as mth]
[re-frame.core :as rf]))
+(defn- hiccup-with-properties
+ "Add an empty property map to Hiccup vectors without one."
+ [[tag & rest :as hiccup]]
+ (if (map? (first rest))
+ hiccup
+ (into [tag {}] rest)))
+
;; ---------- Post hiccup conversion logic ----------
(defn md-dark-image
- "Extract the dark mode src from the markdown
- and add it to the hiccup props."
+ "Extracts the dark mode src from the markdown
+ and add it to the hiccup props."
[[tag {:keys [srcdark] :as props} value]]
(if (and srcdark (= :dark @(rf/subscribe [:subs/pattern '{:app/theme ?x}])))
[tag (assoc props :src (:srcdark props)) value]
[tag props value]))
+(defn md-code-block
+ "Decorates code blocks:
+
+ - Mark code blocks without any specified languages as plain text.
+ - Make code blocks responsive to dark and light themes."
+ [h]
+ (let [theme @(rf/subscribe [:subs/pattern '{:app/theme ?x}])
+ [_ props content] (hiccup-with-properties h)]
+ (if (= :code (get content 0))
+ (let [[_ code-props code-content] (hiccup-with-properties content)]
+ [:pre (if (= :dark theme)
+ (merge-with #(str/join " " %&) {:class "dark"} props)
+ props)
+ [:code (merge {:class "text"} code-props)
+ code-content]])
+ h)))
+
(defn post-hiccup
- "Given the hiccup, apply some customisation."
+ "Given the Hiccup content, applies customizations to images and code blocks:
+
+ - Add dark versions to images.
+ - Mark code blocks without any specified languages as plain text."
[hiccup]
(->> hiccup
(postwalk
(fn [h]
- (if (and (vector? h) (= :img (first h)))
+ (cond
+ (and (vector? h) (= :img (first h)))
(md-dark-image h)
+ (and (vector? h) (= :pre (first h)))
+ (md-code-block h)
+ :else
h)))))
;; ---------- Markdown to Hiccup ----------
diff --git a/client/web/src/flybot/client/web/core/dom/page/post.cljs b/client/web/src/flybot/client/web/core/dom/page/post.cljs
index 3320d06b..10006d80 100644
--- a/client/web/src/flybot/client/web/core/dom/page/post.cljs
+++ b/client/web/src/flybot/client/web/core/dom/page/post.cljs
@@ -220,14 +220,18 @@
(user-info editor-name last-edit-date nil :editor)])])))
(defn post-view
- [{:post/keys [css-class image-beside hiccup-content] :as post}]
+ [{:post/keys [id css-class image-beside hiccup-content] :as post}]
(let [{:image/keys [src src-dark alt]} image-beside
src (if (= :dark @(rf/subscribe [:subs/pattern '{:app/theme ?x}]))
src-dark src)
- full-content [[:div {:style {:height 0}}
- [:a {:id (web.utils/post->url-identifier post)}]]
+ fragment-anchor [:div {:style {:height 0}}
+ [:a {:id (web.utils/post->url-identifier post)}]]
+ code-highlighting (fn [] (rf/dispatch [:evt.app/highlight-code
+ (str "post-" id)]))
+ full-content [fragment-anchor
[post-authors post]
- hiccup-content]]
+ hiccup-content
+ [code-highlighting]]]
(if (seq src)
;; returns 2 hiccup divs to be displayed in 2 columns
[:div.post-body
@@ -254,7 +258,7 @@
(when-not (utils/temporary-id? id)
[:div.post
{:key id
- :id id}
+ :id (str "post-" id)}
[post-view post]]))
(defn post-read
@@ -264,7 +268,7 @@
:as post}]
[:div.post
{:key id
- :id id}
+ :id (str "post-" id)}
[:div.post-header
[:form
[edit-button id]
@@ -279,7 +283,7 @@
[{:post/keys [id]}]
[:div.post
{:key id
- :id id}
+ :id (str "post-" id)}
[:div.post-header
(when-not @(rf/subscribe [:subs/pattern '{:form/fields {:post/to-delete? ?x}}])
[:form
@@ -339,7 +343,7 @@
(let [post-title (client.utils/post->title post)]
[:div.post.short
{:key id
- :id id}
+ :id (str "post-" id)}
[post-link post
[:div.post-body
{:class css-class}
diff --git a/deps.edn b/deps.edn
index 48be3065..d9349be2 100644
--- a/deps.edn
+++ b/deps.edn
@@ -36,6 +36,7 @@
:client {:extra-deps {com.bhauman/figwheel-main {:mvn/version "0.2.18"}
org.clojure/clojurescript {:mvn/version "1.11.60"}
reagent/reagent {:mvn/version "1.2.0"}
+ cljsjs/highlight {:mvn/version "11.7.0-0"}
cljsjs/react {:mvn/version "18.2.0-1"}
cljsjs/react-dom {:mvn/version "18.2.0-1"}
cljsjs/react-toastify {:mvn/version "9.1.0-0"}
diff --git a/resources/public/css/highlight.css b/resources/public/css/highlight.css
new file mode 100644
index 00000000..89572966
--- /dev/null
+++ b/resources/public/css/highlight.css
@@ -0,0 +1,270 @@
+/*
+ Themes are obtained from the highlight.js repository at
+ https://github.com/highlightjs/highlight.js/tree/main/src/styles
+ */
+
+pre code.hljs {
+ display: block;
+ overflow-x: auto;
+ padding: 1rem;
+}
+
+code.hljs {
+ padding: 3px 5px;
+}
+
+/* ------------------------------------------------------------------------- */
+
+/*!
+ Theme: GitHub
+ Description: Light theme as seen on github.com
+ Author: github.com
+ Maintainer: @Hirse
+ Updated: 2021-05-15
+
+ Outdated base version: https://github.com/primer/github-syntax-light
+ Current colors taken from GitHub's CSS
+*/
+
+.hljs {
+ color: #24292e;
+ background: #ffffff;
+}
+
+.hljs-doctag,
+.hljs-keyword,
+.hljs-meta .hljs-keyword,
+.hljs-template-tag,
+.hljs-template-variable,
+.hljs-type,
+.hljs-variable.language_ {
+ /* prettylights-syntax-keyword */
+ color: #d73a49;
+}
+
+.hljs-title,
+.hljs-title.class_,
+.hljs-title.class_.inherited__,
+.hljs-title.function_ {
+ /* prettylights-syntax-entity */
+ color: #6f42c1;
+}
+
+.hljs-attr,
+.hljs-attribute,
+.hljs-literal,
+.hljs-meta,
+.hljs-number,
+.hljs-operator,
+.hljs-variable,
+.hljs-selector-attr,
+.hljs-selector-class,
+.hljs-selector-id {
+ /* prettylights-syntax-constant */
+ color: #005cc5;
+}
+
+.hljs-regexp,
+.hljs-string,
+.hljs-meta .hljs-string {
+ /* prettylights-syntax-string */
+ color: #032f62;
+}
+
+.hljs-built_in,
+.hljs-symbol {
+ /* prettylights-syntax-variable */
+ color: #e36209;
+}
+
+.hljs-comment,
+.hljs-code,
+.hljs-formula {
+ /* prettylights-syntax-comment */
+ color: #6a737d;
+}
+
+.hljs-name,
+.hljs-quote,
+.hljs-selector-tag,
+.hljs-selector-pseudo {
+ /* prettylights-syntax-entity-tag */
+ color: #22863a;
+}
+
+.hljs-subst {
+ /* prettylights-syntax-storage-modifier-import */
+ color: #24292e;
+}
+
+.hljs-section {
+ /* prettylights-syntax-markup-heading */
+ color: #005cc5;
+ font-weight: bold;
+}
+
+.hljs-bullet {
+ /* prettylights-syntax-markup-list */
+ color: #735c0f;
+}
+
+.hljs-emphasis {
+ /* prettylights-syntax-markup-italic */
+ color: #24292e;
+ font-style: italic;
+}
+
+.hljs-strong {
+ /* prettylights-syntax-markup-bold */
+ color: #24292e;
+ font-weight: bold;
+}
+
+.hljs-addition {
+ /* prettylights-syntax-markup-inserted */
+ color: #22863a;
+ background-color: #f0fff4;
+}
+
+.hljs-deletion {
+ /* prettylights-syntax-markup-deleted */
+ color: #b31d28;
+ background-color: #ffeef0;
+}
+
+.hljs-char.escape_,
+.hljs-link,
+.hljs-params,
+.hljs-property,
+.hljs-punctuation,
+.hljs-tag {
+ /* purposely ignored */
+}
+
+/* ------------------------------------------------------------------------- */
+
+/*!
+ Theme: GitHub Dark
+ Description: Dark theme as seen on github.com
+ Author: github.com
+ Maintainer: @Hirse
+ Updated: 2021-05-15
+
+ Outdated base version: https://github.com/primer/github-syntax-dark
+ Current colors taken from GitHub's CSS
+*/
+
+.dark .hljs {
+ color: #c9d1d9;
+ background: #0d1117;
+}
+
+.dark .hljs-doctag,
+.dark .hljs-keyword,
+.dark .hljs-meta .hljs-keyword,
+.dark .hljs-template-tag,
+.dark .hljs-template-variable,
+.dark .hljs-type,
+.dark .hljs-variable.language_ {
+ /* prettylights-syntax-keyword */
+ color: #ff7b72;
+}
+
+.dark .hljs-title,
+.dark .hljs-title.class_,
+.dark .hljs-title.class_.inherited__,
+.dark .hljs-title.function_ {
+ /* prettylights-syntax-entity */
+ color: #d2a8ff;
+}
+
+.dark .hljs-attr,
+.dark .hljs-attribute,
+.dark .hljs-literal,
+.dark .hljs-meta,
+.dark .hljs-number,
+.dark .hljs-operator,
+.dark .hljs-variable,
+.dark .hljs-selector-attr,
+.dark .hljs-selector-class,
+.dark .hljs-selector-id {
+ /* prettylights-syntax-constant */
+ color: #79c0ff;
+}
+
+.dark .hljs-regexp,
+.dark .hljs-string,
+.dark .hljs-meta .hljs-string {
+ /* prettylights-syntax-string */
+ color: #a5d6ff;
+}
+
+.dark .hljs-built_in,
+.dark .hljs-symbol {
+ /* prettylights-syntax-variable */
+ color: #ffa657;
+}
+
+.dark .hljs-comment,
+.dark .hljs-code,
+.dark .hljs-formula {
+ /* prettylights-syntax-comment */
+ color: #8b949e;
+}
+
+.dark .hljs-name,
+.dark .hljs-quote,
+.dark .hljs-selector-tag,
+.dark .hljs-selector-pseudo {
+ /* prettylights-syntax-entity-tag */
+ color: #7ee787;
+}
+
+.dark .hljs-subst {
+ /* prettylights-syntax-storage-modifier-import */
+ color: #c9d1d9;
+}
+
+.dark .hljs-section {
+ /* prettylights-syntax-markup-heading */
+ color: #1f6feb;
+ font-weight: bold;
+}
+
+.dark .hljs-bullet {
+ /* prettylights-syntax-markup-list */
+ color: #f2cc60;
+}
+
+.dark .hljs-emphasis {
+ /* prettylights-syntax-markup-italic */
+ color: #c9d1d9;
+ font-style: italic;
+}
+
+.dark .hljs-strong {
+ /* prettylights-syntax-markup-bold */
+ color: #c9d1d9;
+ font-weight: bold;
+}
+
+.dark .hljs-addition {
+ /* prettylights-syntax-markup-inserted */
+ color: #aff5b4;
+ background-color: #033a16;
+}
+
+.dark .hljs-deletion {
+ /* prettylights-syntax-markup-deleted */
+ color: #ffdcd7;
+ background-color: #67060c;
+}
+
+.dark .hljs-char.escape_,
+.dark .hljs-link,
+.dark .hljs-params,
+.dark .hljs-property,
+.dark .hljs-punctuation,
+.dark .hljs-tag {
+ /* purposely ignored */
+}
diff --git a/resources/public/css/style.css b/resources/public/css/style.css
index 4766ed2b..90eab068 100644
--- a/resources/public/css/style.css
+++ b/resources/public/css/style.css
@@ -123,7 +123,8 @@ h6 {
color: var(--text-primary-color);
}
-p {
+p,
+pre{
padding: 0.5rem 0rem;
}
diff --git a/resources/public/index.html b/resources/public/index.html
index fd44bdb5..303644b9 100644
--- a/resources/public/index.html
+++ b/resources/public/index.html
@@ -34,6 +34,7 @@
+